Browse Source

Rebase to upstream; implement modals; fix many bugs

pull/10109/head
dolfies 4 years ago
parent
commit
e58b26433c
  1. 11
      .github/CONTRIBUTING.md
  2. 36
      .github/ISSUE_TEMPLATE/bug_report.yml
  3. 12
      .github/ISSUE_TEMPLATE/config.yml
  4. 7
      .github/PULL_REQUEST_TEMPLATE.md
  5. 32
      .github/workflows/python-publish.yml
  6. 1
      .gitignore
  7. 113
      README.ja.rst
  8. 29
      discord/__init__.py
  9. 6
      discord/__main__.py
  10. 233
      discord/abc.py
  11. 101
      discord/activity.py
  12. 17
      discord/app_commands/__init__.py
  13. 1064
      discord/app_commands/commands.py
  14. 202
      discord/app_commands/errors.py
  15. 609
      discord/app_commands/models.py
  16. 218
      discord/app_commands/namespace.py
  17. 664
      discord/app_commands/transformers.py
  18. 713
      discord/app_commands/tree.py
  19. 517
      discord/appinfo.py
  20. 9
      discord/asset.py
  21. 9
      discord/audit_logs.py
  22. 473
      discord/calls.py
  23. 396
      discord/channel.py
  24. 1197
      discord/client.py
  25. 705
      discord/commands.py
  26. 238
      discord/components.py
  27. 161
      discord/connections.py
  28. 232
      discord/enums.py
  29. 79
      discord/errors.py
  30. 57
      discord/ext/commands/bot.py
  31. 17
      discord/ext/commands/context.py
  32. 6
      discord/ext/commands/converter.py
  33. 3
      discord/ext/commands/help.py
  34. 11
      discord/file.py
  35. 639
      discord/flags.py
  36. 466
      discord/gateway.py
  37. 796
      discord/guild.py
  38. 102
      discord/guild_folder.py
  39. 1199
      discord/http.py
  40. 1001
      discord/interactions.py
  41. 157
      discord/invite.py
  42. 302
      discord/iterators.py
  43. 265
      discord/member.py
  44. 4
      discord/mentions.py
  45. 218
      discord/message.py
  46. 10
      discord/permissions.py
  47. 10
      discord/player.py
  48. 164
      discord/profile.py
  49. 2
      discord/reaction.py
  50. 51
      discord/recorder.py
  51. 119
      discord/relationship.py
  52. 47
      discord/role.py
  53. 18
      discord/scheduled_event.py
  54. 586
      discord/settings.py
  55. 559
      discord/shard.py
  56. 8
      discord/stage_instance.py
  57. 1461
      discord/state.py
  58. 2
      discord/sticker.py
  59. 204
      discord/team.py
  60. 4
      discord/template.py
  61. 275
      discord/threads.py
  62. 327
      discord/tracking.py
  63. 2
      discord/types/activity.py
  64. 21
      discord/types/appinfo.py
  65. 4
      discord/types/components.py
  66. 54
      discord/types/gateway.py
  67. 9
      discord/types/guild.py
  68. 239
      discord/types/interactions.py
  69. 2
      discord/types/message.py
  70. 4
      discord/types/role.py
  71. 15
      discord/types/scheduled_event.py
  72. 6
      discord/types/team.py
  73. 1
      discord/types/threads.py
  74. 7
      discord/types/user.py
  75. 17
      discord/ui/__init__.py
  76. 291
      discord/ui/button.py
  77. 133
      discord/ui/item.py
  78. 212
      discord/ui/modal.py
  79. 355
      discord/ui/select.py
  80. 235
      discord/ui/text_input.py
  81. 571
      discord/ui/view.py
  82. 645
      discord/user.py
  83. 298
      discord/utils.py
  84. 425
      discord/voice_client.py
  85. 163
      discord/webhook/async_.py
  86. 26
      discord/webhook/sync.py
  87. 187
      discord/welcome_screen.py
  88. 5
      docs/_templates/layout.html
  89. 270
      docs/api.rst
  90. 37
      docs/conf.py
  91. 96
      docs/discord.rst
  92. 8
      docs/ext/commands/api.rst
  93. 2
      docs/faq.rst
  94. BIN
      docs/images/discord_bot_tab.png
  95. BIN
      docs/images/discord_bot_user_options.png
  96. BIN
      docs/images/discord_create_app_button.png
  97. BIN
      docs/images/discord_create_app_form.png
  98. BIN
      docs/images/discord_create_bot_user.png
  99. BIN
      docs/images/discord_oauth2.png
  100. BIN
      docs/images/discord_oauth2_perms.png

11
.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.

36
.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.

12
.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!

7
.github/PULL_REQUEST_TEMPLATE.md

@ -2,13 +2,12 @@
<!-- What is this pull request for? Does it fix any issues? -->
## Checklist
## General Info
<!-- Put an x inside [ ] to check it, like so: [x] -->
- [ ] If code changes were made then they have been tested.
- [ ] I have updated the documentation to reflect the changes.
- [ ] This PR fixes an issue.
<!-- - [ ] I have updated the documentation to reflect the changes. -->
- [ ] 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, ...)

32
.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/*

1
.gitignore

@ -15,3 +15,4 @@ docs/crowdin.py
*.flac
*.mo
/.coverage
*test.py

113
README.ja.rst

@ -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ディレクトリに更に多くのサンプルがあります。
リンク
------
- `ドキュメント <https://discordpy.readthedocs.io/ja/latest/index.html>`_
- `公式Discordサーバー <https://discord.gg/nXzj3dg>`_
- `Discord API <https://discord.gg/discord-api>`_

29
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())

6
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='<prefix>')
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')

233
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

101
discord/activity.py

@ -252,6 +252,22 @@ class Activity(BaseActivity):
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<Activity {inner}>'
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'))

17
discord/app_commands/__init__.py

@ -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 *

1064
discord/app_commands/commands.py

File diff suppressed because it is too large

202
discord/app_commands/errors.py

@ -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 <discord.app_commands.Command.error>`
- :meth:`Group.on_error <discord.app_commands.Group.on_error>`
- :meth:`CommandTree.on_error <discord.app_commands.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)

609
discord/app_commands/models.py

@ -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)

218
discord/app_commands/namespace.py

@ -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)

664
discord/app_commands/transformers.py

@ -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 <discord.app_commands.Namespace>`.
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 <discord.app_commands.Namespace>`
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

713
discord/app_commands/tree.py

@ -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)

517
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 <https://discord.com/developers/docs/game-sdk/applications#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 <https://discord.com/developers/docs/game-sdk/applications#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 <https://discord.com/developers/docs/game-sdk/applications#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)

9
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

9
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 = {

473
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]

396
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'<DMChannel id={self.id} recipient={self.recipient!r}>'
@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'<GroupChannel id={self.id} name={self.name!r}>'
@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):

1197
discord/client.py

File diff suppressed because it is too large

705
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'<SlashCommand id={self.id} name={self.name}'
if self.options:
BASE += f' options={len(self.options)}'
if self.children:
BASE += f' children={len(self.children)}'
return BASE + '>'
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'<SubCommand name={self.name}'
if self.options:
BASE += f' options={len(self.options)}'
if self.children:
BASE += f' children={len(self.children)}'
return BASE + '>'
@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'<Option name={self.name} type={self.type} required={self.required}>'
def _convert(self, value):
for choice in self.choices:
if (new_value := choice._convert(value)) != value:
return new_value
return value
class OptionChoice:
"""Represents a choice for an option.
Attributes
----------
name: :class:`str`
The choice's displayed name.
value: Any
The choice's value. The type of this depends on the option's type.
"""
__slots__ = ('name', 'value')
def __init__(self, data: Dict[str, str], type: AppCommandOptionType):
self.name: str = data['name']
self.value: Union[str, int, float]
if type is AppCommandOptionType.string:
self.value = data['value']
elif type is AppCommandOptionType.integer:
self.value = int(data['value'])
elif type is AppCommandOptionType.number:
self.value = float(data['value'])
def __repr__(self) -> str:
return f'<OptionChoice name={self.name} value={self.value}>'
def _convert(self, value):
if value == self.name:
return self.value
return value
def _command_factory(command_type: int) -> Tuple[AppCommandType, Type[BaseCommand]]:
value = try_enum(AppCommandType, command_type)
if value is AppCommandType.chat_input:
return value, SlashCommand
elif value is AppCommandType.user:
return value, UserCommand
elif value is AppCommandType.message:
return value, MessageCommand
else:
return value, BaseCommand # IDK about this

238
discord/components.py

@ -24,9 +24,12 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from asyncio import TimeoutError
from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING, Tuple, Union
from .enums import try_enum, ComponentType, ButtonStyle, TextStyle
from .utils import get_slots, MISSING
from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, InteractionType
from .errors import InvalidData
from .utils import get_slots, MISSING, time_snowflake, utcnow
from .partial_emoji import PartialEmoji, _EmojiTag
if TYPE_CHECKING:
@ -41,6 +44,8 @@ if TYPE_CHECKING:
TextInput as TextInputPayload,
)
from .emoji import Emoji
from .interactions import Interaction
from .message import Message
__all__ = (
@ -72,10 +77,11 @@ class Component:
The type of component.
"""
__slots__: Tuple[str, ...] = ('type',)
__slots__: Tuple[str, ...] = ('type', 'message')
__repr_info__: ClassVar[Tuple[str, ...]]
type: ComponentType
message: Message
def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__)
@ -112,15 +118,18 @@ class ActionRow(Component):
The type of component.
children: List[:class:`Component`]
The children components that this holds, if any.
message: :class:`Message`
The originating message.
"""
__slots__: Tuple[str, ...] = ('children',)
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: ComponentPayload):
def __init__(self, data: ComponentPayload, message: Message):
self.message = message
self.type: ComponentType = try_enum(ComponentType, data['type'])
self.children: List[Component] = [_component_factory(d) for d in data.get('components', [])]
self.children: List[Component] = [_component_factory(d, message) for d in data.get('components', [])]
def to_dict(self) -> ActionRowPayload:
return {
@ -134,11 +143,6 @@ class Button(Component):
This inherits from :class:`Component`.
.. note::
The user constructible and usable type to create a button is :class:`discord.ui.Button`
not this one.
.. versionadded:: 2.0
Attributes
@ -156,6 +160,8 @@ class Button(Component):
The label of the button, if any.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the button, if available.
message: :class:`Message`
The originating message, if any.
"""
__slots__: Tuple[str, ...] = (
@ -169,7 +175,8 @@ class Button(Component):
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: ButtonComponentPayload):
def __init__(self, data: ButtonComponentPayload, message: Message):
self.message = message
self.type: ComponentType = try_enum(ComponentType, data['type'])
self.style: ButtonStyle = try_enum(ButtonStyle, data['style'])
self.custom_id: Optional[str] = data.get('custom_id')
@ -182,23 +189,53 @@ class Button(Component):
except KeyError:
self.emoji = None
def to_dict(self) -> ButtonComponentPayload:
payload = {
'type': 2,
'style': int(self.style),
'label': self.label,
'disabled': self.disabled,
def to_dict(self) -> dict:
return {
'component_type': self.type.value,
'custom_id': self.custom_id,
}
if self.custom_id:
payload['custom_id'] = self.custom_id
async def click(self) -> Union[str, Interaction]:
"""|coro|
Clicks the button.
Raises
-------
InvalidData
Didn't receive a response from Discord
(doesn't mean the interaction failed).
NotFound
The originating message was not found.
HTTPException
Clicking the button failed.
Returns
--------
Union[:class:`str`, :class:`Interaction`]
The button's URL or the interaction that was created.
"""
if self.url:
payload['url'] = self.url
return self.url
if self.emoji:
payload['emoji'] = self.emoji.to_dict()
message = self.message
state = message._state
nonce = str(time_snowflake(utcnow()))
type = InteractionType.component
return payload # type: ignore - Type checker does not understand these are the same
state._interaction_cache[nonce] = (int(type), None, message.channel)
try:
await state.http.interact(type, self.to_dict(), message.channel, message, nonce=nonce)
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 SelectMenu(Component):
@ -207,11 +244,6 @@ class SelectMenu(Component):
A select menu is functionally the same as a dropdown, however
on mobile it renders a bit differently.
.. note::
The user constructible and usable type to create a select menu is
:class:`discord.ui.Select` not this one.
.. versionadded:: 2.0
Attributes
@ -222,14 +254,16 @@ class SelectMenu(Component):
The placeholder text that is shown if nothing is selected, if any.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
max_values: :class:`int`
The maximum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
options: List[:class:`SelectOption`]
A list of options that can be selected in this menu.
disabled: :class:`bool`
Whether the select is disabled or not.
hash: :class:`str`
Unknown.
message: :class:`Message`
The originating message, if any.
"""
__slots__: Tuple[str, ...] = (
@ -239,11 +273,13 @@ class SelectMenu(Component):
'max_values',
'options',
'disabled',
'hash',
)
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: SelectMenuPayload):
def __init__(self, data: SelectMenuPayload, message: Message):
self.message = message
self.type = ComponentType.select
self.custom_id: str = data['custom_id']
self.placeholder: Optional[str] = data.get('placeholder')
@ -251,28 +287,56 @@ class SelectMenu(Component):
self.max_values: int = data.get('max_values', 1)
self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])]
self.disabled: bool = data.get('disabled', False)
self.hash: str = data.get('hash', '')
def to_dict(self) -> SelectMenuPayload:
payload: SelectMenuPayload = {
'type': self.type.value,
def to_dict(self, options: Tuple[SelectOption]) -> dict:
return {
'compontent_type': self.type.value,
'custom_id': self.custom_id,
'min_values': self.min_values,
'max_values': self.max_values,
'options': [op.to_dict() for op in self.options],
'disabled': self.disabled,
'values': [option.value for option in options]
}
if self.placeholder:
payload['placeholder'] = self.placeholder
async def choose(self, *options: SelectOption) -> Interaction:
"""|coro|
Chooses the given options from the select menu.
Raises
-------
InvalidData
Didn't receive a response from Discord
(doesn't mean the interaction failed).
NotFound
The originating message was not found.
HTTPException
Choosing the options failed.
Returns
--------
:class:`Interaction`
The interaction that was created.
"""
message = self.message
state = message._state
nonce = str(time_snowflake(utcnow()))
type = InteractionType.component
return payload
state._interaction_cache[nonce] = (int(type), None, message.channel)
await state.http.interact(type, self.to_dict(options), message.channel, message, nonce=nonce)
try:
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
return i
class SelectOption:
"""Represents a select menu's option.
These can be created by users.
.. versionadded:: 2.0
Attributes
@ -356,29 +420,10 @@ class SelectOption:
default=data.get('default', False),
)
def to_dict(self) -> SelectOptionPayload:
payload: SelectOptionPayload = {
'label': self.label,
'value': self.value,
'default': self.default,
}
if self.emoji:
payload['emoji'] = self.emoji.to_dict() # type: ignore - This Dict[str, Any] is compatible with PartialEmoji
if self.description:
payload['description'] = self.description
return payload
class TextInput(Component):
"""Represents a text input from the Discord Bot UI Kit.
.. note::
The user constructible and usable type to create a text input is
:class:`discord.ui.TextInput` not this one.
.. versionadded:: 2.0
Attributes
@ -406,7 +451,8 @@ class TextInput(Component):
'label',
'custom_id',
'placeholder',
'value',
'_value',
'_answer',
'required',
'min_length',
'max_length',
@ -414,62 +460,72 @@ class TextInput(Component):
__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
def __init__(self, data: TextInputPayload) -> None:
def __init__(self, data: TextInputPayload, _ = MISSING) -> None:
self.type: ComponentType = ComponentType.text_input
self.style: TextStyle = try_enum(TextStyle, data['style'])
self.label: str = data['label']
self.custom_id: str = data['custom_id']
self.placeholder: Optional[str] = data.get('placeholder')
self.value: Optional[str] = data.get('value')
self._value: Optional[str] = data.get('value')
self.required: bool = data.get('required', True)
self.min_length: Optional[int] = data.get('min_length')
self.max_length: Optional[int] = data.get('max_length')
def to_dict(self) -> TextInputPayload:
payload: TextInputPayload = {
def to_dict(self) -> dict:
return {
'type': self.type.value,
'style': self.style.value,
'label': self.label,
'custom_id': self.custom_id,
'required': self.required,
'value': self.value,
}
if self.placeholder:
payload['placeholder'] = self.placeholder
if self.value:
payload['value'] = self.value
@property
def value(self) -> Optional[str]:
"""Optional[:class:`str]`: The current value of the text input. Defaults to :attr:`default`.
if self.min_length:
payload['min_length'] = self.min_length
This can be set to change the answer to the text input.
"""
return getattr(self, '_answer', self._value)
if self.max_length:
payload['max_length'] = self.max_length
@value.setter
def value(self, value: Optional[str]) -> None:
length = len(value) if value is not None else 0
if (self.required or value is not None) and ((self.min_length is not None and length < self.min_length) or (self.max_length is not None and length > self.max_length)):
raise ValueError(f'value cannot be shorter than {self.min_length or 0} or longer than {self.max_length or "infinity"}')
return payload
self._answer = value
@property
def default(self) -> Optional[str]:
"""Optional[:class:`str`]: The default value of the text input.
"""Optional[:class:`str`]: The default value of the text input."""
return self._value
def answer(self, value: Optional[str], /) -> None:
"""A shorthand method to answer the text input.
Parameters
----------
value: Optional[:class:`str`]
The value to set the answer to.
This is an alias to :attr:`value`.
Raises
------
ValueError
The answer is shorter than :attr:`min_length` or longer than :attr:`max_length`.
"""
return self.value
self.value = value
def _component_factory(data: ComponentPayload) -> Component:
def _component_factory(data: ComponentPayload, message: Message = MISSING) -> Component:
# The type checker does not properly do narrowing here
component_type = data['type']
if component_type == 1:
return ActionRow(data)
return ActionRow(data, message)
elif component_type == 2:
# The type checker does not properly do narrowing here.
return Button(data) # type: ignore
return Button(data, message) # type: ignore
elif component_type == 3:
# The type checker does not properly do narrowing here.
return SelectMenu(data) # type: ignore
return SelectMenu(data, message) # type: ignore
elif component_type == 4:
# The type checker does not properly do narrowing here.
return TextInput(data) # type: ignore
return TextInput(data, message) # type: ignore
else:
as_enum = try_enum(ComponentType, component_type)
return Component._raw_construct(type=as_enum)

161
discord/connections.py

@ -0,0 +1,161 @@
"""
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 typing import Optional, TYPE_CHECKING
from .utils import MISSING
if TYPE_CHECKING:
from .state import ConnectionState
__all__ = (
'PartialConnection',
'Connection',
)
class PartialConnection:
"""Represents a partial Discord profile connection
This is the info you get for other people's connections.
Attributes
----------
id: :class:`str`
The connection's account ID.
name: :class:`str`
The connection's account name.
type: :class:`str`
The connection service (e.g. 'youtube')
verified: :class:`bool`
Whether the connection is verified.
revoked: :class:`bool`
Whether the connection is revoked.
visible: :class:`bool`
Whether the connection is visible on the user's profile.
"""
__slots__ = ('id', 'name', 'type', 'verified', 'revoked', 'visible')
def __init__(self, data: dict):
self._update(data)
def _update(self, data: dict):
self.id: str = data['id']
self.name: str = data['name']
self.type: str = data['type']
self.verified: bool = data['verified']
self.revoked: bool = data.get('revoked', False)
self.visible: bool = True
class Connection(PartialConnection):
"""Represents a Discord profile connection
Attributes
----------
friend_sync: :class:`bool`
Whether friends are synced over the connection.
show_activity: :class:`bool`
Whether activities from this connection will be shown in presences.
access_token: :class:`str`
The OAuth2 access token for the account, if applicable.
"""
__slots__ = ('_state', 'visible', 'friend_sync', 'show_activity', 'access_token')
def __init__(self, *, data: dict, state: ConnectionState):
super().__init__(data)
self._state = state
self.access_token: Optional[str] = None
def _update(self, data: dict):
super()._update(data)
self.visible: bool = bool(data.get('visibility', True))
self.friend_sync: bool = data.get('friend_sync', False)
self.show_activity: bool = data.get('show_activity', True)
# Only sometimes in the payload
try:
self.access_token: Optional[str] = data['access_token']
except KeyError:
pass
async def edit(self, *, visible: bool = MISSING, show_activity: bool = MISSING):
"""|coro|
Edit the connection.
All parameters are optional.
Parameters
----------
visible: :class:`bool`
Whether the connection is visible on your profile.
Raises
------
HTTPException
Editing the connection failed.
"""
payload = {}
if visible is not MISSING:
payload['visibility'] = visible
if show_activity is not MISSING:
payload['show_activity'] = show_activity
data = await self._state.http.edit_connection(self.type, self.id, **payload)
self._update(data)
async def delete(self) -> None:
"""|coro|
Removes the connection.
Raises
------
HTTPException
Deleting the connection failed.
"""
await self._state.http.delete_connection(self.type, self.id)
async def fetch_access_token(self) -> str:
"""|coro|
Retrieves a new access token for the connection.
Raises
------
HTTPException
Retrieving the access token failed.
Returns
-------
:class:`str`
The new access token.
"""
data = await self._state.http.get_connection_token(self.type, self.id)
self.access_token = token = data['access_token']
return token

232
discord/enums.py

@ -54,7 +54,6 @@ __all__ = (
'TextStyle',
'PrivacyLevel',
'InteractionType',
'InteractionResponseType',
'NSFWLevel',
'MFALevel',
'Locale',
@ -62,6 +61,25 @@ __all__ = (
'EventStatus',
'AppCommandType',
'AppCommandOptionType',
'RelationshipType',
'HypeSquadHouse',
'PremiumType',
'UserContentFilter',
'FriendFlags',
'Theme',
'StickerAnimationOptions',
'RelationshipAction',
'UnavailableGuildType',
'RequiredActionType',
'ReportType',
'BrowserEnum',
'ApplicationVerificationState',
'StoreApplicationState',
'RPCApplicationState',
'InviteType',
'ScheduledEventStatus',
'ScheduledEventEntityType',
'ApplicationType',
)
if TYPE_CHECKING:
@ -157,7 +175,7 @@ class EnumMeta(type):
return cls._enum_member_map_[key]
def __setattr__(cls, name, value):
raise TypeError('Enums are immutable.')
raise TypeError('Enums are immutable')
def __delattr__(cls, attr):
raise TypeError('Enums are immutable')
@ -200,6 +218,9 @@ class ChannelType(Enum):
def __str__(self):
return self.name
def __int__(self):
return self.value
class MessageType(Enum):
default = 0
@ -208,7 +229,10 @@ class MessageType(Enum):
call = 3
channel_name_change = 4
channel_icon_change = 5
channel_pinned_message = 6
pins_add = 6
member_join = 7
user_join = 7
new_member = 7
premium_guild_subscription = 8
premium_guild_tier_1 = 9
@ -222,9 +246,10 @@ class MessageType(Enum):
guild_discovery_grace_period_final_warning = 17
thread_created = 18
reply = 19
application_command = 20
chat_input_command = 20
thread_starter_message = 21
guild_invite_reminder = 22
context_menu_command = 23
class SpeakingState(Enum):
@ -260,6 +285,60 @@ class ContentFilter(Enum, comparable=True):
return self.name
class UserContentFilter(Enum):
always = 0
on_interaction = 1
never = 2
class StickerAnimationOptions(Enum):
disabled = 2
friends = 1
all_messages = 0
class FriendFlags(Enum):
noone = 0
mutual_guilds = 1
mutual_friends = 2
guild_and_friends = 3
everyone = 4
def to_dict(self):
if self.value == 0:
return {'all': False, 'mutual_friends': False, 'mutual_guilds': False}
if self.value == 1:
return {'all': False, 'mutual_friends': False, 'mutual_guilds': True}
if self.value == 2:
return {'all': False, 'mutual_friends': True, 'mutual_guilds': False}
if self.value == 3:
return {'all': False, 'mutual_friends': True, 'mutual_guilds': True}
if self.value == 4:
return {'all': True, 'mutual_friends': True, 'mutual_guilds': True}
@classmethod
def _from_dict(cls, data):
all = data.get('all')
mutual_guilds = data.get('mutual_guilds')
mutual_friends = data.get('mutual_friends')
if all:
return cls.everyone
elif mutual_guilds and mutual_friends:
return cls.guild_and_friends
elif mutual_guilds:
return cls.mutual_guilds
elif mutual_friends:
return cls.mutual_friends
else:
return cls.noone
class Theme(Enum):
light = 'light'
dark = 'dark'
class Status(Enum):
online = 'online'
offline = 'offline'
@ -279,14 +358,30 @@ class DefaultAvatar(Enum):
green = 2
orange = 3
red = 4
pink = 5
def __str__(self):
return self.name
class RelationshipType(Enum, comparable=True):
friend = 1
blocked = 2
incoming_request = 3
outgoing_request = 4
class NotificationLevel(Enum, comparable=True):
all_messages = 0
all = 0
only_mentions = 1
nothing = 2
none = 2
server_default = 3
default = 3
def __int__(self):
return self.value
class AuditLogActionCategory(Enum):
@ -441,6 +536,7 @@ class UserFlags(Enum):
partner = 2
hypesquad = 4
bug_hunter = 8
bug_hunter_level_1 = 8
mfa_sms = 16
premium_promo_dismissed = 32
hypesquad_bravery = 64
@ -448,9 +544,11 @@ class UserFlags(Enum):
hypesquad_balance = 256
early_supporter = 512
team_user = 1024
partner_or_verification_application = 2048
system = 4096
has_unread_urgent_messages = 8192
bug_hunter_level_2 = 16384
underage_deleted = 32768
verified_bot = 65536
verified_bot_developer = 131072
discord_certified_moderator = 262144
@ -471,6 +569,17 @@ class ActivityType(Enum):
return self.value
class HypeSquadHouse(Enum):
bravery = 1
brilliance = 2
balance = 3
class PremiumType(Enum, comparable=True):
nitro_classic = 1
nitro = 2
class TeamMembershipState(Enum):
invited = 1
accepted = 2
@ -512,12 +621,60 @@ class StickerFormatType(Enum):
return lookup[self]
class ReportType(Enum):
illegal_content = 1
harassment = 2
phishing = 3
self_harm = 4
nsfw_content = 5
def __int__(self):
return self.value
class RelationshipAction(Enum):
send_friend_request = 'request'
unfriend = 'unfriend'
accept_request = 'accept'
deny_request = 'deny'
block = 'block'
unblock = 'unblock'
remove_pending_request = 'remove'
class UnavailableGuildType(Enum):
existing = 'ready'
joined = 'joined'
class RequiredActionType(Enum):
verify_phone = 'REQUIRE_VERIFIED_PHONE'
verify_email = 'REQUIRE_VERIFIED_EMAIL'
complete_captcha = 'REQUIRE_CAPTCHA'
accept_terms = 'AGREEMENTS'
class BrowserEnum(Enum):
google_chrome = 'chrome'
chrome = 'chrome'
chromium = 'chromium'
microsoft_edge = 'microsoft-edge'
edge = 'microsoft-edge'
opera = 'opera'
class InviteTarget(Enum):
unknown = 0
stream = 1
embedded_application = 2
class InviteType(Enum):
guild = 0
group_dm = 1
friend = 2
class InteractionType(Enum):
ping = 1
application_command = 2
@ -525,17 +682,8 @@ class InteractionType(Enum):
autocomplete = 4
modal_submit = 5
class InteractionResponseType(Enum):
pong = 1
# ack = 2 (deprecated)
# channel_message = 3 (deprecated)
channel_message = 4 # (with source)
deferred_channel_message = 5 # (with source)
deferred_message_update = 6 # for components
message_update = 7 # for components
autocomplete_result = 8
modal = 9 # for modals
def __int__(self) -> int:
return self.value
class VideoQualityMode(Enum):
@ -587,9 +735,24 @@ class TextStyle(Enum):
class PrivacyLevel(Enum):
public = 1
closed = 2
guild_only = 2
class ScheduledEventEntityType(Enum):
stage_instance = 1
voice = 2
external = 3
class ScheduledEventStatus(Enum):
scheduled = 1
active = 2
completed = 3
canceled = 4
class NSFWLevel(Enum, comparable=True):
default = 0
explicit = 1
@ -602,6 +765,42 @@ class MFALevel(Enum, comparable=True):
require_2fa = 1
class ApplicationVerificationState(Enum, comparable=True):
ineligible = 1
unsubmitted = 2
submitted = 3
succeeded = 4
class StoreApplicationState(Enum, comparable=True):
none = 1
paid = 2
submitted = 3
approved = 4
rejected = 5
blocked = 6
class RPCApplicationState(Enum, comparable=True):
disabled = 0
none = 0
unsubmitted = 1
submitted = 2
approved = 3
rejected = 4
class ApplicationType(Enum):
none = None
game = 1
music = 2
ticketed_events = 3
guild_role_subscriptions = 4
T = TypeVar('T')
class Locale(Enum):
american_english = 'en-US'
british_english = 'en-GB'
@ -659,7 +858,9 @@ class EventStatus(Enum):
class AppCommandOptionType(Enum):
subcommand = 1
sub_command = 1
subcommand_group = 2
sub_command_group = 2
string = 3
integer = 4
boolean = 5
@ -676,6 +877,9 @@ class AppCommandType(Enum):
user = 2
message = 3
def __int__(self) -> int:
return self.value
def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore - This is narrowed below

79
discord/errors.py

@ -35,8 +35,6 @@ if TYPE_CHECKING:
except ModuleNotFoundError:
_ResponseType = ClientResponse
from .interactions import Interaction
__all__ = (
'DiscordException',
'ClientException',
@ -46,10 +44,9 @@ __all__ = (
'NotFound',
'DiscordServerError',
'InvalidData',
'AuthFailure',
'LoginFailure',
'ConnectionClosed',
'PrivilegedIntentsRequired',
'InteractionResponded',
)
@ -58,7 +55,6 @@ class DiscordException(Exception):
Ideally speaking, this could be caught to handle any exceptions raised from this library.
"""
pass
@ -67,15 +63,13 @@ class ClientException(DiscordException):
These are usually for exceptions that happened due to user input.
"""
pass
class GatewayNotFound(DiscordException):
"""An exception that is raised when the gateway for Discord could not be found"""
def __init__(self):
message = 'The gateway to connect to discord was not found.'
message = 'The gateway to connect to Discord was not found.'
super().__init__(message)
@ -106,21 +100,22 @@ class HTTPException(DiscordException):
The response of the failed HTTP request. This is an
instance of :class:`aiohttp.ClientResponse`. In some cases
this could also be a :class:`requests.Response`.
text: :class:`str`
The text of the error. Could be an empty string.
status: :class:`int`
The status code of the HTTP request.
code: :class:`int`
The Discord specific error code for the failure.
json: :class:`dict`
The raw error JSON.
"""
def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]):
self.response: _ResponseType = response
self.status: int = response.status # type: ignore - This attribute is filled by the library even if using requests
self.code: int
self.text: str
if isinstance(message, dict):
self.json = message
self.code = message.get('code', 0)
base = message.get('message', '')
errors = message.get('errors')
@ -146,7 +141,6 @@ class Forbidden(HTTPException):
Subclass of :exc:`HTTPException`
"""
pass
@ -155,7 +149,6 @@ class NotFound(HTTPException):
Subclass of :exc:`HTTPException`
"""
pass
@ -166,7 +159,6 @@ class DiscordServerError(HTTPException):
.. versionadded:: 1.5
"""
pass
@ -174,19 +166,20 @@ class InvalidData(ClientException):
"""Exception that's raised when the library encounters unknown
or invalid data from Discord.
"""
pass
class LoginFailure(ClientException):
class AuthFailure(ClientException):
"""Exception that's raised when the :meth:`Client.login` function
fails to log you in from improper credentials or some other misc.
failure.
"""
pass
LoginFailure = AuthFailure
class ConnectionClosed(ClientException):
"""Exception that's raised when the gateway connection is
closed for reasons that could not be handled internally.
@ -197,61 +190,11 @@ class ConnectionClosed(ClientException):
The close code of the websocket.
reason: :class:`str`
The reason provided for the closure.
shard_id: Optional[:class:`int`]
The shard ID that got closed if applicable.
"""
def __init__(self, socket: ClientWebSocketResponse, *, shard_id: Optional[int], code: Optional[int] = None):
def __init__(self, socket: ClientWebSocketResponse, *, code: Optional[int] = None):
# This exception is just the same exception except
# reconfigured to subclass ClientException for users
self.code: int = code or socket.close_code or -1
# aiohttp doesn't seem to consistently provide close reason
self.reason: str = ''
self.shard_id: Optional[int] = shard_id
super().__init__(f'Shard ID {self.shard_id} WebSocket closed with {self.code}')
class PrivilegedIntentsRequired(ClientException):
"""Exception that's raised when the gateway is requesting privileged intents
but they're not ticked in the developer page yet.
Go to https://discord.com/developers/applications/ and enable the intents
that are required. Currently these are as follows:
- :attr:`Intents.members`
- :attr:`Intents.presences`
Attributes
-----------
shard_id: Optional[:class:`int`]
The shard ID that got closed if applicable.
"""
def __init__(self, shard_id: Optional[int]):
self.shard_id: Optional[int] = shard_id
msg = (
'Shard ID %s is requesting privileged intents that have not been explicitly enabled in the '
'developer portal. It is recommended to go to https://discord.com/developers/applications/ '
'and explicitly enable the privileged intents within your application\'s page. If this is not '
'possible, then consider disabling the privileged intents instead.'
)
super().__init__(msg % shard_id)
class InteractionResponded(ClientException):
"""Exception that's raised when sending another interaction response using
:class:`InteractionResponse` when one has already been done before.
An interaction can only respond once.
.. versionadded:: 2.0
Attributes
-----------
interaction: :class:`Interaction`
The interaction that's already been responded to.
"""
def __init__(self, interaction: Interaction):
self.interaction: Interaction = interaction
super().__init__('This interaction has already been responded to before')
super().__init__(f'WebSocket closed with {self.code}')

57
discord/ext/commands/bot.py

@ -60,7 +60,6 @@ __all__ = (
'when_mentioned',
'when_mentioned_or',
'Bot',
'AutoShardedBot',
)
MISSING: Any = discord.utils.MISSING
@ -68,10 +67,10 @@ MISSING: Any = discord.utils.MISSING
T = TypeVar('T')
CFT = TypeVar('CFT', bound='CoroFunc')
CXT = TypeVar('CXT', bound='Context')
BT = TypeVar('BT', bound='Union[Bot, AutoShardedBot]')
BT = TypeVar('BT', bound='Bot')
def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]:
def when_mentioned(bot: Bot, msg: Message) -> List[str]:
"""A callable that implements a command prefix equivalent to being mentioned.
These are meant to be passed into the :attr:`.Bot.command_prefix` attribute.
@ -80,7 +79,7 @@ def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]:
return [f'<@{bot.user.id}> ', f'<@!{bot.user.id}> '] # type: ignore
def when_mentioned_or(*prefixes: str) -> Callable[[Union[Bot, AutoShardedBot], Message], List[str]]:
def when_mentioned_or(*prefixes: str) -> Callable[[Bot, Message], List[str]]:
"""A callable that implements when mentioned or other prefixes provided.
These are meant to be passed into the :attr:`.Bot.command_prefix` attribute.
@ -148,11 +147,24 @@ class BotBase(GroupMixin):
self.strip_after_prefix = options.get('strip_after_prefix', False)
if self.owner_id and self.owner_ids:
raise TypeError('Both owner_id and owner_ids are set.')
raise TypeError('Both owner_id and owner_ids are set')
if self.owner_ids and not isinstance(self.owner_ids, collections.abc.Collection):
raise TypeError(f'owner_ids must be a collection not {self.owner_ids.__class__!r}')
self_bot = options.get('self_bot', False)
user_bot = options.get('user_bot', False)
if self_bot and user_bot:
raise TypeError('Both self_bot and user_bot are set')
if self_bot:
self._skip_check = lambda x, y: x != y
elif user_bot:
self._skip_check = lambda *_: False
else:
self._skip_check = lambda x, y: x == y
if help_command is _default:
self.help_command = DefaultHelpCommand()
else:
@ -340,37 +352,27 @@ class BotBase(GroupMixin):
Checks if a :class:`~discord.User` or :class:`~discord.Member` is the owner of
this bot.
If an :attr:`owner_id` is not set, it is fetched automatically
through the use of :meth:`~.Bot.application_info`.
.. versionchanged:: 1.3
The function also checks if the application is team-owned if
:attr:`owner_ids` is not set.
Parameters
-----------
user: :class:`.abc.User`
The user to check for.
Raises
-------
AttributeError
Owners aren't set.
Returns
--------
:class:`bool`
Whether the user is the owner.
"""
if self.owner_id:
return user.id == self.owner_id
elif self.owner_ids:
return user.id in self.owner_ids
else:
app = await self.application_info() # type: ignore
if app.team:
self.owner_ids = ids = {m.id for m in app.team.members}
return user.id in ids
else:
self.owner_id = owner_id = app.owner.id
return user.id == owner_id
raise AttributeError('Owners aren\'t set.')
def before_invoke(self, coro: CFT) -> CFT:
"""A decorator that registers a coroutine as a pre-invoke hook.
@ -992,7 +994,7 @@ class BotBase(GroupMixin):
view = StringView(message.content)
ctx = cls(prefix=None, view=view, bot=self, message=message)
if message.author.id == self.user.id: # type: ignore
if self._skip_check(message.author.id, self.user.id): # type: ignore
return ctx
prefix = await self.get_prefix(message)
@ -1148,8 +1150,7 @@ class Bot(BotBase, discord.Client):
information on implementing a help command, see :ref:`ext_commands_help_command`.
owner_id: Optional[:class:`int`]
The user ID that owns the bot. If this is not set and is then queried via
:meth:`.is_owner` then it is fetched automatically using
:meth:`~.Bot.application_info`.
:meth:`.is_owner` then it will error.
owner_ids: Optional[Collection[:class:`int`]]
The user IDs that owns the bot. This is similar to :attr:`owner_id`.
If this is not set and the application is team based, then it is
@ -1167,11 +1168,3 @@ class Bot(BotBase, discord.Client):
"""
pass
class AutoShardedBot(BotBase, discord.AutoShardedClient):
"""This is similar to :class:`.Bot` except that it is inherited from
:class:`discord.AutoShardedClient` instead.
"""
pass

17
discord/ext/commands/context.py

@ -43,10 +43,9 @@ if TYPE_CHECKING:
from discord.user import ClientUser, User
from discord.voice_client import VoiceProtocol
from .bot import Bot, AutoShardedBot
from .bot import Bot
from .cog import Cog
from .core import Command
from .help import HelpCommand
from .view import StringView
# fmt: off
@ -59,7 +58,7 @@ MISSING: Any = discord.utils.MISSING
T = TypeVar('T')
BotT = TypeVar('BotT', bound="Union[Bot, AutoShardedBot]")
BotT = TypeVar('BotT', bound="Bot")
CogT = TypeVar('CogT', bound="Cog")
if TYPE_CHECKING:
@ -405,3 +404,15 @@ class Context(discord.abc.Messageable, Generic[BotT]):
@discord.utils.copy_doc(Message.reply)
async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message:
return await self.message.reply(content, **kwargs)
@discord.utils.copy_doc(Message.message_commands)
def message_commands(
self,
query: Optional[str] = None,
*,
limit: Optional[int] = None,
command_ids: Optional[List[int]] = None,
applications: bool = True,
application: Optional[discord.abc.Snowflake] = None,
):
return self.message.message_commands(query, limit=limit, command_ids=command_ids, applications=applications, application=application)

6
discord/ext/commands/converter.py

@ -51,9 +51,9 @@ if TYPE_CHECKING:
from .context import Context
from discord.state import Channel
from discord.threads import Thread
from .bot import Bot, AutoShardedBot
from .bot import Bot
_Bot = Union[Bot, AutoShardedBot]
_Bot = Bot
__all__ = (
@ -206,7 +206,7 @@ class MemberConverter(IDConverter[discord.Member]):
return discord.utils.find(lambda m: m.name == argument or m.nick == argument, members)
async def query_member_by_id(self, bot, guild, user_id):
ws = bot._get_websocket(shard_id=guild.shard_id)
ws = bot.ws
cache = guild._state.member_cache_flags.joined
if ws.is_ratelimited():
# If we're being rate limited on the WS, then fall back to using the HTTP API

3
discord/ext/commands/help.py

@ -25,10 +25,9 @@ DEALINGS IN THE SOFTWARE.
import itertools
import copy
import functools
import inspect
import re
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
import discord.utils

11
discord/file.py

@ -62,6 +62,10 @@ class File:
The filename to display when uploading to Discord.
If this is not given then it defaults to ``fp.name`` or if ``fp`` is
a string then the ``filename`` will default to the string given.
description: Optional[:class:`str`]
The description (alt text) for the file.
.. versionadded:: 2.0
spoiler: :class:`bool`
Whether the attachment is a spoiler.
description: Optional[:class:`str`]
@ -91,10 +95,9 @@ class File:
self._original_pos = 0
self._owner = True
# aiohttp only uses two methods from IOBase
# read and close, since I want to control when the files
# close, I need to stub it so it doesn't close unless
# I tell it to
# aiohttp only uses two methods from IOBase (read and close)
# Since I want to control when the files close,
# I need to stub it so it doesn't close unless I tell it to
self._closer = self.fp.close
self.fp.close = lambda: None

639
discord/flags.py

@ -36,7 +36,6 @@ __all__ = (
'SystemChannelFlags',
'MessageFlags',
'PublicUserFlags',
'Intents',
'MemberCacheFlags',
'ApplicationFlags',
)
@ -94,7 +93,7 @@ def fill_with_flags(*, inverted: bool = False):
return decorator
# n.b. flags must inherit from this and use the decorator above
# Flags must inherit from this and use the decorator above
class BaseFlags:
VALID_FLAGS: ClassVar[Dict[str, int]]
DEFAULT_VALUE: ClassVar[int]
@ -186,7 +185,7 @@ class SystemChannelFlags(BaseFlags):
__slots__ = ()
# For some reason the flags for system channels are "inverted"
# ergo, if they're set then it means "suppress" (off in the GUI toggle)
# Ergo, if they're set then it means "suppress" (off in the GUI toggle)
# Since this is counter-intuitive from an API perspective and annoying
# these will be inverted automatically
@ -199,7 +198,7 @@ class SystemChannelFlags(BaseFlags):
elif toggle is False:
self.value |= o
else:
raise TypeError('Value to set for SystemChannelFlags must be a bool.')
raise TypeError('Value to set for SystemChannelFlags must be a bool')
@flag_value
def join_notifications(self):
@ -285,7 +284,7 @@ class MessageFlags(BaseFlags):
@flag_value
def urgent(self):
""":class:`bool`: Returns ``True`` if the source message is an urgent message.
""":class:`bool`: Returns ``True`` if the message is an urgent message.
An urgent message is one sent by Discord Trust and Safety.
"""
@ -293,7 +292,7 @@ class MessageFlags(BaseFlags):
@flag_value
def has_thread(self):
""":class:`bool`: Returns ``True`` if the source message is associated with a thread.
""":class:`bool`: Returns ``True`` if the message is associated with a thread.
.. versionadded:: 2.0
"""
@ -301,7 +300,7 @@ class MessageFlags(BaseFlags):
@flag_value
def ephemeral(self):
""":class:`bool`: Returns ``True`` if the source message is ephemeral.
""":class:`bool`: Returns ``True`` if the message is ephemeral.
.. versionadded:: 2.0
"""
@ -376,9 +375,20 @@ class PublicUserFlags(BaseFlags):
@flag_value
def bug_hunter(self):
""":class:`bool`: Returns ``True`` if the user is a Bug Hunter"""
""":class:`bool`: Returns ``True`` if the user is a level 1 Bug Hunter
There is an alias for this called :attr:`bug_hunter_level_1`.
"""
return UserFlags.bug_hunter.value
@alias_flag_value
def bug_hunter_level_1(self):
""":class:`bool`: Returns ``True`` if the user is a Bug Hunter
This is an alias of :attr:`bug_hunter`.
"""
return UserFlags.bug_hunter_level_1.value
@flag_value
def hypesquad_bravery(self):
""":class:`bool`: Returns ``True`` if the user is a HypeSquad Bravery member."""
@ -411,7 +421,7 @@ class PublicUserFlags(BaseFlags):
@flag_value
def bug_hunter_level_2(self):
""":class:`bool`: Returns ``True`` if the user is a Bug Hunter Level 2"""
""":class:`bool`: Returns ``True`` if the user is a level 2 Bug Hunter"""
return UserFlags.bug_hunter_level_2.value
@flag_value
@ -458,504 +468,70 @@ class PublicUserFlags(BaseFlags):
return UserFlags.spammer.value
def all(self) -> List[UserFlags]:
"""List[:class:`UserFlags`]: Returns all public flags the user has."""
"""List[:class:`UserFlags`]: Returns all flags the user has."""
return [public_flag for public_flag in UserFlags if self._has_flag(public_flag.value)]
@fill_with_flags()
class Intents(BaseFlags):
r"""Wraps up a Discord gateway intent flag.
Similar to :class:`Permissions`\, the properties provided are two way.
You can set and retrieve individual bits using the properties as if they
were regular bools.
To construct an object you can pass keyword arguments denoting the flags
to enable or disable.
This is used to disable certain gateway features that are unnecessary to
run your bot. To make use of this, it is passed to the ``intents`` keyword
argument of :class:`Client`.
.. versionadded:: 1.5
.. container:: operations
.. describe:: x == y
Checks if two flags are equal.
.. describe:: x != y
Checks if two flags are not equal.
.. describe:: hash(x)
Return the flag's hash.
.. describe:: iter(x)
Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs.
Attributes
-----------
value: :class:`int`
The raw value. You should query flags via the properties
rather than using this raw value.
"""
__slots__ = ()
def __init__(self, **kwargs: bool):
self.value = self.DEFAULT_VALUE
for key, value in kwargs.items():
if key not in self.VALID_FLAGS:
raise TypeError(f'{key!r} is not a valid flag name.')
setattr(self, key, value)
@classmethod
def all(cls: Type[Intents]) -> Intents:
"""A factory method that creates a :class:`Intents` with everything enabled."""
bits = max(cls.VALID_FLAGS.values()).bit_length()
value = (1 << bits) - 1
self = cls.__new__(cls)
self.value = value
return self
@classmethod
def none(cls: Type[Intents]) -> Intents:
"""A factory method that creates a :class:`Intents` with everything disabled."""
self = cls.__new__(cls)
self.value = self.DEFAULT_VALUE
return self
@classmethod
def default(cls: Type[Intents]) -> Intents:
"""A factory method that creates a :class:`Intents` with everything enabled
except :attr:`presences`, :attr:`members`, and :attr:`message_content`.
"""
self = cls.all()
self.presences = False
self.members = False
self.message_content = False
return self
@flag_value
def guilds(self):
""":class:`bool`: Whether guild related events are enabled.
This corresponds to the following events:
- :func:`on_guild_join`
- :func:`on_guild_remove`
- :func:`on_guild_available`
- :func:`on_guild_unavailable`
- :func:`on_guild_channel_update`
- :func:`on_guild_channel_create`
- :func:`on_guild_channel_delete`
- :func:`on_guild_channel_pins_update`
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`Client.guilds`
- :class:`Guild` and all its attributes.
- :meth:`Client.get_channel`
- :meth:`Client.get_all_channels`
It is highly advisable to leave this intent enabled for your bot to function.
"""
return 1 << 0
@flag_value
def members(self):
""":class:`bool`: Whether guild member related events are enabled.
This corresponds to the following events:
- :func:`on_member_join`
- :func:`on_member_remove`
- :func:`on_member_update`
- :func:`on_user_update`
This also corresponds to the following attributes and classes in terms of cache:
- :meth:`Client.get_all_members`
- :meth:`Client.get_user`
- :meth:`Guild.chunk`
- :meth:`Guild.fetch_members`
- :meth:`Guild.get_member`
- :attr:`Guild.members`
- :attr:`Member.roles`
- :attr:`Member.nick`
- :attr:`Member.premium_since`
- :attr:`User.name`
- :attr:`User.avatar`
- :attr:`User.discriminator`
For more information go to the :ref:`member intent documentation <need_members_intent>`.
class PrivateUserFlags(PublicUserFlags):
r"""Wraps up the Discord User flags.
.. note::
These are only available on your own user flags.
Currently, this requires opting in explicitly via the developer portal as well.
Bots in over 100 guilds will need to apply to Discord for verification.
"""
return 1 << 1
@flag_value
def bans(self):
""":class:`bool`: Whether guild ban related events are enabled.
This corresponds to the following events:
.. container:: operations
- :func:`on_member_ban`
- :func:`on_member_unban`
.. describe:: x == y
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 2
Checks if two UserFlags are equal.
.. describe:: x != y
@flag_value
def emojis(self):
""":class:`bool`: Alias of :attr:`.emojis_and_stickers`.
Checks if two UserFlags are not equal.
.. describe:: hash(x)
.. versionchanged:: 2.0
Changed to an alias.
"""
return 1 << 3
Return the flag's hash.
.. describe:: iter(x)
@alias_flag_value
def emojis_and_stickers(self):
""":class:`bool`: Whether guild emoji and sticker related events are enabled.
Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs.
Note that aliases are not shown.
.. versionadded:: 2.0
This corresponds to the following events:
- :func:`on_guild_emojis_update`
- :func:`on_guild_stickers_update`
This also corresponds to the following attributes and classes in terms of cache:
- :class:`Emoji`
- :class:`GuildSticker`
- :meth:`Client.get_emoji`
- :meth:`Client.get_sticker`
- :meth:`Client.emojis`
- :meth:`Client.stickers`
- :attr:`Guild.emojis`
- :attr:`Guild.stickers`
"""
return 1 << 3
@flag_value
def integrations(self):
""":class:`bool`: Whether guild integration related events are enabled.
This corresponds to the following events:
- :func:`on_guild_integrations_update`
- :func:`on_integration_create`
- :func:`on_integration_update`
- :func:`on_raw_integration_delete`
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 4
@flag_value
def webhooks(self):
""":class:`bool`: Whether guild webhook related events are enabled.
This corresponds to the following events:
- :func:`on_webhooks_update`
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 5
@flag_value
def invites(self):
""":class:`bool`: Whether guild invite related events are enabled.
This corresponds to the following events:
- :func:`on_invite_create`
- :func:`on_invite_delete`
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 6
@flag_value
def voice_states(self):
""":class:`bool`: Whether guild voice state related events are enabled.
This corresponds to the following events:
- :func:`on_voice_state_update`
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`VoiceChannel.members`
- :attr:`VoiceChannel.voice_states`
- :attr:`Member.voice`
.. note::
This intent is required to connect to voice.
"""
return 1 << 7
@flag_value
def presences(self):
""":class:`bool`: Whether guild presence related events are enabled.
This corresponds to the following events:
- :func:`on_presence_update`
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`Member.activities`
- :attr:`Member.status`
- :attr:`Member.raw_status`
For more information go to the :ref:`presence intent documentation <need_presence_intent>`.
.. note::
Currently, this requires opting in explicitly via the developer portal as well.
Bots in over 100 guilds will need to apply to Discord for verification.
"""
return 1 << 8
@alias_flag_value
def messages(self):
""":class:`bool`: Whether guild and direct message related events are enabled.
This is a shortcut to set or get both :attr:`guild_messages` and :attr:`dm_messages`.
This corresponds to the following events:
- :func:`on_message` (both guilds and DMs)
- :func:`on_message_edit` (both guilds and DMs)
- :func:`on_message_delete` (both guilds and DMs)
- :func:`on_raw_message_delete` (both guilds and DMs)
- :func:`on_raw_message_edit` (both guilds and DMs)
This also corresponds to the following attributes and classes in terms of cache:
- :class:`Message`
- :attr:`Client.cached_messages`
Note that due to an implicit relationship this also corresponds to the following events:
- :func:`on_reaction_add` (both guilds and DMs)
- :func:`on_reaction_remove` (both guilds and DMs)
- :func:`on_reaction_clear` (both guilds and DMs)
"""
return (1 << 9) | (1 << 12)
@flag_value
def guild_messages(self):
""":class:`bool`: Whether guild message related events are enabled.
See also :attr:`dm_messages` for DMs or :attr:`messages` for both.
This corresponds to the following events:
- :func:`on_message` (only for guilds)
- :func:`on_message_edit` (only for guilds)
- :func:`on_message_delete` (only for guilds)
- :func:`on_raw_message_delete` (only for guilds)
- :func:`on_raw_message_edit` (only for guilds)
This also corresponds to the following attributes and classes in terms of cache:
- :class:`Message`
- :attr:`Client.cached_messages` (only for guilds)
Note that due to an implicit relationship this also corresponds to the following events:
- :func:`on_reaction_add` (only for guilds)
- :func:`on_reaction_remove` (only for guilds)
- :func:`on_reaction_clear` (only for guilds)
"""
return 1 << 9
@flag_value
def dm_messages(self):
""":class:`bool`: Whether direct message related events are enabled.
See also :attr:`guild_messages` for guilds or :attr:`messages` for both.
This corresponds to the following events:
- :func:`on_message` (only for DMs)
- :func:`on_message_edit` (only for DMs)
- :func:`on_message_delete` (only for DMs)
- :func:`on_raw_message_delete` (only for DMs)
- :func:`on_raw_message_edit` (only for DMs)
This also corresponds to the following attributes and classes in terms of cache:
- :class:`Message`
- :attr:`Client.cached_messages` (only for DMs)
Note that due to an implicit relationship this also corresponds to the following events:
- :func:`on_reaction_add` (only for DMs)
- :func:`on_reaction_remove` (only for DMs)
- :func:`on_reaction_clear` (only for DMs)
"""
return 1 << 12
@alias_flag_value
def reactions(self):
""":class:`bool`: Whether guild and direct message reaction related events are enabled.
This is a shortcut to set or get both :attr:`guild_reactions` and :attr:`dm_reactions`.
This corresponds to the following events:
- :func:`on_reaction_add` (both guilds and DMs)
- :func:`on_reaction_remove` (both guilds and DMs)
- :func:`on_reaction_clear` (both guilds and DMs)
- :func:`on_raw_reaction_add` (both guilds and DMs)
- :func:`on_raw_reaction_remove` (both guilds and DMs)
- :func:`on_raw_reaction_clear` (both guilds and DMs)
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`Message.reactions` (both guild and DM messages)
Attributes
-----------
value: :class:`int`
The raw value. This value is a bit array field of a 53-bit integer
representing the currently available flags. You should query
flags via the properties rather than using this raw value.
"""
return (1 << 10) | (1 << 13)
@flag_value
def guild_reactions(self):
""":class:`bool`: Whether guild message reaction related events are enabled.
See also :attr:`dm_reactions` for DMs or :attr:`reactions` for both.
This corresponds to the following events:
- :func:`on_reaction_add` (only for guilds)
- :func:`on_reaction_remove` (only for guilds)
- :func:`on_reaction_clear` (only for guilds)
- :func:`on_raw_reaction_add` (only for guilds)
- :func:`on_raw_reaction_remove` (only for guilds)
- :func:`on_raw_reaction_clear` (only for guilds)
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`Message.reactions` (only for guild messages)
"""
return 1 << 10
__slots__ = ()
@flag_value
def dm_reactions(self):
""":class:`bool`: Whether direct message reaction related events are enabled.
See also :attr:`guild_reactions` for guilds or :attr:`reactions` for both.
This corresponds to the following events:
- :func:`on_reaction_add` (only for DMs)
- :func:`on_reaction_remove` (only for DMs)
- :func:`on_reaction_clear` (only for DMs)
- :func:`on_raw_reaction_add` (only for DMs)
- :func:`on_raw_reaction_remove` (only for DMs)
- :func:`on_raw_reaction_clear` (only for DMs)
This also corresponds to the following attributes and classes in terms of cache:
- :attr:`Message.reactions` (only for DM messages)
"""
return 1 << 13
@alias_flag_value
def typing(self):
""":class:`bool`: Whether guild and direct message typing related events are enabled.
This is a shortcut to set or get both :attr:`guild_typing` and :attr:`dm_typing`.
This corresponds to the following events:
- :func:`on_typing` (both guilds and DMs)
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return (1 << 11) | (1 << 14)
def premium_promo_dismissed(self):
""":class:`bool`: Returns ``True`` if the user has dismissed the premium promo."""
return UserFlags.premium_promo_dismissed.value
@flag_value
def guild_typing(self):
""":class:`bool`: Whether guild and direct message typing related events are enabled.
See also :attr:`dm_typing` for DMs or :attr:`typing` for both.
This corresponds to the following events:
- :func:`on_typing` (only for guilds)
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 11
def has_unread_urgent_messages(self):
""":class:`bool`: Returns ``True`` if the user has unread urgent system messages."""
return UserFlags.has_unread_urgent_messages.value
@flag_value
def dm_typing(self):
""":class:`bool`: Whether guild and direct message typing related events are enabled.
See also :attr:`guild_typing` for guilds or :attr:`typing` for both.
This corresponds to the following events:
- :func:`on_typing` (only for DMs)
This does not correspond to any attributes or classes in the library in terms of cache.
"""
return 1 << 14
def mfa_sms(self):
""":class:`bool`: Returns ``True`` if the user has SMS recovery for MFA enabled."""
return UserFlags.mfa_sms.value
@flag_value
def message_content(self):
""":class:`bool`: Whether message content, attachments, embeds and components will be available in messages
which do not meet the following criteria:
- The message was sent by the client
- The message was sent in direct messages
- The message mentions the client
This applies to the following events:
- :func:`on_message`
- :func:`on_message_edit`
- :func:`on_message_delete`
- :func:`on_raw_message_edit`
For more information go to the :ref:`message content intent documentation <need_message_content_intent>`.
.. note::
Currently, this requires opting in explicitly via the developer portal as well.
Bots in over 100 guilds will need to apply to Discord for verification.
.. versionadded:: 2.0
"""
return 1 << 15
def underage_deleted(self):
""":class:`bool`: Returns ``True`` if the user has been flagged for deletion for being underage."""
return UserFlags.underage_deleted.value
@flag_value
def guild_scheduled_events(self):
""":class:`bool`: Whether guild scheduled event related events are enabled.
This corresponds to the following events:
- :func:`on_scheduled_event_create`
- :func:`on_scheduled_event_update`
- :func:`on_scheduled_event_delete`
- :func:`on_scheduled_event_user_add`
- :func:`on_scheduled_event_user_remove`
.. versionadded:: 2.0
"""
return 1 << 16
def partner_or_verification_application(self):
""":class:`bool`: Returns ``True`` if the user has a partner or a verification application."""
return UserFlags.partner_or_verification_application.value
@fill_with_flags()
@ -966,11 +542,6 @@ class MemberCacheFlags(BaseFlags):
Note that the bot's own member is always cached. This class is passed
to the ``member_cache_flags`` parameter in :class:`Client`.
Due to a quirk in how Discord works, in order to ensure proper cleanup
of cache resources it is recommended to have :attr:`Intents.members`
enabled. Otherwise the library cannot know when a member leaves a guild and
is thus unable to cleanup after itself.
To construct an object you can pass keyword arguments denoting the flags
to enable or disable.
@ -1035,53 +606,29 @@ class MemberCacheFlags(BaseFlags):
def voice(self):
""":class:`bool`: Whether to cache members that are in voice.
This requires :attr:`Intents.voice_states`.
Members that leave voice are no longer cached.
"""
return 1
@flag_value
def joined(self):
""":class:`bool`: Whether to cache members that joined the guild
or are chunked as part of the initial log in flow.
def other(self):
""":class:`bool`: Whether to cache members that are collected from other means.
This requires :attr:`Intents.members`.
This does not apply to members explicitly cached (e.g. :attr:`Guild.chunk`, :attr:`Guild.fetch_members`).
Members that leave the guild are no longer cached.
There is an alias for this called :attr:`joined`.
"""
return 2
@classmethod
def from_intents(cls: Type[MemberCacheFlags], intents: Intents) -> MemberCacheFlags:
"""A factory method that creates a :class:`MemberCacheFlags` based on
the currently selected :class:`Intents`.
Parameters
------------
intents: :class:`Intents`
The intents to select from.
Returns
---------
:class:`MemberCacheFlags`
The resulting member cache flags.
"""
self = cls.none()
if intents.members:
self.joined = True
if intents.voice_states:
self.voice = True
return self
@alias_flag_value
def joined(self):
""":class:`bool`: Whether to cache members that are collected from other means.
def _verify_intents(self, intents: Intents):
if self.voice and not intents.voice_states:
raise ValueError('MemberCacheFlags.voice requires Intents.voice_states')
This does not apply to members explicitly cached (e.g. :attr:`Guild.chunk`, :attr:`Guild.fetch_members`).
if self.joined and not intents.members:
raise ValueError('MemberCacheFlags.joined requires Intents.members')
This is an alias for :attr:`other`.
"""
return 2
@property
def _voice_only(self):
@ -1125,27 +672,47 @@ class ApplicationFlags(BaseFlags):
"""
return 1 << 12
@alias_flag_value
def presence(self):
""":class:`bool`: Alias for :attr:`gateway_presence`."""
return 1 << 12
@flag_value
def gateway_presence_limited(self):
""":class:`bool`: Returns ``True`` if the application is allowed to receive limited
""":class:`bool`: Returns ``True`` if the application is allowed to receive
presence information over the gateway.
"""
return 1 << 13
@alias_flag_value
def presence_limited(self):
""":class:`bool`: Alias for :attr:`gateway_presence_limited`."""
return 1 << 13
@flag_value
def gateway_guild_members(self):
""":class:`bool`: Returns ``True`` if the application is verified and is allowed to
receive guild members information over the gateway.
receive full guild member lists.
"""
return 1 << 14
@alias_flag_value
def guild_members(self):
""":class:`bool`: Alias for :attr:`gateway_guild_members`."""
return 1 << 14
@flag_value
def gateway_guild_members_limited(self):
""":class:`bool`: Returns ``True`` if the application is allowed to receive limited
guild members information over the gateway.
""":class:`bool`: Returns ``True`` if the application is allowed to receive full
guild member lists.
"""
return 1 << 15
@alias_flag_value
def guild_members_limited(self):
""":class:`bool`: Alias for :attr:`gateway_guild_members_limited`."""
return 1 << 15
@flag_value
def verification_pending_guild_limit(self):
""":class:`bool`: Returns ``True`` if the application is currently pending verification
@ -1161,11 +728,31 @@ class ApplicationFlags(BaseFlags):
@flag_value
def gateway_message_content(self):
""":class:`bool`: Returns ``True`` if the application is verified and is allowed to
read message content in guilds."""
receive message content."""
return 1 << 18
@alias_flag_value
def message_content(self):
""":class:`bool`: Alias for :attr:`gateway_message_content`."""
return 1 << 18
@flag_value
def gateway_message_content_limited(self):
""":class:`bool`: Returns ``True`` if the application is unverified and is allowed to
""":class:`bool`: Returns ``True`` if the application is allowed to
read message content in guilds."""
return 1 << 19
@alias_flag_value
def message_content_limited(self):
""":class:`bool`: Alias for :attr:`gateway_message_content_limited`."""
return 1 << 19
@flag_value
def embedded_first_party(self):
""":class:`bool`: Returns ``True`` if the embedded application is published by Discord."""
return 1 << 20
@flag_value
def embedded_released(self):
""":class:`bool`: Returns ``True`` if the embedded application is released to the public."""
return 1 << 1

466
discord/gateway.py

@ -28,7 +28,6 @@ from collections import deque
import concurrent.futures
import logging
import struct
import sys
import time
import threading
import traceback
@ -42,6 +41,7 @@ from . import utils
from .activity import BaseActivity
from .enums import SpeakingState
from .errors import ConnectionClosed
from .recorder import SSRC
_log = logging.getLogger(__name__)
@ -61,11 +61,9 @@ if TYPE_CHECKING:
class ReconnectWebSocket(Exception):
"""Signals to safely reconnect the websocket."""
def __init__(self, shard_id, *, resume=True):
self.shard_id = shard_id
def __init__(self, *, resume: bool = True):
self.resume = resume
self.op = 'RESUME' if resume else 'IDENTIFY'
self.op: str = 'RESUME' if resume else 'IDENTIFY'
class WebSocketClosure(Exception):
@ -89,7 +87,6 @@ class GatewayRatelimiter:
self.window: float = 0.0
self.per: float = per
self.lock: asyncio.Lock = asyncio.Lock()
self.shard_id: Optional[int] = None
def is_ratelimited(self) -> bool:
current = time.time()
@ -119,71 +116,62 @@ class GatewayRatelimiter:
async with self.lock:
delta = self.get_delay()
if delta:
_log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delta)
_log.warning('Gateway is ratelimited, waiting %.2f seconds.', delta)
await asyncio.sleep(delta)
class KeepAliveHandler(threading.Thread):
def __init__(
self,
*args: Any,
ws: DiscordWebSocket,
interval: Optional[float] = None,
shard_id: Optional[int] = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome
def __init__(self, *, ws: DiscordWebSocket, interval: Optional[float] = None):
self.ws: DiscordWebSocket = ws
self._main_thread_id: int = ws.thread_id
self.interval: Optional[float] = interval
self.daemon: bool = True
self.shard_id: Optional[int] = shard_id
self.msg: str = 'Keeping shard ID %s websocket alive with sequence %s.'
self.block_msg: str = 'Shard ID %s heartbeat blocked for more than %s seconds.'
self.behind_msg: str = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.'
self._stop_ev: threading.Event = threading.Event()
self._last_ack: float = time.perf_counter()
self.heartbeat_timeout: float = self.ws._max_heartbeat_timeout
self.msg: str = 'Keeping websocket alive.'
self.block_msg: str = 'Heartbeat blocked for more than %s seconds.'
self.behind_msg: str = 'Can\'t keep up, websocket is %.1fs behind.'
self.not_responding_msg: str = 'Gateway has stopped responding. Closing and restarting.'
self.no_stop_msg: str = 'An error occurred while stopping the gateway. Ignoring.'
self._stop: asyncio.Event = asyncio.Event()
self._last_send: float = time.perf_counter()
self._last_recv: float = time.perf_counter()
self._last_ack: float = time.perf_counter()
self.latency: float = float('inf')
self.heartbeat_timeout: float = ws._max_heartbeat_timeout
def run(self) -> None:
while not self._stop_ev.wait(self.interval):
async def run(self) -> None:
while True:
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
except asyncio.TimeoutError:
pass
else:
return
if self._last_recv + self.heartbeat_timeout < time.perf_counter():
_log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id)
coro = self.ws.close(4000)
f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop)
_log.warning(self.not_responding_msg)
try:
f.result()
await self.ws.close(4000)
except Exception:
_log.exception('An error occurred while stopping the gateway. Ignoring.')
_log.exception(self.no_stop_msg)
finally:
self.stop()
return
data = self.get_payload()
_log.debug(self.msg, self.shard_id, data['d'])
coro = self.ws.send_heartbeat(data)
f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop)
_log.debug(self.msg)
try:
# block until sending is complete
total = 0
while True:
try:
f.result(10)
await asyncio.wait_for(self.ws.send_heartbeat(data), timeout=10)
break
except concurrent.futures.TimeoutError:
except asyncio.TimeoutError:
total += 10
try:
frame = sys._current_frames()[self._main_thread_id]
except KeyError:
msg = self.block_msg
else:
stack = ''.join(traceback.format_stack(frame))
msg = f'{self.block_msg}\nLoop thread traceback (most recent call last):\n{stack}'
_log.warning(msg, self.shard_id, total)
stack = ''.join(traceback.format_stack())
msg = f'{self.block_msg}\nLoop traceback (most recent call last):\n{stack}'
_log.warning(msg, total)
except Exception:
self.stop()
@ -196,8 +184,11 @@ class KeepAliveHandler(threading.Thread):
'd': self.ws.sequence,
}
def start(self) -> None:
self.ws.loop.create_task(self.run())
def stop(self) -> None:
self._stop_ev.set()
self._stop.set()
def tick(self) -> None:
self._last_recv = time.perf_counter()
@ -207,16 +198,18 @@ class KeepAliveHandler(threading.Thread):
self._last_ack = ack_time
self.latency = ack_time - self._last_send
if self.latency > 10:
_log.warning(self.behind_msg, self.shard_id, self.latency)
_log.warning(self.behind_msg, self.latency)
class VoiceKeepAliveHandler(KeepAliveHandler):
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.recent_ack_latencies: Deque[float] = deque(maxlen=20)
self.msg: str = 'Keeping shard ID %s voice websocket alive with timestamp %s.'
self.block_msg: str = 'Shard ID %s voice heartbeat blocked for more than %s seconds'
self.behind_msg: str = 'High socket latency, shard ID %s heartbeat is %.1fs behind'
self.recent_ack_latencies: deque[float] = deque(maxlen=20)
self.msg: str = 'Keeping voice websocket alive.'
self.block_msg: str = 'Voice heartbeat blocked for more than %s seconds'
self.behind_msg: str = 'High socket latency, heartbeat is %.1fs behind'
self.not_responding_msg: str = 'Voice gateway has stopped responding. Closing and restarting.'
self.no_stop_msg: str = 'An error occurred while stopping the voice gateway. Ignoring.'
def get_payload(self) -> Dict[str, Any]:
return {
@ -230,18 +223,15 @@ class VoiceKeepAliveHandler(KeepAliveHandler):
self._last_recv = ack_time
self.latency = ack_time - self._last_send
self.recent_ack_latencies.append(self.latency)
class DiscordClientWebSocketResponse(aiohttp.ClientWebSocketResponse):
async def close(self, *, code: int = 4000, message: bytes = b'') -> bool:
return await super().close(code=code, message=message)
if self.latency > 10:
_log.warning(self.behind_msg, self.latency)
DWS = TypeVar('DWS', bound='DiscordWebSocket')
class DiscordWebSocket:
"""Implements a WebSocket for Discord's gateway v10.
"""Implements a WebSocket for Discord's gateway v9.
Attributes
-----------
@ -263,7 +253,7 @@ class DiscordWebSocket:
RECONNECT
Receive only. Tells the client to reconnect to a new gateway.
REQUEST_MEMBERS
Send only. Asks for the full member list of a guild.
Send only. Asks for the guild members.
INVALIDATE_SESSION
Receive only. Tells the client to optionally invalidate the session
and IDENTIFY again.
@ -273,7 +263,13 @@ class DiscordWebSocket:
Receive only. Confirms receiving of a heartbeat. Not having it implies
a connection issue.
GUILD_SYNC
Send only. Requests a guild sync.
Send only. Requests a guild sync. This is unfortunately no longer functional.
CALL_CONNECT
Send only. Maybe used for calling? Probably just tracking.
GUILD_SUBSCRIBE
Send only. Subscribes you to guilds/guild members. Might respond with GUILD_MEMBER_LIST_UPDATE.
REQUEST_COMMANDS
Send only. Requests application commands from a guild. Responds with GUILD_APPLICATION_COMMANDS_UPDATE.
gateway
The gateway we are currently connected to.
token
@ -290,6 +286,9 @@ class DiscordWebSocket:
shard_count: Optional[int]
gateway: str
_max_heartbeat_timeout: float
_user_agent: str
_super_properties: Dict[str, Any]
_zlib_enabled: bool
# fmt: off
DISPATCH = 0
@ -304,18 +303,21 @@ class DiscordWebSocket:
INVALIDATE_SESSION = 9
HELLO = 10
HEARTBEAT_ACK = 11
GUILD_SYNC = 12
GUILD_SYNC = 12 # :(
CALL_CONNECT = 13
GUILD_SUBSCRIBE = 14
REQUEST_COMMANDS = 24
# fmt: on
def __init__(self, socket: aiohttp.ClientWebSocketResponse, *, loop: asyncio.AbstractEventLoop) -> None:
self.socket: aiohttp.ClientWebSocketResponse = socket
self.loop: asyncio.AbstractEventLoop = loop
# an empty dispatcher to prevent crashes
# An empty dispatcher to prevent crashes
self._dispatch: Callable[..., Any] = lambda *args: None
# generic event listeners
# Generic event listeners
self._dispatch_listeners: List[EventListener] = []
# the keep alive
# The keep alive
self._keep_alive: Optional[KeepAliveHandler] = None
self.thread_id: int = threading.get_ident()
@ -347,7 +349,6 @@ class DiscordWebSocket:
*,
initial: bool = False,
gateway: Optional[str] = None,
shard_id: Optional[int] = None,
session: Optional[str] = None,
sequence: Optional[int] = None,
resume: bool = False,
@ -360,7 +361,7 @@ class DiscordWebSocket:
socket = await client.http.ws_connect(gateway)
ws = cls(socket, loop=client.loop)
# dynamically add attributes needed
# Dynamically add attributes needed
ws.token = client.http.token
ws._connection = client._connection
ws._discord_parsers = client._connection.parsers
@ -368,12 +369,12 @@ class DiscordWebSocket:
ws.gateway = gateway
ws.call_hooks = client._connection.call_hooks
ws._initial_identify = initial
ws.shard_id = shard_id
ws._rate_limiter.shard_id = shard_id
ws.shard_count = client._connection.shard_count
ws.session_id = session
ws.sequence = sequence
ws._max_heartbeat_timeout = client._connection.heartbeat_timeout
ws._user_agent = client.http.user_agent
ws._super_properties = client.http.super_properties
ws._zlib_enabled = client.http.zlib
if client._enable_debug_events:
ws.send = ws.debug_send
@ -381,9 +382,9 @@ class DiscordWebSocket:
client._connection._update_references(ws)
_log.debug('Created websocket connected to %s', gateway)
_log.debug('Connected to %s.', gateway)
# poll event for OP Hello
# Poll for Hello
await ws.poll_event()
if not resume:
@ -404,7 +405,7 @@ class DiscordWebSocket:
Parameters
-----------
event: :class:`str`
The event name in all upper case to wait for.
The event to wait for.
predicate
A function that takes a data parameter to check for event
properties. The data parameter is the 'd' key in the JSON message.
@ -418,6 +419,7 @@ class DiscordWebSocket:
A future to wait for.
"""
event = event.upper()
future = self.loop.create_future()
entry = EventListener(event=event, predicate=predicate, result=result, future=future)
self._dispatch_listeners.append(entry)
@ -429,37 +431,30 @@ class DiscordWebSocket:
'op': self.IDENTIFY,
'd': {
'token': self.token,
'properties': {
'$os': sys.platform,
'$browser': 'discord.py',
'$device': 'discord.py',
'$referrer': '',
'$referring_domain': '',
'capabilities': 253,
'properties': self._super_properties,
'presence': {
'status': 'online',
'since': 0,
'activities': [],
'afk': False
},
'compress': True,
'large_threshold': 250,
'v': 3,
},
}
if self.shard_id is not None and self.shard_count is not None:
payload['d']['shard'] = [self.shard_id, self.shard_count]
state = self._connection
if state._activity is not None or state._status is not None:
payload['d']['presence'] = {
'status': state._status,
'game': state._activity,
'since': 0,
'afk': False,
'compress': False,
'client_state': {
'guild_hashes': {},
'highest_last_message_id': '0',
'read_state_version': 0,
'user_guild_settings_version': -1
}
}
}
if state._intents is not None:
payload['d']['intents'] = state._intents.value
if not self._zlib_enabled:
payload['d']['compress'] = True
await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify)
await self.call_hooks('before_identify', initial=self._initial_identify)
await self.send_as_json(payload)
_log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id)
_log.info('Gateway has sent the IDENTIFY payload.')
async def resume(self) -> None:
"""Sends the RESUME packet."""
@ -473,7 +468,7 @@ class DiscordWebSocket:
}
await self.send_as_json(payload)
_log.info('Shard ID %s has sent the RESUME payload.', self.shard_id)
_log.info('Gateway has sent the RESUME payload.')
async def received_message(self, msg: Any, /) -> None:
if type(msg) is bytes:
@ -488,7 +483,7 @@ class DiscordWebSocket:
self.log_receive(msg)
msg = utils._from_json(msg)
_log.debug('For Shard ID %s: WebSocket Event: %s', self.shard_id, msg)
_log.debug('Gateway event: %s.', msg)
event = msg.get('t')
if event:
self._dispatch('socket_event_type', event)
@ -504,12 +499,12 @@ class DiscordWebSocket:
if op != self.DISPATCH:
if op == self.RECONNECT:
# "reconnect" can only be handled by the Client
# RECONNECT can only be handled by the Client
# so we terminate our connection and raise an
# internal exception signalling to reconnect.
# internal exception signalling to reconnect
_log.debug('Received RECONNECT opcode.')
await self.close()
raise ReconnectWebSocket(self.shard_id)
raise ReconnectWebSocket
if op == self.HEARTBEAT_ACK:
if self._keep_alive:
@ -524,8 +519,8 @@ class DiscordWebSocket:
if op == self.HELLO:
interval = data['heartbeat_interval'] / 1000.0
self._keep_alive = KeepAliveHandler(ws=self, interval=interval, shard_id=self.shard_id)
# send a heartbeat immediately
self._keep_alive = KeepAliveHandler(ws=self, interval=interval)
# Send a heartbeat immediately
await self.send_as_json(self._keep_alive.get_payload())
self._keep_alive.start()
return
@ -533,13 +528,13 @@ class DiscordWebSocket:
if op == self.INVALIDATE_SESSION:
if data is True:
await self.close()
raise ReconnectWebSocket(self.shard_id)
raise ReconnectWebSocket
self.sequence = None
self.session_id = None
_log.info('Shard ID %s session has been invalidated.', self.shard_id)
_log.info('Gateway session has been invalidated.')
await self.close(code=1000)
raise ReconnectWebSocket(self.shard_id, resume=False)
raise ReconnectWebSocket(resume=False)
_log.warning('Unknown OP code %s.', op)
return
@ -548,34 +543,24 @@ class DiscordWebSocket:
self._trace = trace = data.get('_trace', [])
self.sequence = msg['s']
self.session_id = data['session_id']
# pass back shard ID to ready handler
data['__shard_id__'] = self.shard_id
_log.info(
'Shard ID %s has connected to Gateway: %s (Session ID: %s).',
self.shard_id,
', '.join(trace),
self.session_id,
)
_log.info('Connected to Gateway: %s (Session ID: %s).',
', '.join(trace), self.session_id)
await self.voice_state() # Initial OP 4
elif event == 'RESUMED':
self._trace = trace = data.get('_trace', [])
# pass back the shard ID to the resumed handler
data['__shard_id__'] = self.shard_id
_log.info(
'Shard ID %s has successfully RESUMED session %s under trace %s.',
self.shard_id,
self.session_id,
', '.join(trace),
)
_log.info('Gateway has successfully RESUMED session %s under trace %s.',
self.session_id, ', '.join(trace))
try:
func = self._discord_parsers[event]
except KeyError:
_log.debug('Unknown event %s.', event)
else:
_log.debug('Parsing event %s.', event)
func(data)
# remove the dispatched listeners
# Remove the dispatched listeners
removed = []
for index, entry in enumerate(self._dispatch_listeners):
if entry.event != event:
@ -625,10 +610,10 @@ class DiscordWebSocket:
elif msg.type is aiohttp.WSMsgType.BINARY:
await self.received_message(msg.data)
elif msg.type is aiohttp.WSMsgType.ERROR:
_log.debug('Received %s', msg)
_log.debug('Received %s.', msg)
raise msg.data
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSE):
_log.debug('Received %s', msg)
_log.debug('Received %s.', msg)
raise WebSocketClosure
except (asyncio.TimeoutError, WebSocketClosure) as e:
# Ensure the keep alive handler is closed
@ -638,15 +623,15 @@ class DiscordWebSocket:
if isinstance(e, asyncio.TimeoutError):
_log.info('Timed out receiving packet. Attempting a reconnect.')
raise ReconnectWebSocket(self.shard_id) from None
raise ReconnectWebSocket from None
code = self._close_code or self.socket.close_code
if self._can_handle_close():
_log.info('Websocket closed with %s, attempting a reconnect.', code)
raise ReconnectWebSocket(self.shard_id) from None
raise ReconnectWebSocket from None
else:
_log.info('Websocket closed with %s, cannot reconnect.', code)
raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None
raise ConnectionClosed(self.socket, code=code) from None
async def debug_send(self, data: str, /) -> None:
await self._rate_limiter.block()
@ -662,7 +647,7 @@ class DiscordWebSocket:
await self.send(utils._to_json(data))
except RuntimeError as exc:
if not self._can_handle_close():
raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc
raise ConnectionClosed(self.socket) from exc
async def send_heartbeat(self, data: Any) -> None:
# This bypasses the rate limit handling code since it has a higher priority
@ -670,19 +655,20 @@ class DiscordWebSocket:
await self.socket.send_str(utils._to_json(data))
except RuntimeError as exc:
if not self._can_handle_close():
raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc
raise ConnectionClosed(self.socket) from exc
async def change_presence(
self,
*,
activity: Optional[BaseActivity] = None,
activities: Optional[List[BaseActivity]] = None,
status: Optional[str] = None,
since: float = 0.0,
afk: bool = False
) -> None:
if activity is not None:
if not isinstance(activity, BaseActivity):
raise TypeError('activity must derive from BaseActivity.')
activities = [activity.to_dict()]
if activities is not None:
if not all(isinstance(activity, BaseActivity) for activity in activities):
raise TypeError('activity must derive from BaseActivity')
activities = [activity.to_dict() for activity in activities]
else:
activities = []
@ -693,52 +679,76 @@ class DiscordWebSocket:
'op': self.PRESENCE,
'd': {
'activities': activities,
'afk': False,
'afk': afk,
'since': since,
'status': status,
},
'status': str(status)
}
}
sent = utils._to_json(payload)
_log.debug('Sending "%s" to change status', sent)
_log.debug('Sending "%s" to change presence.', sent)
await self.send(sent)
async def request_lazy_guild(self, guild_id, *, typing=None, threads=None, activities=None, members=None, channels=None, thread_member_lists=None):
payload = {
'op': self.GUILD_SUBSCRIBE,
'd': {
'guild_id': str(guild_id),
}
}
data = payload['d']
if typing is not None:
data['typing'] = typing
if threads is not None:
data['threads'] = threads
if activities is not None:
data['activities'] = activities
if members is not None:
data['members'] = members
if channels is not None:
data['channels'] = channels
if thread_member_lists is not None:
data['thread_member_lists'] = thread_member_lists
_log.debug('Subscribing to guild %s with payload %s', guild_id, payload['d'])
await self.send_as_json(payload)
async def request_chunks(
self,
guild_id: int,
guild_ids: List[int],
query: Optional[str] = None,
*,
limit: int,
limit: Optional[int] = None,
user_ids: Optional[List[int]] = None,
presences: bool = False,
presences: bool = True,
nonce: Optional[str] = None,
) -> None:
payload = {
'op': self.REQUEST_MEMBERS,
'd': {
'guild_id': guild_id,
'presences': presences,
'guild_id': guild_ids,
'query': query,
'limit': limit,
},
'presences': presences,
'user_ids': user_ids,
}
}
if nonce:
payload['d']['nonce'] = nonce
if user_ids:
payload['d']['user_ids'] = user_ids
if query is not None:
payload['d']['query'] = query
await self.send_as_json(payload)
async def voice_state(
self,
guild_id: int,
channel_id: Optional[int],
guild_id: Optional[int] = None,
channel_id: Optional[int] = None,
self_mute: bool = False,
self_deaf: bool = False,
self_video: bool = False,
*,
preferred_region: Optional[str] = None,
) -> None:
payload = {
'op': self.VOICE_STATE,
@ -747,10 +757,63 @@ class DiscordWebSocket:
'channel_id': channel_id,
'self_mute': self_mute,
'self_deaf': self_deaf,
},
'self_video': self_video,
}
}
_log.debug('Updating our voice state to %s.', payload)
if preferred_region is not None:
payload['d']['preferred_region'] = preferred_region
_log.debug('Updating %s voice state to %s.', guild_id or 'client', payload)
await self.send_as_json(payload)
async def access_dm(self, channel_id: int):
payload = {
'op': self.CALL_CONNECT,
'd': {
'channel_id': str(channel_id)
}
}
_log.debug('Sending ACCESS_DM for channel %s.', channel_id)
await self.send_as_json(payload)
async def request_commands(
self,
guild_id: int,
type: int,
*,
nonce: Optional[str] = None,
limit: Optional[int] = None,
applications: Optional[bool] = None,
offset: int = 0,
query: Optional[str] = None,
command_ids: Optional[List[int]] = None,
application_id: Optional[int] = None,
) -> None:
payload = {
'op': self.REQUEST_COMMANDS,
'd': {
'guild_id': guild_id,
'type': type,
}
}
if nonce is not None:
payload['d']['nonce'] = nonce
if applications is not None:
payload['d']['applications'] = applications
if limit is not None and limit != 25:
payload['d']['limit'] = limit
if offset:
payload['d']['offset'] = offset
if query is not None:
payload['d']['query'] = query
if command_ids is not None:
payload['d']['command_ids'] = command_ids
if application_id is not None:
payload['d']['application_id'] = application_id
await self.send_as_json(payload)
async def close(self, code: int = 4000) -> None:
@ -778,10 +841,10 @@ class DiscordVoiceWebSocket:
Receive only. Tells the websocket that the initial connection has completed.
HEARTBEAT
Send only. Keeps your websocket connection alive.
SESSION_DESCRIPTION
SELECT_PROTOCOL_ACK
Receive only. Gives you the secret key required for voice.
SPEAKING
Send only. Notifies the client if you are currently speaking.
Send and receive. Notifies the client if anyone begins speaking.
HEARTBEAT_ACK
Receive only. Tells you your heartbeat has been acknowledged.
RESUME
@ -790,10 +853,8 @@ class DiscordVoiceWebSocket:
Receive only. Tells you that your websocket connection was acknowledged.
RESUMED
Sent only. Tells you that your RESUME request has succeeded.
CLIENT_CONNECT
Indicates a user has connected to voice.
CLIENT_DISCONNECT
Receive only. Indicates a user has disconnected from voice.
Receive only. Indicates a user has disconnected from voice.
"""
if TYPE_CHECKING:
@ -803,18 +864,21 @@ class DiscordVoiceWebSocket:
_max_heartbeat_timeout: float
# fmt: off
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SESSION_DESCRIPTION = 4
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
CLIENT_CONNECT = 12
CLIENT_DISCONNECT = 13
IDENTIFY = 0
SELECT_PROTOCOL = 1
READY = 2
HEARTBEAT = 3
SELECT_PROTOCOL_ACK = 4
SPEAKING = 5
HEARTBEAT_ACK = 6
RESUME = 7
HELLO = 8
RESUMED = 9
VIDEO = 12
CLIENT_DISCONNECT = 13
SESSION_UPDATE = 14
MEDIA_SINK_WANTS = 15
VOICE_BACKEND_VERSION = 16
# fmt: on
def __init__(
@ -836,7 +900,7 @@ class DiscordVoiceWebSocket:
pass
async def send_as_json(self, data: Any) -> None:
_log.debug('Sending voice websocket frame: %s.', data)
_log.debug('Voice gateway sending: %s.', data)
await self.ws.send_str(utils._to_json(data))
send_heartbeat = send_as_json
@ -871,7 +935,7 @@ class DiscordVoiceWebSocket:
"""Creates a voice websocket for the :class:`VoiceClient`."""
gateway = 'wss://' + client.endpoint + '/?v=4'
http = client._state.http
socket = await http.ws_connect(gateway, compress=15)
socket = await http.ws_connect(gateway, compress=15, host=client.endpoint)
ws = cls(socket, loop=client.loop, hook=hook)
ws.gateway = gateway
ws._connection = client
@ -922,7 +986,7 @@ class DiscordVoiceWebSocket:
await self.send_as_json(payload)
async def received_message(self, msg: Dict[str, Any]) -> None:
_log.debug('Voice websocket frame received: %s', msg)
_log.debug('Voice gateway event: %s.', msg)
op = msg['op']
data = msg.get('d')
@ -932,14 +996,26 @@ class DiscordVoiceWebSocket:
self._keep_alive.ack() # type: ignore - _keep_alive can't be None at this point
elif op == self.RESUMED:
_log.info('Voice RESUME succeeded.')
elif op == self.SESSION_DESCRIPTION:
# type-checker thinks data could be None
self._connection.mode = data['mode'] # type: ignore
await self.load_secret_key(data) # type: ignore
self.secret_key = self._connection.secret_key
elif op == self.SELECT_PROTOCOL_ACK:
self._connection.mode = data['mode'] # type: ignore - data can't be None at this point
await self.load_secret_key(data) # type: ignore - data can't be None at this point
elif op == self.HELLO:
interval = data['heartbeat_interval'] / 1000.0 # type: ignore - type-checker thinks data could be None
self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0))
self._keep_alive.start()
elif op == self.SPEAKING:
state = self._connection
user_id = int(data['user_id']) # type: ignore - data can't be None at this point
speaking = data['speaking'] # type: ignore - data can't be None at this point
ssrc = state._flip_ssrc(user_id)
if ssrc is None:
state._set_ssrc(user_id, SSRC(data['ssrc'], speaking)) # type: ignore - data can't be None at this point
else:
ssrc.speaking = speaking
#item = state.guild or state._state
#item._update_speaking_status(user_id, speaking)
await self._hook(self, msg)
@ -955,9 +1031,9 @@ class DiscordVoiceWebSocket:
struct.pack_into('>I', packet, 4, state.ssrc)
state.socket.sendto(packet, (state.endpoint_ip, state.voice_port))
recv = await self.loop.sock_recv(state.socket, 70)
_log.debug('received packet in initial_connection: %s', recv)
_log.debug('Received packet in initial_connection: %s.', recv)
# the ip is ascii starting at the 4th byte and ending at the first null
# The IP is ascii starting at the 4th byte and ending at the first null
ip_start = 4
ip_end = recv.index(0, ip_start)
state.endpoint_ip = recv[ip_start:ip_end].decode('ascii')
@ -965,13 +1041,13 @@ class DiscordVoiceWebSocket:
state.voice_port = struct.unpack_from('>H', recv, len(recv) - 2)[0]
_log.debug('detected ip: %s port: %s', state.endpoint_ip, state.voice_port)
# there *should* always be at least one supported mode (xsalsa20_poly1305)
# There *should* always be at least one supported mode (xsalsa20_poly1305)
modes = [mode for mode in data['modes'] if mode in self._connection.supported_modes]
_log.debug('received supported encryption modes: %s', ", ".join(modes))
_log.debug('Received supported encryption modes: %s.', ", ".join(modes))
mode = modes[0]
await self.select_protocol(state.endpoint_ip, state.voice_port, mode)
_log.info('selected the voice protocol for use (%s)', mode)
_log.info('Selected the voice protocol for use: %s.', mode)
@property
def latency(self) -> float:
@ -988,9 +1064,9 @@ class DiscordVoiceWebSocket:
return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies)
async def load_secret_key(self, data: Dict[str, Any]) -> None:
_log.info('received secret key for voice connection')
self.secret_key = self._connection.secret_key = data.get('secret_key') # type: ignore - type-checker thinks secret_key could be None
async def load_secret_key(self, data):
_log.info('Received secret key for voice connection.')
self.secret_key = self._connection.secret_key = data.get('secret_key')
await self.speak()
await self.speak(SpeakingState.none)
@ -1000,11 +1076,11 @@ class DiscordVoiceWebSocket:
if msg.type is aiohttp.WSMsgType.TEXT:
await self.received_message(utils._from_json(msg.data))
elif msg.type is aiohttp.WSMsgType.ERROR:
_log.debug('Received %s', msg)
raise ConnectionClosed(self.ws, shard_id=None) from msg.data
_log.debug('Voice received %s.', msg)
raise ConnectionClosed(self.ws) from msg.data
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING):
_log.debug('Received %s', msg)
raise ConnectionClosed(self.ws, shard_id=None, code=self._close_code)
_log.debug('Voice received %s.', msg)
raise ConnectionClosed(self.ws, code=self._close_code)
async def close(self, code: int = 1000) -> None:
if self._keep_alive is not None:

796
discord/guild.py

File diff suppressed because it is too large

102
discord/guild_folder.py

@ -0,0 +1,102 @@
"""
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 typing import List, Optional, TYPE_CHECKING
from .colour import Colour
from .object import Object
if TYPE_CHECKING:
from .guild import Guild
from .state import ConnectionState
from .types.snowflake import Snowflake
# fmt: off
__all__ = (
'GuildFolder',
)
# fmt: on
class GuildFolder:
"""Represents a guild folder
.. note::
Guilds not in folders *are* actually in folders API wise, with them being the only member.
Because Discord.
Attributes
----------
id: Union[:class:`str`, :class:`int`]
The ID of the folder.
name: :class:`str`
The name of the folder.
guilds: List[:class:`Guild`]
The guilds in the folder.
"""
__slots__ = ('_state', 'id', 'name', '_colour', 'guilds')
def __init__(self, *, data, state: ConnectionState) -> None:
self._state = state
self.id: Snowflake = data['id']
self.name: str = data['name']
self._colour: int = data['color']
self.guilds: List[Guild] = list(filter(None, map(self._get_guild, data['guild_ids']))) # type: ignore - Lying for better developer UX
def _get_guild(self, id):
return self._state._get_guild(int(id)) or Object(id=int(id))
@property
def colour(self) -> Optional[Colour]:
"""Optional[:class:`Colour`] The colour of the folder.
There is an alias for this called :attr:`color`.
"""
colour = self._colour
return Colour(colour) if colour is not None else None
@property
def color(self) -> Optional[Colour]:
"""Optional[:class:`Color`] The color of the folder.
This is an alias for :attr:`colour`.
"""
return self.colour
def __str__(self) -> str:
return self.name or 'None'
def __repr__(self) -> str:
return f'<GuildFolder id={self.id} name={self.name} guilds={self.guilds!r}>'
def __len__(self) -> int:
return len(self.name)
def __eq__(self, other) -> bool:
return isinstance(other, GuildFolder) and self.id == other.id
def __ne__(self, other) -> bool:
return not self.__eq__(other)

1199
discord/http.py

File diff suppressed because it is too large

1001
discord/interactions.py

File diff suppressed because it is too large

157
discord/invite.py

@ -26,12 +26,12 @@ from __future__ import annotations
from typing import List, Optional, Union, TYPE_CHECKING
from .asset import Asset
from .utils import parse_time, snowflake_time, _get_as_snowflake
from .utils import parse_time, snowflake_time, _get_as_snowflake, MISSING
from .object import Object
from .mixins import Hashable
from .enums import ChannelType, VerificationLevel, InviteTarget, try_enum
from .appinfo import PartialAppInfo
from .scheduled_event import ScheduledEvent
from .enums import ChannelType, VerificationLevel, InviteTarget, InviteType, try_enum
from .welcome_screen import WelcomeScreen
__all__ = (
'PartialInviteChannel',
@ -52,12 +52,14 @@ if TYPE_CHECKING:
)
from .state import ConnectionState
from .guild import Guild
from .abc import GuildChannel
from .abc import GuildChannel, PrivateChannel, Snowflake
from .user import User
from .abc import Snowflake
from .appinfo import PartialApplication
from .message import Message
from .channel import GroupChannel
InviteGuildType = Union[Guild, 'PartialInviteGuild', Object]
InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object]
InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object, PrivateChannel]
import datetime
@ -98,7 +100,14 @@ class PartialInviteChannel:
__slots__ = ('id', 'name', 'type')
def __init__(self, data: InviteChannelPayload):
def __new__(cls, data: Optional[InviteChannelPayload]):
if data is None:
return
return super().__new__(cls)
def __init__(self, data: Optional[InviteChannelPayload]):
if data is None:
return
self.id: int = int(data['id'])
self.name: str = data['name']
self.type: ChannelType = try_enum(ChannelType, data['type'])
@ -262,8 +271,13 @@ class Invite(Hashable):
A value of ``0`` indicates that it doesn't expire.
code: :class:`str`
The URL fragment used for the invite.
type: :class:`InviteType`
The type of invite.
.. versionadded:: 2.0
guild: Optional[Union[:class:`Guild`, :class:`Object`, :class:`PartialInviteGuild`]]
The guild the invite is for. Can be ``None`` if it's from a group direct message.
The guild the invite is for. Can be ``None`` if not a guild invite.
revoked: :class:`bool`
Indicates if the invite has been revoked.
created_at: :class:`datetime.datetime`
@ -289,8 +303,8 @@ class Invite(Hashable):
.. versionadded:: 2.0
channel: Union[:class:`abc.GuildChannel`, :class:`Object`, :class:`PartialInviteChannel`]
The channel the invite is for.
channel: Optional[Union[:class:`abc.GuildChannel`, :class:`Object`, :class:`PartialInviteChannel`]]
The channel the invite is for. Can be ``None`` if not a guild invite.
target_type: :class:`InviteTarget`
The type of target for the voice channel invite.
@ -301,7 +315,7 @@ class Invite(Hashable):
.. versionadded:: 2.0
target_application: Optional[:class:`PartialAppInfo`]
target_application: Optional[:class:`PartialApplication`]
The embedded application the invite targets, if any.
.. versionadded:: 2.0
@ -312,6 +326,10 @@ class Invite(Hashable):
scheduled_event_id: Optional[:class:`int`]
The ID of the scheduled event associated with this invite, if any.
.. versionadded:: 2.0
welcome_screen: Optional[:class:`WelcomeScreen`]
The guild's welcome screen, if available.
.. versionadded:: 2.0
"""
@ -335,6 +353,9 @@ class Invite(Hashable):
'expires_at',
'scheduled_event',
'scheduled_event_id',
'_message',
'welcome_screen',
'type',
)
BASE = 'https://discord.gg'
@ -346,8 +367,10 @@ class Invite(Hashable):
data: InvitePayload,
guild: Optional[Union[PartialInviteGuild, Guild]] = None,
channel: Optional[Union[PartialInviteChannel, GuildChannel]] = None,
welcome_screen: Optional[WelcomeScreen] = None,
):
self._state: ConnectionState = state
self.type: InviteType = try_enum(InviteType, data.get('type', 0))
self.max_age: Optional[int] = data.get('max_age')
self.code: str = data['code']
self.guild: Optional[InviteGuildType] = self._resolve_guild(data.get('guild'), guild)
@ -358,6 +381,7 @@ class Invite(Hashable):
self.max_uses: Optional[int] = data.get('max_uses')
self.approximate_presence_count: Optional[int] = data.get('approximate_presence_count')
self.approximate_member_count: Optional[int] = data.get('approximate_member_count')
self._message: Optional[Message] = data.get('message')
expires_at = data.get('expires_at', None)
self.expires_at: Optional[datetime.datetime] = parse_time(expires_at) if expires_at else None
@ -373,9 +397,12 @@ class Invite(Hashable):
self.target_type: InviteTarget = try_enum(InviteTarget, data.get("target_type", 0))
application = data.get('target_application')
self.target_application: Optional[PartialAppInfo] = (
PartialAppInfo(data=application, state=state) if application else None
)
if application is not None:
from .appinfo import PartialApplication
application = PartialApplication(data=application, state=state)
self.target_application: Optional[PartialApplication] = application
self.welcome_screen = welcome_screen
scheduled_event = data.get('guild_scheduled_event')
self.scheduled_event: Optional[ScheduledEvent] = (
@ -389,39 +416,41 @@ class Invite(Hashable):
self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None
@classmethod
def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self:
def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload, message: Optional[Message] = None) -> Self:
guild: Optional[Union[Guild, PartialInviteGuild]]
try:
guild_data = data['guild']
except KeyError:
# If we're here, then this is a group DM
guild = None
welcome_screen = None
else:
guild_id = int(guild_data['id'])
guild = state._get_guild(guild_id)
if guild is None:
# If it's not cached, then it has to be a partial guild
guild = PartialInviteGuild(state, guild_data, guild_id)
# As far as I know, invites always need a channel
# So this should never raise.
channel: Union[PartialInviteChannel, GuildChannel] = PartialInviteChannel(data['channel'])
if guild is not None and not isinstance(guild, PartialInviteGuild):
# Upgrade the partial data if applicable
channel = guild.get_channel(channel.id) or channel
welcome_screen = guild_data.get('welcome_screen')
if welcome_screen is not None:
welcome_screen = WelcomeScreen(data=welcome_screen, guild=guild)
return cls(state=state, data=data, guild=guild, channel=channel)
channel = PartialInviteChannel(data.get('channel'))
channel = state.get_channel(getattr(channel, 'id', None)) or channel
if message is not None:
data['message'] = message
return cls(state=state, data=data, guild=guild, channel=channel, welcome_screen=welcome_screen)
@classmethod
def from_gateway(cls, *, state: ConnectionState, data: GatewayInvitePayload) -> Self:
guild_id: Optional[int] = _get_as_snowflake(data, 'guild_id')
guild: Optional[Union[Guild, Object]] = state._get_guild(guild_id)
channel_id = int(data['channel_id'])
if guild is not None:
channel = guild.get_channel(channel_id) or Object(id=channel_id) # type: ignore
else:
guild = Object(id=guild_id) if guild_id is not None else None
channel = Object(id=channel_id)
channel_id = _get_as_snowflake(data, 'channel_id')
if guild_id is not None:
guild: Optional[Union[Guild, Object]] = state._get_guild(guild_id) or Object(id=guild_id)
if channel_id is not None:
channel: Optional[InviteChannelType] = state.get_channel(channel_id) or Object(id=channel_id) # type: ignore
return cls(state=state, data=data, guild=guild, channel=channel) # type: ignore
@ -457,8 +486,8 @@ class Invite(Hashable):
def __repr__(self) -> str:
return (
f'<Invite code={self.code!r} guild={self.guild!r} '
f'online={self.approximate_presence_count} '
f'<Invite code={self.code!r} type={self.type!r} '
f'guild={self.guild!r} '
f'members={self.approximate_member_count}>'
)
@ -501,6 +530,69 @@ class Invite(Hashable):
return self
async def use(self) -> Union[Guild, User, GroupChannel]:
"""|coro|
Uses the invite.
Either joins a guild, joins a group DM, or adds a friend.
There is an alias for this called :func:`accept`.
.. versionadded:: 1.9
Raises
------
:exc:`.HTTPException`
Using the invite failed.
Returns
-------
Union[:class:`Guild`, :class:`User`, :class:`GroupChannel`]
The guild/group DM joined, or user added as a friend.
"""
state = self._state
type = self.type
if (message := self._message):
kwargs = {'message': message}
else:
kwargs = {
'guild_id': getattr(self.guild, 'id', MISSING),
'channel_id': getattr(self.channel, 'id', MISSING),
'channel_type': getattr(self.channel, 'type', MISSING),
}
data = await state.http.accept_invite(self.code, type, **kwargs)
if type is InviteType.guild:
from .guild import Guild
return Guild(data=data['guild'], state=state)
elif type is InviteType.group_dm:
from .channel import GroupChannel
return GroupChannel(data=data['channel'], state=state, me=state.user) # type: ignore
else:
from .user import User
return User(data=data['inviter'], state=state)
async def accept(self) -> Union[Guild, User, GroupChannel]:
"""|coro|
Uses the invite.
Either joins a guild, joins a group DM, or adds a friend.
This is an alias of :func:`use`.
.. versionadded:: 1.9
Raises
------
:exc:`.HTTPException`
Using the invite failed.
Returns
-------
Union[:class:`Guild`, :class:`User`, :class:`GroupChannel`]
The guild/group DM joined, or user added as a friend.
"""
return await self.use()
async def delete(self, *, reason: Optional[str] = None):
"""|coro|
@ -522,5 +614,4 @@ class Invite(Hashable):
HTTPException
Revoking the invite failed.
"""
await self._state.http.delete_invite(self.code, reason=reason)

302
discord/iterators.py

@ -0,0 +1,302 @@
"""
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 asyncio
from typing import Awaitable, TYPE_CHECKING, TypeVar, Optional, Any, Callable, Union, List, Tuple, AsyncIterator, Dict
from .errors import InvalidData
from .utils import time_snowflake, utcnow
from .object import Object
from .commands import _command_factory
from .enums import AppCommandType
__all__ = (
'CommandIterator',
'FakeCommandIterator',
)
if TYPE_CHECKING:
from .user import User
from .message import Message
from .abc import Snowflake, Messageable
from .commands import ApplicationCommand
from .channel import DMChannel
T = TypeVar('T')
OT = TypeVar('OT')
_Func = Callable[[T], Union[OT, Awaitable[OT]]]
OLDEST_OBJECT = Object(id=0)
def _is_fake(item: Union[Messageable, Message]) -> bool: # I hate this too, but <circular imports> and performance exist
try:
item.guild # type: ignore
except AttributeError:
return True
try:
item.channel.me # type: ignore
except AttributeError:
return False
return True
class CommandIterator:
def __new__(cls, *args, **kwargs) -> Union[CommandIterator, FakeCommandIterator]:
if _is_fake(args[0]):
return FakeCommandIterator(*args)
else:
return super().__new__(cls)
def __init__(
self,
item: Union[Messageable, Message],
type: AppCommandType,
query: Optional[str] = None,
limit: Optional[int] = None,
command_ids: Optional[List[int]] = None,
**kwargs,
) -> None:
if query and command_ids:
raise TypeError('Cannot specify both query and command_ids')
if limit is not None and limit <= 0:
raise ValueError('limit must be > 0')
self.item = item
self.channel = None
self.state = item._state
self._tuple = None
self.type = type
_, self.cls = _command_factory(int(type))
self.query = query
self.limit = limit
self.command_ids = command_ids
self.applications: bool = kwargs.get('applications', True)
self.application: Snowflake = kwargs.get('application', None)
self.commands = asyncio.Queue()
async def _process_args(self) -> Tuple[DMChannel, Optional[str], Optional[Union[User, Message]]]:
item = self.item
if self.type is AppCommandType.user:
channel = await item._get_channel() # type: ignore
if getattr(item, 'bot', None):
item = item
else:
item = None
text = 'user'
elif self.type is AppCommandType.message:
message = self.item
channel = message.channel # type: ignore
text = 'message'
elif self.type is AppCommandType.chat_input:
channel = await item._get_channel() # type: ignore
item = None
text = None
self._process_kwargs(channel) # type: ignore
return channel, text, item # type: ignore
def _process_kwargs(self, channel) -> None:
kwargs = {
'guild_id': channel.guild.id,
'type': self.type.value,
'offset': 0,
}
if self.applications:
kwargs['applications'] = True # Only sent if it's True...
if (app := self.application):
kwargs['application'] = app.id
if (query := self.query) is not None:
kwargs['query'] = query
if (cmds := self.command_ids):
kwargs['command_ids'] = cmds
self.kwargs = kwargs
async def iterate(self) -> AsyncIterator[ApplicationCommand]:
while True:
if self.commands.empty():
await self.fill_commands()
try:
yield self.commands.get_nowait()
except asyncio.QueueEmpty:
break
def _get_retrieve(self):
l = self.limit
if l is None or l > 100:
r = 100
else:
r = l
self.retrieve = r
return r > 0
async def fill_commands(self) -> None:
if not self._tuple: # Do the required setup
self._tuple = await self._process_args()
if not self._get_retrieve():
return
state = self.state
kwargs = self.kwargs
retrieve = self.retrieve
nonce = str(time_snowflake(utcnow()))
def predicate(d):
return d.get('nonce') == nonce
data = None
for _ in range(3):
await state.ws.request_commands(**kwargs, limit=retrieve, nonce=nonce)
try:
data: Optional[Dict[str, Any]] = await asyncio.wait_for(state.ws.wait_for('guild_application_commands_update', predicate), timeout=3)
except asyncio.TimeoutError:
pass
if data is None:
raise InvalidData('Didn\'t receive a response from Discord')
cmds = data['application_commands']
if len(cmds) < retrieve:
self.limit = 0
elif self.limit is not None:
self.limit -= retrieve
kwargs['offset'] += retrieve
for cmd in cmds:
self.commands.put_nowait(self.create_command(cmd))
for app in data.get('applications', []):
...
def create_command(self, data) -> ApplicationCommand:
channel, item, value = self._tuple # type: ignore
if item is not None:
kwargs = {item: value}
else:
kwargs = {}
return self.cls(state=channel._state, data=data, channel=channel, **kwargs)
class FakeCommandIterator:
def __init__(
self,
item: Union[User, Message, DMChannel],
type: AppCommandType,
query: Optional[str] = None,
limit: Optional[int] = None,
command_ids: Optional[List[int]] = None,
) -> None:
if query and command_ids:
raise TypeError('Cannot specify both query and command_ids')
if limit is not None and limit <= 0:
raise ValueError('limit must be > 0')
self.item = item
self.channel = None
self._tuple = None
self.type = type
_, self.cls = _command_factory(int(type))
self.query = query
self.limit = limit
self.command_ids = command_ids
self.has_more = False
self.commands = asyncio.Queue()
async def _process_args(self) -> Tuple[DMChannel, Optional[str], Optional[Union[User, Message]]]:
item = self.item
if self.type is AppCommandType.user:
channel = await item._get_channel() # type: ignore
if getattr(item, 'bot', None):
item = item
else:
item = None
text = 'user'
elif self.type is AppCommandType.message:
message = self.item
channel = message.channel # type: ignore
text = 'message'
elif self.type is AppCommandType.chat_input:
channel = await item._get_channel() # type: ignore
item = None
text = None
if not channel.recipient.bot: # type: ignore - Type checker cannot understand this
raise TypeError('User is not a bot')
return channel, text, item # type: ignore
async def iterate(self) -> AsyncIterator[ApplicationCommand]:
while True:
if self.commands.empty():
await self.fill_commands()
try:
yield self.commands.get_nowait()
except asyncio.QueueEmpty:
break
async def fill_commands(self) -> None:
if self.has_more:
return
if not (stuff := self._tuple):
self._tuple = channel, _, _ = await self._process_args()
else:
channel = stuff[0]
limit = self.limit or -1
data = await channel._state.http.get_application_commands(channel.recipient.id)
ids = self.command_ids
query = self.query and self.query.lower()
type = self.type.value
for cmd in data:
if cmd['type'] != type:
continue
if ids:
if not int(cmd['id']) in ids:
continue
if query:
if not query in cmd['name'].lower():
continue
self.commands.put_nowait(self.create_command(cmd))
limit -= 1
if limit == 0:
break
self.has_more = True
def create_command(self, data) -> ApplicationCommand:
channel, item, value = self._tuple # type: ignore
if item is not None:
kwargs = {item: value}
else:
kwargs = {}
return self.cls(state=channel._state, data=data, channel=channel, **kwargs)

265
discord/member.py

@ -27,7 +27,6 @@ from __future__ import annotations
import datetime
import inspect
import itertools
import sys
from operator import attrgetter
from typing import Any, Dict, List, Literal, Optional, TYPE_CHECKING, Tuple, Union
@ -39,10 +38,11 @@ from .utils import MISSING
from .user import BaseUser, User, _UserTag
from .activity import create_activity, ActivityTypes
from .permissions import Permissions
from .enums import Status, try_enum
from .enums import AppCommandType, RelationshipAction, Status, try_enum
from .errors import ClientException
from .colour import Colour
from .object import Object
from .iterators import CommandIterator
__all__ = (
'VoiceState',
@ -53,7 +53,7 @@ if TYPE_CHECKING:
from typing_extensions import Self
from .asset import Asset
from .channel import DMChannel, VoiceChannel, StageChannel
from .channel import DMChannel, VoiceChannel, StageChannel, GroupChannel
from .flags import PublicUserFlags
from .guild import Guild
from .types.activity import (
@ -77,6 +77,7 @@ if TYPE_CHECKING:
)
VocalGuildChannel = Union[VoiceChannel, StageChannel]
ConnectableChannel = Union[VocalGuildChannel, DMChannel, GroupChannel]
class VoiceState:
@ -86,8 +87,12 @@ class VoiceState:
------------
deaf: :class:`bool`
Indicates if the user is currently deafened by the guild.
Doesn't apply to private channels.
mute: :class:`bool`
Indicates if the user is currently muted by the guild.
Doesn't apply to private channels.
self_mute: :class:`bool`
Indicates if the user is currently muted by their own accord.
self_deaf: :class:`bool`
@ -117,7 +122,7 @@ class VoiceState:
afk: :class:`bool`
Indicates if the user is currently in the AFK channel in the guild.
channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`]]
channel: Optional[Union[:class:`VoiceChannel`, :class:`StageChannel`, :class:`DMChannel`, :class:`GroupChannel`]]
The voice channel that the user is currently connected to. ``None`` if the user
is not currently in a voice channel.
"""
@ -152,7 +157,7 @@ class VoiceState:
self.deaf: bool = data.get('deaf', False)
self.suppress: bool = data.get('suppress', False)
self.requested_to_speak_at: Optional[datetime.datetime] = utils.parse_time(data.get('request_to_speak_timestamp'))
self.channel: Optional[VocalGuildChannel] = channel
self.channel: Optional[ConnectableChannel] = channel
def __repr__(self) -> str:
attrs = [
@ -168,10 +173,11 @@ class VoiceState:
class _ClientStatus:
__slots__ = ('_status', 'desktop', 'mobile', 'web')
__slots__ = ('_status', '_this', 'desktop', 'mobile', 'web')
def __init__(self):
self._status: str = 'offline'
self._this: str = 'offline'
self.desktop: Optional[str] = None
self.mobile: Optional[str] = None
@ -199,6 +205,7 @@ class _ClientStatus:
self = cls.__new__(cls) # bypass __init__
self._status = client_status._status
self._this = client_status._this
self.desktop = client_status.desktop
self.mobile = client_status.mobile
@ -209,26 +216,26 @@ class _ClientStatus:
def flatten_user(cls):
for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()):
# ignore private/special methods
if attr.startswith('_'):
continue
# Ignore private/special methods (or not)
# if attr.startswith('_'):
# continue
# don't override what we already have
# Don't override what we already have
if attr in cls.__dict__:
continue
# if it's a slotted attribute or a property, redirect it
# slotted members are implemented as member_descriptors in Type.__dict__
# If it's a slotted attribute or a property, redirect it
# Slotted members are implemented as member_descriptors in Type.__dict__
if not hasattr(value, '__annotations__'):
getter = attrgetter('_user.' + attr)
setattr(cls, attr, property(getter, doc=f'Equivalent to :attr:`User.{attr}`'))
else:
# Technically, this can also use attrgetter
# However I'm not sure how I feel about "functions" returning properties
# It probably breaks something in Sphinx.
# probably a member function by now
# Technically, this can also use attrgetter,
# however I'm not sure how I feel about "functions" returning properties
# It probably breaks something in Sphinx
# Probably a member function by now
def generate_function(x):
# We want sphinx to properly show coroutine functions as coroutines
# We want Sphinx to properly show coroutine functions as coroutines
if inspect.iscoroutinefunction(value):
async def general(self, *args, **kwargs): # type: ignore
@ -250,7 +257,7 @@ def flatten_user(cls):
@flatten_user
class Member(discord.abc.Messageable, _UserTag):
class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
"""Represents a Discord member to a :class:`Guild`.
This implements a lot of the functionality of :class:`User`.
@ -280,15 +287,6 @@ class Member(discord.abc.Messageable, _UserTag):
joined_at: Optional[:class:`datetime.datetime`]
An aware datetime object that specifies the date and time in UTC that the member joined the guild.
If the member left and rejoined the guild, this will be the latest date. In certain cases, this can be ``None``.
activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]
The activities that the user is currently doing.
.. note::
Due to a Discord API limitation, a user's Spotify activity may not appear
if they are listening to a song with a title longer
than 128 characters. See :issue:`1738` for more information.
guild: :class:`Guild`
The guild that the member belongs to.
nick: Optional[:class:`str`]
@ -311,16 +309,16 @@ class Member(discord.abc.Messageable, _UserTag):
'_roles',
'joined_at',
'premium_since',
'activities',
'_activities',
'guild',
'pending',
'nick',
'timed_out_until',
'_permissions',
'_client_status',
'_user',
'_state',
'_avatar',
'_communication_disabled_until',
)
if TYPE_CHECKING:
@ -348,16 +346,10 @@ class Member(discord.abc.Messageable, _UserTag):
self.premium_since: Optional[datetime.datetime] = utils.parse_time(data.get('premium_since'))
self._roles: utils.SnowflakeList = utils.SnowflakeList(map(int, data['roles']))
self._client_status: _ClientStatus = _ClientStatus()
self.activities: Tuple[ActivityTypes, ...] = tuple()
self._activities: Tuple[ActivityTypes, ...] = tuple()
self.nick: Optional[str] = data.get('nick', None)
self.pending: bool = data.get('pending', False)
self._avatar: Optional[str] = data.get('avatar')
self._permissions: Optional[int]
try:
self._permissions = int(data['permissions'])
except KeyError:
self._permissions = None
self.timed_out_until: Optional[datetime.datetime] = utils.parse_time(data.get('communication_disabled_until'))
def __str__(self) -> str:
@ -410,13 +402,12 @@ class Member(discord.abc.Messageable, _UserTag):
self._roles = utils.SnowflakeList(member._roles, is_sorted=True)
self.joined_at = member.joined_at
self.premium_since = member.premium_since
self._activities = member._activities
self._client_status = _ClientStatus._copy(member._client_status)
self.guild = member.guild
self.nick = member.nick
self.pending = member.pending
self.activities = member.activities
self.timed_out_until = member.timed_out_until
self._permissions = member._permissions
self._state = member._state
self._avatar = member._avatar
@ -425,13 +416,11 @@ class Member(discord.abc.Messageable, _UserTag):
self._user = member._user
return self
async def _get_channel(self):
ch = await self.create_dm()
return ch
def _update(self, data: GuildMemberUpdateEvent) -> Optional[Member]:
old = Member._copy(self)
def _update(self, data: GuildMemberUpdateEvent) -> None:
# the nickname change is optional,
# if it isn't in the payload then it didn't change
# Some changes are optional
# If they aren't in the payload then they didn't change
try:
self.nick = data['nick']
except KeyError:
@ -447,13 +436,20 @@ class Member(discord.abc.Messageable, _UserTag):
self._roles = utils.SnowflakeList(map(int, data['roles']))
self._avatar = data.get('avatar')
attrs = {'joined_at', 'premium_since', '_roles', '_avatar', 'timed_out_until', 'nick', 'pending'}
if any(getattr(self, attr) != getattr(old, attr) for attr in attrs):
return old
def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> Optional[Tuple[User, User]]:
self.activities = tuple(map(create_activity, data['activities']))
if self._self:
return
self._activities = tuple(map(create_activity, data['activities']))
self._client_status._update(data['status'], data['client_status'])
if len(user) > 1:
return self._update_inner_user(user)
return None
def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]:
u = self._user
@ -463,13 +459,14 @@ class Member(discord.abc.Messageable, _UserTag):
if original != modified:
to_return = User._copy(self._user)
u.name, u._avatar, u.discriminator, u._public_flags = modified
# Signal to dispatch on_user_update
# Signal to dispatch user_update
return to_return, u
@property
def status(self) -> Status:
""":class:`Status`: The member's overall status. If the value is unknown, then it will be a :class:`str` instead."""
return try_enum(Status, self._client_status._status)
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status._status)
@property
def raw_status(self) -> str:
@ -477,31 +474,37 @@ class Member(discord.abc.Messageable, _UserTag):
.. versionadded:: 1.5
"""
return self._client_status._status
client_status = self._client_status if not self._self else self._state.client._client_status
return client_status._status
@status.setter
def status(self, value: Status) -> None:
# internal use only
self._client_status._status = str(value)
# Internal use only
client_status = self._client_status if not self._self else self._state.client._client_status
client_status._status = str(value)
@property
def mobile_status(self) -> Status:
""":class:`Status`: The member's status on a mobile device, if applicable."""
return try_enum(Status, self._client_status.mobile or 'offline')
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.mobile or 'offline')
@property
def desktop_status(self) -> Status:
""":class:`Status`: The member's status on the desktop client, if applicable."""
return try_enum(Status, self._client_status.desktop or 'offline')
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.desktop or 'offline')
@property
def web_status(self) -> Status:
""":class:`Status`: The member's status on the web client, if applicable."""
return try_enum(Status, self._client_status.web or 'offline')
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.web or 'offline')
def is_on_mobile(self) -> bool:
""":class:`bool`: A helper function that determines if a member is active on a mobile device."""
return self._client_status.mobile is not None
client_status = self._client_status if not self._self else self._state.client._client_status
return client_status.mobile is not None
@property
def colour(self) -> Colour:
@ -512,11 +515,9 @@ class Member(discord.abc.Messageable, _UserTag):
There is an alias for this named :attr:`color`.
"""
roles = self.roles[1:] # remove @everyone
roles = self.roles[1:] # Remove @everyone
# highest order of the colour is the one that gets rendered.
# if the highest is the default colour then the next one with a colour
# is chosen instead
# Highest role with a colour is the one that's rendered
for role in reversed(roles):
if role.colour.value:
return role.colour
@ -590,6 +591,22 @@ class Member(discord.abc.Messageable, _UserTag):
return None
return Asset._from_guild_avatar(self._state, self.guild.id, self.id, self._avatar)
@property
def activities(self) -> Tuple[ActivityTypes, ...]:
"""Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities that
the user is currently doing.
.. note::
Due to a Discord API limitation, a user's Spotify activity may not appear
if they are listening to a song with a title longer
than 128 characters. See :issue:`1738` for more information.
"""
if self._self:
return self._state.client.activities
return self._activities
@property
def activity(self) -> Optional[ActivityTypes]:
"""Optional[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the primary
@ -667,27 +684,15 @@ class Member(discord.abc.Messageable, _UserTag):
return base
@property
def resolved_permissions(self) -> Optional[Permissions]:
"""Optional[:class:`Permissions`]: Returns the member's resolved permissions
from an interaction.
This is only available in interaction contexts and represents the resolved
permissions of the member in the channel the interaction was executed in.
This is more or less equivalent to calling :meth:`abc.GuildChannel.permissions_for`
but stored and returned as an attribute by the Discord API rather than computed.
.. versionadded:: 2.0
"""
if self._permissions is None:
return None
return Permissions(self._permissions)
@property
def voice(self) -> Optional[VoiceState]:
"""Optional[:class:`VoiceState`]: Returns the member's current voice state."""
return self.guild._voice_state_for(self._user.id)
@property
def _self(self) -> bool:
return self._user.id == self._state.self_id
async def ban(
self,
*,
@ -724,6 +729,7 @@ class Member(discord.abc.Messageable, _UserTag):
roles: List[discord.abc.Snowflake] = MISSING,
voice_channel: Optional[VocalGuildChannel] = MISSING,
timed_out_until: Optional[datetime.datetime] = MISSING,
avatar: Optional[bytes] = MISSING,
reason: Optional[str] = None,
) -> Optional[Member]:
"""|coro|
@ -750,6 +756,13 @@ class Member(discord.abc.Messageable, _UserTag):
All parameters are optional.
.. note::
To upload an avatar, a :term:`py:bytes-like object` must be passed in that
represents the image being uploaded. If this is done through a file
then the file must be opened via ``open('some_filename', 'rb')`` and
the :term:`py:bytes-like object` is given through the use of ``fp.read()``.
.. versionchanged:: 1.1
Can now pass ``None`` to ``voice_channel`` to kick a member from voice.
@ -768,7 +781,6 @@ class Member(discord.abc.Messageable, _UserTag):
Indicates if the member should be suppressed in stage channels.
.. versionadded:: 1.7
roles: List[:class:`Role`]
The member's new list of roles. This *replaces* the roles.
voice_channel: Optional[:class:`VoiceChannel`]
@ -779,7 +791,11 @@ class Member(discord.abc.Messageable, _UserTag):
This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow`.
.. versionadded:: 2.0
avatar: Optional[:class:`bytes`]
The member's new guild avatar. Pass ``None`` to remove the avatar.
You can only change your own guild avatar.
.. versionadded:: 2.0
reason: Optional[:class:`str`]
The reason for editing this member. Shows up on the audit log.
@ -802,13 +818,17 @@ class Member(discord.abc.Messageable, _UserTag):
guild_id = self.guild.id
me = self._state.self_id == self.id
payload: Dict[str, Any] = {}
data = None
if nick is not MISSING:
nick = nick or ''
if me:
await http.change_my_nickname(guild_id, nick, reason=reason)
else:
payload['nick'] = nick
payload['nick'] = nick
if avatar is not MISSING:
payload['avatar'] = utils._bytes_to_base64_data(avatar) if avatar is not None else None
if me and payload:
data = await http.edit_me(**payload)
payload = {}
if deafen is not MISSING:
payload['deaf'] = deafen
@ -852,7 +872,9 @@ class Member(discord.abc.Messageable, _UserTag):
if payload:
data = await http.edit_member(guild_id, self.id, reason=reason, **payload)
return Member(data=data, guild=self.guild, state=self._state)
if data:
return Member(data=data, guild=self.guild, state=self._state) # type: ignore
async def request_to_speak(self) -> None:
"""|coro|
@ -1028,3 +1050,82 @@ class Member(discord.abc.Messageable, _UserTag):
if self.timed_out_until is not None:
return utils.utcnow() < self.timed_out_until
return False
async def send_friend_request(self) -> None: # TODO: check if the req returns a relationship obj
"""|coro|
Sends the member a friend request.
Raises
-------
Forbidden
Not allowed to send a friend request to the member.
HTTPException
Sending the friend request failed.
"""
await self._state.http.add_relationship(self._user.id, action=RelationshipAction.send_friend_request)
def user_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 user commands are available to use.
Examples
---------
Usage ::
async for command in member.user_commands():
print(command.name)
Flattening into a list ::
commands = await member.user_commands().flatten()
# commands is now a list of UserCommand...
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 limit was not > 0.
Both query and command_ids were passed.
HTTPException
Getting the commands failed.
Yields
-------
:class:`.UserCommand`
A user command.
"""
iterator = CommandIterator(
self,
AppCommandType.user,
query,
limit,
command_ids,
applications=applications,
application=application,
)
return iterator.iterate()

4
discord/mentions.py

@ -137,9 +137,9 @@ class AllowedMentions:
return data # type: ignore
def merge(self, other: AllowedMentions) -> AllowedMentions:
# Creates a new AllowedMentions by merging from another one.
# Creates a new AllowedMentions by merging from another one
# Merge is done by using the 'self' values unless explicitly
# overridden by the 'other' values.
# overridden by the 'other' values
everyone = self.everyone if other.everyone is default else other.everyone
users = self.users if other.users is default else other.users
roles = self.roles if other.roles is default else other.roles

218
discord/message.py

@ -47,7 +47,8 @@ from . import utils
from .reaction import Reaction
from .emoji import Emoji
from .partial_emoji import PartialEmoji
from .enums import MessageType, ChannelType, try_enum
from .calls import CallMessage
from .enums import MessageType, ChannelType, AppCommandType, try_enum
from .errors import HTTPException
from .components import _component_factory
from .embeds import Embed
@ -61,6 +62,8 @@ from .mixins import Hashable
from .sticker import StickerItem
from .threads import Thread
from .channel import PartialMessageable
from .iterators import CommandIterator
from .interactions import Interaction
if TYPE_CHECKING:
from typing_extensions import Self
@ -91,7 +94,6 @@ if TYPE_CHECKING:
from .mentions import AllowedMentions
from .user import User
from .role import Role
from .ui.view import View
EmojiInputType = Union[Emoji, PartialEmoji, str]
@ -113,8 +115,8 @@ def convert_emoji_reaction(emoji):
if isinstance(emoji, PartialEmoji):
return emoji._as_reaction()
if isinstance(emoji, str):
# Reactions can be in :name:id format, but not <:name:id>.
# No existing emojis have <> in them, so this should be okay.
# Reactions can be in :name:id format, but not <:name:id>
# Emojis can't have <> in them, so this should be okay
return emoji.strip('<>')
raise TypeError(f'emoji argument must be str, Emoji, or Reaction not {emoji.__class__.__name__}.')
@ -382,8 +384,8 @@ class DeletedReferencedMessage:
@property
def id(self) -> int:
""":class:`int`: The message ID of the deleted referenced message."""
# the parent's message id won't be None here
return self._parent.message_id # type: ignore
# The parent's message id won't be None here
return self._parent.message_id # type: ignore
@property
def channel_id(self) -> int:
@ -505,7 +507,7 @@ class MessageReference:
result['guild_id'] = self.guild_id
if self.fail_if_not_exists is not None:
result['fail_if_not_exists'] = self.fail_if_not_exists
return result # type: ignore - Type checker doesn't understand these are the same.
return result # type: ignore - Type checker doesn't understand these are the same
to_message_reference_dict = to_dict
@ -518,7 +520,7 @@ def flatten_handlers(cls):
if key.startswith('_handle_') and key != '_handle_member'
]
# store _handle_member last
# Store _handle_member last
handlers.append(('member', cls._handle_member))
cls._HANDLERS = handlers
cls._CACHED_SLOTS = [attr for attr in cls.__slots__ if attr.startswith('_cs_')]
@ -558,13 +560,16 @@ class Message(Hashable):
content: :class:`str`
The actual contents of the message.
nonce: Optional[Union[:class:`str`, :class:`int`]]
The value used by the discord guild and the client to verify that the message is successfully sent.
The value used by Discord clients to verify that the message is successfully sent.
This is not stored long term within Discord's servers and is only used ephemerally.
embeds: List[:class:`Embed`]
A list of embeds the message has.
channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`]
channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`]
The :class:`TextChannel` or :class:`Thread` that the message was sent from.
Could be a :class:`DMChannel` or :class:`GroupChannel` if it's a private message.
call: Optional[:class:`CallMessage`]
The call that the message refers to. This is only applicable to messages of type
:attr:`MessageType.call`.
reference: Optional[:class:`~discord.MessageReference`]
The message that this message references. This is only applicable to messages of
type :attr:`MessageType.pins_add`, crossposted messages created by a
@ -640,6 +645,14 @@ class Message(Hashable):
.. versionadded:: 2.0
guild: Optional[:class:`Guild`]
The guild that the message belongs to, if applicable.
application_id: Optional[:class:`int`]
The application that sent this message, if applicable.
.. versionadded:: 2.0
interaction: Optional[:class:`Interaction`]
The interaction the message is replying to, if applicable.
.. versionadded:: 2.0
"""
__slots__ = (
@ -655,6 +668,7 @@ class Message(Hashable):
'content',
'channel',
'webhook_id',
'application_id',
'mention_everyone',
'embeds',
'id',
@ -673,6 +687,8 @@ class Message(Hashable):
'stickers',
'components',
'guild',
'call',
'interaction',
)
if TYPE_CHECKING:
@ -694,6 +710,7 @@ class Message(Hashable):
self._state: ConnectionState = state
self.id: int = int(data['id'])
self.webhook_id: Optional[int] = utils._get_as_snowflake(data, 'webhook_id')
self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id')
self.reactions: List[Reaction] = [Reaction(message=self, data=d) for d in data.get('reactions', [])]
self.attachments: List[Attachment] = [Attachment(data=a, state=self._state) for a in data['attachments']]
self.embeds: List[Embed] = [Embed.from_dict(a) for a in data['embeds']]
@ -709,10 +726,12 @@ class Message(Hashable):
self.content: str = data['content']
self.nonce: Optional[Union[int, str]] = data.get('nonce')
self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])]
self.components: List[Component] = [_component_factory(d) for d in data.get('components', [])]
self.components: List[Component] = [_component_factory(d, self) for d in data.get('components', [])]
self.call: Optional[CallMessage] = None
self.interaction: Optional[Interaction] = None
try:
# if the channel doesn't have a guild attribute, we handle that
# If the channel doesn't have a guild attribute, we handle that
self.guild = channel.guild # type: ignore
except AttributeError:
self.guild = state._get_guild(utils._get_as_snowflake(data, 'guild_id'))
@ -731,7 +750,7 @@ class Message(Hashable):
if resolved is None:
ref.resolved = DeletedReferencedMessage(ref)
else:
# Right now the channel IDs match but maybe in the future they won't.
# Right now the channel IDs match but maybe in the future they won't
if ref.channel_id == channel.id:
chan = channel
elif isinstance(channel, Thread) and channel.parent_id == ref.channel_id:
@ -739,10 +758,10 @@ class Message(Hashable):
else:
chan, _ = state._get_guild_channel(resolved, ref.guild_id)
# the channel will be the correct type here
# The channel will be the correct type here
ref.resolved = self.__class__(channel=chan, data=resolved, state=state) # type: ignore
for handler in ('author', 'member', 'mentions', 'mention_roles'):
for handler in ('author', 'member', 'mentions', 'mention_roles', 'call', 'interaction'):
try:
getattr(self, f'_handle_{handler}')(data[handler])
except KeyError:
@ -913,8 +932,28 @@ class Message(Hashable):
if role is not None:
self.role_mentions.append(role)
def _handle_call(self, call) -> None:
if call is None or self.type is not MessageType.call:
self.call = None
return
participants = []
for uid in map(int, call.get('participants', [])):
if uid == self.author.id:
participants.append(self.author)
else:
user = utils.find(lambda u: u.id == uid, self.mentions)
if user is not None:
participants.append(user)
call['participants'] = participants
self.call = CallMessage(message=self, **call)
def _handle_components(self, components: List[ComponentPayload]):
self.components = [_component_factory(d) for d in components]
self.components = [_component_factory(d, self) for d in components]
def _handle_interaction(self, interaction: Dict[str, Any]):
self.interaction = Interaction._from_message(self, **interaction)
def _rebind_cached_references(self, new_guild: Guild, new_channel: Union[TextChannel, Thread]) -> None:
self.guild = new_guild
@ -979,7 +1018,7 @@ class Message(Hashable):
for member in self.mentions
}
# add the <@!user_id> cases as well..
# Add the <@!user_id> cases as well..
second_mention_transforms = {
re.escape(f'<@!{member.id}>'): '@' + member.display_name
for member in self.mentions
@ -1031,7 +1070,8 @@ class Message(Hashable):
return self.type not in (
MessageType.default,
MessageType.reply,
MessageType.application_command,
MessageType.chat_input_command,
MessageType.context_menu_command,
MessageType.thread_starter_message,
)
@ -1045,7 +1085,12 @@ class Message(Hashable):
returns an English message denoting the contents of the system message.
"""
if self.type is MessageType.default:
if self.type in {
MessageType.default,
MessageType.reply,
MessageType.chat_input_command,
MessageType.context_menu_command,
}:
return self.content
if self.type is MessageType.recipient_add:
@ -1089,6 +1134,16 @@ class Message(Hashable):
created_at_ms = int(self.created_at.timestamp() * 1000)
return formats[created_at_ms % len(formats)].format(self.author.name)
if self.type is MessageType.call:
call_ended = self.call.ended_timestamp is not None # type: ignore
if self.channel.me in self.call.participants: # type: ignore
return f'{self.author.name} started a call.'
elif call_ended:
return f'You missed a call from {self.author.name}'
else:
return f'{self.author.name} started a call \N{EM DASH} Join the call.'
if self.type is MessageType.premium_guild_subscription:
if not self.content:
return f'{self.author.name} just boosted the server!'
@ -1114,10 +1169,10 @@ class Message(Hashable):
return f'{self.author.name} just boosted the server **{self.content}** times! {self.guild} has achieved **Level 3!**'
if self.type is MessageType.channel_follow_add:
return f'{self.author.name} has added {self.content} to this channel'
return f'{self.author.name} has added **{self.content}** to this channel. Its most important updates will show up here.'
if self.type is MessageType.guild_stream:
# the author will be a Member
# The author will be a Member
return f'{self.author.name} is live! Now streaming {self.author.activity.name}' # type: ignore
if self.type is MessageType.guild_discovery_disqualified:
@ -1135,14 +1190,11 @@ class Message(Hashable):
if self.type is MessageType.thread_created:
return f'{self.author.name} started a thread: **{self.content}**. See all **threads**.'
if self.type is MessageType.reply:
return self.content
if self.type is MessageType.thread_starter_message:
if self.reference is None or self.reference.resolved is None:
return 'Sorry, we couldn\'t load the first message in this thread'
# the resolved message for the reference will be a Message
# The resolved message for the reference will be a Message
return self.reference.resolved.content # type: ignore
if self.type is MessageType.guild_invite_reminder:
@ -1193,12 +1245,10 @@ class Message(Hashable):
self,
*,
content: Optional[str] = ...,
embed: Optional[Embed] = ...,
attachments: List[Union[Attachment, File]] = ...,
suppress: bool = ...,
delete_after: Optional[float] = ...,
allowed_mentions: Optional[AllowedMentions] = ...,
view: Optional[View] = ...,
) -> Message:
...
@ -1207,25 +1257,20 @@ class Message(Hashable):
self,
*,
content: Optional[str] = ...,
embeds: List[Embed] = ...,
attachments: List[Union[Attachment, File]] = ...,
suppress: bool = ...,
delete_after: Optional[float] = ...,
allowed_mentions: Optional[AllowedMentions] = ...,
view: Optional[View] = ...,
) -> Message:
...
async def edit(
self,
content: Optional[str] = MISSING,
embed: Optional[Embed] = MISSING,
embeds: List[Embed] = MISSING,
attachments: List[Union[Attachment, File]] = MISSING,
suppress: bool = MISSING,
delete_after: Optional[float] = None,
allowed_mentions: Optional[AllowedMentions] = MISSING,
view: Optional[View] = MISSING,
) -> Message:
"""|coro|
@ -1248,14 +1293,6 @@ class Message(Hashable):
content: Optional[:class:`str`]
The new content to replace the message with.
Could be ``None`` to remove the content.
embed: Optional[:class:`Embed`]
The new embed to replace the original with.
Could be ``None`` to remove the embed.
embeds: List[:class:`Embed`]
The new embeds to replace the original with. Must be a maximum of 10.
To remove all embeds ``[]`` should be passed.
.. versionadded:: 2.0
attachments: List[Union[:class:`Attachment`, :class:`File`]]
A list of attachments to keep in the message as well as new files to upload. If ``[]`` is passed
then all attachments are removed.
@ -1283,9 +1320,6 @@ class Message(Hashable):
are used instead.
.. versionadded:: 1.4
view: Optional[:class:`~discord.ui.View`]
The updated view to update this message with. If ``None`` is passed then
the view is removed.
Raises
-------
@ -1293,9 +1327,7 @@ class Message(Hashable):
Editing the message failed.
Forbidden
Tried to suppress a message without permissions or
edited a message's content or embed that isn't yours.
TypeError
You specified both ``embed`` and ``embeds``
edit a message that isn't yours.
Returns
--------
@ -1309,25 +1341,16 @@ class Message(Hashable):
else:
flags = MISSING
if view is not MISSING:
self._state.prevent_view_updates_for(self.id)
params = handle_message_parameters(
content=content,
flags=flags,
embed=embed,
embeds=embeds,
attachments=attachments,
view=view,
allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_allowed_mentions,
)
data = await self._state.http.edit_message(self.channel.id, self.id, params=params)
message = Message(state=self._state, channel=self.channel, data=data)
if view and not view.is_finished():
self._state.store_view(view, self.id)
if delete_after is not None:
await self.delete(delay=delete_after)
@ -1653,10 +1676,23 @@ class Message(Hashable):
auto_archive_duration=auto_archive_duration or default_auto_archive_duration,
rate_limit_per_user=slowmode_delay,
reason=reason,
location='Message',
)
return Thread(guild=self.guild, state=self._state, data=data)
async def reply(self, content: Optional[str] = None, **kwargs) -> Message:
async def ack(self) -> None:
"""|coro|
Marks this message as read.
Raises
-------
HTTPException
Acking failed.
"""
await self._state.http.ack_message(self.channel.id, self.id)
async def reply(self, content: Optional[str] = None, **kwargs) -> Message: # TODO: implement thread create "nudge"
"""|coro|
A shortcut method to :meth:`.abc.Messageable.send` to reply to the
@ -1687,6 +1723,76 @@ class Message(Hashable):
return await self.channel.send(content, reference=self, **kwargs)
def message_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 message 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 message.message_commands():
print(command.name)
Flattening into a list ::
commands = await message.message_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.
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:`.MessageCommand`
A message command.
"""
iterator = CommandIterator(
self,
AppCommandType.message,
query,
limit,
command_ids,
applications=applications,
application=application,
)
return iterator.iterate()
def to_reference(self, *, fail_if_not_exists: bool = True) -> MessageReference:
"""Creates a :class:`~discord.MessageReference` from the current message.

10
discord/permissions.py

@ -196,6 +196,9 @@ class Permissions(BaseFlags):
"Membership" permissions from the official Discord UI set to ``True``.
.. versionadded:: 1.7
.. versionchanged:: 2.0
Added :attr:`moderate_members` permission.
"""
return cls(0b10000000000001100000000000000000000000111)
@ -574,7 +577,7 @@ def _augment_from_permissions(cls):
cls.VALID_NAMES = set(Permissions.VALID_FLAGS)
aliases = set()
# make descriptors for all the valid names and aliases
# Make descriptors for all the valid names and aliases
for name, value in Permissions.__dict__.items():
if isinstance(value, permission_alias):
key = value.alias
@ -584,7 +587,7 @@ def _augment_from_permissions(cls):
else:
continue
# god bless Python
# God bless Python
def getter(self, x=key):
return self._values.get(x)
@ -681,6 +684,9 @@ class PermissionOverwrite:
send_messages_in_threads: Optional[bool]
external_stickers: Optional[bool]
use_external_stickers: Optional[bool]
start_embedded_activities: Optional[bool]
moderate_members: Optional[bool]
timeout_members: Optional[bool]
def __init__(self, **kwargs: Optional[bool]):
self._values: Dict[str, Optional[bool]] = {}

10
discord/player.py

@ -47,7 +47,7 @@ from .utils import MISSING
if TYPE_CHECKING:
from typing_extensions import Self
from .voice_client import VoiceClient
from .voice_client import Player
AT = TypeVar('AT', bound='AudioSource')
@ -635,18 +635,18 @@ class PCMVolumeTransformer(AudioSource, Generic[AT]):
class AudioPlayer(threading.Thread):
DELAY: float = OpusEncoder.FRAME_LENGTH / 1000.0
def __init__(self, source: AudioSource, client: VoiceClient, *, after=None):
def __init__(self, source: AudioSource, client: Player, *, after=None):
threading.Thread.__init__(self)
self.daemon: bool = True
self.source: AudioSource = source
self.client: VoiceClient = client
self.client: Player = client
self.after: Optional[Callable[[Optional[Exception]], Any]] = after
self._end: threading.Event = threading.Event()
self._resumed: threading.Event = threading.Event()
self._resumed.set() # we are not paused
self._current_error: Optional[Exception] = None
self._connected: threading.Event = client._connected
self._connected: threading.Event = client.client._connected
self._lock: threading.Lock = threading.Lock()
if after is not None and not callable(after):
@ -657,7 +657,7 @@ class AudioPlayer(threading.Thread):
self._start = time.perf_counter()
# getattr lookup speed ups
play_audio = self.client.send_audio_packet
play_audio = self.client.send
self._speak(SpeakingState.voice)
while not self._end.is_set():

164
discord/profile.py

@ -0,0 +1,164 @@
"""
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 typing import List, Optional, TYPE_CHECKING
from .connections import PartialConnection
from .flags import PublicUserFlags
from .member import Member
from .object import Object
from .permissions import Permissions
from .user import Note, User
from . import utils
if TYPE_CHECKING:
from datetime import datetime
from .guild import Guild
from .state import ConnectionState
__all__ = (
'UserProfile',
'MemberProfile',
)
class Profile:
"""Represents a Discord profile.
.. versionadded:: 2.0
Attributes
----------
application_id: Optional[:class:`int`]
The ID of the application that this user is attached to, if applicable.
install_url: Optional[:class:`str`]
The URL to invite the application to your guild with.
bio: Optional[:class:`str`]
The user's "about me" field. Could be ``None``.
premium_since: Optional[:class:`datetime.datetime`]
An aware datetime object that specifies how long a user has been premium (had Nitro).
``None`` if the user is not a premium user.
boosting_since: Optional[:class:`datetime.datetime`]
An aware datetime object that specifies when a user first boosted a guild.
connections: Optional[List[:class:`PartialConnection`]]
The connected accounts that show up on the profile.
note: :class:`Note`
Represents the note on the profile.
mutual_guilds: Optional[List[:class:`Guild`]]
A list of guilds that you share with the user.
``None`` if you didn't fetch mutuals.
mutual_friends: Optional[List[:class:`User`]]
A list of friends that you share with the user.
``None`` if you didn't fetch mutuals.
"""
if TYPE_CHECKING:
id: int
application_id: Optional[int]
_state: ConnectionState
def __init__(self, **kwargs) -> None: # TODO: type data
data = kwargs.pop('data')
user = data['user']
if (member := data.get('guild_member')) is not None:
member['user'] = user
kwargs['data'] = member
else:
kwargs['data'] = user
super().__init__(**kwargs)
self._flags: int = user.pop('flags', 0)
self.bio: Optional[str] = user.pop('bio') or None
self.note: Note = Note(kwargs['state'], self.id, user=self)
self.premium_since: Optional[datetime] = utils.parse_time(data['premium_since'])
self.boosting_since: Optional[datetime] = utils.parse_time(data['premium_guild_since'])
self.connections: List[PartialConnection] = [PartialConnection(d) for d in data['connected_accounts']] # TODO: parse these
self.mutual_guilds: Optional[List[Guild]] = self._parse_mutual_guilds(data.get('mutual_guilds'))
self.mutual_friends: Optional[List[User]] = self._parse_mutual_friends(data.get('mutual_friends'))
application = data.get('application', {})
install_params = application.get('install_params', {})
self.application_id = app_id = utils._get_as_snowflake(application, 'id')
self.install_url = application.get('custom_install_url') if not install_params else utils.oauth_url(app_id, permissions=Permissions(int(install_params.get('permissions', 0))), scopes=install_params.get('scopes', utils.MISSING)) # type: ignore - app_id is always present here
def _parse_mutual_guilds(self, mutual_guilds) -> Optional[List[Guild]]:
if mutual_guilds is None:
return
state = self._state
def get_guild(guild):
return state._get_guild(int(guild['id'])) or Object(id=int(guild['id']))
return list(filter(None, map(get_guild, mutual_guilds))) # type: ignore - Lying for better developer UX
def _parse_mutual_friends(self, mutual_friends) -> Optional[List[User]]:
if mutual_friends is None:
return
state = self._state
return [state.store_user(friend) for friend in mutual_friends]
@property
def flags(self) -> PublicUserFlags:
""":class:`PublicUserFlags`: The flags the user has."""
return PublicUserFlags._from_value(self._flags)
@property
def premium(self) -> bool:
""":class:`bool`: Indicates if the user is a premium user.
There is an alias for this named :attr:`nitro`.
"""
return self.premium_since is not None
@property
def nitro(self) -> bool:
""":class:`bool`: Indicates if the user is a premium user.
This is an alias for :attr:`premium`.
"""
return self.premium
class UserProfile(Profile, User):
"""Represents a Discord user's profile. This is a :class:`User` with extended attributes."""
def __repr__(self) -> str:
return f'<UserProfile id={self.id} name={self.name!r} discriminator={self.discriminator!r} bot={self.bot} system={self.system} premium={self.premium}>'
class MemberProfile(Profile, Member):
"""Represents a Discord member's profile. This is a :class:`Member` with extended attributes."""
def __repr__(self) -> str:
return (
f'<MemberProfile id={self._user.id} name={self._user.name!r} discriminator={self._user.discriminator!r}'
f' bot={self._user.bot} nick={self.nick!r} premium={self.premium} guild={self.guild!r}>'
)

2
discord/reaction.py

@ -182,7 +182,7 @@ class Reaction:
Usage ::
# I do not actually recommend doing this.
# I do not actually recommend doing this
async for user in reaction.users():
await channel.send(f'{user} has reacted with {reaction.emoji}!')

51
discord/recorder.py

@ -0,0 +1,51 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Who do I put here???
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 struct
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .voice_client import VoiceClient
unpacker = struct.Struct('>xxHII')
class SSRC:
def __init__(self, ssrc: int, speaking: bool) -> None:
self._ssrc = ssrc
self.speaking = speaking
def __repr__(self) -> str:
return str(self._ssrc)
class VoicePacket: # IN-PROGRESS
def __init__(self, client: VoiceClient, data: bytes):
self.client = client
_data = bytearray(data)
self.data: bytearray = data[12:]
self.header: bytearray = data[:12]

119
discord/relationship.py

@ -0,0 +1,119 @@
"""
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 Optional, TYPE_CHECKING
from .enums import RelationshipAction, RelationshipType, try_enum
if TYPE_CHECKING:
from .state import ConnectionState
from .user import User
# fmt: off
__all__ = (
'Relationship',
)
# fmt: on
class Relationship:
"""Represents a relationship in Discord.
A relationship is like a friendship, a person who is blocked, etc.
Attributes
-----------
nickname: :class:`str`
The user's friend nickname (if applicable).
user: :class:`User`
The user you have the relationship with.
type: :class:`RelationshipType`
The type of relationship you have.
"""
__slots__ = ('nickname', 'type', 'user', '_state')
def __init__(self, *, state: ConnectionState, data) -> None: # TODO: type data
self._state = state
self.type: RelationshipType = try_enum(RelationshipType, data['type'])
self.user: User = state.store_user(data['user'])
self.nickname: Optional[str] = data.get('nickname', None)
def __repr__(self) -> str:
return f'<Relationship user={self.user!r} type={self.type!r}>'
async def delete(self) -> None:
"""|coro|
Deletes the relationship.
Raises
------
HTTPException
Deleting the relationship failed.
"""
if self.type is RelationshipType.friend:
await self._state.http.remove_relationship(self.user.id, action=RelationshipAction.unfriend)
elif self.type is RelationshipType.blocked:
await self._state.http.remove_relationship(self.user.id, action=RelationshipAction.unblock)
elif self.type is RelationshipType.incoming_request:
await self._state.http.remove_relationship(self.user.id, action=RelationshipAction.deny_request)
elif self.type is RelationshipType.outgoing_request:
await self._state.http.remove_relationship(self.user.id, action=RelationshipAction.remove_pending_request)
async def accept(self) -> None:
"""|coro|
Accepts the relationship request. Only applicable for
type :class:`RelationshipType.incoming_request`.
Raises
-------
HTTPException
Accepting the relationship failed.
"""
await self._state.http.add_relationship(self.user.id, action=RelationshipAction.accept_request)
async def change_nickname(self, nick: Optional[str]) -> None:
"""|coro|
Changes a relationship's nickname. Only applicable for
type :class:`RelationshipType.friend`.
Parameters
----------
nick: Optional[:class:`str`]
The nickname to change to.
Raises
-------
HTTPException
Changing the nickname failed.
.. versionadded:: 1.9
"""
await self._state.http.change_friend_nickname(self.user.id, nick)
self.nickname = nick

47
discord/role.py

@ -29,7 +29,8 @@ from .asset import Asset
from .permissions import Permissions
from .colour import Colour
from .mixins import Hashable
from .utils import snowflake_time, _bytes_to_base64_data, _get_as_snowflake, MISSING
from .utils import snowflake_time, _get_as_snowflake, MISSING, _bytes_to_base64_data
from .partial_emoji import PartialEmoji
__all__ = (
'RoleTags',
@ -344,6 +345,8 @@ class Role(Hashable):
@property
def mention(self) -> str:
""":class:`str`: Returns a string that allows you to mention a role."""
if self.id == self.guild.id:
return '@everyone'
return f'<@&{self.id}>'
@property
@ -364,7 +367,7 @@ class Role(Hashable):
raise ValueError("Cannot move default role")
if self.position == position:
return # Save discord the extra request.
return # Save Discord the extra request
http = self._state.http
@ -388,6 +391,8 @@ class Role(Hashable):
color: Union[Colour, int] = MISSING,
hoist: bool = MISSING,
display_icon: Optional[Union[bytes, str]] = MISSING,
icon: Optional[bytes] = MISSING,
unicode_emoji: Optional[str] = MISSING,
mentionable: bool = MISSING,
position: int = MISSING,
reason: Optional[str] = MISSING,
@ -408,7 +413,7 @@ class Role(Hashable):
Edits are no longer in-place, the newly edited role is returned instead.
.. versionadded:: 2.0
The ``display_icon`` keyword-only parameter was added.
The ``display_icon``, ``icon``, and ``unicode_emoji`` keyword-only parameters were added.
.. versionchanged:: 2.0
This function no-longer raises ``InvalidArgument`` instead raising
@ -430,6 +435,15 @@ class Role(Hashable):
Could be ``None`` to denote removal of the icon.
Only PNG/JPEG is supported.
This is only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`.
icon: Optional[:class:`bytes`]
A :term:`py:bytes-like object` representing the icon that should be used as a role icon.
Could be ``None`` to denote removal of the icon.
Only PNG/JPEG is supported.
This is only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`.
unicode_emoji: Optional[:class:`str`]
A unicode emoji that should be used as a role icon.
:attr:`icon` takes precedence over this, but both can be set.
This is only available to guilds that contain ``ROLE_ICONS`` in :attr:`Guild.features`.
mentionable: :class:`bool`
Indicates if the role should be mentionable by others.
position: :class:`int`
@ -445,14 +459,18 @@ class Role(Hashable):
HTTPException
Editing the role failed.
ValueError
An invalid position was given or the default
role was asked to be moved.
An invalid position was given, the default
role was asked to be moved, or both ``display_icon``
and ``icon``/``unicode_emoji`` were set.
Returns
--------
:class:`Role`
The newly edited role.
"""
if display_icon and (icon or unicode_emoji):
raise ValueError('Cannot set both icon/unicode_emoji and display_icon')
if position is not MISSING:
await self._move(position, reason=reason)
@ -476,12 +494,25 @@ class Role(Hashable):
payload['hoist'] = hoist
if display_icon is not MISSING:
payload['icon'] = None
payload['unicode_emoji'] = None
if isinstance(display_icon, bytes):
payload['icon'] = _bytes_to_base64_data(display_icon)
else:
elif display_icon:
payload['unicode_emoji'] = display_icon
else:
payload['icon'] = None
payload['unicode_emoji'] = None
if icon is not MISSING:
if icon is None:
payload['icon'] = icon
else:
payload['icon'] = _bytes_to_base64_data(icon)
if unicode_emoji is not MISSING:
if unicode_emoji is None:
payload['unicode_emoji'] = None
else:
payload['unicode_emoji'] = unicode_emoji
if mentionable is not MISSING:
payload['mentionable'] = mentionable

18
discord/scheduled_event.py

@ -205,7 +205,7 @@ class ScheduledEvent(Hashable):
The scheduled event that was started.
"""
if self.status is not EventStatus.scheduled:
raise ValueError('This scheduled event is already running.')
raise ValueError('This scheduled event is already running')
return await self.edit(status=EventStatus.active, reason=reason)
@ -240,7 +240,7 @@ class ScheduledEvent(Hashable):
The scheduled event that was ended.
"""
if self.status is not EventStatus.active:
raise ValueError('This scheduled event is not active.')
raise ValueError('This scheduled event is not active')
return await self.edit(status=EventStatus.ended, reason=reason)
@ -275,7 +275,7 @@ class ScheduledEvent(Hashable):
The scheduled event that was cancelled.
"""
if self.status is not EventStatus.scheduled:
raise ValueError('This scheduled event is already running.')
raise ValueError('This scheduled event is already running')
return await self.edit(status=EventStatus.cancelled, reason=reason)
@ -363,7 +363,7 @@ class ScheduledEvent(Hashable):
if start_time is not MISSING:
if start_time.tzinfo is None:
raise ValueError(
'start_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time.'
'start_time must be an aware datetime. Consider using discord.utils.utcnow() or datetime.datetime.now().astimezone() for local time'
)
payload['scheduled_start_time'] = start_time.isoformat()
@ -372,7 +372,7 @@ class ScheduledEvent(Hashable):
if privacy_level is not MISSING:
if not isinstance(privacy_level, PrivacyLevel):
raise TypeError('privacy_level must be of type PrivacyLevel.')
raise TypeError('privacy_level must be of type PrivacyLevel')
payload['privacy_level'] = privacy_level.value
@ -480,12 +480,13 @@ class ScheduledEvent(Hashable):
users = await self._state.http.get_scheduled_event_users(
self.guild_id, self.id, limit=retrieve, with_member=False, before=before_id
)
users = users['users']
if users:
if limit is not None:
limit -= len(users)
before = Object(id=users[-1]['user']['id'])
before = Object(id=users[-1]['id'])
return users, before, limit
@ -494,12 +495,13 @@ class ScheduledEvent(Hashable):
users = await self._state.http.get_scheduled_event_users(
self.guild_id, self.id, limit=retrieve, with_member=False, after=after_id
)
users = users['users']
if users:
if limit is not None:
limit -= len(users)
after = Object(id=users[0]['user']['id'])
after = Object(id=users[0]['id'])
return users, after, limit
@ -537,7 +539,7 @@ class ScheduledEvent(Hashable):
if predicate:
data = filter(predicate, data)
users = (self._state.store_user(raw_user['user']) for raw_user in data)
users = (self._state.store_user(raw_user) for raw_user in data)
for user in users:
yield user

586
discord/settings.py

@ -0,0 +1,586 @@
"""
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, timedelta
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from .activity import create_settings_activity
from .enums import FriendFlags, Locale, NotificationLevel, Status, StickerAnimationOptions, Theme, UserContentFilter, try_enum
from .guild_folder import GuildFolder
from .utils import MISSING, parse_time, utcnow
if TYPE_CHECKING:
from .abc import GuildChannel
from .activity import CustomActivity
from .guild import Guild
from .state import ConnectionState
from .tracking import Tracking
__all__ = (
'ChannelSettings',
'GuildSettings',
'UserSettings',
)
class UserSettings:
"""Represents the Discord client settings.
Attributes
----------
afk_timeout: :class:`int`
How long (in seconds) the user needs to be AFK until Discord
sends push notifications to your mobile device.
allow_accessibility_detection: :class:`bool`
Whether or not to allow Discord to track screen reader usage.
animate_emojis: :class:`bool`
Whether or not to animate emojis in the chat.
contact_sync_enabled: :class:`bool`
Whether or not to enable the contact sync on Discord mobile.
convert_emoticons: :class:`bool`
Whether or not to automatically convert emoticons into emojis.
e.g. :-) -> 😃
default_guilds_restricted: :class:`bool`
Whether or not to automatically disable DMs between you and
members of new guilds you join.
detect_platform_accounts: :class:`bool`
Whether or not to automatically detect accounts from services
like Steam and Blizzard when you open the Discord client.
developer_mode: :class:`bool`
Whether or not to enable developer mode.
disable_games_tab: :class:`bool`
Whether or not to disable the showing of the Games tab.
enable_tts_command: :class:`bool`
Whether or not to allow tts messages to be played/sent.
gif_auto_play: :class:`bool`
Whether or not to automatically play gifs that are in the chat.
inline_attachment_media: :class:`bool`
Whether or not to display attachments when they are uploaded in chat.
inline_embed_media: :class:`bool`
Whether or not to display videos and images from links posted in chat.
message_display_compact: :class:`bool`
Whether or not to use the compact Discord display mode.
native_phone_integration_enabled: :class:`bool`
Whether or not to enable the new Discord mobile phone number friend
requesting features.
render_embeds: :class:`bool`
Whether or not to render embeds that are sent in the chat.
render_reactions: :class:`bool`
Whether or not to render reactions that are added to messages.
show_current_game: :class:`bool`
Whether or not to display the game that you are currently playing.
stream_notifications_enabled: :class:`bool`
Unknown.
timezone_offset: :class:`int`
The timezone offset to use.
view_nsfw_guilds: :class:`bool`
Whether or not to show NSFW guilds on iOS.
"""
if TYPE_CHECKING: # Fuck me
afk_timeout: int
allow_accessibility_detection: bool
animate_emojis: bool
contact_sync_enabled: bool
convert_emoticons: bool
default_guilds_restricted: bool
detect_platform_accounts: bool
developer_mode: bool
disable_games_tab: bool
enable_tts_command: bool
gif_auto_play: bool
inline_attachment_media: bool
inline_embed_media: bool
message_display_compact: bool
native_phone_integration_enabled: bool
render_embeds: bool
render_reactions: bool
show_current_game: bool
stream_notifications_enabled: bool
timezone_offset: int
view_nsfw_guilds: bool
def __init__(self, *, data, state: ConnectionState) -> None:
self._state = state
self._update(data)
def __repr__(self) -> str:
return '<Settings>'
def _get_guild(self, id: int) -> Optional[Guild]:
return self._state._get_guild(int(id))
def _update(self, data: Dict[str, Any]) -> None:
RAW_VALUES = {
'afk_timeout',
'allow_accessibility_detection',
'animate_emojis',
'contact_sync_enabled',
'convert_emoticons',
'default_guilds_restricted',
'detect_platform_accounts',
'developer_mode',
'disable_games_tab',
'enable_tts_command',
'gif_auto_play',
'inline_attachment_media',
'inline_embed_media',
'message_display_compact',
'native_phone_integration_enabled',
'render_embeds',
'render_reactions',
'show_current_game',
'stream_notifications_enabled',
'timezone_offset',
'view_nsfw_guilds',
}
for key, value in data.items():
if key in RAW_VALUES:
setattr(self, key, value)
else:
setattr(self, '_' + key, value)
async def edit(self, **kwargs) -> UserSettings:
"""|coro|
Edits the client user's settings.
.. versionchanged:: 2.0
The edit is no longer in-place, instead the newly edited settings are returned.
Parameters
----------
afk_timeout: :class:`int`
How long (in seconds) the user needs to be AFK until Discord
sends push notifications to your mobile device.
allow_accessibility_detection: :class:`bool`
Whether or not to allow Discord to track screen reader usage.
animate_emojis: :class:`bool`
Whether or not to animate emojis in the chat.
animate_stickers: :class:`StickerAnimationOptions`
Whether or not to animate stickers in the chat.
contact_sync_enabled: :class:`bool`
Whether or not to enable the contact sync on Discord mobile.
convert_emoticons: :class:`bool`
Whether or not to automatically convert emoticons into emojis.
e.g. :-) -> 😃
default_guilds_restricted: :class:`bool`
Whether or not to automatically disable DMs between you and
members of new guilds you join.
detect_platform_accounts: :class:`bool`
Whether or not to automatically detect accounts from services
like Steam and Blizzard when you open the Discord client.
developer_mode: :class:`bool`
Whether or not to enable developer mode.
disable_games_tab: :class:`bool`
Whether or not to disable the showing of the Games tab.
enable_tts_command: :class:`bool`
Whether or not to allow tts messages to be played/sent.
explicit_content_filter: :class:`UserContentFilter`
The filter for explicit content in all messages.
friend_source_flags: :class:`FriendFlags`
Who can add you as a friend.
gif_auto_play: :class:`bool`
Whether or not to automatically play gifs that are in the chat.
guild_positions: List[:class:`abc.Snowflake`]
A list of guilds in order of the guild/guild icons that are on
the left hand side of the UI.
inline_attachment_media: :class:`bool`
Whether or not to display attachments when they are uploaded in chat.
inline_embed_media: :class:`bool`
Whether or not to display videos and images from links posted in chat.
locale: :class:`Locale`
The :rfc:`3066` language identifier of the locale to use for the language
of the Discord client.
message_display_compact: :class:`bool`
Whether or not to use the compact Discord display mode.
native_phone_integration_enabled: :class:`bool`
Whether or not to enable the new Discord mobile phone number friend
requesting features.
render_embeds: :class:`bool`
Whether or not to render embeds that are sent in the chat.
render_reactions: :class:`bool`
Whether or not to render reactions that are added to messages.
restricted_guilds: List[:class:`abc.Snowflake`]
A list of guilds that you will not receive DMs from.
show_current_game: :class:`bool`
Whether or not to display the game that you are currently playing.
stream_notifications_enabled: :class:`bool`
Unknown.
theme: :class:`Theme`
The theme of the Discord UI.
timezone_offset: :class:`int`
The timezone offset to use.
view_nsfw_guilds: :class:`bool`
Whether or not to show NSFW guilds on iOS.
Raises
-------
HTTPException
Editing the settings failed.
Returns
-------
:class:`.UserSettings`
The client user's updated settings.
"""
return await self._state.user.edit_settings(**kwargs) # type: ignore
async def fetch_tracking(self) -> Tracking:
"""|coro|
Retrieves your :class:`Tracking` settings.
Raises
------
HTTPException
Retrieving the tracking settings failed.
Returns
-------
:class:`Tracking`
The tracking settings.
"""
data = await self._state.http.get_tracking()
return Tracking(state=self._state, data=data)
@property
def tracking(self) -> Optional[Tracking]:
"""Optional[:class:`Tracking`]: Returns your tracking settings if available."""
return self._state.consents
@property
def animate_stickers(self) -> StickerAnimationOptions:
""":class:`StickerAnimationOptions`: Whether or not to animate stickers in the chat."""
return try_enum(StickerAnimationOptions, getattr(self, '_animate_stickers', 0))
@property
def custom_activity(self) -> Optional[CustomActivity]:
"""Optional[:class:`CustomActivity]: The set custom activity."""
return create_settings_activity(data=getattr(self, '_custom_status', None), state=self._state)
@property
def explicit_content_filter(self) -> UserContentFilter:
""":class:`UserContentFilter`: The filter for explicit content in all messages."""
return try_enum(UserContentFilter, getattr(self, '_explicit_content_filter', 1))
@property
def friend_source_flags(self) -> FriendFlags:
""":class:`FriendFlags`: Who can add you as a friend."""
return FriendFlags._from_dict(getattr(self, '_friend_source_flags', {'all': True}))
@property
def guild_folders(self) -> List[GuildFolder]:
"""List[:class:`GuildFolder`]: A list of guild folders."""
state = self._state
return [GuildFolder(data=folder, state=state) for folder in getattr(self, '_guild_folders', [])]
@property
def guild_positions(self) -> List[Guild]:
"""List[:class:`Guild`]: A list of guilds in order of the guild/guild icons that are on the left hand side of the UI."""
return list(filter(None, map(self._get_guild, getattr(self, '_guild_positions', []))))
@property
def locale(self) -> Locale:
""":class:`Locale`: The :rfc:`3066` language identifier
of the locale to use for the language of the Discord client."""
return try_enum(Locale, getattr(self, '_locale', 'en-US'))
@property
def passwordless(self) -> bool:
""":class:`bool`: Whether the account is passwordless."""
return getattr(self, '_passwordless', False)
@property
def restricted_guilds(self) -> List[Guild]:
"""List[:class:`Guild`]: A list of guilds that you will not receive DMs from."""
return list(filter(None, map(self._get_guild, getattr(self, '_restricted_guilds', []))))
@property
def status(self) -> Status:
"""Optional[:class:`Status`]: The configured status."""
return try_enum(Status, getattr(self, '_status', 'online'))
@property
def theme(self) -> Theme:
""":class:`Theme`: The theme of the Discord UI."""
return try_enum(Theme, getattr(self, '_theme', 'dark')) # Sane default :)
class MuteConfig:
def __init__(self, muted: bool, config: Dict[str, str]) -> None:
until = parse_time(config.get('end_time'))
if until is not None:
if until <= utcnow():
muted = False
until = None
self.muted: bool = muted
self.until: Optional[datetime] = until
for item in {'__bool__', '__eq__', '__float__', '__int__', '__str__'}:
setattr(self, item, getattr(muted, item))
def __repr__(self) -> str:
return f'<MuteConfig muted={self.muted} until={self.until}>'
def __bool__(self) -> bool:
return bool(self.muted)
def __eq__(self, other) -> bool:
return self.muted == other
def __ne__(self, other) -> bool:
return not self.muted == other
class ChannelSettings:
"""Represents a channel's notification settings"""
if TYPE_CHECKING:
_channel_id: int
level: NotificationLevel
muted: MuteConfig
collapsed: bool
def __init__(self, guild_id, *, data: Dict[str, Any] = {}, state: ConnectionState) -> None:
self._guild_id: int = guild_id
self._state = state
self._update(data)
def _update(self, data: Dict[str, Any]) -> None:
self._channel_id = int(data['channel_id'])
self.collapsed = data.get('collapsed', False)
self.level = try_enum(NotificationLevel, data.get('message_notifications', 3)) # type: ignore
self.muted = MuteConfig(data.get('muted', False), data.get('mute_config') or {})
@property
def channel(self) -> Optional[GuildChannel]:
"""Optional[:class:`GuildChannel`]: Returns the channel these settings are for."""
guild = self._state._get_guild(self._guild_id)
return guild and guild.get_channel(self._channel_id)
async def edit(self,
*,
muted: bool = MISSING,
duration: Optional[int] = MISSING,
collapsed: bool = MISSING,
level: NotificationLevel = MISSING,
) -> Optional[ChannelSettings]:
"""|coro|
Edits the channel's notification settings.
All parameters are optional.
Parameters
-----------
muted: :class:`bool`
Indicates if the channel should be muted or not.
duration: Optional[Union[:class:`int`, :class:`float`]]
The amount of time in hours that the channel should be muted for.
Defaults to indefinite.
collapsed: :class:`bool`
Unknown.
level: :class:`NotificationLevel`
Determines what level of notifications you receive for the channel.
Raises
-------
HTTPException
Editing the settings failed.
Returns
--------
:class:`ChannelSettings`
The new notification settings.
"""
payload = {}
data = None
if muted is not MISSING:
payload['muted'] = muted
if duration is not MISSING:
if muted is MISSING:
payload['muted'] = True
if duration is not None:
mute_config = {
'selected_time_window': duration * 3600,
'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat()
}
payload['mute_config'] = mute_config
if collapsed is not MISSING:
payload['collapsed'] = collapsed
if level is not MISSING:
payload['message_notifications'] = level.value
if payload:
fields = {'channel_overrides': {str(self._channel_id): payload}}
data = await self._state.http.edit_guild_settings(self._guild_id, fields)
if data:
return ChannelSettings(
self._guild_id,
data=data['channel_overrides'][str(self._channel_id)],
state=self._state
)
else:
return self
class GuildSettings:
"""Represents a guild's notification settings."""
if TYPE_CHECKING:
_channel_overrides: Dict[int, ChannelSettings]
_guild_id: int
version: int
muted: MuteConfig
suppress_everyone: bool
suppress_roles: bool
hide_muted_channels: bool
mobile_push_notifications: bool
level: NotificationLevel
def __init__(self, *, data: Dict[str, Any], state: ConnectionState) -> None:
self._state = state
self._update(data)
def _update(self, data: Dict[str, Any]) -> None:
self._guild_id = guild_id = int(data['guild_id'])
self.version = data.get('version', -1) # Overriden by real data
self.suppress_everyone = data.get('suppress_everyone', False)
self.suppress_roles = data.get('suppress_roles', False)
self.hide_muted_channels = data.get('hide_muted_channels', False)
self.mobile_push_notifications = data.get('mobile_push', True)
self.level = try_enum(NotificationLevel, data.get('message_notifications', 3))
self.muted = MuteConfig(data.get('muted', False), data.get('mute_config') or {})
self._channel_overrides = overrides = {}
state = self._state
for override in data.get('channel_overrides', []):
channel_id = int(override['channel_id'])
overrides[channel_id] = ChannelSettings(guild_id, data=override, state=state)
@property
def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: Returns the guild that these settings are for."""
return self._state._get_guild(self._guild_id)
@property
def channel_overrides(self) -> List[ChannelSettings]:
"""List[:class:`ChannelSettings`: Returns a list of all the overrided channel notification settings."""
return list(self._channel_overrides.values())
async def edit(
self,
muted: bool = MISSING,
duration: Optional[int] = MISSING,
level: NotificationLevel = MISSING,
suppress_everyone: bool = MISSING,
suppress_roles: bool = MISSING,
mobile_push_notifications: bool = MISSING,
hide_muted_channels: bool = MISSING,
) -> Optional[GuildSettings]:
"""|coro|
Edits the guild's notification settings.
All parameters are optional.
Parameters
-----------
muted: :class:`bool`
Indicates if the guild should be muted or not.
duration: Optional[Union[:class:`int`, :class:`float`]]
The amount of time in hours that the guild should be muted for.
Defaults to indefinite.
level: :class:`NotificationLevel`
Determines what level of notifications you receive for the guild.
suppress_everyone: :class:`bool`
Indicates if @everyone mentions should be suppressed for the guild.
suppress_roles: :class:`bool`
Indicates if role mentions should be suppressed for the guild.
mobile_push_notifications: :class:`bool`
Indicates if push notifications should be sent to mobile devices for this guild.
hide_muted_channels: :class:`bool`
Indicates if channels that are muted should be hidden from the sidebar.
Raises
-------
HTTPException
Editing the settings failed.
Returns
--------
:class:`GuildSettings`
The new notification settings.
"""
payload = {}
data = None
if muted is not MISSING:
payload['muted'] = muted
if duration is not MISSING:
if muted is MISSING:
payload['muted'] = True
if duration is not None:
mute_config = {
'selected_time_window': duration * 3600,
'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat()
}
payload['mute_config'] = mute_config
if level is not MISSING:
payload['message_notifications'] = level.value
if suppress_everyone is not MISSING:
payload['suppress_everyone'] = suppress_everyone
if suppress_roles is not MISSING:
payload['suppress_roles'] = suppress_roles
if mobile_push_notifications is not MISSING:
payload['mobile_push'] = mobile_push_notifications
if hide_muted_channels is not MISSING:
payload['hide_muted_channels'] = hide_muted_channels
if payload:
data = await self._state.http.edit_guild_settings(self._guild_id, payload)
if data:
return GuildSettings(data=data, state=self._state)
else:
return self

559
discord/shard.py

@ -1,559 +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 asyncio
import logging
import aiohttp
from .state import AutoShardedConnectionState
from .client import Client
from .backoff import ExponentialBackoff
from .gateway import *
from .errors import (
ClientException,
HTTPException,
GatewayNotFound,
ConnectionClosed,
PrivilegedIntentsRequired,
)
from .enums import Status
from typing import TYPE_CHECKING, Any, Callable, Tuple, Type, Optional, List, Dict
if TYPE_CHECKING:
from .gateway import DiscordWebSocket
from .activity import BaseActivity
from .enums import Status
__all__ = (
'AutoShardedClient',
'ShardInfo',
)
_log = logging.getLogger(__name__)
class EventType:
close = 0
reconnect = 1
resume = 2
identify = 3
terminate = 4
clean_close = 5
class EventItem:
__slots__ = ('type', 'shard', 'error')
def __init__(self, etype: int, shard: Optional['Shard'], error: Optional[Exception]) -> None:
self.type: int = etype
self.shard: Optional['Shard'] = shard
self.error: Optional[Exception] = error
def __lt__(self, other: Any) -> bool:
if not isinstance(other, EventItem):
return NotImplemented
return self.type < other.type
def __eq__(self, other: Any) -> bool:
if not isinstance(other, EventItem):
return NotImplemented
return self.type == other.type
def __hash__(self) -> int:
return hash(self.type)
class Shard:
def __init__(self, ws: DiscordWebSocket, client: AutoShardedClient, queue_put: Callable[[EventItem], None]) -> None:
self.ws: DiscordWebSocket = ws
self._client: Client = client
self._dispatch: Callable[..., None] = client.dispatch
self._queue_put: Callable[[EventItem], None] = queue_put
self.loop: asyncio.AbstractEventLoop = self._client.loop
self._disconnect: bool = False
self._reconnect = client._reconnect
self._backoff: ExponentialBackoff = ExponentialBackoff()
self._task: Optional[asyncio.Task] = None
self._handled_exceptions: Tuple[Type[Exception], ...] = (
OSError,
HTTPException,
GatewayNotFound,
ConnectionClosed,
aiohttp.ClientError,
asyncio.TimeoutError,
)
@property
def id(self) -> int:
# DiscordWebSocket.shard_id is set in the from_client classmethod
return self.ws.shard_id # type: ignore
def launch(self) -> None:
self._task = self.loop.create_task(self.worker())
def _cancel_task(self) -> None:
if self._task is not None and not self._task.done():
self._task.cancel()
async def close(self) -> None:
self._cancel_task()
await self.ws.close(code=1000)
async def disconnect(self) -> None:
await self.close()
self._dispatch('shard_disconnect', self.id)
async def _handle_disconnect(self, e: Exception) -> None:
self._dispatch('disconnect')
self._dispatch('shard_disconnect', self.id)
if not self._reconnect:
self._queue_put(EventItem(EventType.close, self, e))
return
if self._client.is_closed():
return
if isinstance(e, OSError) and e.errno in (54, 10054):
# If we get Connection reset by peer then always try to RESUME the connection.
exc = ReconnectWebSocket(self.id, resume=True)
self._queue_put(EventItem(EventType.resume, self, exc))
return
if isinstance(e, ConnectionClosed):
if e.code == 4014:
self._queue_put(EventItem(EventType.terminate, self, PrivilegedIntentsRequired(self.id)))
return
if e.code != 1000:
self._queue_put(EventItem(EventType.close, self, e))
return
retry = self._backoff.delay()
_log.error('Attempting a reconnect for shard ID %s in %.2fs', self.id, retry, exc_info=e)
await asyncio.sleep(retry)
self._queue_put(EventItem(EventType.reconnect, self, e))
async def worker(self) -> None:
while not self._client.is_closed():
try:
await self.ws.poll_event()
except ReconnectWebSocket as e:
etype = EventType.resume if e.resume else EventType.identify
self._queue_put(EventItem(etype, self, e))
break
except self._handled_exceptions as e:
await self._handle_disconnect(e)
break
except asyncio.CancelledError:
break
except Exception as e:
self._queue_put(EventItem(EventType.terminate, self, e))
break
async def reidentify(self, exc: ReconnectWebSocket) -> None:
self._cancel_task()
self._dispatch('disconnect')
self._dispatch('shard_disconnect', self.id)
_log.info('Got a request to %s the websocket at Shard ID %s.', exc.op, self.id)
try:
coro = DiscordWebSocket.from_client(
self._client,
resume=exc.resume,
shard_id=self.id,
session=self.ws.session_id,
sequence=self.ws.sequence,
)
self.ws = await asyncio.wait_for(coro, timeout=60.0)
except self._handled_exceptions as e:
await self._handle_disconnect(e)
except asyncio.CancelledError:
return
except Exception as e:
self._queue_put(EventItem(EventType.terminate, self, e))
else:
self.launch()
async def reconnect(self) -> None:
self._cancel_task()
try:
coro = DiscordWebSocket.from_client(self._client, shard_id=self.id)
self.ws = await asyncio.wait_for(coro, timeout=60.0)
except self._handled_exceptions as e:
await self._handle_disconnect(e)
except asyncio.CancelledError:
return
except Exception as e:
self._queue_put(EventItem(EventType.terminate, self, e))
else:
self.launch()
class ShardInfo:
"""A class that gives information and control over a specific shard.
You can retrieve this object via :meth:`AutoShardedClient.get_shard`
or :attr:`AutoShardedClient.shards`.
.. versionadded:: 1.4
Attributes
------------
id: :class:`int`
The shard ID for this shard.
shard_count: Optional[:class:`int`]
The shard count for this cluster. If this is ``None`` then the bot has not started yet.
"""
__slots__ = ('_parent', 'id', 'shard_count')
def __init__(self, parent: Shard, shard_count: Optional[int]) -> None:
self._parent: Shard = parent
self.id: int = parent.id
self.shard_count: Optional[int] = shard_count
def is_closed(self) -> bool:
""":class:`bool`: Whether the shard connection is currently closed."""
return not self._parent.ws.open
async def disconnect(self) -> None:
"""|coro|
Disconnects a shard. When this is called, the shard connection will no
longer be open.
If the shard is already disconnected this does nothing.
"""
if self.is_closed():
return
await self._parent.disconnect()
async def reconnect(self) -> None:
"""|coro|
Disconnects and then connects the shard again.
"""
if not self.is_closed():
await self._parent.disconnect()
await self._parent.reconnect()
async def connect(self) -> None:
"""|coro|
Connects a shard. If the shard is already connected this does nothing.
"""
if not self.is_closed():
return
await self._parent.reconnect()
@property
def latency(self) -> float:
""":class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds for this shard."""
return self._parent.ws.latency
def is_ws_ratelimited(self) -> bool:
""":class:`bool`: Whether the websocket is currently rate limited.
This can be useful to know when deciding whether you should query members
using HTTP or via the gateway.
.. versionadded:: 1.6
"""
return self._parent.ws.is_ratelimited()
class AutoShardedClient(Client):
"""A client similar to :class:`Client` except it handles the complications
of sharding for the user into a more manageable and transparent single
process bot.
When using this client, you will be able to use it as-if it was a regular
:class:`Client` with a single shard when implementation wise internally it
is split up into multiple shards. This allows you to not have to deal with
IPC or other complicated infrastructure.
It is recommended to use this client only if you have surpassed at least
1000 guilds.
If no :attr:`.shard_count` is provided, then the library will use the
Bot Gateway endpoint call to figure out how many shards to use.
If a ``shard_ids`` parameter is given, then those shard IDs will be used
to launch the internal shards. Note that :attr:`.shard_count` must be provided
if this is used. By default, when omitted, the client will launch shards from
0 to ``shard_count - 1``.
Attributes
------------
shard_ids: Optional[List[:class:`int`]]
An optional list of shard_ids to launch the shards with.
"""
if TYPE_CHECKING:
_connection: AutoShardedConnectionState
def __init__(self, *args: Any, loop: Optional[asyncio.AbstractEventLoop] = None, **kwargs: Any) -> None:
kwargs.pop('shard_id', None)
self.shard_ids: Optional[List[int]] = kwargs.pop('shard_ids', None)
super().__init__(*args, loop=loop, **kwargs)
if self.shard_ids is not None:
if self.shard_count is None:
raise ClientException('When passing manual shard_ids, you must provide a shard_count.')
elif not isinstance(self.shard_ids, (list, tuple)):
raise ClientException('shard_ids parameter must be a list or a tuple.')
# instead of a single websocket, we have multiple
# the key is the shard_id
self.__shards = {}
self._connection._get_websocket = self._get_websocket
self._connection._get_client = lambda: self
self.__queue = asyncio.PriorityQueue()
def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket:
if shard_id is None:
# guild_id won't be None if shard_id is None and shard_count won't be None here
shard_id = (guild_id >> 22) % self.shard_count # type: ignore
return self.__shards[shard_id].ws
def _get_state(self, **options: Any) -> AutoShardedConnectionState:
return AutoShardedConnectionState(
dispatch=self.dispatch,
handlers=self._handlers,
hooks=self._hooks,
http=self.http,
loop=self.loop,
**options,
)
@property
def latency(self) -> float:
""":class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.
This operates similarly to :meth:`Client.latency` except it uses the average
latency of every shard's latency. To get a list of shard latency, check the
:attr:`latencies` property. Returns ``nan`` if there are no shards ready.
"""
if not self.__shards:
return float('nan')
return sum(latency for _, latency in self.latencies) / len(self.__shards)
@property
def latencies(self) -> List[Tuple[int, float]]:
"""List[Tuple[:class:`int`, :class:`float`]]: A list of latencies between a HEARTBEAT and a HEARTBEAT_ACK in seconds.
This returns a list of tuples with elements ``(shard_id, latency)``.
"""
return [(shard_id, shard.ws.latency) for shard_id, shard in self.__shards.items()]
def get_shard(self, shard_id: int, /) -> Optional[ShardInfo]:
"""
Gets the shard information at a given shard ID or ``None`` if not found.
.. versionchanged:: 2.0
``shard_id`` parameter is now positional-only.
Returns
--------
Optional[:class:`ShardInfo`]
Information about the shard with given ID. ``None`` if not found.
"""
try:
parent = self.__shards[shard_id]
except KeyError:
return None
else:
return ShardInfo(parent, self.shard_count)
@property
def shards(self) -> Dict[int, ShardInfo]:
"""Mapping[int, :class:`ShardInfo`]: Returns a mapping of shard IDs to their respective info object."""
return {shard_id: ShardInfo(parent, self.shard_count) for shard_id, parent in self.__shards.items()}
async def launch_shard(self, gateway: str, shard_id: int, *, initial: bool = False) -> None:
try:
coro = DiscordWebSocket.from_client(self, initial=initial, gateway=gateway, shard_id=shard_id)
ws = await asyncio.wait_for(coro, timeout=180.0)
except Exception:
_log.exception('Failed to connect for shard_id: %s. Retrying...', shard_id)
await asyncio.sleep(5.0)
return await self.launch_shard(gateway, shard_id)
# keep reading the shard while others connect
self.__shards[shard_id] = ret = Shard(ws, self, self.__queue.put_nowait)
ret.launch()
async def launch_shards(self) -> None:
if self.shard_count is None:
self.shard_count, gateway = await self.http.get_bot_gateway()
else:
gateway = await self.http.get_gateway()
self._connection.shard_count = self.shard_count
shard_ids = self.shard_ids or range(self.shard_count)
self._connection.shard_ids = shard_ids
for shard_id in shard_ids:
initial = shard_id == shard_ids[0]
await self.launch_shard(gateway, shard_id, initial=initial)
self._connection.shards_launched.set()
async def connect(self, *, reconnect: bool = True) -> None:
self._reconnect = reconnect
await self.launch_shards()
while not self.is_closed():
item = await self.__queue.get()
if item.type == EventType.close:
await self.close()
if isinstance(item.error, ConnectionClosed):
if item.error.code != 1000:
raise item.error
if item.error.code == 4014:
raise PrivilegedIntentsRequired(item.shard.id) from None
return
elif item.type in (EventType.identify, EventType.resume):
await item.shard.reidentify(item.error)
elif item.type == EventType.reconnect:
await item.shard.reconnect()
elif item.type == EventType.terminate:
await self.close()
raise item.error
elif item.type == EventType.clean_close:
return
async def close(self) -> None:
"""|coro|
Closes the connection to Discord.
"""
if self.is_closed():
return
self._closed = True
for vc in self.voice_clients:
try:
await vc.disconnect(force=True)
except Exception:
pass
to_close = [asyncio.ensure_future(shard.close(), loop=self.loop) for shard in self.__shards.values()]
if to_close:
await asyncio.wait(to_close)
await self.http.close()
self.__queue.put_nowait(EventItem(EventType.clean_close, None, None))
async def change_presence(
self,
*,
activity: Optional[BaseActivity] = None,
status: Optional[Status] = None,
shard_id: Optional[int] = None,
) -> None:
"""|coro|
Changes the client's presence.
Example: ::
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.
status: Optional[:class:`Status`]
Indicates what status to change to. If ``None``, then
:attr:`Status.online` is used.
shard_id: Optional[:class:`int`]
The shard_id to change the presence to. If not specified
or ``None``, then it will change the presence of every
shard the bot can see.
Raises
------
TypeError
If the ``activity`` parameter is not of proper type.
"""
if status is None:
status_value = 'online'
status_enum = Status.online
elif status is Status.offline:
status_value = 'invisible'
status_enum = Status.offline
else:
status_enum = status
status_value = str(status)
if shard_id is None:
for shard in self.__shards.values():
await shard.ws.change_presence(activity=activity, status=status_value)
guilds = self._connection.guilds
else:
shard = self.__shards[shard_id]
await shard.ws.change_presence(activity=activity, status=status_value)
guilds = [g for g in self._connection.guilds if g.shard_id == shard_id]
activities = () if activity is None else (activity,)
for guild in guilds:
me = guild.me
if me is None:
continue
# Member.activities is typehinted as Tuple[ActivityType, ...], we may be setting it as Tuple[BaseActivity, ...]
me.activities = activities # type: ignore
me.status = status_enum
def is_ws_ratelimited(self) -> bool:
""":class:`bool`: Whether the websocket is currently rate limited.
This can be useful to know when deciding whether you should query members
using HTTP or via the gateway.
This implementation checks if any of the shards are rate limited.
For more granular control, consider :meth:`ShardInfo.is_ws_ratelimited`.
.. versionadded:: 1.6
"""
return any(shard.ws.is_ratelimited() for shard in self.__shards.values())

8
discord/stage_instance.py

@ -104,10 +104,15 @@ class StageInstance(Hashable):
def __repr__(self) -> str:
return f'<StageInstance id={self.id} guild={self.guild!r} channel_id={self.channel_id} topic={self.topic!r}>'
@property
def discoverable(self) -> bool:
"""Whether the stage instance is discoverable."""
return not self.discoverable_disabled
@cached_slot_property('_cs_channel')
def channel(self) -> Optional[StageChannel]:
"""Optional[:class:`StageChannel`]: The channel that stage instance is running in."""
# the returned channel will always be a StageChannel or None
# The returned channel will always be a StageChannel or None
return self._state.get_channel(self.channel_id) # type: ignore
async def edit(
@ -142,7 +147,6 @@ class StageInstance(Hashable):
HTTPException
Editing a stage instance failed.
"""
payload = {}
if topic is not MISSING:

1461
discord/state.py

File diff suppressed because it is too large

2
discord/sticker.py

@ -162,7 +162,7 @@ class _StickerTag(Hashable, AssetMixin):
The content of the asset.
"""
if self.format is StickerFormatType.lottie:
raise TypeError('Cannot read stickers of format "lottie".')
raise TypeError('Cannot read stickers of format "lottie"')
return await super().read()

204
discord/team.py

@ -29,15 +29,19 @@ from .user import BaseUser
from .asset import Asset
from .enums import TeamMembershipState, try_enum
from typing import TYPE_CHECKING, Optional, List
from typing import TYPE_CHECKING, Optional, overload, List, Union
if TYPE_CHECKING:
from .abc import Snowflake
from .state import ConnectionState
from .types.team import (
Team as TeamPayload,
TeamMember as TeamMemberPayload,
)
from .types.user import User as UserPayload
MISSING = utils.MISSING
__all__ = (
'Team',
@ -46,7 +50,7 @@ __all__ = (
class Team:
"""Represents an application team for a bot provided by Discord.
"""Represents an application team.
Attributes
-------------
@ -57,21 +61,35 @@ class Team:
owner_id: :class:`int`
The team's owner ID.
members: List[:class:`TeamMember`]
A list of the members in the team
.. versionadded:: 1.3
A list of the members in the team.
A call to :meth:`fetch_members` may be required to populate this past the owner.
"""
if TYPE_CHECKING:
owner_id: int
members: List[TeamMember]
__slots__ = ('_state', 'id', 'name', '_icon', 'owner_id', 'members')
def __init__(self, state: ConnectionState, data: TeamPayload):
self._state: ConnectionState = state
self._update(data)
def _update(self, data: TeamPayload):
self.id: int = int(data['id'])
self.name: str = data['name']
self._icon: Optional[str] = data['icon']
self.owner_id: Optional[int] = utils._get_as_snowflake(data, 'owner_user_id')
self.members: List[TeamMember] = [TeamMember(self, self._state, member) for member in data['members']]
self.owner_id = owner_id = int(data['owner_user_id'])
self.members = members = [TeamMember(self, self._state, member) for member in data.get('members', [])]
if owner_id not in members and owner_id == self._state.self_id: # Discord moment
user: UserPayload = self._state.user._to_minimal_user_json() # type: ignore
member: TeamMemberPayload = {
'user': user,
'team_id': self.id,
'membership_state': 2,
'permissions': ['*'],
}
members.append(TeamMember(self, self._state, member))
def __repr__(self) -> str:
return f'<{self.__class__.__name__} id={self.id} name={self.name}>'
@ -88,6 +106,154 @@ class Team:
"""Optional[:class:`TeamMember`]: The team's owner."""
return utils.get(self.members, id=self.owner_id)
async def edit(
self,
*,
name: str = MISSING,
icon: Optional[bytes] = MISSING,
owner: Snowflake = MISSING,
) -> None:
"""|coro|
Edits the team.
Parameters
-----------
name: :class:`str`
The name of the team.
icon: Optional[:class:`bytes`]
The icon of the team.
owner: :class:`Snowflake`
The team's owner.
Raises
-------
Forbidden
You do not have permissions to edit the team.
HTTPException
Editing the team failed.
"""
payload = {}
if name is not MISSING:
payload['name'] = name
if icon is not MISSING:
if icon is not None:
payload['icon'] = utils._bytes_to_base64_data(icon)
else:
payload['icon'] = ''
if owner is not MISSING:
payload['owner_user_id'] = owner.id
data = await self._state.http.edit_team(self.id, payload)
self._update(data)
async def fetch_members(self) -> List[TeamMember]:
"""|coro|
Retrieves the team's members.
Returns
--------
List[:class:`TeamMember`]
The team's members.
Raises
-------
Forbidden
You do not have permissions to fetch the team's members.
HTTPException
Retrieving the team members failed.
"""
data = await self._state.http.get_team_members(self.id)
members = [TeamMember(self, self._state, member) for member in data]
self.members = members
return members
@overload
async def invite_member(self, user: BaseUser) -> TeamMember:
...
@overload
async def invite_member(self, user: str) -> TeamMember:
...
@overload
async def invite_member(self, username: str, discriminator: str) -> TeamMember:
...
async def invite_member(self, *args: Union[BaseUser, str]) -> TeamMember:
"""|coro|
Invites a member to the team.
This function can be used in multiple ways.
.. code-block:: python
# Passing a user object:
await team.invite_member(user)
# Passing a stringified user:
await team.invite_member('Jake#0001')
# Passing a username and discriminator:
await team.invite_member('Jake', '0001')
Parameters
-----------
user: Union[:class:`User`, :class:`str`]
The user to invite.
username: :class:`str`
The username of the user to invite.
discriminator: :class:`str`
The discriminator of the user to invite.
More than 2 parameters or less than 1 parameter raises a :exc:`TypeError`.
Raises
-------
Forbidden
You do not have permissions to invite the user.
:exc:`.HTTPException`
Inviting the user failed.
Returns
-------
:class:`.TeamMember`
The new member.
"""
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'invite_member() takes 1 or 2 arguments but {len(args)} were given')
state = self._state
data = await state.http.invite_team_member(self.id, username, discrim)
member = TeamMember(self, state, data)
self.members.append(member)
return member
async def delete(self) -> None:
"""|coro|
Deletes the team.
Raises
-------
Forbidden
You do not have permissions to delete the team.
HTTPException
Deleting the team failed.
"""
await self._state.http.delete_team(self.id)
class TeamMember(BaseUser):
"""Represents a team member in a team.
@ -114,18 +280,10 @@ class TeamMember(BaseUser):
Attributes
-------------
name: :class:`str`
The team member's username.
id: :class:`int`
The team member's unique ID.
discriminator: :class:`str`
The team member's discriminator. This is given when the username has conflicts.
bot: :class:`bool`
Specifies if the user is a bot account.
team: :class:`Team`
The team that the member is from.
membership_state: :class:`TeamMembershipState`
The membership state of the member (e.g. invited or accepted)
The membership state of the member (i.e. invited or accepted)
"""
__slots__ = ('team', 'membership_state', 'permissions')
@ -141,3 +299,17 @@ class TeamMember(BaseUser):
f'<{self.__class__.__name__} id={self.id} name={self.name!r} '
f'discriminator={self.discriminator!r} membership_state={self.membership_state!r}>'
)
async def remove(self) -> None:
"""|coro|
Removes the member from the team.
Raises
-------
Forbidden
You do not have permissions to remove the member.
HTTPException
Removing the member failed.
"""
await self._state.http.remove_team_member(self.team.id, self.id)

4
discord/template.py

@ -53,10 +53,6 @@ class _PartialTemplateState:
self.__state = state
self.http = _FriendlyHttpAttributeErrorHelper()
@property
def shard_count(self):
return self.__state.shard_count
@property
def user(self):
return self.__state.user

275
discord/threads.py

@ -28,12 +28,13 @@ from typing import Callable, Dict, Iterable, List, Optional, Union, TYPE_CHECKIN
from datetime import datetime
import time
import asyncio
import copy
from .mixins import Hashable
from .abc import Messageable
from .enums import ChannelType, try_enum
from .errors import ClientException
from .utils import MISSING, parse_time, _get_as_snowflake
from .errors import ClientException, InvalidData
from .utils import MISSING, parse_time, snowflake_time, _get_as_snowflake
__all__ = (
'Thread',
@ -41,13 +42,14 @@ __all__ = (
)
if TYPE_CHECKING:
from datetime import datetime
from .types.threads import (
Thread as ThreadPayload,
ThreadMember as ThreadMemberPayload,
ThreadMetadata,
ThreadArchiveDuration,
)
from .types.snowflake import SnowflakeList
from .guild import Guild
from .channel import TextChannel, CategoryChannel
from .member import Member
@ -90,9 +92,9 @@ class Thread(Messageable, Hashable):
id: :class:`int`
The thread ID.
parent_id: :class:`int`
The parent :class:`TextChannel` ID this thread belongs to.
The ID of the parent :class:`TextChannel` this thread belongs to.
owner_id: :class:`int`
The user's ID that created this thread.
The ID of the user that created this thread.
last_message_id: Optional[:class:`int`]
The last message ID of the message sent to this thread. It may
*not* point to an existing or valid message.
@ -105,9 +107,6 @@ class Thread(Messageable, Hashable):
An approximate number of messages in this thread. This caps at 50.
member_count: :class:`int`
An approximate number of members in this thread. This caps at 50.
me: Optional[:class:`ThreadMember`]
A thread member representing yourself, if you've joined the thread.
This could not be available.
archived: :class:`bool`
Whether the thread is archived.
locked: :class:`bool`
@ -115,8 +114,6 @@ class Thread(Messageable, Hashable):
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.
@ -137,13 +134,12 @@ class Thread(Messageable, Hashable):
'message_count',
'member_count',
'slowmode_delay',
'me',
'locked',
'archived',
'invitable',
'archiver_id',
'auto_archive_duration',
'archive_timestamp',
'_member_ids',
'_created_at',
)
@ -175,36 +171,39 @@ class Thread(Messageable, Hashable):
self.slowmode_delay = data.get('rate_limit_per_user', 0)
self.message_count = data['message_count']
self.member_count = data['member_count']
self._member_ids = data['member_ids_preview']
self._unroll_metadata(data['thread_metadata'])
try:
member = data['member']
except KeyError:
self.me = None
else:
self.me = ThreadMember(self, member)
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._created_at = parse_time(data.get('creation_timestamp'))
self.locked = data.get('locked', False)
self.invitable = data.get('invitable', True)
self._created_at = parse_time(data.get('create_timestamp'))
def _update(self, data):
try:
self.name = data['name']
except KeyError:
pass
old = copy.copy(self)
self.slowmode_delay = data.get('rate_limit_per_user', 0)
try:
self._unroll_metadata(data['thread_metadata'])
except KeyError:
pass
if (meta := data.get('thread_metadata')) is not None:
self._unroll_metadata(meta)
if (name := data.get('name')) is not None:
self.name = name
if (last_message_id := _get_as_snowflake(data, 'last_message_id')) is not None:
self.last_message_id = last_message_id
if (message_count := data.get('message_count')) is not None:
self.message_count = message_count
if (member_count := data.get('member_count')) is not None:
self.member_count = member_count
if (member_ids := data.get('member_ids_preview')) is not None:
self._member_ids = member_ids
attrs = [x for x in self.__slots__ if not any(y in x for y in ('member', 'guild', 'state', 'count'))]
if any(getattr(self, attr) != getattr(old, attr) for attr in attrs):
return old
@property
def type(self) -> ChannelType:
@ -213,9 +212,20 @@ class Thread(Messageable, Hashable):
@property
def parent(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: The parent channel this thread belongs to."""
"""Optional[:class:`TextChannel`]: The parent channel this thread belongs to.
There is an alias for this named :attr:`channel`.
"""
return self.guild.get_channel(self.parent_id) # type: ignore
@property
def channel(self) -> Optional[TextChannel]:
"""Optional[:class:`TextChannel`]: The parent channel this thread belongs to.
This is an alias of :attr:`parent`.
"""
return self.parent
@property
def owner(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member this thread belongs to."""
@ -226,13 +236,21 @@ class Thread(Messageable, Hashable):
""":class:`str`: The string that allows you to mention the thread."""
return f'<#{self.id}>'
@property
def created_at(self) -> datetime:
""":class:`datetime.datetime`: Returns the thread's creation time in UTC.
.. note::
This may be inaccurate for threads created before January 9th, 2022.
"""
return self._created_at or snowflake_time(self.id)
@property
def members(self) -> List[ThreadMember]:
"""List[:class:`ThreadMember`]: A list of thread members in this thread.
This requires :attr:`Intents.members` to be properly filled. Most of the time however,
this data is not provided by the gateway and a call to :meth:`fetch_members` is
needed.
Initial members are not provided by Discord. You must call :func:`fetch_members`
or have thread subscribing enabled.
"""
return list(self._members.values())
@ -298,14 +316,17 @@ class Thread(Messageable, Hashable):
return parent.category_id
@property
def created_at(self) -> Optional[datetime]:
"""An aware timestamp of when the thread was created in UTC.
.. note::
def me(self) -> Optional[ThreadMember]:
"""Optional[:class:`ThreadMember`]: A thread member representing yourself, if you've joined the thread.
This timestamp only exists for threads created after 9 January 2022, otherwise returns ``None``.
This might not be available.
"""
return self._created_at
self_id = self._state.self_id
return self._members.get(self_id) # type: ignore
@me.setter
def me(self, member) -> None:
self._members[member.id] = member
def is_private(self) -> bool:
""":class:`bool`: Whether the thread is a private thread.
@ -370,17 +391,13 @@ class Thread(Messageable, 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).
Usable only by bot accounts.
.. 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.
Parameters
-----------
@ -389,13 +406,8 @@ class Thread(Messageable, Hashable):
Raises
------
ClientException
The number of messages to delete was more than 100.
Forbidden
You do not have proper permissions to delete the messages or
you're not using a bot account.
NotFound
If single delete, then the message was already deleted.
You do not have proper permissions to delete the messages.
HTTPException
Deleting the messages failed.
"""
@ -403,18 +415,9 @@ class Thread(Messageable, Hashable):
messages = list(messages)
if len(messages) == 0:
return # do nothing
if len(messages) == 1:
message_id = messages[0].id
await self._state.http.delete_message(self.id, message_id)
return
return # Do nothing
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)
await self._state._delete_messages(self.id, messages)
async def purge(
self,
@ -425,7 +428,6 @@ class Thread(Messageable, Hashable):
after: Optional[SnowflakeTime] = None,
around: Optional[SnowflakeTime] = None,
oldest_first: Optional[bool] = False,
bulk: bool = True,
) -> List[Message]:
"""|coro|
@ -433,10 +435,8 @@ class Thread(Messageable, 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 (unless you are a user
account). 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.
Examples
---------
@ -446,8 +446,8 @@ class Thread(Messageable, Hashable):
def is_me(m):
return m.author == client.user
deleted = await thread.purge(limit=100, check=is_me)
await thread.send(f'Deleted {len(deleted)} message(s)')
deleted = await channel.purge(limit=100, check=is_me)
await channel.send(f'Deleted {len(deleted)} message(s)')
Parameters
-----------
@ -465,10 +465,6 @@ class Thread(Messageable, 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.
Raises
-------
@ -482,54 +478,30 @@ class Thread(Messageable, 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
async def _single_delete_strategy(messages: Iterable[Message]):
for m in messages:
await m.delete()
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)
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)
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)
elif count == 1:
# delete a single message
await ret[-1].delete()
# Some messages remaining to poll
to_delete = ret[-count:]
await state._delete_messages(channel_id, to_delete)
return ret
@ -542,6 +514,7 @@ class Thread(Messageable, Hashable):
invitable: bool = MISSING,
slowmode_delay: int = MISSING,
auto_archive_duration: ThreadArchiveDuration = MISSING,
reason: Optional[str] = None,
) -> Thread:
"""|coro|
@ -571,6 +544,8 @@ class Thread(Messageable, Hashable):
slowmode_delay: :class:`int`
Specifies the slowmode rate limit for user in this thread, in seconds.
A value of ``0`` disables slowmode. The maximum value possible is ``21600``.
reason: Optional[:class:`str`]
The reason for editing the thread. Shows up on the audit log.
Raises
-------
@ -598,7 +573,7 @@ class Thread(Messageable, Hashable):
if slowmode_delay is not MISSING:
payload['rate_limit_per_user'] = slowmode_delay
data = await self._state.http.edit_channel(self.id, **payload)
data = await self._state.http.edit_channel(self.id, **payload, reason=reason)
# The data payload will always be a Thread payload
return Thread(data=data, state=self._state, guild=self.guild) # type: ignore
@ -675,48 +650,35 @@ class Thread(Messageable, Hashable):
"""
await self._state.http.remove_user_from_thread(self.id, user.id)
async def fetch_member(self, user_id: int, /) -> ThreadMember:
"""|coro|
Retrieves a :class:`ThreadMember` for the given user ID.
Raises
-------
NotFound
The specified user is not a member of this thread.
HTTPException
Retrieving the member failed.
Returns
--------
:class:`ThreadMember`
The thread member from the user ID.
"""
data = await self._state.http.get_thread_member(self.id, user_id)
return ThreadMember(parent=self, data=data)
async def fetch_members(self) -> List[ThreadMember]:
"""|coro|
Retrieves all :class:`ThreadMember` that are in this thread.
This requires :attr:`Intents.members` to get information about members
other than yourself.
Retrieves all :class:`ThreadMember` that are in this thread,
along with their respective :class:`Member`.
Raises
-------
HTTPException
Retrieving the members failed.
InvalidData
Discord didn't respond with the members.
Returns
--------
List[:class:`ThreadMember`]
All thread members in the thread.
"""
state = self._state
await state.ws.request_lazy_guild(self.parent.guild.id, thread_member_lists=[self.id]) # type: ignore
future = state.ws.wait_for('thread_member_list_update', lambda d: int(d['thread_id']) == self.id)
try:
data = await asyncio.wait_for(future, timeout=15)
except asyncio.TimeoutError as exc:
raise InvalidData('Didn\'t receieve a response from Discord') from exc
members = await self._state.http.get_thread_members(self.id)
return [ThreadMember(parent=self, data=data) for data in members]
members = [ThreadMember(self, {'member': member}) for member in data['members']] # type: ignore
for m in members:
self._add_member(m)
return self.members # Includes correct self.me
async def delete(self):
"""|coro|
@ -752,13 +714,13 @@ class Thread(Messageable, Hashable):
:class:`PartialMessage`
The partial message.
"""
from .message import PartialMessage
return PartialMessage(channel=self, id=message_id)
def _add_member(self, member: ThreadMember) -> None:
self._members[member.id] = member
if member.id != self._state.self_id:
self._members[member.id] = member
def _pop_member(self, member_id: int) -> Optional[ThreadMember]:
return self._members.pop(member_id, None)
@ -793,8 +755,12 @@ class ThreadMember(Hashable):
The thread member's ID.
thread_id: :class:`int`
The thread's ID.
joined_at: :class:`datetime.datetime`
joined_at: Optional[:class:`datetime.datetime`]
The time the member joined the thread in UTC.
Only reliably available for yourself or members joined while the user is connected to the gateway.
flags: :class:`int`
The thread member's flags. Will be its own class in the future.
Only reliably available for yourself or members joined while the user is connected to the gateway.
"""
__slots__ = (
@ -815,21 +781,40 @@ class ThreadMember(Hashable):
return f'<ThreadMember id={self.id} thread_id={self.thread_id} joined_at={self.joined_at!r}>'
def _from_data(self, data: ThreadMemberPayload):
state = self._state
try:
self.id = int(data['user_id'])
except KeyError:
assert self._state.self_id is not None
self.id = self._state.self_id
assert state.self_id is not None
self.id = state.self_id
try:
self.thread_id = int(data['id'])
except KeyError:
self.thread_id = self.parent.id
self.joined_at = parse_time(data['join_timestamp'])
self.flags = data['flags']
self.joined_at = parse_time(data.get('join_timestamp'))
self.flags = data.get('flags')
if (mdata := data.get('member')) is not None:
guild = self.parent.parent.guild # type: ignore
mdata['guild_id'] = guild.id
self.id = user_id = int(data['user_id'])
mdata['presence'] = data.get('presence')
if guild.get_member(user_id) is not None:
state.parse_guild_member_update(mdata)
else:
state.parse_guild_member_add(mdata)
@property
def thread(self) -> Thread:
""":class:`Thread`: The thread this member belongs to."""
return self.parent
@property
def member(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member this :class:`ThreadMember` represents. If the member
is not cached then this will be ``None``.
"""
return self.parent.parent.guild.get_member(self.id) # type: ignore

327
discord/tracking.py

@ -0,0 +1,327 @@
"""
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 base64 import b64encode
import json
from typing import Any, Dict, overload, Optional, TYPE_CHECKING
from .utils import MISSING
if TYPE_CHECKING:
from .enums import ChannelType
from .types.snowflake import Snowflake
from .state import ConnectionState
__all__ = (
'ContextProperties',
'Tracking',
)
class ContextProperties: # Thank you Discord-S.C.U.M
"""Represents the Discord X-Context-Properties header.
This header is essential for certain actions (e.g. joining guilds, friend requesting).
"""
__slots__ = ('_data', 'value')
def __init__(self, data) -> None:
self._data: Dict[str, Snowflake] = data
self.value: str = self._encode_data(data)
def _encode_data(self, data) -> str:
library = {
'Friends': 'eyJsb2NhdGlvbiI6IkZyaWVuZHMifQ==',
'ContextMenu': 'eyJsb2NhdGlvbiI6IkNvbnRleHRNZW51In0=',
'User Profile': 'eyJsb2NhdGlvbiI6IlVzZXIgUHJvZmlsZSJ9',
'Add Friend': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmQifQ==',
'Guild Header': 'eyJsb2NhdGlvbiI6Ikd1aWxkIEhlYWRlciJ9',
'Group DM': 'eyJsb2NhdGlvbiI6Ikdyb3VwIERNIn0=',
'DM Channel': 'eyJsb2NhdGlvbiI6IkRNIENoYW5uZWwifQ==',
'/app': 'eyJsb2NhdGlvbiI6ICIvYXBwIn0=',
'Login': 'eyJsb2NhdGlvbiI6IkxvZ2luIn0=',
'Register': 'eyJsb2NhdGlvbiI6IlJlZ2lzdGVyIn0=',
'Verify Email': 'eyJsb2NhdGlvbiI6IlZlcmlmeSBFbWFpbCJ9',
'New Group DM': 'eyJsb2NhdGlvbiI6Ik5ldyBHcm91cCBETSJ9',
'Add Friends to DM': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmRzIHRvIERNIn0=',
'None': 'e30='
}
try:
return library[data.get('location', 'None')]
except KeyError:
return b64encode(json.dumps(data, separators=(',', ':')).encode()).decode('utf-8')
@classmethod
def _empty(cls) -> ContextProperties:
return cls({})
@classmethod
def _from_friends_page(cls) -> ContextProperties:
data = {
'location': 'Friends'
}
return cls(data)
@classmethod
def _from_context_menu(cls) -> ContextProperties:
data = {
'location': 'ContextMenu'
}
return cls(data)
@classmethod
def _from_user_profile(cls) -> ContextProperties:
data = {
'location': 'User Profile'
}
return cls(data)
@classmethod
def _from_add_friend_page(cls) -> ContextProperties:
data = {
'location': 'Add Friend'
}
return cls(data)
@classmethod
def _from_guild_header_menu(cls) -> ContextProperties:
data = {
'location': 'Guild Header'
}
return cls(data)
@classmethod
def _from_group_dm(cls) -> ContextProperties:
data = {
'location': 'Group DM'
}
return cls(data)
@classmethod
def _from_new_group_dm(cls) -> ContextProperties:
data = {
'location': 'New Group DM'
}
return cls(data)
@classmethod
def _from_dm_channel(cls) -> ContextProperties:
data = {
'location': 'DM Channel'
}
return cls(data)
@classmethod
def _from_add_to_dm(cls) -> ContextProperties:
data = {
'location': 'Add Friends to DM'
}
return cls(data)
@classmethod
def _from_app(cls) -> ContextProperties:
data = {
'location': '/app'
}
return cls(data)
@classmethod
def _from_login(cls) -> ContextProperties:
data = {
'location': 'Login'
}
return cls(data)
@classmethod
def _from_register(cls) -> ContextProperties:
data = {
'location': 'Register'
}
return cls(data)
@classmethod
def _from_verification(cls) -> ContextProperties:
data = {
'location': 'Verify Email'
}
return cls(data)
@classmethod
def _from_accept_invite_page(
cls,
*,
guild_id: Snowflake = MISSING,
channel_id: Snowflake = MISSING,
channel_type: ChannelType = MISSING,
) -> ContextProperties:
data: Dict[str, Snowflake] = {
'location': 'Accept Invite Page',
}
if guild_id is not MISSING:
data['location_guild_id'] = str(guild_id)
if channel_id is not MISSING:
data['location_channel_id'] = str(channel_id)
if channel_type is not MISSING:
data['location_channel_type'] = int(channel_type)
return cls(data)
@classmethod
def _from_join_guild_popup(
cls,
*,
guild_id: Snowflake = MISSING,
channel_id: Snowflake = MISSING,
channel_type: ChannelType = MISSING,
) -> ContextProperties:
data: Dict[str, Snowflake] = {
'location': 'Join Guild',
}
if guild_id is not MISSING:
data['location_guild_id'] = str(guild_id)
if channel_id is not MISSING:
data['location_channel_id'] = str(channel_id)
if channel_type is not MISSING:
data['location_channel_type'] = int(channel_type)
return cls(data)
@classmethod
def _from_invite_embed(
cls,
*,
guild_id: Optional[Snowflake],
channel_id: Snowflake,
message_id: Snowflake,
channel_type: Optional[ChannelType],
) -> ContextProperties:
data = {
'location': 'Invite Button Embed',
'location_guild_id': str(guild_id) if guild_id else None,
'location_channel_id': str(channel_id),
'location_channel_type': int(channel_type) if channel_type else None,
'location_message_id': str(message_id),
}
return cls(data)
@property
def location(self) -> Optional[str]:
return self._data.get('location') # type: ignore
@property
def guild_id(self) -> Optional[int]:
data = self._data.get('location_guild_id')
if data is not None:
return int(data)
@property
def channel_id(self) -> Optional[int]:
data = self._data.get('location_channel_id')
if data is not None:
return int(data)
@property
def channel_type(self) -> Optional[int]:
return self._data.get('location_channel_type') # type: ignore
@property
def message_id(self) -> Optional[int]:
data = self._data.get('location_message_id')
if data is not None:
return int(data)
def __bool__(self) -> bool:
return self.value is not None
def __str__(self) -> str:
return self._data.get('location', 'None') # type: ignore
def __repr__(self) -> str:
return f'<ContextProperties location={self.location}>'
def __eq__(self, other) -> bool:
return isinstance(other, ContextProperties) and self.value == other.value
def __ne__(self, other) -> bool:
return not self.__eq__(other)
class Tracking:
"""Represents your Discord tracking settings.
Attributes
----------
personalization: :class:`bool`
Whether you have consented to your data being used for personalization.
usage_statistics: :class:`bool`
Whether you have consented to your data being used for usage statistics.
"""
__slots__ = ('_state', 'personalization', 'usage_statistics')
def __init__(self, *, data: Dict[str, Dict[str, bool]], state: ConnectionState) -> None:
self._state = state
self._update(data)
def __bool__(self) -> bool:
return any({self.personalization, self.usage_statistics})
def _update(self, data: Dict[str, Dict[str, bool]]):
self.personalization = data.get('personalization', {}).get('consented', False)
self.usage_statistics = data.get('usage_statistics', {}).get('consented', False)
@overload
async def edit(self) -> None:
...
@overload
async def edit(
self,
*,
personalization: bool = ...,
usage_statistics: bool = ...,
) -> None:
...
async def edit(self, **kwargs) -> None:
"""|coro|
Edits your tracking settings.
Parameters
----------
personalization: :class:`bool`
Whether you have consented to your data being used for personalization.
usage_statistics: :class:`bool`
Whether you have consented to your data being used for usage statistics.
"""
payload = {
'grant': [k for k, v in kwargs.items() if v is True],
'revoke': [k for k, v in kwargs.items() if v is False],
}
data = await self._state.http.edit_tracking(payload)
self._update(data)

2
discord/types/activity.py

@ -34,7 +34,7 @@ StatusType = Literal['idle', 'dnd', 'online', 'offline']
class PartialPresenceUpdate(TypedDict):
user: User
guild_id: Snowflake
guild_id: Optional[Snowflake]
status: StatusType
activities: List[Activity]
client_status: ClientStatus

21
discord/types/appinfo.py

@ -50,14 +50,6 @@ class _AppInfoOptional(TypedDict, total=False):
hook: bool
max_participants: int
class AppInfo(BaseAppInfo, _AppInfoOptional):
rpc_origins: List[str]
owner: User
bot_public: bool
bot_require_code_grant: bool
class _PartialAppInfoOptional(TypedDict, total=False):
rpc_origins: List[str]
cover_image: str
@ -71,7 +63,12 @@ class _PartialAppInfoOptional(TypedDict, total=False):
class PartialAppInfo(_PartialAppInfoOptional, BaseAppInfo):
pass
class GatewayAppInfo(TypedDict):
id: Snowflake
flags: int
class AppInfo(PartialAppInfo, _AppInfoOptional):
owner: User
integration_public: bool
integration_require_code_grant: bool
secret: str
verification_state: int
store_application_state: int
rpc_application_state: int
interactions_endpoint_url: str

4
discord/types/components.py

@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import List, Literal, TypedDict, Union
from typing import List, Literal, Optional, TypedDict, Union
from .emoji import PartialEmoji
ComponentType = Literal[1, 2, 3, 4]
@ -76,7 +76,7 @@ class SelectMenu(_SelectMenuOptional):
class _TextInputOptional(TypedDict, total=False):
placeholder: str
value: str
value: Optional[str]
required: bool
min_length: int
max_length: int

54
discord/types/gateway.py

@ -22,8 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from typing import List, Literal, Optional, TypedDict
from typing import List, Literal, Optional, TypedDict, Union
from .activity import PartialPresenceUpdate
from .voice import GuildVoiceState
@ -37,41 +36,55 @@ from .member import MemberWithUser
from .snowflake import Snowflake
from .message import Message
from .sticker import GuildSticker
from .appinfo import GatewayAppInfo, PartialAppInfo
from .guild import Guild, UnavailableGuild
from .appinfo import PartialAppInfo
from .guild import Guild, UnavailableGuild, SupplementalGuild
from .user import User
from .threads import Thread, ThreadMember
from .scheduled_event import GuildScheduledEvent
from .channel import DMChannel, GroupDMChannel
class SessionStartLimit(TypedDict):
total: int
remaining: int
reset_after: int
max_concurrency: int
PresenceUpdateEvent = PartialPresenceUpdate
class Gateway(TypedDict):
url: str
class GatewayBot(Gateway):
shards: int
session_start_limit: SessionStartLimit
class ShardInfo(TypedDict):
shard_id: int
shard_count: int
class ReadyEvent(TypedDict):
v: int
user: User
guilds: List[UnavailableGuild]
analytics_token: str
connected_accounts: List[dict]
country_code: str
friend_suggestion_count: int
geo_ordered_rtc_regions: List[str]
guilds: List[Guild]
merged_members: List[List[MemberWithUser]]
private_channels: List[Union[DMChannel, GroupDMChannel]]
relationships: List[dict]
sessions: List[dict]
session_id: str
shard: ShardInfo
application: GatewayAppInfo
user: User
user_guild_settings: dict
user_settings: dict
user_settings_proto: str
users: List[User]
v: int
class MergedPresences(TypedDict):
friends: List[PresenceUpdateEvent]
guilds: List[List[PresenceUpdateEvent]]
class ReadySupplementalEvent(TypedDict):
guilds: List[SupplementalGuild]
merged_members: List[List[MemberWithUser]]
merged_presences: MergedPresences
ResumedEvent = Literal[None]
@ -146,9 +159,6 @@ class MessageReactionRemoveEmojiEvent(_MessageReactionRemoveEmojiEventOptional):
InteractionCreateEvent = Interaction
PresenceUpdateEvent = PartialPresenceUpdate
UserUpdateEvent = User

9
discord/types/guild.py

@ -32,7 +32,7 @@ from .voice import GuildVoiceState
from .welcome_screen import WelcomeScreen
from .activity import PartialPresenceUpdate
from .role import Role
from .member import Member
from .member import Member, MemberWithUser
from .emoji import Emoji
from .user import User
from .sticker import GuildSticker
@ -62,7 +62,7 @@ class _GuildOptional(TypedDict, total=False):
large: bool
member_count: int
voice_states: List[GuildVoiceState]
members: List[Member]
members: List[MemberWithUser]
channels: List[GuildChannel]
presences: List[PartialPresenceUpdate]
threads: List[Thread]
@ -175,3 +175,8 @@ class _RolePositionRequired(TypedDict):
class RolePositionUpdate(_RolePositionRequired, total=False):
position: Optional[Snowflake]
class SupplementalGuild(UnavailableGuild):
embedded_activities: list
voice_states: List[GuildVoiceState]

239
discord/types/interactions.py

@ -1,239 +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, Dict, List, Literal, TypedDict, Union
from .channel import ChannelTypeWithoutThread, ThreadMetadata
from .threads import ThreadType
from .member import Member
from .message import Attachment
from .role import Role
from .snowflake import Snowflake
from .user import User
if TYPE_CHECKING:
from .message import Message
InteractionType = Literal[1, 2, 3, 4, 5]
class _BasePartialChannel(TypedDict):
id: Snowflake
name: str
permissions: str
class PartialChannel(_BasePartialChannel):
type: ChannelTypeWithoutThread
class PartialThread(_BasePartialChannel):
type: ThreadType
thread_metadata: ThreadMetadata
parent_id: Snowflake
class ResolvedData(TypedDict, total=False):
users: Dict[str, User]
members: Dict[str, Member]
roles: Dict[str, Role]
channels: Dict[str, Union[PartialChannel, PartialThread]]
messages: Dict[str, Message]
attachments: Dict[str, Attachment]
class _BaseApplicationCommandInteractionDataOption(TypedDict):
name: str
class _CommandGroupApplicationCommandInteractionDataOption(_BaseApplicationCommandInteractionDataOption):
type: Literal[1, 2]
options: List[ApplicationCommandInteractionDataOption]
class _BaseValueApplicationCommandInteractionDataOption(_BaseApplicationCommandInteractionDataOption, total=False):
focused: bool
class _StringValueApplicationCommandInteractionDataOption(_BaseValueApplicationCommandInteractionDataOption):
type: Literal[3]
value: str
class _IntegerValueApplicationCommandInteractionDataOption(_BaseValueApplicationCommandInteractionDataOption):
type: Literal[4]
value: int
class _BooleanValueApplicationCommandInteractionDataOption(_BaseValueApplicationCommandInteractionDataOption):
type: Literal[5]
value: bool
class _SnowflakeValueApplicationCommandInteractionDataOption(_BaseValueApplicationCommandInteractionDataOption):
type: Literal[6, 7, 8, 9, 11]
value: Snowflake
class _NumberValueApplicationCommandInteractionDataOption(_BaseValueApplicationCommandInteractionDataOption):
type: Literal[10]
value: float
_ValueApplicationCommandInteractionDataOption = Union[
_StringValueApplicationCommandInteractionDataOption,
_IntegerValueApplicationCommandInteractionDataOption,
_BooleanValueApplicationCommandInteractionDataOption,
_SnowflakeValueApplicationCommandInteractionDataOption,
_NumberValueApplicationCommandInteractionDataOption,
]
ApplicationCommandInteractionDataOption = Union[
_CommandGroupApplicationCommandInteractionDataOption,
_ValueApplicationCommandInteractionDataOption,
]
class _BaseApplicationCommandInteractionDataOptional(TypedDict, total=False):
resolved: ResolvedData
class _BaseApplicationCommandInteractionData(_BaseApplicationCommandInteractionDataOptional):
id: Snowflake
name: str
class ChatInputApplicationCommandInteractionData(_BaseApplicationCommandInteractionData, total=False):
type: Literal[1]
options: List[ApplicationCommandInteractionDataOption]
class _BaseNonChatInputApplicationCommandInteractionData(_BaseApplicationCommandInteractionData):
target_id: Snowflake
class UserApplicationCommandInteractionData(_BaseNonChatInputApplicationCommandInteractionData):
type: Literal[2]
class MessageApplicationCommandInteractionData(_BaseNonChatInputApplicationCommandInteractionData):
type: Literal[3]
ApplicationCommandInteractionData = Union[
ChatInputApplicationCommandInteractionData,
UserApplicationCommandInteractionData,
MessageApplicationCommandInteractionData,
]
class _BaseMessageComponentInteractionData(TypedDict):
custom_id: str
class ButtonMessageComponentInteractionData(_BaseMessageComponentInteractionData):
type: Literal[2]
class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData):
component_type: Literal[3]
values: List[str]
MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData]
class ModalSubmitTextInputInteractionData(TypedDict):
type: Literal[4]
custom_id: str
value: str
ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData
class ModalSubmitActionRowInteractionData(TypedDict):
type: Literal[1]
components: List[ModalSubmitComponentItemInteractionData]
ModalSubmitComponentInteractionData = Union[ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData]
class ModalSubmitInteractionData(TypedDict):
custom_id: str
components: List[ModalSubmitActionRowInteractionData]
InteractionData = Union[
ApplicationCommandInteractionData,
MessageComponentInteractionData,
ModalSubmitInteractionData,
]
class _BaseInteractionOptional(TypedDict, total=False):
guild_id: Snowflake
channel_id: Snowflake
class _BaseInteraction(_BaseInteractionOptional):
id: Snowflake
application_id: Snowflake
token: str
version: Literal[1]
class PingInteraction(_BaseInteraction):
type: Literal[1]
class ApplicationCommandInteraction(_BaseInteraction):
type: Literal[2, 4]
data: ApplicationCommandInteractionData
class MessageComponentInteraction(_BaseInteraction):
type: Literal[3]
data: MessageComponentInteractionData
class ModalSubmitInteraction(_BaseInteraction):
type: Literal[5]
data: ModalSubmitInteractionData
Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction]
class MessageInteraction(TypedDict):
id: Snowflake
type: InteractionType
name: str
user: User

2
discord/types/message.py

@ -118,7 +118,7 @@ class _MessageOptional(TypedDict, total=False):
components: List[Component]
MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21]
MessageType = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 18, 19, 20, 21, 22, 23]
class Message(_MessageOptional):

4
discord/types/role.py

@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import TypedDict, Optional
from typing import Optional, TypedDict
from .snowflake import Snowflake
@ -32,6 +32,8 @@ class _RoleOptional(TypedDict, total=False):
icon: Optional[str]
unicode_emoji: Optional[str]
tags: RoleTags
icon: Optional[str]
unicode_emoji: Optional[str]
class Role(_RoleOptional):

15
discord/types/scheduled_event.py

@ -103,14 +103,17 @@ GuildScheduledEventWithUserCount = Union[
]
class ScheduledEventUser(TypedDict):
guild_scheduled_event_id: Snowflake
user: User
class ScheduledEventUser(User):
...
class ScheduledEventUserWithMember(ScheduledEventUser):
member: Member
guild_member: Member
class ScheduledEventUsers(TypedDict):
users: List[ScheduledEventUser]
ScheduledEventUsers = List[ScheduledEventUser]
ScheduledEventUsersWithMember = List[ScheduledEventUserWithMember]
class ScheduledEventUsersWithMember(TypedDict):
users: ScheduledEventUserWithMember

6
discord/types/team.py

@ -26,12 +26,12 @@ from __future__ import annotations
from typing import TypedDict, List, Optional
from .user import PartialUser
from .user import User
from .snowflake import Snowflake
class TeamMember(TypedDict):
user: PartialUser
user: User
membership_state: int
permissions: List[str]
team_id: Snowflake
@ -40,6 +40,6 @@ class TeamMember(TypedDict):
class Team(TypedDict):
id: Snowflake
name: str
owner_id: Snowflake
owner_user_id: Snowflake
members: List[TeamMember]
icon: Optional[str]

1
discord/types/threads.py

@ -68,6 +68,7 @@ class Thread(_ThreadOptional):
message_count: int
rate_limit_per_user: int
thread_metadata: ThreadMetadata
member_ids_preview: List[Snowflake]
class ThreadPaginationPayload(TypedDict):

7
discord/types/user.py

@ -40,9 +40,14 @@ class User(PartialUser, total=False):
bot: bool
system: bool
mfa_enabled: bool
local: str
locale: str
verified: bool
email: Optional[str]
flags: int
premium_type: PremiumType
public_flags: int
banner: Optional[str]
accent_color: Optional[int]
bio: str
analytics_token: str
phone: Optional[str]

17
discord/ui/__init__.py

@ -1,17 +0,0 @@
"""
discord.ui
~~~~~~~~~~~
Bot UI Kit helper for the Discord API
:copyright: (c) 2015-present Rapptz
:license: MIT, see LICENSE for more details.
"""
from .view import *
from .modal import *
from .item import *
from .button import *
from .select import *
from .text_input import *

291
discord/ui/button.py

@ -1,291 +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 Callable, Optional, TYPE_CHECKING, Tuple, TypeVar, Union
import inspect
import os
from .item import Item, ItemCallbackType
from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji, _EmojiTag
from ..components import Button as ButtonComponent
__all__ = (
'Button',
'button',
)
if TYPE_CHECKING:
from typing_extensions import Self
from .view import View
from ..emoji import Emoji
V = TypeVar('V', bound='View', covariant=True)
class Button(Item[V]):
"""Represents a UI button.
.. versionadded:: 2.0
Parameters
------------
style: :class:`discord.ButtonStyle`
The style of the button.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
url: Optional[:class:`str`]
The URL this button sends you to.
disabled: :class:`bool`
Whether the button is disabled or not.
label: Optional[:class:`str`]
The label of the button, if any.
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]]
The emoji of the button, if available.
row: Optional[:class:`int`]
The relative row this button belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
"""
__item_repr_attributes__: Tuple[str, ...] = (
'style',
'url',
'disabled',
'label',
'emoji',
'row',
)
def __init__(
self,
*,
style: ButtonStyle = ButtonStyle.secondary,
label: Optional[str] = None,
disabled: bool = False,
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
row: Optional[int] = None,
):
super().__init__()
if custom_id is not None and url is not None:
raise TypeError('cannot mix both url and custom_id with Button')
self._provided_custom_id = custom_id is not None
if url is None and custom_id is None:
custom_id = os.urandom(16).hex()
if url is not None:
style = ButtonStyle.link
if emoji is not None:
if isinstance(emoji, str):
emoji = PartialEmoji.from_str(emoji)
elif isinstance(emoji, _EmojiTag):
emoji = emoji._to_partial()
else:
raise TypeError(f'expected emoji to be str, Emoji, or PartialEmoji not {emoji.__class__}')
self._underlying = ButtonComponent._raw_construct(
type=ComponentType.button,
custom_id=custom_id,
url=url,
disabled=disabled,
label=label,
style=style,
emoji=emoji,
)
self.row = row
@property
def style(self) -> ButtonStyle:
""":class:`discord.ButtonStyle`: The style of the button."""
return self._underlying.style
@style.setter
def style(self, value: ButtonStyle):
self._underlying.style = value
@property
def custom_id(self) -> Optional[str]:
"""Optional[:class:`str`]: The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
"""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: Optional[str]):
if value is not None and not isinstance(value, str):
raise TypeError('custom_id must be None or str')
self._underlying.custom_id = value
@property
def url(self) -> Optional[str]:
"""Optional[:class:`str`]: The URL this button sends you to."""
return self._underlying.url
@url.setter
def url(self, value: Optional[str]):
if value is not None and not isinstance(value, str):
raise TypeError('url must be None or str')
self._underlying.url = value
@property
def disabled(self) -> bool:
""":class:`bool`: Whether the button is disabled or not."""
return self._underlying.disabled
@disabled.setter
def disabled(self, value: bool):
self._underlying.disabled = bool(value)
@property
def label(self) -> Optional[str]:
"""Optional[:class:`str`]: The label of the button, if available."""
return self._underlying.label
@label.setter
def label(self, value: Optional[str]):
self._underlying.label = str(value) if value is not None else value
@property
def emoji(self) -> Optional[PartialEmoji]:
"""Optional[:class:`.PartialEmoji`]: The emoji of the button, if available."""
return self._underlying.emoji
@emoji.setter
def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]): # type: ignore
if value is not None:
if isinstance(value, str):
self._underlying.emoji = PartialEmoji.from_str(value)
elif isinstance(value, _EmojiTag):
self._underlying.emoji = value._to_partial()
else:
raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__} instead')
else:
self._underlying.emoji = None
@classmethod
def from_component(cls, button: ButtonComponent) -> Self:
return cls(
style=button.style,
label=button.label,
disabled=button.disabled,
custom_id=button.custom_id,
url=button.url,
emoji=button.emoji,
row=None,
)
@property
def type(self) -> ComponentType:
return self._underlying.type
def to_component_dict(self):
return self._underlying.to_dict()
def is_dispatchable(self) -> bool:
return self.custom_id is not None
def is_persistent(self) -> bool:
if self.style is ButtonStyle.link:
return self.url is not None
return super().is_persistent()
def refresh_component(self, button: ButtonComponent) -> None:
self._underlying = button
def button(
*,
label: Optional[str] = None,
custom_id: Optional[str] = None,
disabled: bool = False,
style: ButtonStyle = ButtonStyle.secondary,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
row: Optional[int] = None,
) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]:
"""A decorator that attaches a button to a component.
The function being decorated should have three parameters, ``self`` representing
the :class:`discord.ui.View`, the :class:`discord.ui.Button` being pressed and
the :class:`discord.Interaction` you receive.
.. note::
Buttons with a URL cannot be created with this function.
Consider creating a :class:`Button` manually instead.
This is because buttons with a URL do not have a callback
associated with them since Discord does not do any processing
with it.
Parameters
------------
label: Optional[:class:`str`]
The label of the button, if any.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
It is recommended not to set this parameter to prevent conflicts.
style: :class:`.ButtonStyle`
The style of the button. Defaults to :attr:`.ButtonStyle.grey`.
disabled: :class:`bool`
Whether the button is disabled or not. Defaults to ``False``.
emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]]
The emoji of the button. This can be in string form or a :class:`.PartialEmoji`
or a full :class:`.Emoji`.
row: Optional[:class:`int`]
The relative row this button belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
"""
def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]:
if not inspect.iscoroutinefunction(func):
raise TypeError('button function must be a coroutine function')
func.__discord_ui_model_type__ = Button
func.__discord_ui_model_kwargs__ = {
'style': style,
'custom_id': custom_id,
'url': None,
'disabled': disabled,
'label': label,
'emoji': emoji,
'row': row,
}
return func
return decorator # type: ignore

133
discord/ui/item.py

@ -1,133 +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, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar
from ..interactions import Interaction
# fmt: off
__all__ = (
'Item',
)
# fmt: on
if TYPE_CHECKING:
from ..enums import ComponentType
from .view import View
from ..components import Component
I = TypeVar('I', bound='Item')
V = TypeVar('V', bound='View', covariant=True)
ItemCallbackType = Callable[[V, I, Interaction], Coroutine[Any, Any, Any]]
class Item(Generic[V]):
"""Represents the base UI item that all UI components inherit from.
The current UI items supported are:
- :class:`discord.ui.Button`
- :class:`discord.ui.Select`
.. versionadded:: 2.0
"""
__item_repr_attributes__: Tuple[str, ...] = ('row',)
def __init__(self):
self._view: Optional[V] = None
self._row: Optional[int] = None
self._rendered_row: Optional[int] = None
# This works mostly well but there is a gotcha with
# the interaction with from_component, since that technically provides
# a custom_id most dispatchable items would get this set to True even though
# it might not be provided by the library user. However, this edge case doesn't
# actually affect the intended purpose of this check because from_component is
# only called upon edit and we're mainly interested during initial creation time.
self._provided_custom_id: bool = False
def to_component_dict(self) -> Dict[str, Any]:
raise NotImplementedError
def refresh_component(self, component: Component) -> None:
return None
def refresh_state(self, data: Dict[str, Any]) -> None:
return None
@classmethod
def from_component(cls: Type[I], component: Component) -> I:
return cls()
@property
def type(self) -> ComponentType:
raise NotImplementedError
def is_dispatchable(self) -> bool:
return False
def is_persistent(self) -> bool:
return self._provided_custom_id
def __repr__(self) -> str:
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__)
return f'<{self.__class__.__name__} {attrs}>'
@property
def row(self) -> Optional[int]:
return self._row
@row.setter
def row(self, value: Optional[int]):
if value is None:
self._row = None
elif 5 > value >= 0:
self._row = value
else:
raise ValueError('row cannot be negative or greater than or equal to 5')
@property
def width(self) -> int:
return 1
@property
def view(self) -> Optional[V]:
"""Optional[:class:`View`]: The underlying view for this item."""
return self._view
async def callback(self, interaction: Interaction):
"""|coro|
The callback associated with this UI item.
This can be overriden by subclasses.
Parameters
-----------
interaction: :class:`.Interaction`
The interaction that triggered this UI item.
"""
pass

212
discord/ui/modal.py

@ -1,212 +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 asyncio
import logging
import os
import sys
import time
import traceback
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, ClassVar, List
from ..utils import MISSING, find
from .item import Item
from .view import View
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
# fmt: off
__all__ = (
'Modal',
)
# fmt: on
_log = logging.getLogger(__name__)
class Modal(View):
"""Represents a UI modal.
This object must be inherited to create a modal popup window within discord.
.. versionadded:: 2.0
Examples
----------
.. code-block:: python3
from discord import ui
class Questionnaire(ui.Modal, title='Questionnaire Response'):
name = ui.TextInput(label='Name')
answer = ui.TextInput(label='Answer', style=discord.TextStyle.paragraph)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.send_message(f'Thanks for your response, {self.name}!', ephemeral=True)
Parameters
-----------
title: :class:`str`
The title of the modal.
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
custom_id: :class:`str`
The ID of the modal that gets received during an interaction.
If not given then one is generated for you.
Attributes
------------
timeout: Optional[:class:`float`]
Timeout from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
title: :class:`str`
The title of the modal.
children: List[:class:`Item`]
The list of children attached to this view.
custom_id: :class:`str`
The ID of the modal that gets received during an interaction.
"""
if TYPE_CHECKING:
title: str
__discord_ui_modal__ = True
__modal_children_items__: ClassVar[Dict[str, Item]] = {}
def __init_subclass__(cls, *, title: str = MISSING) -> None:
if title is not MISSING:
cls.title = title
children = {}
for base in reversed(cls.__mro__):
for name, member in base.__dict__.items():
if isinstance(member, Item):
children[name] = member
cls.__modal_children_items__ = children
def _init_children(self) -> List[Item]:
children = []
for name, item in self.__modal_children_items__.items():
item = deepcopy(item)
setattr(self, name, item)
item._view = self
children.append(item)
return children
def __init__(
self,
*,
title: str = MISSING,
timeout: Optional[float] = None,
custom_id: str = MISSING,
) -> None:
if title is MISSING and getattr(self, 'title', MISSING) is MISSING:
raise ValueError('Modal must have a title')
elif title is not MISSING:
self.title = title
self.custom_id: str = os.urandom(16).hex() if custom_id is MISSING else custom_id
super().__init__(timeout=timeout)
async def on_submit(self, interaction: Interaction):
"""|coro|
Called when the modal is submitted.
Parameters
-----------
interaction: :class:`.Interaction`
The interaction that submitted this modal.
"""
pass
async def on_error(self, error: Exception, interaction: Interaction) -> None:
"""|coro|
A callback that is called when :meth:`on_submit`
fails with an error.
The default implementation prints the traceback to stderr.
Parameters
-----------
error: :class:`Exception`
The exception that was raised.
interaction: :class:`~discord.Interaction`
The interaction that led to the failure.
"""
print(f'Ignoring exception in modal {self}:', file=sys.stderr)
traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr)
def refresh(self, components: Sequence[ModalSubmitComponentInteractionDataPayload]):
for component in components:
if component['type'] == 1:
self.refresh(component['components'])
else:
item = find(lambda i: i.custom_id == component['custom_id'], self.children) # type: ignore
if item is None:
_log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id'])
continue
item.refresh_state(component) # type: ignore
async def _scheduled_task(self, interaction: Interaction):
try:
if self.timeout:
self.__timeout_expiry = time.monotonic() + self.timeout
allow = await self.interaction_check(interaction)
if not allow:
return
await self.on_submit(interaction)
if not interaction.response._responded:
await interaction.response.defer()
except Exception as e:
return await self.on_error(e, interaction)
else:
# No error, so assume this will always happen
# In the future, maybe this will require checking if we set an error response.
self.stop()
def _dispatch_submit(self, interaction: Interaction) -> None:
asyncio.create_task(self._scheduled_task(interaction), name=f'discord-ui-modal-dispatch-{self.id}')
def to_dict(self) -> Dict[str, Any]:
payload = {
'custom_id': self.custom_id,
'title': self.title,
'components': self.to_components(),
}
return payload

355
discord/ui/select.py

@ -1,355 +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 List, Optional, TYPE_CHECKING, Tuple, TypeVar, Callable, Union
import inspect
import os
from .item import Item, ItemCallbackType
from ..enums import ComponentType
from ..partial_emoji import PartialEmoji
from ..emoji import Emoji
from ..utils import MISSING
from ..components import (
SelectOption,
SelectMenu,
)
__all__ = (
'Select',
'select',
)
if TYPE_CHECKING:
from typing_extensions import Self
from .view import View
from ..types.components import SelectMenu as SelectMenuPayload
from ..types.interactions import (
MessageComponentInteractionData,
)
V = TypeVar('V', bound='View', covariant=True)
class Select(Item[V]):
"""Represents a UI select menu.
This is usually represented as a drop down menu.
In order to get the selected items that the user has chosen, use :attr:`Select.values`.
.. versionadded:: 2.0
Parameters
------------
custom_id: :class:`str`
The ID of the select menu that gets received during an interaction.
If not given then one is generated for you.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
max_values: :class:`int`
The maximum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
options: List[:class:`discord.SelectOption`]
A list of options that can be selected in this menu.
disabled: :class:`bool`
Whether the select is disabled or not.
row: Optional[:class:`int`]
The relative row this select menu belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
"""
__item_repr_attributes__: Tuple[str, ...] = (
'placeholder',
'min_values',
'max_values',
'options',
'disabled',
)
def __init__(
self,
*,
custom_id: str = MISSING,
placeholder: Optional[str] = None,
min_values: int = 1,
max_values: int = 1,
options: List[SelectOption] = MISSING,
disabled: bool = False,
row: Optional[int] = None,
) -> None:
super().__init__()
self._selected_values: List[str] = []
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
options = [] if options is MISSING else options
self._underlying = SelectMenu._raw_construct(
custom_id=custom_id,
type=ComponentType.select,
placeholder=placeholder,
min_values=min_values,
max_values=max_values,
options=options,
disabled=disabled,
)
self.row = row
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the select menu that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError('custom_id must be None or str')
self._underlying.custom_id = value
@property
def placeholder(self) -> Optional[str]:
"""Optional[:class:`str`]: The placeholder text that is shown if nothing is selected, if any."""
return self._underlying.placeholder
@placeholder.setter
def placeholder(self, value: Optional[str]):
if value is not None and not isinstance(value, str):
raise TypeError('placeholder must be None or str')
self._underlying.placeholder = value
@property
def min_values(self) -> int:
""":class:`int`: The minimum number of items that must be chosen for this select menu."""
return self._underlying.min_values
@min_values.setter
def min_values(self, value: int):
self._underlying.min_values = int(value)
@property
def max_values(self) -> int:
""":class:`int`: The maximum number of items that must be chosen for this select menu."""
return self._underlying.max_values
@max_values.setter
def max_values(self, value: int):
self._underlying.max_values = int(value)
@property
def options(self) -> List[SelectOption]:
"""List[:class:`discord.SelectOption`]: A list of options that can be selected in this menu."""
return self._underlying.options
@options.setter
def options(self, value: List[SelectOption]):
if not isinstance(value, list):
raise TypeError('options must be a list of SelectOption')
if not all(isinstance(obj, SelectOption) for obj in value):
raise TypeError('all list items must subclass SelectOption')
self._underlying.options = value
def add_option(
self,
*,
label: str,
value: str = MISSING,
description: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
default: bool = False,
):
"""Adds an option to the select menu.
To append a pre-existing :class:`discord.SelectOption` use the
:meth:`append_option` method instead.
Parameters
-----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not given, defaults to the label. Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]]
The emoji of the option, if available. This can either be a string representing
the custom or unicode emoji or an instance of :class:`.PartialEmoji` or :class:`.Emoji`.
default: :class:`bool`
Whether this option is selected by default.
Raises
-------
ValueError
The number of options exceeds 25.
"""
option = SelectOption(
label=label,
value=value,
description=description,
emoji=emoji,
default=default,
)
self.append_option(option)
def append_option(self, option: SelectOption):
"""Appends an option to the select menu.
Parameters
-----------
option: :class:`discord.SelectOption`
The option to append to the select menu.
Raises
-------
ValueError
The number of options exceeds 25.
"""
if len(self._underlying.options) > 25:
raise ValueError('maximum number of options already provided')
self._underlying.options.append(option)
@property
def disabled(self) -> bool:
""":class:`bool`: Whether the select is disabled or not."""
return self._underlying.disabled
@disabled.setter
def disabled(self, value: bool):
self._underlying.disabled = bool(value)
@property
def values(self) -> List[str]:
"""List[:class:`str`]: A list of values that have been selected by the user."""
return self._selected_values
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> SelectMenuPayload:
return self._underlying.to_dict()
def refresh_component(self, component: SelectMenu) -> None:
self._underlying = component
def refresh_state(self, data: MessageComponentInteractionData) -> None:
self._selected_values = data.get('values', [])
@classmethod
def from_component(cls, component: SelectMenu) -> Self:
return cls(
custom_id=component.custom_id,
placeholder=component.placeholder,
min_values=component.min_values,
max_values=component.max_values,
options=component.options,
disabled=component.disabled,
row=None,
)
@property
def type(self) -> ComponentType:
return self._underlying.type
def is_dispatchable(self) -> bool:
return True
def select(
*,
placeholder: Optional[str] = None,
custom_id: str = MISSING,
min_values: int = 1,
max_values: int = 1,
options: List[SelectOption] = MISSING,
disabled: bool = False,
row: Optional[int] = None,
) -> Callable[[ItemCallbackType[V, Select[V]]], Select[V]]:
"""A decorator that attaches a select menu to a component.
The function being decorated should have three parameters, ``self`` representing
the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and
the :class:`discord.Interaction` you receive.
In order to get the selected items that the user has chosen within the callback
use :attr:`Select.values`.
Parameters
------------
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
custom_id: :class:`str`
The ID of the select menu that gets received during an interaction.
It is recommended not to set this parameter to prevent conflicts.
row: Optional[:class:`int`]
The relative row this select menu belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
max_values: :class:`int`
The maximum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
options: List[:class:`discord.SelectOption`]
A list of options that can be selected in this menu.
disabled: :class:`bool`
Whether the select is disabled or not. Defaults to ``False``.
"""
def decorator(func: ItemCallbackType[V, Select[V]]) -> ItemCallbackType[V, Select[V]]:
if not inspect.iscoroutinefunction(func):
raise TypeError('select function must be a coroutine function')
func.__discord_ui_model_type__ = Select
func.__discord_ui_model_kwargs__ = {
'placeholder': placeholder,
'custom_id': custom_id,
'row': row,
'min_values': min_values,
'max_values': max_values,
'options': options,
'disabled': disabled,
}
return func
return decorator # type: ignore

235
discord/ui/text_input.py

@ -1,235 +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 os
from typing import TYPE_CHECKING, Optional, Tuple, TypeVar
from ..components import TextInput as TextInputComponent
from ..enums import ComponentType, TextStyle
from ..utils import MISSING
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import TextInput as TextInputPayload
from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitTextInputInteractionDataPayload
from .view import View
# fmt: off
__all__ = (
'TextInput',
)
# fmt: on
V = TypeVar('V', bound='View', covariant=True)
class TextInput(Item[V]):
"""Represents a UI text input.
.. versionadded:: 2.0
Parameters
------------
label: :class:`str`
The label to display above the text input.
custom_id: :class:`str`
The ID of the text input that gets received during an interaction.
If not given then one is generated for you.
style: :class:`discord.TextStyle`
The style of the text input.
placeholder: Optional[:class:`str`]
The placeholder text to display when the text input is empty.
default: Optional[:class:`str`]
The default value of the text input.
required: :class:`bool`
Whether the text input is required.
min_length: Optional[:class:`int`]
The minimum length of the text input.
max_length: Optional[:class:`int`]
The maximum length of the text input.
row: Optional[:class:`int`]
The relative row this text input belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
"""
__item_repr_attributes__: Tuple[str, ...] = (
'label',
'placeholder',
'required',
)
def __init__(
self,
*,
label: str,
style: TextStyle = TextStyle.short,
custom_id: str = MISSING,
placeholder: Optional[str] = None,
default: Optional[str] = None,
required: bool = True,
min_length: Optional[int] = None,
max_length: Optional[int] = None,
row: Optional[int] = None,
) -> None:
super().__init__()
self._value: Optional[str] = default
self._provided_custom_id = custom_id is not MISSING
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id
self._underlying = TextInputComponent._raw_construct(
type=ComponentType.text_input,
label=label,
style=style,
custom_id=custom_id,
placeholder=placeholder,
value=default,
required=required,
min_length=min_length,
max_length=max_length,
)
self.row: Optional[int] = row
def __str__(self) -> str:
return self.value or ''
@property
def custom_id(self) -> str:
""":class:`str`: The ID of the select menu that gets received during an interaction."""
return self._underlying.custom_id
@custom_id.setter
def custom_id(self, value: str) -> None:
if not isinstance(value, str):
raise TypeError('custom_id must be None or str')
self._underlying.custom_id = value
@property
def width(self) -> int:
return 5
@property
def value(self) -> Optional[str]:
"""Optional[:class:`str`]: The value of the text input."""
return self._value
@property
def label(self) -> str:
""":class:`str`: The label of the text input."""
return self._underlying.label
@label.setter
def label(self, value: str) -> None:
self._underlying.label = value
@property
def placeholder(self) -> Optional[str]:
""":class:`str`: The placeholder text to display when the text input is empty."""
return self._underlying.placeholder
@placeholder.setter
def placeholder(self, value: Optional[str]) -> None:
self._underlying.placeholder = value
@property
def required(self) -> bool:
""":class:`bool`: Whether the text input is required."""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = value
@property
def min_length(self) -> Optional[int]:
""":class:`int`: The minimum length of the text input."""
return self._underlying.min_length
@min_length.setter
def min_length(self, value: Optional[int]) -> None:
self._underlying.min_length = value
@property
def max_length(self) -> Optional[int]:
""":class:`int`: The maximum length of the text input."""
return self._underlying.max_length
@max_length.setter
def max_length(self, value: Optional[int]) -> None:
self._underlying.max_length = value
@property
def style(self) -> TextStyle:
""":class:`discord.TextStyle`: The style of the text input."""
return self._underlying.style
@style.setter
def style(self, value: TextStyle) -> None:
self._underlying.style = value
@property
def default(self) -> Optional[str]:
""":class:`str`: The default value of the text input."""
return self._underlying.value
@default.setter
def default(self, value: Optional[str]) -> None:
self._underlying.value = value
def to_component_dict(self) -> TextInputPayload:
return self._underlying.to_dict()
def refresh_component(self, component: TextInputComponent) -> None:
self._underlying = component
def refresh_state(self, data: ModalSubmitTextInputInteractionDataPayload) -> None:
self._value = data.get('value', None)
@classmethod
def from_component(cls, component: TextInputComponent) -> Self:
return cls(
label=component.label,
style=component.style,
custom_id=component.custom_id,
placeholder=component.placeholder,
default=component.value,
required=component.required,
min_length=component.min_length,
max_length=component.max_length,
row=None,
)
@property
def type(self) -> ComponentType:
return ComponentType.text_input
def is_dispatchable(self) -> bool:
return False

571
discord/ui/view.py

@ -1,571 +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, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple
from functools import partial
from itertools import groupby
import traceback
import asyncio
import logging
import sys
import time
import os
from .item import Item, ItemCallbackType
from ..components import (
Component,
ActionRow as ActionRowComponent,
_component_factory,
Button as ButtonComponent,
SelectMenu as SelectComponent,
)
# fmt: off
__all__ = (
'View',
)
# fmt: on
if TYPE_CHECKING:
from ..interactions import Interaction
from ..message import Message
from ..types.components import Component as ComponentPayload
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
from ..state import ConnectionState
from .modal import Modal
_log = logging.getLogger(__name__)
def _walk_all_components(components: List[Component]) -> Iterator[Component]:
for item in components:
if isinstance(item, ActionRowComponent):
yield from item.children
else:
yield item
def _component_to_item(component: Component) -> Item:
if isinstance(component, ButtonComponent):
from .button import Button
return Button.from_component(component)
if isinstance(component, SelectComponent):
from .select import Select
return Select.from_component(component)
return Item.from_component(component)
class _ViewWeights:
# fmt: off
__slots__ = (
'weights',
)
# fmt: on
def __init__(self, children: List[Item]):
self.weights: List[int] = [0, 0, 0, 0, 0]
key = lambda i: sys.maxsize if i.row is None else i.row
children = sorted(children, key=key)
for row, group in groupby(children, key=key):
for item in group:
self.add_item(item)
def find_open_space(self, item: Item) -> int:
for index, weight in enumerate(self.weights):
if weight + item.width <= 5:
return index
raise ValueError('could not find open space for item')
def add_item(self, item: Item) -> None:
if item.row is not None:
total = self.weights[item.row] + item.width
if total > 5:
raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)')
self.weights[item.row] = total
item._rendered_row = item.row
else:
index = self.find_open_space(item)
self.weights[index] += item.width
item._rendered_row = index
def remove_item(self, item: Item) -> None:
if item._rendered_row is not None:
self.weights[item._rendered_row] -= item.width
item._rendered_row = None
def clear(self) -> None:
self.weights = [0, 0, 0, 0, 0]
class View:
"""Represents a UI view.
This object must be inherited to create a UI within Discord.
.. versionadded:: 2.0
Parameters
-----------
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
Attributes
------------
timeout: Optional[:class:`float`]
Timeout from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
children: List[:class:`Item`]
The list of children attached to this view.
"""
__discord_ui_view__: ClassVar[bool] = True
__discord_ui_modal__: ClassVar[bool] = False
__view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = []
def __init_subclass__(cls) -> None:
children: List[ItemCallbackType[Any, Any]] = []
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if hasattr(member, '__discord_ui_model_type__'):
children.append(member)
if len(children) > 25:
raise TypeError('View cannot have more than 25 children')
cls.__view_children_items__ = children
def _init_children(self) -> List[Item]:
children = []
for func in self.__view_children_items__:
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
item.callback = partial(func, self, item) # type: ignore
item._view = self
setattr(self, func.__name__, item)
children.append(item)
return children
def __init__(self, *, timeout: Optional[float] = 180.0):
self.timeout = timeout
self.children: List[Item] = self._init_children()
self.__weights = _ViewWeights(self.children)
loop = asyncio.get_running_loop()
self.id: str = os.urandom(16).hex()
self.__cancel_callback: Optional[Callable[[View], None]] = None
self.__timeout_expiry: Optional[float] = None
self.__timeout_task: Optional[asyncio.Task[None]] = None
self.__stopped: asyncio.Future[bool] = loop.create_future()
def __repr__(self) -> str:
return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>'
async def __timeout_task_impl(self) -> None:
while True:
# Guard just in case someone changes the value of the timeout at runtime
if self.timeout is None:
return
if self.__timeout_expiry is None:
return self._dispatch_timeout()
# Check if we've elapsed our currently set timeout
now = time.monotonic()
if now >= self.__timeout_expiry:
return self._dispatch_timeout()
# Wait N seconds to see if timeout data has been refreshed
await asyncio.sleep(self.__timeout_expiry - now)
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
return item._rendered_row or 0
children = sorted(self.children, key=key)
components: List[Dict[str, Any]] = []
for _, group in groupby(children, key=key):
children = [item.to_component_dict() for item in group]
if not children:
continue
components.append(
{
'type': 1,
'components': children,
}
)
return components
@classmethod
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View:
"""Converts a message's components into a :class:`View`.
The :attr:`.Message.components` of a message are read-only
and separate types from those in the ``discord.ui`` namespace.
In order to modify and edit message components they must be
converted into a :class:`View` first.
Parameters
-----------
message: :class:`discord.Message`
The message with components to convert into a view.
timeout: Optional[:class:`float`]
The timeout of the converted view.
Returns
--------
:class:`View`
The converted view. This always returns a :class:`View` and not
one of its subclasses.
"""
view = View(timeout=timeout)
for component in _walk_all_components(message.components):
view.add_item(_component_to_item(component))
return view
@property
def _expires_at(self) -> Optional[float]:
if self.timeout:
return time.monotonic() + self.timeout
return None
def add_item(self, item: Item) -> None:
"""Adds an item to the view.
Parameters
-----------
item: :class:`Item`
The item to add to the view.
Raises
--------
TypeError
An :class:`Item` was not passed.
ValueError
Maximum number of children has been exceeded (25)
or the row the item is trying to be added to is full.
"""
if len(self.children) > 25:
raise ValueError('maximum number of children exceeded')
if not isinstance(item, Item):
raise TypeError(f'expected Item not {item.__class__!r}')
self.__weights.add_item(item)
item._view = self
self.children.append(item)
def remove_item(self, item: Item) -> None:
"""Removes an item from the view.
Parameters
-----------
item: :class:`Item`
The item to remove from the view.
"""
try:
self.children.remove(item)
except ValueError:
pass
else:
self.__weights.remove_item(item)
def clear_items(self) -> None:
"""Removes all items from the view."""
self.children.clear()
self.__weights.clear()
async def interaction_check(self, interaction: Interaction) -> bool:
"""|coro|
A callback that is called when an interaction happens within the view
that checks whether the view should process item callbacks for the interaction.
This is useful to override if, for example, you want to ensure that the
interaction author is a given user.
The default implementation of this returns ``True``.
.. note::
If an exception occurs within the body then the check
is considered a failure and :meth:`on_error` is called.
Parameters
-----------
interaction: :class:`~discord.Interaction`
The interaction that occurred.
Returns
---------
:class:`bool`
Whether the view children's callbacks should be called.
"""
return True
async def on_timeout(self) -> None:
"""|coro|
A callback that is called when a view's timeout elapses without being explicitly stopped.
"""
pass
async def on_error(self, error: Exception, item: Item, interaction: Interaction) -> None:
"""|coro|
A callback that is called when an item's callback or :meth:`interaction_check`
fails with an error.
The default implementation prints the traceback to stderr.
Parameters
-----------
error: :class:`Exception`
The exception that was raised.
item: :class:`Item`
The item that failed the dispatch.
interaction: :class:`~discord.Interaction`
The interaction that led to the failure.
"""
print(f'Ignoring exception in view {self} for item {item}:', file=sys.stderr)
traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr)
async def _scheduled_task(self, item: Item, interaction: Interaction):
try:
if self.timeout:
self.__timeout_expiry = time.monotonic() + self.timeout
allow = await self.interaction_check(interaction)
if not allow:
return
await item.callback(interaction)
if not interaction.response._responded:
await interaction.response.defer()
except Exception as e:
return await self.on_error(e, item, interaction)
def _start_listening_from_store(self, store: ViewStore) -> None:
self.__cancel_callback = partial(store.remove_view)
if self.timeout:
loop = asyncio.get_running_loop()
if self.__timeout_task is not None:
self.__timeout_task.cancel()
self.__timeout_expiry = time.monotonic() + self.timeout
self.__timeout_task = loop.create_task(self.__timeout_task_impl())
def _dispatch_timeout(self):
if self.__stopped.done():
return
if self.__cancel_callback:
self.__cancel_callback(self)
self.__cancel_callback = None
self.__stopped.set_result(True)
asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}')
def _dispatch_item(self, item: Item, interaction: Interaction):
if self.__stopped.done():
return
asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}')
def refresh(self, components: List[Component]):
# This is pretty hacky at the moment
# fmt: off
old_state: Dict[Tuple[int, str], Item] = {
(item.type.value, item.custom_id): item # type: ignore
for item in self.children
if item.is_dispatchable()
}
# fmt: on
children: List[Item] = []
for component in _walk_all_components(components):
try:
older = old_state[(component.type.value, component.custom_id)] # type: ignore
except (KeyError, AttributeError):
children.append(_component_to_item(component))
else:
older.refresh_component(component)
children.append(older)
self.children = children
def stop(self) -> None:
"""Stops listening to interaction events from this view.
This operation cannot be undone.
"""
if not self.__stopped.done():
self.__stopped.set_result(False)
self.__timeout_expiry = None
if self.__timeout_task is not None:
self.__timeout_task.cancel()
self.__timeout_task = None
if self.__cancel_callback:
self.__cancel_callback(self)
self.__cancel_callback = None
def is_finished(self) -> bool:
""":class:`bool`: Whether the view has finished interacting."""
return self.__stopped.done()
def is_dispatching(self) -> bool:
""":class:`bool`: Whether the view has been added for dispatching purposes."""
return self.__cancel_callback is not None
def is_persistent(self) -> bool:
""":class:`bool`: Whether the view is set up as persistent.
A persistent view has all their components with a set ``custom_id`` and
a :attr:`timeout` set to ``None``.
"""
return self.timeout is None and all(item.is_persistent() for item in self.children)
async def wait(self) -> bool:
"""Waits until the view has finished interacting.
A view is considered finished when :meth:`stop` is called
or it times out.
Returns
--------
:class:`bool`
If ``True``, then the view timed out. If ``False`` then
the view finished normally.
"""
return await self.__stopped
class ViewStore:
def __init__(self, state: ConnectionState):
# (component_type, message_id, custom_id): (View, Item)
self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {}
# message_id: View
self._synced_message_views: Dict[int, View] = {}
# custom_id: Modal
self._modals: Dict[str, Modal] = {}
self._state: ConnectionState = state
@property
def persistent_views(self) -> Sequence[View]:
# fmt: off
views = {
view.id: view
for (_, (view, _)) in self._views.items()
if view.is_persistent()
}
# fmt: on
return list(views.values())
def __verify_integrity(self):
to_remove: List[Tuple[int, Optional[int], str]] = []
for (k, (view, _)) in self._views.items():
if view.is_finished():
to_remove.append(k)
for k in to_remove:
del self._views[k]
def add_view(self, view: View, message_id: Optional[int] = None):
view._start_listening_from_store(self)
if view.__discord_ui_modal__:
self._modals[view.custom_id] = view # type: ignore
return
self.__verify_integrity()
for item in view.children:
if item.is_dispatchable():
self._views[(item.type.value, message_id, item.custom_id)] = (view, item) # type: ignore
if message_id is not None:
self._synced_message_views[message_id] = view
def remove_view(self, view: View):
if view.__discord_ui_modal__:
self._modals.pop(view.custom_id, None) # type: ignore
return
for item in view.children:
if item.is_dispatchable():
self._views.pop((item.type.value, item.custom_id), None) # type: ignore
for key, value in self._synced_message_views.items():
if value.id == view.id:
del self._synced_message_views[key]
break
def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction):
self.__verify_integrity()
message_id: Optional[int] = interaction.message and interaction.message.id
key = (component_type, message_id, custom_id)
# Fallback to None message_id searches in case a persistent view
# was added without an associated message_id
value = self._views.get(key) or self._views.get((component_type, None, custom_id))
if value is None:
return
view, item = value
item.refresh_state(interaction.data) # type: ignore
view._dispatch_item(item, interaction)
def dispatch_modal(
self,
custom_id: str,
interaction: Interaction,
components: List[ModalSubmitComponentInteractionDataPayload],
):
modal = self._modals.get(custom_id)
if modal is None:
_log.debug("Modal interaction referencing unknown custom_id %s. Discarding", custom_id)
return
modal.refresh(components)
modal._dispatch_submit(interaction)
def is_message_tracked(self, message_id: int):
return message_id in self._synced_message_views
def remove_message_tracking(self, message_id: int) -> Optional[View]:
return self._synced_message_views.pop(message_id, None)
def update_from_message(self, message_id: int, components: List[ComponentPayload]):
# pre-req: is_message_tracked == true
view = self._synced_message_views[message_id]
view.refresh([_component_factory(d) for d in components])

645
discord/user.py

@ -24,37 +24,173 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union
import discord.abc
from .asset import Asset
from .colour import Colour
from .enums import DefaultAvatar
from .enums import Locale, AppCommandType, DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum
from .errors import ClientException, NotFound
from .flags import PublicUserFlags
from .utils import snowflake_time, _bytes_to_base64_data, MISSING
from .iterators import FakeCommandIterator
from .object import Object
from .relationship import Relationship
from .settings import UserSettings
from .utils import _bytes_to_base64_data, _get_as_snowflake, cached_slot_property, copy_doc, snowflake_time, MISSING
if TYPE_CHECKING:
from typing_extensions import Self
from datetime import datetime
from .abc import Snowflake as _Snowflake
from .calls import PrivateCall
from .channel import DMChannel
from .guild import Guild
from .member import VoiceState
from .message import Message
from .profile import UserProfile
from .state import ConnectionState
from .types.channel import DMChannel as DMChannelPayload
from .types.user import (
PartialUser as PartialUserPayload,
User as UserPayload,
)
from .types.snowflake import Snowflake
__all__ = (
'User',
'ClientUser',
'Note',
)
class Note:
"""Represents a Discord note."""
__slots__ = ('_state', '_note', '_user_id', '_user')
def __init__(
self, state: ConnectionState, user_id: int, *, user: _Snowflake = MISSING, note: Optional[str] = MISSING
) -> None:
self._state = state
self._user_id = user_id
self._note = note
if user is not MISSING:
self._user = user
@property
def note(self) -> Optional[str]:
"""Returns the note.
Raises
-------
ClientException
Attempted to access note without fetching it.
"""
if self._note is MISSING:
raise ClientException('Note is not fetched')
return self._note
@cached_slot_property('_user')
def user(self) -> _Snowflake:
""":class:`Snowflake`: Returns the :class:`User` the note belongs to.
If the user isn't in the cache, it returns a
:class:`Object` instead.
"""
user_id = self._user_id
user = self._state.get_user(user_id)
if user is None:
user = Object(user_id)
return user
async def fetch(self) -> Optional[str]:
"""|coro|
Retrieves the note.
Raises
-------
HTTPException
Fetching the note failed.
Returns
--------
Optional[:class:`str`]
The note or ``None`` if it doesn't exist.
"""
try:
data = await self._state.http.get_note(self.user.id)
self._note = data['note']
return data['note']
except NotFound: # 404 = no note
self._note = None
return None
async def edit(self, note: Optional[str]) -> None:
"""|coro|
Changes the note.
Raises
-------
HTTPException
Changing the note failed.
"""
await self._state.http.set_note(self._user_id, note=note)
self._note = note
async def delete(self) -> None:
"""|coro|
A shortcut to :meth:`.edit` that deletes the note.
Raises
-------
HTTPException
Deleting the note failed.
"""
await self.edit(None)
def __str__(self) -> str:
note = self._note
if note is MISSING:
raise ClientException('Note is not fetched')
elif note is None:
return ''
else:
return note
def __repr__(self) -> str:
base = f'<Note user={self.user!r}'
note = self._note
if note is not MISSING:
note = note or '""'
base += f' note={note}'
return base + '>'
def __len__(self) -> int:
if (note := self._note):
return len(note)
return 0
def __eq__(self, other: Note) -> bool:
try:
return isinstance(other, Note) and self._note == other._note and self._user_id == other._user_id
except TypeError:
return False
def __ne__(self, other: Note) -> bool:
return not self.__eq__(other)
def __bool__(self) -> bool:
try:
return bool(self._note)
except TypeError:
return False
class _UserTag:
__slots__ = ()
id: int
@ -142,8 +278,14 @@ class BaseUser(_UserTag):
'avatar': self._avatar,
'discriminator': self.discriminator,
'bot': self.bot,
'system': self.system,
}
@property
def voice(self) -> Optional[VoiceState]:
"""Optional[:class:`VoiceState`]: Returns the user's current voice state."""
return self._state._voice_state_for(self.id)
@property
def public_flags(self) -> PublicUserFlags:
""":class:`PublicUserFlags`: The publicly available flags the user has."""
@ -273,7 +415,6 @@ class BaseUser(_UserTag):
:class:`bool`
Indicates if the user is mentioned in the message.
"""
if message.mention_everyone:
return True
@ -301,6 +442,9 @@ class ClientUser(BaseUser):
Returns the user's name with discriminator.
.. versionchanged:: 2.0
:attr:`Locale` is now a :class:`Locale` instead of a Optional[:class:`str`].
Attributes
-----------
name: :class:`str`
@ -308,48 +452,142 @@ class ClientUser(BaseUser):
id: :class:`int`
The user's unique ID.
discriminator: :class:`str`
The user's discriminator. This is given when the username has conflicts.
The user's discriminator.
bio: Optional[:class:`str`]
The user's "about me" field. Could be ``None``.
avatar: Optional[:class:`str`]
The avatar hash the user has. Could be ``None``.
bot: :class:`bool`
Specifies if the user is a bot account.
system: :class:`bool`
Specifies if the user is a system user (i.e. represents Discord officially).
.. versionadded:: 1.3
verified: :class:`bool`
Specifies if the user's email is verified.
locale: Optional[:class:`str`]
email: Optional[:class:`str`]
The email of the user.
phone: Optional[:class:`int`]
The phone number of the user.
.. versionadded:: 1.9
locale: Optional[:class:`Locale`]
The IETF language tag used to identify the language the user is using.
mfa_enabled: :class:`bool`
Specifies if the user has MFA turned on and working.
premium: :class:`bool`
Specifies if the user is a premium user (i.e. has Discord Nitro).
premium_type: Optional[:class:`PremiumType`]
Specifies the type of premium a user has (i.e. Nitro or Nitro Classic). Could be None if the user is not premium.
note: :class:`Note`
The user's note. Not pre-fetched.
nsfw_allowed: :class:`bool`
Specifies if the user should be allowed to access NSFW content.
.. versionadded:: 2.0
"""
__slots__ = ('locale', '_flags', 'verified', 'mfa_enabled', '__weakref__')
__slots__ = (
'__weakref__',
'locale',
'_flags',
'verified',
'mfa_enabled',
'email',
'phone',
'premium_type',
'note',
'premium',
'bio',
'nsfw_allowed',
)
if TYPE_CHECKING:
verified: bool
locale: Optional[str]
mfa_enabled: bool
email: Optional[str]
phone: Optional[int]
locale: Locale
_flags: int
mfa_enabled: bool
premium: bool
premium_type: Optional[PremiumType]
bio: Optional[str]
nsfw_allowed: bool
def __init__(self, *, state: ConnectionState, data: UserPayload) -> None:
super().__init__(state=state, data=data)
self.note: Note = Note(state, self.id)
def __repr__(self) -> str:
return (
f'<ClientUser id={self.id} name={self.name!r} discriminator={self.discriminator!r}'
f' bot={self.bot} verified={self.verified} mfa_enabled={self.mfa_enabled}>'
f' bot={self.bot} verified={self.verified} mfa_enabled={self.mfa_enabled} premium={self.premium}>'
)
def _update(self, data: UserPayload) -> None:
super()._update(data)
# There's actually an Optional[str] phone field as well but I won't use it
self.verified = data.get('verified', False)
self.locale = data.get('locale')
self.email = data.get('email')
self.phone = _get_as_snowflake(data, 'phone')
self.locale = try_enum(Locale, data.get('locale', 'en-US'))
self._flags = data.get('flags', 0)
self.mfa_enabled = data.get('mfa_enabled', False)
self.premium = data.get('premium', False)
self.premium_type = try_enum(PremiumType, data.get('premium_type', None))
self.bio = data.get('bio') or None
self.nsfw_allowed = data.get('nsfw_allowed', False)
async def edit(self, *, username: str = MISSING, avatar: Optional[bytes] = MISSING) -> ClientUser:
def get_relationship(self, user_id: int) -> Relationship:
"""Retrieves the :class:`Relationship` if applicable.
Parameters
-----------
user_id: :class:`int`
The user ID to check if we have a relationship with them.
Returns
--------
Optional[:class:`Relationship`]
The relationship if available or ``None``.
"""
return self._state._relationships.get(user_id)
@property
def relationships(self) -> List[Relationship]:
"""List[:class:`User`]: Returns all the relationships that the user has."""
return list(self._state._relationships.values())
@property
def friends(self) -> List[Relationship]:
r"""List[:class:`User`]: Returns all the users that the user is friends with."""
return [r.user for r in self._state._relationships.values() if r.type is RelationshipType.friend]
@property
def blocked(self) -> List[Relationship]:
r"""List[:class:`User`]: Returns all the users that the user has blocked."""
return [r.user for r in self._state._relationships.values() if r.type is RelationshipType.blocked]
@property
def settings(self) -> Optional[UserSettings]:
"""Optional[:class:`UserSettings`]: Returns the user's settings."""
return self._state.settings
async def edit(
self,
*,
username: str = MISSING,
avatar: Optional[bytes] = MISSING,
password: str = MISSING,
new_password: str = MISSING,
email: str = MISSING,
house: Optional[HypeSquadHouse] = MISSING,
discriminator: Snowflake = MISSING,
banner: Optional[bytes] = MISSING,
accent_colour: Colour = MISSING,
accent_color: Colour = MISSING,
bio: Optional[str] = MISSING,
date_of_birth: datetime = MISSING,
) -> ClientUser:
"""|coro|
Edits the current profile of the client.
@ -361,8 +599,6 @@ class ClientUser(BaseUser):
then the file must be opened via ``open('some_filename', 'rb')`` and
the :term:`py:bytes-like object` is given through the use of ``fp.read()``.
The only image formats supported for uploading is JPEG and PNG.
.. versionchanged:: 2.0
The edit is no longer in-place, instead the newly edited client user is returned.
@ -372,39 +608,188 @@ class ClientUser(BaseUser):
Parameters
-----------
password: :class:`str`
The current password for the client's account.
Required for everything except avatar, banner, accent_colour, date_of_birth, and bio.
new_password: :class:`str`
The new password you wish to change to.
email: :class:`str`
The new email you wish to change to.
house: Optional[:class:`HypeSquadHouse`]
The hypesquad house you wish to change to.
Could be ``None`` to leave the current house.
username: :class:`str`
The new username you wish to change to.
discriminator: :class:`int`
The new discriminator you wish to change to.
Can only be used if you have Nitro.
avatar: Optional[:class:`bytes`]
A :term:`py:bytes-like object` representing the image to upload.
Could be ``None`` to denote no avatar.
banner: :class:`bytes`
A :term:`py:bytes-like object` representing the image to upload.
Could be ``None`` to denote no banner.
accent_colour/_color: :class:`Colour`
A :class:`Colour` object of the colour you want to set your profile to.
bio: :class:`str`
Your 'about me' section.
Could be ``None`` to represent no 'about me'.
date_of_birth: :class:`datetime.datetime`
Your date of birth. Can only ever be set once.
Raises
------
HTTPException
Editing your profile failed.
ValueError
Wrong image format passed for ``avatar``.
Password was not passed when it was required.
`house` field was not a :class:`HypeSquadHouse`.
`date_of_birth` field was not a :class:`datetime.datetime`.
`accent_colo(u)r` parameter was not a :class:`Colour`.
Returns
---------
:class:`ClientUser`
The newly edited client user.
"""
payload: Dict[str, Any] = {}
if username is not MISSING:
payload['username'] = username
args: Dict[str, Any] = {}
if any(x is not MISSING for x in ('new_password', 'email', 'username', 'discriminator')):
if password is MISSING:
raise ValueError('Password is required')
args['password'] = password
if avatar is not MISSING:
if avatar is not None:
payload['avatar'] = _bytes_to_base64_data(avatar)
args['avatar'] = _bytes_to_base64_data(avatar)
else:
args['avatar'] = None
if banner is not MISSING:
if banner is not None:
args['banner'] = _bytes_to_base64_data(banner)
else:
args['banner'] = None
if accent_color is not MISSING or accent_colour is not MISSING:
colour = accent_colour if accent_colour is not MISSING else accent_color
if colour is None:
args['accent_color'] = colour
elif not isinstance(colour, Colour):
raise ValueError('`accent_colo(u)r` parameter was not a Colour')
else:
args['accent_color'] = accent_color.value
if email is not MISSING:
args['email'] = email
if username is not MISSING:
args['username'] = username
if discriminator is not MISSING:
args['discriminator'] = discriminator
if new_password is not MISSING:
args['new_password'] = new_password
if bio is not MISSING:
args['bio'] = bio or ''
if date_of_birth is not MISSING:
if not isinstance(date_of_birth, datetime):
raise ValueError('`date_of_birth` parameter was not a datetime')
args['date_of_birth'] = date_of_birth.strftime('%F')
http = self._state.http
if house is not MISSING:
if house is None:
await http.leave_hypesquad_house()
elif not isinstance(house, HypeSquadHouse):
raise ValueError('`house` parameter was not a HypeSquadHouse')
else:
payload['avatar'] = None
await http.change_hypesquad_house(house.value)
data = await http.edit_profile(args)
try:
http._token(data['token'])
except KeyError:
pass
data: UserPayload = await self._state.http.edit_profile(payload)
return ClientUser(state=self._state, data=data)
async def fetch_settings(self) -> UserSettings:
"""|coro|
Retrieves your settings.
.. note::
This method is an API call. For general usage, consider :attr:`settings` instead.
Raises
-------
HTTPException
Retrieving your settings failed.
Returns
--------
:class:`UserSettings`
The current settings for your account.
"""
data = await self._state.http.get_settings()
return UserSettings(data=data, state=self._state)
@copy_doc(UserSettings.edit)
async def edit_settings(self, **kwargs) -> UserSettings: # TODO: I really wish I didn't have to do this...
payload = {}
content_filter = kwargs.pop('explicit_content_filter', None)
if content_filter:
payload['explicit_content_filter'] = content_filter.value
animate_stickers = kwargs.pop('animate_stickers', None)
if animate_stickers:
payload['animate_stickers'] = animate_stickers.value
friend_flags = kwargs.pop('friend_source_flags', None)
if friend_flags:
payload['friend_source_flags'] = friend_flags.to_dict()
guild_positions = kwargs.pop('guild_positions', None)
if guild_positions:
guild_positions = [str(x.id) for x in guild_positions]
payload['guild_positions'] = guild_positions
restricted_guilds = kwargs.pop('restricted_guilds', None)
if restricted_guilds:
restricted_guilds = [str(x.id) for x in restricted_guilds]
payload['restricted_guilds'] = restricted_guilds
status = kwargs.pop('status', None)
if status:
payload['status'] = status.value
custom_activity = kwargs.pop('custom_activity', MISSING)
if custom_activity is not MISSING:
payload['custom_status'] = custom_activity and custom_activity.to_settings_dict()
class User(BaseUser, discord.abc.Messageable):
theme = kwargs.pop('theme', None)
if theme:
payload['theme'] = theme.value
locale = kwargs.pop('locale', None)
if locale:
payload['locale'] = str(locale)
payload.update(kwargs)
state = self._state
data = await state.http.edit_settings(**payload)
return UserSettings(data=data, state=self._state)
class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):
"""Represents a Discord user.
.. container:: operations
@ -432,7 +817,7 @@ class User(BaseUser, discord.abc.Messageable):
id: :class:`int`
The user's unique ID.
discriminator: :class:`str`
The user's discriminator. This is given when the username has conflicts.
The user's discriminator.
bot: :class:`bool`
Specifies if the user is a bot account.
system: :class:`bool`
@ -442,7 +827,13 @@ class User(BaseUser, discord.abc.Messageable):
__slots__ = ('__weakref__',)
def __repr__(self) -> str:
return f'<User id={self.id} name={self.name!r} discriminator={self.discriminator!r} bot={self.bot}>'
return f'<{self.__class__.__name__} id={self.id} name={self.name!r} discriminator={self.discriminator!r} bot={self.bot} system={self.system}>'
def _get_voice_client_key(self) -> Tuple[int, str]:
return self._state.self_id, 'self_id'
def _get_voice_state_pair(self) -> Tuple[int, int]:
return self._state.self_id, self.dm_channel.id
async def _get_channel(self):
ch = await self.create_dm()
@ -458,16 +849,22 @@ class User(BaseUser, discord.abc.Messageable):
return self._state._get_private_channel_by_user(self.id)
@property
def mutual_guilds(self) -> List[Guild]:
"""List[:class:`Guild`]: The guilds that the user shares with the client.
.. note::
This will only return mutual guilds within the client's internal cache.
def call(self) -> Optional[PrivateCall]:
return getattr(self.dm_channel, 'call', None)
.. versionadded:: 1.7
"""
return [guild for guild in self._state._guilds.values() if guild.get_member(self.id)]
@property
def relationship(self) -> Optional[Relationship]:
"""Optional[:class:`Relationship`]: Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise."""
return self._state.user.get_relationship(self.id)
async def connect(self, *, ring=True, **kwargs):
channel = await self._get_channel()
call = self.call
if call is not None:
ring = False
await super().connect(_channel=channel, **kwargs)
if ring:
await channel._initial_ring()
async def create_dm(self) -> DMChannel:
"""|coro|
@ -489,3 +886,179 @@ class User(BaseUser, discord.abc.Messageable):
state = self._state
data: DMChannelPayload = await state.http.start_private_message(self.id)
return state.add_dm_channel(data)
def user_commands(
self,
query: Optional[str] = None,
*,
limit: Optional[int] = None,
command_ids: Optional[List[int]] = [],
**_,
):
"""Returns an iterator that allows you to see what user commands are available to use on this user.
Only available on bots.
.. note::
All parameters here are faked, as the only way to get commands in a DM is to fetch them all at once.
Because of this, some are silently ignored. The ones below currently work.
It is recommended to not pass any parameters to this iterator.
Examples
---------
Usage ::
async for command in user.user_commands():
print(command.name)
Flattening into a list ::
commands = await user.user_commands().flatten()
# commands is now a list of UserCommand...
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. Defaults to ``None`` to iterate over all results. Must be at least 1.
command_ids: Optional[List[:class:`int`]]
List of command IDs to search for. If the command doesn't exist it won't be returned.
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:`.UserCommand`
A user command.
"""
iterator = FakeCommandIterator(self, AppCommandType.user, query, limit, command_ids)
return iterator.iterate()
def is_friend(self) -> bool:
""":class:`bool`: Checks if the user is your friend."""
r = self.relationship
if r is None:
return False
return r.type is RelationshipType.friend
def is_blocked(self) -> bool:
""":class:`bool`: Checks if the user is blocked."""
r = self.relationship
if r is None:
return False
return r.type is RelationshipType.blocked
async def block(self) -> None: # TODO: maybe return relationship
"""|coro|
Blocks the user.
Raises
-------
Forbidden
Not allowed to block this user.
HTTPException
Blocking the user failed.
"""
await self._state.http.add_relationship(self.id, type=RelationshipType.blocked.value, action=RelationshipAction.block)
async def unblock(self) -> None:
"""|coro|
Unblocks the user.
Raises
-------
Forbidden
Not allowed to unblock this user.
HTTPException
Unblocking the user failed.
"""
await self._state.http.remove_relationship(self.id, action=RelationshipAction.unblock)
async def remove_friend(self) -> None:
"""|coro|
Removes the user as a friend.
Raises
-------
Forbidden
Not allowed to remove this user as a friend.
HTTPException
Removing the user as a friend failed.
"""
await self._state.http.remove_relationship(self.id, action=RelationshipAction.unfriend)
async def send_friend_request(self) -> None: # TODO: maybe return relationship
"""|coro|
Sends the user a friend request.
Raises
-------
Forbidden
Not allowed to send a friend request to the user.
HTTPException
Sending the friend request failed.
"""
await self._state.http.send_friend_request(self.name, self.discriminator)
async def profile(
self, *, with_mutuals: bool = True, fetch_note: bool = True
) -> UserProfile:
"""|coro|
Gets the user's profile.
Parameters
------------
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
-------
Forbidden
Not allowed to fetch this profile.
HTTPException
Fetching the profile failed.
Returns
--------
:class:`UserProfile`
The profile of the user.
"""
from .profile import UserProfile
user_id = self.id
state = self._state
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 self._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

298
discord/utils.py

@ -50,6 +50,7 @@ from typing import (
overload,
TYPE_CHECKING,
)
import collections
import unicodedata
from base64 import b64encode
from bisect import bisect_left
@ -58,12 +59,21 @@ import functools
from inspect import isawaitable as _isawaitable, signature as _signature
from operator import attrgetter
import json
import logging
import os
import platform
import random
import re
import string
import subprocess
import sys
import tempfile
from threading import Timer
import types
import warnings
import yarl
from .enums import BrowserEnum
try:
import orjson
@ -86,10 +96,13 @@ __all__ = (
'escape_mentions',
'as_chunks',
'format_dt',
'set_target',
)
DISCORD_EPOCH = 1420070400000
_log = logging.getLogger(__name__)
class _MissingSentinel:
def __eq__(self, other):
@ -121,14 +134,17 @@ class _cached_property:
if TYPE_CHECKING:
from aiohttp import ClientSession
from functools import cached_property as cached_property
from typing_extensions import ParamSpec
from .permissions import Permissions
from .abc import Snowflake
from .abc import Messageable, Snowflake
from .invite import Invite
from .message import Message
from .template import Template
from .commands import ApplicationCommand
class _RequestLike(Protocol):
headers: Mapping[str, Any]
@ -275,7 +291,7 @@ def oauth_url(
scopes: Iterable[str] = MISSING,
disable_guild_select: bool = False,
) -> str:
"""A helper function that returns the OAuth2 URL for inviting the bot
"""A helper function that returns the OAuth2 URL for inviting a bot
into guilds.
.. versionchanged:: 2.0
@ -286,7 +302,7 @@ def oauth_url(
Parameters
-----------
client_id: Union[:class:`int`, :class:`str`]
The client ID for your bot.
The client ID for the bot.
permissions: :class:`~discord.Permissions`
The permissions you're requesting. If not given then you won't be requesting any
permissions.
@ -295,7 +311,7 @@ def oauth_url(
redirect_uri: :class:`str`
An optional valid redirect URI.
scopes: Iterable[:class:`str`]
An optional valid list of scopes. Defaults to ``('bot',)``.
An optional valid list of scopes. Defaults to ``('bot', 'applications.commands')``.
.. versionadded:: 1.7
disable_guild_select: :class:`bool`
@ -309,7 +325,7 @@ def oauth_url(
The OAuth2 URL for inviting the bot into guilds.
"""
url = f'https://discord.com/oauth2/authorize?client_id={client_id}'
url += '&scope=' + '+'.join(scopes or ('bot',))
url += '&scope=' + '+'.join(scopes or ('bot', 'applications.commands'))
if permissions is not MISSING:
url += f'&permissions={permissions.value}'
if guild is not MISSING:
@ -1132,3 +1148,275 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None)
if style is None:
return f'<t:{int(dt.timestamp())}>'
return f'<t:{int(dt.timestamp())}:{style}>'
def set_target(
items: Iterable[ApplicationCommand], *, channel: Messageable = None, message: Message = None, user: Snowflake = None
) -> None:
"""A helper function to set the target for a list of items.
This is used to set the target for a list of application commands.
Suppresses all AttributeErrors so you can pass multiple types of commands and
not worry about which elements support which parameter.
Parameters
-----------
items: Iterable[:class:`ApplicationCommand`]
A list of items to set the target for.
channel: :class:`Messageable`
The channel to target.
message: :class:`Message`
The message to target.
user: :class:`Snowflake`
The user to target.
"""
attrs = {
'target_channel': channel,
'target_message': message,
'target_user': user,
}
for item in items:
for k, v in attrs.items():
if v is not None:
try:
setattr(item, k, v) # type: ignore
except AttributeError:
pass
def _generate_session_id() -> str:
return ''.join(random.choices(string.ascii_letters + string.digits, k=16))
class ExpiringQueue(asyncio.Queue): # Inspired from https://github.com/NoahCardoza/CaptchaHarvester
def __init__(self, timeout: int, maxsize: int = 0) -> None:
super().__init__(maxsize)
self.timeout = timeout
self.timers: asyncio.Queue = asyncio.Queue()
async def put(self, item: str) -> None:
thread: Timer = Timer(self.timeout, self.expire)
thread.start()
await self.timers.put(thread)
await super().put(item)
async def get(self, block: bool = True) -> str:
if block:
thread = await self.timers.get()
else:
thread = self.timers.get_nowait()
thread.cancel()
if block:
return await super().get()
else:
return self.get_nowait()
def expire(self) -> None:
try:
self._queue.popleft()
except:
pass
def to_list(self) -> List[str]:
return list(self._queue)
class ExpiringString(collections.UserString):
def __init__(self, data: str, timeout: int) -> None:
super().__init__(data)
self._timer: Timer = Timer(timeout, self._destruct)
self._timer.start()
def _update(self, data: str, timeout: int) -> None:
try:
self._timer.cancel()
except:
pass
self.data = data
self._timer: Timer = Timer(timeout, self._destruct)
self._timer.start()
def _destruct(self) -> None:
self.data = ''
def destroy(self) -> None:
self._destruct()
self._timer.cancel()
class Browser: # Inspired from https://github.com/NoahCardoza/CaptchaHarvester
def __init__(self, browser: Union[BrowserEnum, str] = None) -> None:
if isinstance(browser, (BrowserEnum, type(None))):
try:
browser = self.get_browser(browser)
except Exception:
raise RuntimeError('Could not find browser. Please pass browser path manually.')
if browser is None:
raise RuntimeError('Could not find browser. Please pass browser path manually.')
self.browser: str = browser
self.proc: subprocess.Popen = MISSING
def get_mac_browser(pkg: str, binary: str) -> Optional[os.PathLike]:
import plistlib as plist
pfile: str = f'{os.environ["HOME"]}/Library/Preferences/{pkg}.plist'
if os.path.exists(pfile):
with open(pfile, 'rb') as f:
binary_path: Optional[str] = plist.load(f).get('LastRunAppBundlePath')
if binary_path is not None:
return os.path.join(binary_path, 'Contents', 'MacOS', binary)
def get_windows_browser(browser: str) -> Optional[str]:
import winreg as reg
reg_path: str = f'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{browser}.exe'
exe_path: Optional[str] = None
for install_type in reg.HKEY_CURRENT_USER, reg.HKEY_LOCAL_MACHINE:
try:
reg_key: str = reg.OpenKey(install_type, reg_path, 0, reg.KEY_READ)
exe_path: Optional[str] = reg.QueryValue(reg_key, None)
reg_key.Close()
if not os.path.isfile(exe_path):
continue
except reg.WindowsError:
pass
else:
break
return exe_path
def get_linux_browser(browser: str) -> Optional[str]:
from shutil import which as exists
possibilities: List[str] = [browser + channel for channel in ('', '-beta', '-dev', '-developer', '-canary')]
for browser in possibilities:
if exists(browser):
return browser
registry: Dict[str, Dict[str, functools.partial]] = {
'Windows': {
'chrome': functools.partial(get_windows_browser, 'chrome'),
'chromium': functools.partial(get_windows_browser, 'chromium'),
'microsoft-edge': functools.partial(get_windows_browser, 'msedge'),
'opera': functools.partial(get_windows_browser, 'opera'),
},
'Darwin': {
'chrome': functools.partial(get_mac_browser, 'com.google.Chrome', 'Google Chrome'),
'chromium': functools.partial(get_mac_browser, 'org.chromium.Chromium', 'Chromium'),
'microsoft-edge': functools.partial(get_mac_browser, 'com.microsoft.Edge', 'Microsoft Edge'),
'opera': functools.partial(get_mac_browser, 'com.operasoftware.Opera', 'Opera'),
},
'Linux': {
'chrome': functools.partial(get_linux_browser, 'chrome'),
'chromium': functools.partial(get_linux_browser, 'chromium'),
'microsoft-edge': functools.partial(get_linux_browser, 'microsoft-edge'),
'opera': functools.partial(get_linux_browser, 'opera'),
}
}
def get_browser(self, browser: Optional[BrowserEnum] = None) -> Optional[str]:
if browser is not None:
return self.registry.get(platform.system(), {})[browser.value]()
for browser in self.registry.get(platform.system(), {}).values():
browser = browser()
if browser is not None:
return browser
@property
def running(self) -> bool:
try:
return self.proc.poll() is None
except:
return False
def launch(
self,
domain: Optional[str] = None,
server: Tuple[Optional[str], Optional[int]] = (None, None),
width: int = 400,
height: int = 500,
browser_args: List[str] = [],
extensions: Optional[str] = None
) -> None:
browser_command: List[str] = [self.browser, *browser_args]
if extensions:
browser_command.append(f'--load-extension={extensions}')
browser_command.extend((
'--disable-default-apps',
'--no-default-browser-check',
'--no-check-default-browser',
'--no-first-run',
'--ignore-certificate-errors',
'--disable-background-networking',
'--disable-component-update',
'--disable-domain-reliability',
f'--user-data-dir={os.path.join(tempfile.TemporaryDirectory().name, "Profiles")}',
f'--host-rules=MAP {domain} {server[0]}:{server[1]}',
f'--window-size={width},{height}',
f'--app=https://{domain}'
))
self.proc = subprocess.Popen(browser_command, stdout=-1, stderr=-1)
def stop(self) -> None:
try:
self.proc.terminate()
except:
pass
async def _get_info(session: ClientSession) -> Tuple[str, str, int]:
for _ in range(3):
try:
async with session.get('https://discord-user-api.cf/api/v1/properties/web', timeout=5) as resp:
json = await resp.json()
return json['chrome_user_agent'], json['chrome_version'], json['client_build_number']
except Exception:
continue
_log.warning('Info API down. Falling back to manual fetching...')
ua = await _get_user_agent(session)
bn = await _get_build_number(session)
bv = await _get_browser_version(session)
return ua, bv, bn
async def _get_build_number(session: ClientSession) -> int: # Thank you Discord-S.C.U.M
"""Fetches client build number"""
try:
login_page_request = await session.get('https://discord.com/login', timeout=7)
login_page = await login_page_request.text()
build_url = 'https://discord.com/assets/' + re.compile(r'assets/+([a-z0-9]+)\.js').findall(login_page)[-2] + '.js'
build_request = await session.get(build_url, timeout=7)
build_file = await build_request.text()
build_index = build_file.find('buildNumber') + 24
return int(build_file[build_index:build_index + 6])
except asyncio.TimeoutError:
_log.critical('Could not fetch client build number. Falling back to hardcoded value...')
return 117300
async def _get_user_agent(session: ClientSession) -> str:
"""Fetches the latest Windows 10/Chrome user-agent."""
try:
request = await session.request('GET', 'https://jnrbsn.github.io/user-agents/user-agents.json', timeout=7)
response = json.loads(await request.text())
return response[0]
except asyncio.TimeoutError:
_log.critical('Could not fetch user-agent. Falling back to hardcoded value...')
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'
async def _get_browser_version(session: ClientSession) -> str:
"""Fetches the latest Windows 10/Chrome version."""
try:
request = await session.request('GET', 'https://omahaproxy.appspot.com/all.json', timeout=7)
response = json.loads(await request.text())
if response[0]['versions'][4]['channel'] == 'stable':
return response[0]['versions'][4]['version']
raise RuntimeError
except (asyncio.TimeoutError, RuntimeError):
_log.critical('Could not fetch browser version. Falling back to hardcoded value...')
return '99.0.4844.51'

425
discord/voice_client.py

@ -40,11 +40,12 @@ Some documentation to refer to:
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import socket
import logging
import struct
import threading
from typing import Any, Callable, List, Optional, TYPE_CHECKING, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Tuple, Union
from . import opus, utils
from .backoff import ExponentialBackoff
@ -68,8 +69,6 @@ if TYPE_CHECKING:
SupportedModes,
)
VocalGuildChannel = Union[VoiceChannel, StageChannel]
has_nacl: bool
@ -85,7 +84,6 @@ __all__ = (
'VoiceClient',
)
_log = logging.getLogger(__name__)
@ -199,6 +197,161 @@ class VoiceProtocol:
self.client._connection._remove_voice_client(key_id)
class Player:
def __init__(self, client: VoiceClient) -> None:
self.client = client
self.loop: asyncio.AbstractEventLoop = client.loop
self.encoder: Encoder = MISSING
self._player: AudioPlayer = MISSING
def send(self, data: bytes, encode: bool = True) -> None:
"""Sends an audio packet composed of the data.
You must be connected to play audio.
Parameters
----------
data: :class:`bytes`
The :term:`py:bytes-like object` denoting PCM or Opus voice data.
encode: :class:`bool`
Indicates if ``data`` should be encoded into Opus.
Raises
-------
ClientException
You are not connected.
opus.OpusError
Encoding the data failed.
"""
if encode:
data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME)
self.client.send_audio_packet(data)
@property
def ws(self) -> DiscordVoiceWebSocket:
return self.client.ws
@property
def source(self) -> Optional[AudioSource]:
"""Optional[:class:`AudioSource`]: The audio source being played, if playing.
This property can also be used to change the audio source currently being played.
"""
return self._player.source if self._player else None
@source.setter
def source(self, value) -> None:
if not isinstance(value, AudioSource):
raise TypeError('Expected AudioSource not {0.__class__.__name__}'.format(value))
if self._player is None:
raise ValueError('Not playing anything')
self._player._set_source(value)
def is_playing(self) -> bool:
"""Indicates if we're currently playing audio."""
return self._player and self._player.is_playing()
def is_paused(self) -> bool:
"""Indicates if we're playing audio, but if we're paused."""
return self._player and self._player.is_paused()
def play(
self, source: AudioSource, *, after: Callable[[Optional[Exception]], Any] = None
) -> None:
"""Plays an :class:`AudioSource`.
The finalizer, ``after`` is called after the source has been exhausted
or an error occurred.
If an error happens while the audio player is running, the exception is
caught and the audio player is then stopped. If no after callback is
passed, any caught exception will be displayed as if it were raised.
Parameters
-----------
source: :class:`AudioSource`
The audio source we're reading from.
after: Callable[[:class:`Exception`], Any]
The finalizer that is called after the stream is exhausted.
This function must have a single parameter, ``error``, that
denotes an optional exception that was raised during playing.
Raises
-------
ClientException
Already playing audio or not connected.
TypeError
Source is not a :class:`AudioSource` or after is not a callable.
OpusNotLoaded
Source is not opus encoded and opus is not loaded.
"""
if not self.client.is_connected():
raise ClientException('Not connected to voice')
if self.is_playing():
raise ClientException('Already playing audio')
if not isinstance(source, AudioSource):
raise TypeError(f'source must be an AudioSource not {source.__class__.__name__}')
if not self.encoder and not source.is_opus():
self.encoder = opus.Encoder()
self._player = AudioPlayer(source, self, after=after)
self._player.start()
def pause(self) -> None:
"""Pauses the audio playing."""
if self._player:
self._player.pause()
def resume(self) -> None:
"""Resumes the audio playing."""
if self._player:
self._player.resume()
def stop(self) -> None:
"""Stops playing audio."""
if self._player:
self._player.stop()
self._player = MISSING
class Listener:
def __init__(self, client: VoiceClient) -> None:
self.client = client
self.loop: asyncio.AbstractEventLoop = client.loop
self.decoder = None
self._listener = None
@property
def ws(self) -> DiscordVoiceWebSocket:
return self.client.ws
def is_listening(self) -> bool:
"""Indicates if we're currently listening."""
return self._listener is not None and self._listener.is_listening()
def is_paused(self) -> bool:
"""Indicates if we're listening, but we're paused."""
return self._listener is not None and self._listener.is_paused()
def listen(self, sink, *, callback=None) -> None:
if not self.client.is_connected():
raise ClientException('Not connected to voice')
if self.is_listening():
raise ClientException('Already listening')
if not isinstance(sink, AudioSink):
raise TypeError(f'sink must an AudioSink not {sink.__class__.__name__}')
class VoiceClient(VoiceProtocol):
"""Represents a Discord voice connection.
@ -220,17 +373,16 @@ class VoiceClient(VoiceProtocol):
The voice connection token.
endpoint: :class:`str`
The endpoint we are connecting to.
channel: Union[:class:`VoiceChannel`, :class:`StageChannel`]
channel: :class:`abc.Connectable`
The voice channel connected to.
loop: :class:`asyncio.AbstractEventLoop`
The event loop that the voice client is running on.
"""
channel: VocalGuildChannel
channel: abc.Connectable
endpoint_ip: str
voice_port: int
secret_key: List[int]
ssrc: int
def __init__(self, client: Client, channel: abc.Connectable):
if not has_nacl:
@ -243,7 +395,7 @@ class VoiceClient(VoiceProtocol):
self.socket = MISSING
self.loop: asyncio.AbstractEventLoop = state.loop
self._state: ConnectionState = state
# this will be used in the AudioPlayer thread
# This will be used in the threads
self._connected: threading.Event = threading.Event()
self._handshaking: bool = False
@ -255,12 +407,14 @@ class VoiceClient(VoiceProtocol):
self._connections: int = 0
self.sequence: int = 0
self.timestamp: int = 0
self.player = Player(self)
self.listener = Listener(self)
self.timeout: float = 0
self._runner: asyncio.Task = MISSING
self._player: Optional[AudioPlayer] = None
self.encoder: Encoder = MISSING
self._lite_nonce: int = 0
self.ws: DiscordVoiceWebSocket = MISSING
self.idrcs: Dict[int, int] = {}
self.ssids: Dict[int, int] = {}
warn_nacl = not has_nacl
supported_modes: Tuple[SupportedModes, ...] = (
@ -270,23 +424,26 @@ class VoiceClient(VoiceProtocol):
)
@property
def guild(self) -> Guild:
""":class:`Guild`: The guild we're connected to."""
return self.channel.guild
def ssrc(self) -> int:
""":class:`str`: Our ssrc."""
return self.idrcs.get(self.user.id) # type: ignore
@ssrc.setter
def ssrc(self, value):
self.idrcs[self.user.id] = value
self.ssids[value] = self.user.id
@property
def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: The guild we're connected to, if applicable."""
return getattr(self.channel, 'guild', None)
@property
def user(self) -> ClientUser:
""":class:`ClientUser`: The user connected to voice (i.e. ourselves)."""
return self._state.user # type: ignore - user can't be None after login
def checked_add(self, attr, value, limit):
val = getattr(self, attr)
if val + value > limit:
setattr(self, attr, 0)
else:
setattr(self, attr, val + value)
# connection related
# Connection related
async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None:
self.session_id = data['session_id']
@ -300,7 +457,11 @@ class VoiceClient(VoiceProtocol):
# We're being disconnected so cleanup
await self.disconnect()
else:
self.channel = channel_id and self.guild.get_channel(int(channel_id)) # type: ignore - this won't be None
guild = self.guild
if guild is not None:
self.channel = channel_id and guild.get_channel(int(channel_id)) # type: ignore - This won't be None
else:
self.channel = channel_id and self._state._get_private_channel(int(channel_id)) # type: ignore - This won't be None
else:
self._voice_state_complete.set()
@ -309,8 +470,11 @@ class VoiceClient(VoiceProtocol):
_log.info('Ignoring extraneous voice server update.')
return
self.token = data['token']
self.server_id = int(data['guild_id'])
if 'token' in data:
self.token = data['token']
self.server_id = server_id = utils._get_as_snowflake(data, 'guild_id')
if server_id is None:
self.server_id = utils._get_as_snowflake(data, 'channel_id')
endpoint = data.get('endpoint')
if endpoint is None or self.token is None:
@ -322,44 +486,49 @@ class VoiceClient(VoiceProtocol):
self.endpoint, _, _ = endpoint.rpartition(':')
if self.endpoint.startswith('wss://'):
# Just in case, strip it off since we're going to add it later
self.endpoint = self.endpoint[6:]
self.endpoint = self.endpoint[6:] # Shouldn't ever be there...
# This gets set later
self.endpoint_ip = MISSING
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.socket.setblocking(False)
if not self._handshaking:
# If we're not handshaking then we need to terminate our previous connection in the websocket
# If we're not handshaking then we need to terminate our previous connection to the websocket
await self.ws.close(4000)
return
self._voice_server_complete.set()
async def voice_connect(self) -> None:
await self.channel.guild.change_voice_state(channel=self.channel)
channel = await self.channel._get_channel() if self.channel else None
if self.guild:
await self.guild.change_voice_state(channel=channel)
else:
await self._state.client.change_voice_state(channel=channel)
async def voice_disconnect(self) -> None:
_log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', self.channel.id, self.guild.id)
await self.channel.guild.change_voice_state(channel=None)
_log.info('The voice handshake is being terminated for channel ID %s (guild ID %s).', (await self.channel._get_channel()).id, getattr(self.guild, 'id', None))
if self.guild:
await self.guild.change_voice_state(channel=None)
else:
await self._state.client.change_voice_state(channel=None)
def prepare_handshake(self) -> None:
self._voice_state_complete.clear()
self._voice_server_complete.clear()
self._handshaking = True
_log.info('Starting voice handshake... (connection attempt %d)', self._connections + 1)
_log.info('Starting voice handshake (connection attempt %d)...', self._connections + 1)
self._connections += 1
def finish_handshake(self) -> None:
_log.info('Voice handshake complete. Endpoint found %s', self.endpoint)
_log.info('Voice handshake complete. Endpoint found: %s.', self.endpoint)
self._handshaking = False
self._voice_server_complete.clear()
self._voice_state_complete.clear()
async def connect_websocket(self) -> DiscordVoiceWebSocket:
ws = await DiscordVoiceWebSocket.from_client(self)
async def connect_websocket(self, resume=False) -> DiscordVoiceWebSocket:
ws = await DiscordVoiceWebSocket.from_client(self, resume=resume)
self._connected.clear()
while ws.secret_key is None:
await ws.poll_event()
@ -395,11 +564,12 @@ class VoiceClient(VoiceProtocol):
break
except (ConnectionClosed, asyncio.TimeoutError):
if reconnect:
_log.exception('Failed to connect to voice... Retrying...')
_log.exception('Failed to connect to voice. Retrying...')
await asyncio.sleep(1 + i * 2.0)
await self.voice_disconnect()
continue
else:
await self.disconnect(force=True)
raise
if self._runner is MISSING:
@ -427,6 +597,20 @@ class VoiceClient(VoiceProtocol):
else:
return True
async def potential_resume(self) -> bool:
# Attempt to stop the player thread from playing early
self._connected.clear()
self._potentially_reconnecting = True
try:
self.ws = await self.connect_websocket(resume=True)
except (ConnectionClosed, asyncio.TimeoutError):
return False # Reconnect normally if RESUME failed
else:
return True
finally:
self._potentially_reconnecting = False
@property
def latency(self) -> float:
""":class:`float`: Latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds.
@ -455,16 +639,21 @@ class VoiceClient(VoiceProtocol):
await self.ws.poll_event()
except (ConnectionClosed, asyncio.TimeoutError) as exc:
if isinstance(exc, ConnectionClosed):
# The following close codes are undocumented so I will document them here.
# 1000 - normal closure (obviously)
# 4014 - voice channel has been deleted.
# 4015 - voice server has crashed
if exc.code in (1000, 4015):
if exc.code == 1000:
_log.info('Disconnecting from voice normally, close code %d.', exc.code)
await self.disconnect()
break
if exc.code == 4015:
_log.info('Disconnected from voice (close code %d), potentially RESUMEing...', exc.code)
successful = await self.potential_resume()
if not successful:
_log.info('RESUME was unsuccessful, disconnecting from voice normally...')
await self.disconnect()
break
else:
continue
if exc.code == 4014:
_log.info('Disconnected from voice by force... potentially reconnecting.')
_log.info('Disconnected from voice by force (close code %d)... potentially reconnecting.', exc.code)
successful = await self.potential_reconnect()
if not successful:
_log.info('Reconnect was unsuccessful, disconnecting from voice normally...')
@ -481,12 +670,11 @@ class VoiceClient(VoiceProtocol):
_log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry)
self._connected.clear()
await asyncio.sleep(retry)
await self.voice_disconnect()
try:
await self.connect(reconnect=True, timeout=self.timeout)
except asyncio.TimeoutError:
# at this point we've retried 5 times... let's continue the loop.
_log.warning('Could not connect to voice... Retrying...')
# We've retried 5 times, let's continue the loop
_log.warning('Could not connect to voice. Retrying...')
continue
async def disconnect(self, *, force: bool = False) -> None:
@ -497,7 +685,7 @@ class VoiceClient(VoiceProtocol):
if not force and not self.is_connected():
return
self.stop()
self.player.stop() # TODO: Stop listener
self._connected.clear()
try:
@ -518,20 +706,48 @@ class VoiceClient(VoiceProtocol):
Parameters
-----------
channel: Optional[:class:`abc.Snowflake`]
The channel to move to. Must be a voice channel.
The channel to move to. Must be a :class:`abc.Connectable`.
"""
await self.channel.guild.change_voice_state(channel=channel)
if self.guild:
await self.guild.change_voice_state(channel=channel)
else:
await self._state.client.change_voice_state(channel=channel)
def is_connected(self) -> bool:
"""Indicates if the voice client is connected to voice."""
return self._connected.is_set()
# audio related
# Audio related
def _get_voice_packet(self, data):
def _flip_ssrc(self, query) -> Optional[int]:
value = self.idrcs.get(query)
if value is None:
value = self.ssids.get(query)
return value
def _set_ssrc(self, user_id, ssrc) -> None:
self.idrcs[user_id] = ssrc
self.ssids[ssrc] = user_id
def _checked_add(self, attr, value, limit) -> None:
val = getattr(self, attr)
if val + value > limit:
setattr(self, attr, 0)
else:
setattr(self, attr, val + value)
@staticmethod
def _strip_header(data) -> bytes:
if data[0] == 0xbe and data[1] == 0xde and len(data) > 4:
_, length = struct.unpack_from('>HH', data)
offset = 4 + length * 4
data = data[offset:]
return data
def _get_voice_packet(self, data) -> bytes:
header = bytearray(12)
# Formulate rtp header
# Formulate RTP header
header[0] = 0x80
header[1] = 0x78
struct.pack_into('>H', header, 2, self.sequence)
@ -548,22 +764,44 @@ class VoiceClient(VoiceProtocol):
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext
def _decrypt_xsalsa20_poly1305(self, header, data):
box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24)
nonce[:12] = header
return self._strip_header(box.decrypt(bytes(data), bytes(nonce)))
def _encrypt_xsalsa20_poly1305_suffix(self, header: bytes, data) -> bytes:
box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce
def _decrypt_xsalsa20_poly1305_suffix(self, header, data):
box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce_size = nacl.secret.SecretBox.NONCE_SIZE
nonce = data[-nonce_size:]
return self._strip_header(box.decrypt(bytes(data[:-nonce_size]), nonce))
def _encrypt_xsalsa20_poly1305_lite(self, header: bytes, data) -> bytes:
box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24)
nonce[:4] = struct.pack('>I', self._lite_nonce)
self.checked_add('_lite_nonce', 1, 4294967295)
self._checked_add('_lite_nonce', 1, 4294967295)
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4]
def play(self, source: AudioSource, *, after: Optional[Callable[[Optional[Exception]], Any]] = None) -> None:
def _decrypt_xsalsa20_poly1305_lite(self, header, data):
box = nacl.secret.SecretBox(bytes(self.secret_key))
nonce = bytearray(24)
nonce[:4] = data[-4:]
data = data[:-4]
return self._strip_header(box.decrypt(bytes(data), bytes(nonce)))
def play(self, source: AudioSource, *, after: Callable[[Optional[Exception]], Any]=None) -> None:
"""Plays an :class:`AudioSource`.
The finalizer, ``after`` is called after the source has been exhausted
@ -591,45 +829,10 @@ class VoiceClient(VoiceProtocol):
OpusNotLoaded
Source is not opus encoded and opus is not loaded.
"""
return self.player.play(source, after=after)
if not self.is_connected():
raise ClientException('Not connected to voice.')
if self.is_playing():
raise ClientException('Already playing audio.')
if not isinstance(source, AudioSource):
raise TypeError(f'source must be an AudioSource not {source.__class__.__name__}')
if not self.encoder and not source.is_opus():
self.encoder = opus.Encoder()
self._player = AudioPlayer(source, self, after=after)
self._player.start()
def is_playing(self) -> bool:
"""Indicates if we're currently playing audio."""
return self._player is not None and self._player.is_playing()
def is_paused(self) -> bool:
"""Indicates if we're playing audio, but if we're paused."""
return self._player is not None and self._player.is_paused()
def stop(self) -> None:
"""Stops playing audio."""
if self._player:
self._player.stop()
self._player = None
def pause(self) -> None:
"""Pauses the audio playing."""
if self._player:
self._player.pause()
def resume(self) -> None:
"""Resumes the audio playing."""
if self._player:
self._player.resume()
def listen(self, *args, **kwargs):
return self.listener.listen(*args, **kwargs)
@property
def source(self) -> Optional[AudioSource]:
@ -637,19 +840,29 @@ class VoiceClient(VoiceProtocol):
This property can also be used to change the audio source currently being played.
"""
return self._player.source if self._player else None
return self.player.source
@source.setter
def source(self, value: AudioSource) -> None:
if not isinstance(value, AudioSource):
raise TypeError(f'expected AudioSource not {value.__class__.__name__}.')
self.player.source = value
if self._player is None:
raise ValueError('Not playing anything.')
@property
def sink(self) -> Optional[AudioSink]:
"""Optional[:class:`AudioSink`]: Where received audio is being sent.
self._player._set_source(value)
This property can also be used to change the value.
"""
return self.listener.sink
def send_audio_packet(self, data: bytes, *, encode: bool = True) -> None:
@sink.setter
def sink(self, value):
self.listener.sink = value
#if not isinstance(value, AudioSink):
#raise TypeError('Expected AudioSink not {value.__class__.__name__}')
#if self._recorder is None:
#raise ValueError('Not listening')
def send_audio_packet(self, data: bytes) -> None:
"""Sends an audio packet composed of the data.
You must be connected to play audio.
@ -657,9 +870,7 @@ class VoiceClient(VoiceProtocol):
Parameters
----------
data: :class:`bytes`
The :term:`py:bytes-like object` denoting PCM or Opus voice data.
encode: :class:`bool`
Indicates if ``data`` should be encoded into Opus.
The :term:`py:bytes-like object` denoting Opus voice data.
Raises
-------
@ -668,16 +879,12 @@ class VoiceClient(VoiceProtocol):
opus.OpusError
Encoding the data failed.
"""
self._checked_add('sequence', 1, 65535)
packet = self._get_voice_packet(data)
self.checked_add('sequence', 1, 65535)
if encode:
encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME)
else:
encoded_data = data
packet = self._get_voice_packet(encoded_data)
try:
self.socket.sendto(packet, (self.endpoint_ip, self.voice_port))
except BlockingIOError:
_log.warning('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp)
_log.warning('A packet has been dropped (seq: %s, timestamp: %s).', self.sequence, self.timestamp)
self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295)
self._checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295)

163
discord/webhook/async_.py

@ -72,7 +72,6 @@ if TYPE_CHECKING:
from ..guild import Guild
from ..channel import TextChannel
from ..abc import Snowflake
from ..ui.view import View
import datetime
MISSING = utils.MISSING
@ -126,11 +125,11 @@ class AsyncWebhookAdapter:
headers['Content-Type'] = 'application/json'
to_send = utils._to_json(payload)
if auth_token is not None:
headers['Authorization'] = f'Bot {auth_token}'
if auth_token is not None: # TODO: same as sync.py
headers['Authorization'] = f'{auth_token}'
if reason is not None:
headers['X-Audit-Log-Reason'] = urlquote(reason, safe='/ ')
headers['X-Audit-Log-Reason'] = urlquote(reason)
response: Optional[aiohttp.ClientResponse] = None
data: Optional[Union[Dict[str, Any], str]] = None
@ -152,7 +151,7 @@ class AsyncWebhookAdapter:
try:
async with session.request(method, url, data=to_send, headers=headers, params=params) as response:
_log.debug(
'Webhook ID %s with %s %s has returned status code %s',
'Webhook ID %s with %s %s has returned status code %s.',
webhook_id,
method,
url,
@ -166,7 +165,7 @@ class AsyncWebhookAdapter:
if remaining == '0' and response.status != 429:
delta = utils._parse_ratelimit_header(response)
_log.debug(
'Webhook ID %s has been pre-emptively rate limited, waiting %.2f seconds', webhook_id, delta
'Webhook ID %s has been pre-emptively rate limited, waiting %.2f seconds.', webhook_id, delta
)
lock.delay_by(delta)
@ -178,7 +177,7 @@ class AsyncWebhookAdapter:
raise HTTPException(response, data)
retry_after: float = data['retry_after'] # type: ignore
_log.warning('Webhook ID %s is rate limited. Retrying in %.2f seconds', webhook_id, retry_after)
_log.warning('Webhook ID %s is rate limited. Retrying in %.2f seconds.', webhook_id, retry_after)
await asyncio.sleep(retry_after)
continue
@ -344,74 +343,6 @@ class AsyncWebhookAdapter:
route = Route('GET', '/webhooks/{webhook_id}/{webhook_token}', webhook_id=webhook_id, webhook_token=token)
return self.request(route, session=session)
def create_interaction_response(
self,
interaction_id: int,
token: str,
*,
session: aiohttp.ClientSession,
params: MultipartParameters,
) -> Response[None]:
route = Route(
'POST',
'/interactions/{webhook_id}/{webhook_token}/callback',
webhook_id=interaction_id,
webhook_token=token,
)
if params.files:
return self.request(route, session=session, files=params.files, multipart=params.multipart)
else:
return self.request(route, session=session, payload=params.payload)
def get_original_interaction_response(
self,
application_id: int,
token: str,
*,
session: aiohttp.ClientSession,
) -> Response[MessagePayload]:
r = Route(
'GET',
'/webhooks/{webhook_id}/{webhook_token}/messages/@original',
webhook_id=application_id,
webhook_token=token,
)
return self.request(r, session=session)
def edit_original_interaction_response(
self,
application_id: int,
token: str,
*,
session: aiohttp.ClientSession,
payload: Optional[Dict[str, Any]] = None,
multipart: Optional[List[Dict[str, Any]]] = None,
files: Optional[List[File]] = None,
) -> Response[MessagePayload]:
r = Route(
'PATCH',
'/webhooks/{webhook_id}/{webhook_token}/messages/@original',
webhook_id=application_id,
webhook_token=token,
)
return self.request(r, session, payload=payload, multipart=multipart, files=files)
def delete_original_interaction_response(
self,
application_id: int,
token: str,
*,
session: aiohttp.ClientSession,
) -> Response[None]:
r = Route(
'DELETE',
'/webhooks/{webhook_id}/{webhook_token}/messages/@original',
webhook_id=application_id,
webhook_token=token,
)
return self.request(r, session=session)
def interaction_response_params(type: int, data: Optional[Dict[str, Any]] = None) -> MultipartParameters:
payload: Dict[str, Any] = {
@ -435,7 +366,6 @@ def interaction_message_response_params(
embed: Optional[Embed] = MISSING,
embeds: List[Embed] = MISSING,
attachments: List[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING,
allowed_mentions: Optional[AllowedMentions] = MISSING,
previous_allowed_mentions: Optional[AllowedMentions] = None,
) -> MultipartParameters:
@ -471,12 +401,6 @@ def interaction_message_response_params(
else:
data['content'] = None
if view is not MISSING:
if view is not None:
data['components'] = view.to_components()
else:
data['components'] = []
if flags is not MISSING:
data['flags'] = flags.value
@ -627,14 +551,14 @@ class _WebhookState:
return self._parent.http
# Some data classes assign state.http and that should be kosher
# however, using it should result in a late-binding error.
# However, using it should result in a late-binding error
return _FriendlyHttpAttributeErrorHelper()
def __getattr__(self, attr):
if self._parent is not None:
return getattr(self._parent, attr)
raise AttributeError(f'PartialWebhookState does not support {attr!r}.')
raise AttributeError(f'PartialWebhookState does not support {attr!r}')
class WebhookMessage(Message):
@ -657,7 +581,6 @@ class WebhookMessage(Message):
embeds: List[Embed] = MISSING,
embed: Optional[Embed] = MISSING,
attachments: List[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None,
) -> WebhookMessage:
"""|coro|
@ -694,11 +617,6 @@ class WebhookMessage(Message):
allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information.
view: Optional[:class:`~discord.ui.View`]
The updated view to update this message with. If ``None`` is passed then
the view is removed.
.. versionadded:: 2.0
Raises
-------
@ -723,7 +641,6 @@ class WebhookMessage(Message):
embeds=embeds,
embed=embed,
attachments=attachments,
view=view,
allowed_mentions=allowed_mentions,
)
@ -1095,7 +1012,7 @@ class Webhook(BaseWebhook):
"""
m = re.search(r'discord(?:app)?.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,68})', url)
if m is None:
raise ValueError('Invalid webhook URL given.')
raise ValueError('Invalid webhook URL given')
data: Dict[str, Any] = m.groupdict()
data['type'] = 1
@ -1301,13 +1218,11 @@ class Webhook(BaseWebhook):
username: str = MISSING,
avatar_url: Any = MISSING,
tts: bool = MISSING,
ephemeral: bool = MISSING,
file: File = MISSING,
files: List[File] = MISSING,
embed: Embed = MISSING,
embeds: List[Embed] = MISSING,
allowed_mentions: AllowedMentions = MISSING,
view: View = MISSING,
thread: Snowflake = MISSING,
wait: Literal[True],
suppress_embeds: bool = MISSING,
@ -1322,13 +1237,11 @@ class Webhook(BaseWebhook):
username: str = MISSING,
avatar_url: Any = MISSING,
tts: bool = MISSING,
ephemeral: bool = MISSING,
file: File = MISSING,
files: List[File] = MISSING,
embed: Embed = MISSING,
embeds: List[Embed] = MISSING,
allowed_mentions: AllowedMentions = MISSING,
view: View = MISSING,
thread: Snowflake = MISSING,
wait: Literal[False] = ...,
suppress_embeds: bool = MISSING,
@ -1342,13 +1255,11 @@ class Webhook(BaseWebhook):
username: str = MISSING,
avatar_url: Any = MISSING,
tts: bool = False,
ephemeral: bool = False,
file: File = MISSING,
files: List[File] = MISSING,
embed: Embed = MISSING,
embeds: List[Embed] = MISSING,
allowed_mentions: AllowedMentions = MISSING,
view: View = MISSING,
thread: Snowflake = MISSING,
wait: bool = False,
suppress_embeds: bool = False,
@ -1388,13 +1299,6 @@ class Webhook(BaseWebhook):
string then it is explicitly cast using ``str``.
tts: :class:`bool`
Indicates if the message should be sent using text-to-speech.
ephemeral: :class:`bool`
Indicates if the message should only be visible to the user.
This is only available to :attr:`WebhookType.application` webhooks.
If a view is sent with an ephemeral message and it has no timeout set
then the timeout is set to 15 minutes.
.. versionadded:: 2.0
file: :class:`File`
The file to upload. This cannot be mixed with ``files`` parameter.
files: List[:class:`File`]
@ -1410,13 +1314,6 @@ class Webhook(BaseWebhook):
Controls the mentions being processed in this message.
.. versionadded:: 1.4
view: :class:`discord.ui.View`
The view to send with the message. You can only send a view
if this webhook is not partial and has state attached. A
webhook has state attached if the webhook is managed by the
library.
.. versionadded:: 2.0
thread: :class:`~discord.abc.Snowflake`
The thread to send this webhook to.
@ -1437,10 +1334,8 @@ class Webhook(BaseWebhook):
TypeError
You specified both ``embed`` and ``embeds`` or ``file`` and ``files``.
ValueError
The length of ``embeds`` was invalid, there was no token
associated with this webhook or ``ephemeral`` was passed
with the improper webhook type or there was no state
attached with this webhook when giving it a view.
The length of ``embeds`` was invalid, or there was no token
associated with this webhook.
Returns
---------
@ -1454,26 +1349,15 @@ class Webhook(BaseWebhook):
previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
if content is None:
content = MISSING
if ephemeral or suppress_embeds:
if suppress_embeds:
flags = MessageFlags._from_value(0)
flags.ephemeral = ephemeral
flags.suppress_embeds = suppress_embeds
else:
flags = MISSING
application_webhook = self.type is WebhookType.application
if ephemeral and not application_webhook:
raise ValueError('ephemeral messages can only be sent from application webhooks')
if application_webhook:
if self.type is WebhookType.application:
wait = True
if view is not MISSING:
if isinstance(self._state, _WebhookState):
raise ValueError('Webhook views require an associated state with the webhook')
if ephemeral is True and view.timeout is None:
view.timeout = 15 * 60.0
params = handle_message_parameters(
content=content,
username=username,
@ -1484,7 +1368,6 @@ class Webhook(BaseWebhook):
embed=embed,
embeds=embeds,
flags=flags,
view=view,
allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_mentions,
)
@ -1508,10 +1391,6 @@ class Webhook(BaseWebhook):
if wait:
msg = self._create_message(data)
if view is not MISSING and not view.is_finished():
message_id = None if msg is None else msg.id
self._state.store_view(view, message_id)
return msg
async def fetch_message(self, id: int, /) -> WebhookMessage:
@ -1563,7 +1442,6 @@ class Webhook(BaseWebhook):
embeds: List[Embed] = MISSING,
embed: Optional[Embed] = MISSING,
attachments: List[Union[Attachment, File]] = MISSING,
view: Optional[View] = MISSING,
allowed_mentions: Optional[AllowedMentions] = None,
) -> WebhookMessage:
"""|coro|
@ -1601,12 +1479,6 @@ class Webhook(BaseWebhook):
allowed_mentions: :class:`AllowedMentions`
Controls the mentions being processed in this message.
See :meth:`.abc.Messageable.send` for more information.
view: Optional[:class:`~discord.ui.View`]
The updated view to update this message with. If ``None`` is passed then
the view is removed. The webhook must have state attached, similar to
:meth:`send`.
.. versionadded:: 2.0
Raises
-------
@ -1630,19 +1502,12 @@ class Webhook(BaseWebhook):
if self.token is None:
raise ValueError('This webhook does not have a token associated with it')
if view is not MISSING:
if isinstance(self._state, _WebhookState):
raise ValueError('This webhook does not have state associated with it')
self._state.prevent_view_updates_for(message_id)
previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None)
params = handle_message_parameters(
content=content,
attachments=attachments,
embed=embed,
embeds=embeds,
view=view,
allowed_mentions=allowed_mentions,
previous_allowed_mentions=previous_mentions,
)
@ -1658,8 +1523,6 @@ class Webhook(BaseWebhook):
)
message = self._create_message(data)
if view and not view.is_finished():
self._state.store_view(view, message_id)
return message
async def delete_message(self, message_id: int, /) -> None:

26
discord/webhook/sync.py

@ -20,13 +20,13 @@ 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.
"""
# If you're wondering why this is essentially copy pasted from the async_.py
# file, then it's due to needing two separate types to make the typing shenanigans
# a bit easier to write. It's an unfortunate design. Originally, these types were
# merged and an adapter was used to differentiate between the async and sync versions.
# However, this proved to be difficult to provide typings for, so here we are.
If you're wondering why this is essentially copy pasted from the async_.py
file, then it's due to needing two separate types to make the typing shenanigans
a bit easier to write. It's an unfortunate design. Originally, these types were
merged and an adapter was used to differentiate between the async and sync versions.
However, this proved to be difficult to provide typings for, so here we are.
"""
from __future__ import annotations
@ -121,11 +121,11 @@ class WebhookAdapter:
headers['Content-Type'] = 'application/json'
to_send = utils._to_json(payload)
if auth_token is not None:
headers['Authorization'] = f'Bot {auth_token}'
if auth_token is not None: # TODO: is this possible with users?
headers['Authorization'] = f'{auth_token}'
if reason is not None:
headers['X-Audit-Log-Reason'] = urlquote(reason, safe='/ ')
headers['X-Audit-Log-Reason'] = urlquote(reason)
response: Optional[Response] = None
data: Optional[Union[Dict[str, Any], str]] = None
@ -153,7 +153,7 @@ class WebhookAdapter:
method, url, data=to_send, files=file_data, headers=headers, params=params
) as response:
_log.debug(
'Webhook ID %s with %s %s has returned status code %s',
'Webhook ID %s with %s %s has returned status code %s.',
webhook_id,
method,
url,
@ -171,7 +171,7 @@ class WebhookAdapter:
if remaining == '0' and response.status_code != 429:
delta = utils._parse_ratelimit_header(response)
_log.debug(
'Webhook ID %s has been pre-emptively rate limited, waiting %.2f seconds', webhook_id, delta
'Webhook ID %s has been pre-emptively rate limited, waiting %.2f seconds.', webhook_id, delta
)
lock.delay_by(delta)
@ -183,7 +183,7 @@ class WebhookAdapter:
raise HTTPException(response, data)
retry_after: float = data['retry_after'] # type: ignore
_log.warning('Webhook ID %s is rate limited. Retrying in %.2f seconds', webhook_id, retry_after)
_log.warning('Webhook ID %s is rate limited. Retrying in %.2f seconds.', webhook_id, retry_after)
time.sleep(retry_after)
continue
@ -650,7 +650,7 @@ class SyncWebhook(BaseWebhook):
"""
m = re.search(r'discord(?:app)?.com/api/webhooks/(?P<id>[0-9]{17,20})/(?P<token>[A-Za-z0-9\.\-\_]{60,68})', url)
if m is None:
raise ValueError('Invalid webhook URL given.')
raise ValueError('Invalid webhook URL given')
data: Dict[str, Any] = m.groupdict()
data['type'] = 1

187
discord/welcome_screen.py

@ -0,0 +1,187 @@
"""
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 List, Optional, TYPE_CHECKING, Union
from .object import Object
from .partial_emoji import PartialEmoji
from .utils import _get_as_snowflake, MISSING
if TYPE_CHECKING:
from .abc import Snowflake
from .emoji import Emoji
from .guild import Guild
from .state import ConnectionState
from .types.welcome_screen import (
WelcomeScreen as WelcomeScreenPayload,
WelcomeScreenChannel as WelcomeScreenChannelPayload,
)
__all__ = (
'WelcomeChannel',
'WelcomeScreen',
)
class WelcomeChannel:
"""Represents a channel shown on a :class:`WelcomeScreen`.
.. versionadded:: 2.0
Attributes
-----------
channel: :class:`abc.Snowflake`
The channel that is being shown.
description: :class:`str`
The description of the channel.
emoji: Optional[Union[:class:`PartialEmoji`, :class:`Emoji`]
The emoji shown under the description.
"""
def __init__(
self, *, channel: Snowflake, description: str, emoji: Optional[Union[PartialEmoji, Emoji]] = None
) -> None:
self.channel = channel
self.description = description
self.emoji = emoji
def __repr__(self) -> str:
return f'<WelcomeChannel channel={self.channel!r} description={self.description} emoji={self.emoji!r}>'
@classmethod
def _from_dict(cls, *, data: WelcomeScreenChannelPayload, state: ConnectionState) -> WelcomeChannel:
channel_id = _get_as_snowflake(data, 'channel_id')
channel = state.get_channel(channel_id) or Object(id=channel_id)
emoji = None
if (emoji_id := _get_as_snowflake(data, 'emoji_id')) is not None:
emoji = state.get_emoji(emoji_id)
elif (emoji_name := data.get('emoji_name')) is not None:
emoji = PartialEmoji(name=emoji_name)
return cls(channel=channel, description=data.get('description', ''), emoji=emoji)
def _to_dict(self) -> WelcomeScreenChannelPayload:
data = {
'channel_id': self.channel.id,
'description': self.description,
'emoji_id': None,
'emoji_name': None,
}
if (emoji := self.emoji) is not None:
data['emoji_id'] = emoji.id
data['emoji_name'] = emoji.name
return data
class WelcomeScreen:
"""Represents a :class:`Guild`'s welcome screen.
.. versionadded:: 2.0
.. container:: operations
.. describe:: bool(b)
Returns whether the welcome screen is enabled.
Attributes
-----------
guild: Union[:class:`Guild`, :class:`PartialInviteGuild`]
The guild the welcome screen is for.
description: :class:`str`
The text shown on the welcome screen.
welcome_channels: List[:class:`WelcomeChannel`]
The channels shown on the welcome screen.
"""
def __init__(self, *, data: WelcomeScreenPayload, guild: Guild) -> None:
self.guild = guild
self._update(data)
def _update(self, data: WelcomeScreenPayload) -> None:
state = self.guild._state
channels = data.get('welcome_channels', [])
self.welcome_channels = [WelcomeChannel._from_dict(data=channel, state=state) for channel in channels]
self.description = data.get('description', '')
def __repr__(self) -> str:
return f'<WelcomeScreen enabled={self.enabled} description={self.description} welcome_channels={self.welcome_channels!r}>'
def __bool__(self) -> bool:
return self.enabled
@property
def enabled(self) -> bool:
""":class:`bool`: Whether the welcome screen is displayed."""
return 'WELCOME_SCREEN_ENABLED' in self.guild.features
async def edit(
self,
*,
description: str = MISSING,
welcome_channels: List[WelcomeChannel] = MISSING,
enabled: bool = MISSING,
):
"""|coro|
Edit the welcome screen.
You must have the :attr:`~Permissions.manage_guild` permission to do this.
All parameters are optional.
Parameters
------------
enabled: :class:`bool`
Whether the welcome screen will be shown.
description: :class:`str`
The welcome screen's description.
welcome_channels: Optional[List[:class:`WelcomeChannel`]]
The welcome channels (in order).
Raises
-------
HTTPException
Editing the welcome screen failed failed.
Forbidden
You don't have permissions to edit the welcome screen.
"""
payload = {}
if enabled is not MISSING:
payload['enabled'] = enabled
if description is not MISSING:
payload['description'] = description
if welcome_channels is not MISSING:
channels = [channel._to_dict() for channel in welcome_channels] if welcome_channels else []
payload['welcome_channels'] = channels
if payload:
guild = self.guild
data = await guild._state.http.edit_welcome_screen(guild.id, payload)
self._update(data)

5
docs/_templates/layout.html

@ -58,9 +58,8 @@
{#- The main navigation header #}
<header class="grid-item">
<nav>
<a href="{{ pathto(master_doc)|e }}" class="main-heading">discord.py</a>
<a href="https://github.com/Rapptz/discord.py" title="GitHub"><span class="material-icons custom-icons">github</span></a>
<a href="{{ discord_invite }}" title="{{ _('Discord') }}"><span class="material-icons custom-icons">discord</span></a>
<a href="{{ pathto(master_doc)|e }}" class="main-heading">discord.py-self</a>
<a href="https://github.com/dolfies/discord.py-self" title="GitHub"><span class="material-icons custom-icons">github</span></a>
<a href="{{ pathto('faq') }}" title="FAQ"><span class="material-icons">help_center</span></a>
{#- If we have more links we can put them here #}
<a onclick="mobileSearch.open();" title="{{ _('Search') }}" id="open-search" class="mobile-only"><span class="material-icons">search</span></a>

270
docs/api.rst

@ -3,7 +3,7 @@
API Reference
===============
The following section outlines the API of discord.py.
The following section outlines the API of discord.py-self.
.. note::
@ -11,7 +11,7 @@ The following section outlines the API of discord.py.
in an output independent way. If the logging module is not configured,
these logs will not be output anywhere. See :ref:`logging_setup` for
more information on how to set up and use the logging module with
discord.py.
discord.py-self.
Version Related Info
---------------------
@ -45,13 +45,6 @@ Client
.. automethod:: Client.event()
:decorator:
AutoShardedClient
~~~~~~~~~~~~~~~~~~
.. attributetable:: AutoShardedClient
.. autoclass:: AutoShardedClient
:members:
Application Info
------------------
@ -192,8 +185,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
.. warning::
All the events must be a |coroutine_link|_. If they aren't, then you might get unexpected
errors. In order to turn a function into a coroutine they must be ``async def``
functions.
errors. In order to turn a function into a coroutine they must be defined with ``async def``.
.. function:: on_connect()
@ -202,16 +194,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
The warnings on :func:`on_ready` also apply.
.. function:: on_shard_connect(shard_id)
Similar to :func:`on_connect` except used by :class:`AutoShardedClient`
to denote when a particular shard ID has connected to Discord.
.. versionadded:: 1.4
:param shard_id: The shard ID that has connected.
:type shard_id: :class:`int`
.. function:: on_disconnect()
Called when the client has disconnected from Discord, or a connection attempt to Discord has failed.
@ -220,16 +202,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
This function can be called many times without a corresponding :func:`on_connect` call.
.. function:: on_shard_disconnect(shard_id)
Similar to :func:`on_disconnect` except used by :class:`AutoShardedClient`
to denote when a particular shard ID has disconnected from Discord.
.. versionadded:: 1.4
:param shard_id: The shard ID that has disconnected.
:type shard_id: :class:`int`
.. function:: on_ready()
Called when the client is done preparing the data received from Discord. Usually after login is successful
@ -242,28 +214,10 @@ to handle it, which defaults to print a traceback and ignoring the exception.
once. This library implements reconnection logic and thus will
end up calling this event whenever a RESUME request fails.
.. function:: on_shard_ready(shard_id)
Similar to :func:`on_ready` except used by :class:`AutoShardedClient`
to denote when a particular shard ID has become ready.
:param shard_id: The shard ID that is ready.
:type shard_id: :class:`int`
.. function:: on_resumed()
Called when the client has resumed a session.
.. function:: on_shard_resumed(shard_id)
Similar to :func:`on_resumed` except used by :class:`AutoShardedClient`
to denote when a particular shard ID has resumed a session.
.. versionadded:: 1.4
:param shard_id: The shard ID that has resumed.
:type shard_id: :class:`int`
.. function:: on_error(event, *args, **kwargs)
Usually when an event raises an uncaught exception, a traceback is
@ -352,13 +306,13 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when someone begins typing a message.
The ``channel`` parameter can be a :class:`abc.Messageable` instance.
Which could either be :class:`TextChannel`, :class:`GroupChannel`, or
Which could either be :class:`TextChannel`, :class:`GroupChannel`, :class:`Thread`, or
:class:`DMChannel`.
If the ``channel`` is a :class:`TextChannel` then the ``user`` parameter
If the ``channel`` is a :class:`TextChannel` or :class:`Thread` then the ``user`` parameter
is a :class:`Member`, otherwise it is a :class:`User`.
This requires :attr:`Intents.typing` to be enabled.
:param channel: The location where the typing originated from.
:type channel: :class:`abc.Messageable`
@ -371,7 +325,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Message` is created and sent.
This requires :attr:`Intents.messages` to be enabled.
.. warning::
@ -394,7 +348,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
If this occurs increase the :class:`max_messages <Client>` parameter
or use the :func:`on_raw_message_delete` event instead.
This requires :attr:`Intents.messages` to be enabled.
:param message: The deleted message.
:type message: :class:`Message`
@ -411,7 +365,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
If this occurs increase the :class:`max_messages <Client>` parameter
or use the :func:`on_raw_bulk_message_delete` event instead.
This requires :attr:`Intents.messages` to be enabled.
:param messages: The messages that have been deleted.
:type messages: List[:class:`Message`]
@ -424,7 +378,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
If the message is found in the message cache,
it can be accessed via :attr:`RawMessageDeleteEvent.cached_message`
This requires :attr:`Intents.messages` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawMessageDeleteEvent`
@ -437,7 +391,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
If the messages are found in the message cache,
they can be accessed via :attr:`RawBulkMessageDeleteEvent.cached_messages`
This requires :attr:`Intents.messages` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawBulkMessageDeleteEvent`
@ -463,7 +417,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- The message's embeds were suppressed or unsuppressed.
- A call message has received an update to its participants or ending time.
This requires :attr:`Intents.messages` to be enabled.
:param before: The previous version of the message.
:type before: :class:`Message`
@ -489,7 +443,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
denotes an "embed" only edit, which is an edit in which only the embeds are updated by the Discord
embed server.
This requires :attr:`Intents.messages` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawMessageUpdateEvent`
@ -504,9 +458,11 @@ to handle it, which defaults to print a traceback and ignoring the exception.
To get the :class:`Message` being reacted, access it via :attr:`Reaction.message`.
This requires :attr:`Intents.reactions` to be enabled.
.. note::
..
todo: check this out
This doesn't require :attr:`Intents.members` within a guild context,
but due to Discord not providing updated user information in a direct message
@ -524,7 +480,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a message has a reaction added. Unlike :func:`on_reaction_add`, this is
called regardless of the state of the internal message cache.
This requires :attr:`Intents.reactions` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawReactionActionEvent`
@ -556,7 +512,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a message has a reaction removed. Unlike :func:`on_reaction_remove`, this is
called regardless of the state of the internal message cache.
This requires :attr:`Intents.reactions` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawReactionActionEvent`
@ -567,7 +523,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
if the message is not found in the internal message cache, then this event
will not be called. Consider using :func:`on_raw_reaction_clear` instead.
This requires :attr:`Intents.reactions` to be enabled.
:param message: The message that had its reactions cleared.
:type message: :class:`Message`
@ -579,7 +535,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a message has all its reactions removed. Unlike :func:`on_reaction_clear`,
this is called regardless of the state of the internal message cache.
This requires :attr:`Intents.reactions` to be enabled.
:param payload: The raw event payload data.
:type payload: :class:`RawReactionClearEvent`
@ -590,7 +546,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
if the message is not found in the internal message cache, then this event
will not be called. Consider using :func:`on_raw_reaction_clear_emoji` instead.
This requires :attr:`Intents.reactions` to be enabled.
.. versionadded:: 1.3
@ -602,36 +558,15 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a message has a specific reaction removed from it. Unlike :func:`on_reaction_clear_emoji` this is called
regardless of the state of the internal message cache.
This requires :attr:`Intents.reactions` to be enabled.
.. versionadded:: 1.3
:param payload: The raw event payload data.
:type payload: :class:`RawReactionClearEmojiEvent`
.. function:: on_interaction(interaction)
Called when an interaction happened.
This currently happens due to slash command invocations or components being used.
.. warning::
This is a low level function that is not generally meant to be used.
If you are working with components, consider using the callbacks associated
with the :class:`~discord.ui.View` instead as it provides a nicer user experience.
.. versionadded:: 2.0
:param interaction: The interaction data.
:type interaction: :class:`Interaction`
.. function:: on_private_channel_update(before, after)
Called whenever a private group DM is updated. e.g. changed name or topic.
This requires :attr:`Intents.messages` to be enabled.
:param before: The updated group channel's old info.
:type before: :class:`GroupChannel`
:param after: The updated group channel's new info.
@ -653,8 +588,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Note that you can get the guild from :attr:`~abc.GuildChannel.guild`.
This requires :attr:`Intents.guilds` to be enabled.
:param channel: The guild channel that got created or deleted.
:type channel: :class:`abc.GuildChannel`
@ -662,8 +595,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called whenever a guild channel is updated. e.g. changed name, topic, permissions.
This requires :attr:`Intents.guilds` to be enabled.
:param before: The updated guild channel's old info.
:type before: :class:`abc.GuildChannel`
:param after: The updated guild channel's new info.
@ -673,8 +604,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called whenever a message is pinned or unpinned from a guild channel.
This requires :attr:`Intents.guilds` to be enabled.
:param channel: The guild channel that had its pins updated.
:type channel: Union[:class:`abc.GuildChannel`, :class:`Thread`]
:param last_pin: The latest message that was pinned as an aware datetime in UTC. Could be ``None``.
@ -687,8 +616,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Note that you can get the guild from :attr:`Thread.guild`.
This requires :attr:`Intents.guilds` to be enabled.
.. versionadded:: 2.0
:param thread: The thread that got joined.
@ -700,8 +627,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Note that you can get the guild from :attr:`Thread.guild`.
This requires :attr:`Intents.guilds` to be enabled.
.. warning::
Due to technical limitations, this event might not be called
@ -720,8 +645,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Note that you can get the guild from :attr:`Thread.guild`.
This requires :attr:`Intents.guilds` to be enabled.
.. versionadded:: 2.0
:param thread: The thread that got deleted.
@ -734,8 +657,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
You can get the thread a member belongs in by accessing :attr:`ThreadMember.thread`.
This requires :attr:`Intents.members` to be enabled.
.. versionadded:: 2.0
:param member: The member who joined or left.
@ -745,8 +666,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called whenever a thread is updated.
This requires :attr:`Intents.guilds` to be enabled.
.. versionadded:: 2.0
:param before: The updated thread's old info.
@ -758,8 +677,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called whenever an integration is created, modified, or removed from a guild.
This requires :attr:`Intents.integrations` to be enabled.
.. versionadded:: 1.4
:param guild: The guild that had its integrations updated.
@ -769,8 +686,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when an integration is created.
This requires :attr:`Intents.integrations` to be enabled.
.. versionadded:: 2.0
:param integration: The integration that was created.
@ -780,8 +695,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when an integration is updated.
This requires :attr:`Intents.integrations` to be enabled.
.. versionadded:: 2.0
:param integration: The integration that was created.
@ -791,8 +704,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when an integration is deleted.
This requires :attr:`Intents.integrations` to be enabled.
.. versionadded:: 2.0
:param payload: The raw event payload data.
@ -802,8 +713,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called whenever a webhook is created, modified, or removed from a guild channel.
This requires :attr:`Intents.webhooks` to be enabled.
:param channel: The channel that had its webhooks updated.
:type channel: :class:`abc.GuildChannel`
@ -812,8 +721,6 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Member` leaves or joins a :class:`Guild`.
This requires :attr:`Intents.members` to be enabled.
:param member: The member who joined or left.
:type member: :class:`Member`
@ -827,7 +734,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- roles
- pending
This requires :attr:`Intents.members` to be enabled.
:param before: The updated member's old info.
:type before: :class:`Member`
@ -843,7 +750,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- status
- activity
This requires :attr:`Intents.presences` and :attr:`Intents.members` to be enabled.
.. versionadded:: 2.0
@ -862,7 +769,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- username
- discriminator
This requires :attr:`Intents.members` to be enabled.
:param before: The updated user's old info.
:type before: :class:`User`
@ -874,7 +781,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Guild` is either created by the :class:`Client` or when the
:class:`Client` joins a guild.
This requires :attr:`Intents.guilds` to be enabled.
:param guild: The guild that was joined.
:type guild: :class:`Guild`
@ -893,7 +800,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
In order for this event to be invoked then the :class:`Client` must have
been part of the guild to begin with. (i.e. it is part of :attr:`Client.guilds`)
This requires :attr:`Intents.guilds` to be enabled.
:param guild: The guild that got removed.
:type guild: :class:`Guild`
@ -907,7 +814,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- Changed AFK timeout
- etc
This requires :attr:`Intents.guilds` to be enabled.
:param before: The guild prior to being updated.
:type before: :class:`Guild`
@ -921,7 +828,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
To get the guild it belongs to, use :attr:`Role.guild`.
This requires :attr:`Intents.guilds` to be enabled.
:param role: The role that was created or deleted.
:type role: :class:`Role`
@ -930,7 +837,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Role` is changed guild-wide.
This requires :attr:`Intents.guilds` to be enabled.
:param before: The updated role's old info.
:type before: :class:`Role`
@ -941,7 +848,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Guild` adds or removes :class:`Emoji`.
This requires :attr:`Intents.emojis_and_stickers` to be enabled.
:param guild: The guild who got their emojis updated.
:type guild: :class:`Guild`
@ -954,7 +861,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`Guild` updates its stickers.
This requires :attr:`Intents.emojis_and_stickers` to be enabled.
.. versionadded:: 2.0
@ -971,7 +878,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a guild becomes available or unavailable. The guild must have
existed in the :attr:`Client.guilds` cache.
This requires :attr:`Intents.guilds` to be enabled.
:param guild: The :class:`Guild` that has changed availability.
@ -986,7 +893,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
- A member is muted or deafened by their own accord.
- A member is muted or deafened by a guild administrator.
This requires :attr:`Intents.voice_states` to be enabled.
:param member: The member whose voice states changed.
:type member: :class:`Member`
@ -1072,7 +979,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when user gets banned from a :class:`Guild`.
This requires :attr:`Intents.bans` to be enabled.
:param guild: The guild the user got banned from.
:type guild: :class:`Guild`
@ -1085,7 +992,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Called when a :class:`User` gets unbanned from a :class:`Guild`.
This requires :attr:`Intents.bans` to be enabled.
:param guild: The guild the user got unbanned from.
:type guild: :class:`Guild`
@ -1104,7 +1011,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
There is a rare possibility that the :attr:`Invite.guild` and :attr:`Invite.channel`
attributes will be of :class:`Object` rather than the respective models.
This requires :attr:`Intents.invites` to be enabled.
:param invite: The invite that was created.
:type invite: :class:`Invite`
@ -1124,7 +1031,7 @@ to handle it, which defaults to print a traceback and ignoring the exception.
Outside of those two attributes, the only other attribute guaranteed to be
filled by the Discord gateway for this event is :attr:`Invite.code`.
This requires :attr:`Intents.invites` to be enabled.
:param invite: The invite that was deleted.
:type invite: :class:`Invite`
@ -2368,6 +2275,11 @@ of :class:`enum.Enum`.
Represents the default avatar with the color red.
See also :attr:`Colour.red`
.. attribute:: pink
Represents the default avatar with the color pink.
This is not currently used in the client.
.. class:: StickerType
Represents the type of sticker.
@ -3406,6 +3318,15 @@ ClientUser
:members:
:inherited-members:
UserSettings
~~~~~~~~~~~~~
.. attributetable:: UserSettings
.. autoclass:: UserSettings()
:members:
:inherited-members:
User
~~~~~
@ -3419,6 +3340,33 @@ User
.. automethod:: typing
:async-with:
Profile
~~~~~~~~
.. attributetable:: Profile
.. autoclass:: Profile()
:members:
:inherited-members:
Note
~~~~~
.. attributetable:: Note
.. autoclass:: Note()
:members:
:inherited-members:
Relationship
~~~~~~~~~~~~~
.. attributetable:: Relationship
.. autoclass:: Relationship()
:members:
:inherited-members:
Attachment
~~~~~~~~~~~
@ -3484,6 +3432,33 @@ Guild
:type: :class:`User`
GuildSettings
~~~~~~~~~~~~~~
.. attributetable:: GuildSettings
.. autoclass:: GuildSettings()
:members:
:inherited-members:
GuildFolder
~~~~~~~~~~~~
.. attributetable:: GuildFolder
.. autoclass:: GuildFolder()
:members:
:inherited-members:
GuildSubscriptionOptions
~~~~~~~~~~~~~~~~~~~~~~~~~
.. attributetable:: GuildSubscriptionOptions
.. autoclass:: GuildSubscriptionOptions()
:members:
:inherited-members:
ScheduledEvent
~~~~~~~~~~~~~~
@ -3597,6 +3572,17 @@ TextChannel
.. automethod:: typing
:async-with:
ChannelSettings
~~~~~~~~~~~~~~~~
.. attributetable:: ChannelSettings
.. autoclass:: ChannelSettings()
:members:
:inherited-members:
Thread
~~~~~~~~
@ -3915,12 +3901,12 @@ PartialMessage
.. autoclass:: PartialMessage
:members:
Intents
~~~~~~~~~~
SelectOption
~~~~~~~~~~~~~
.. attributetable:: Intents
.. attributetable:: SelectOption
.. autoclass:: Intents
.. autoclass:: SelectOption
:members:
MemberCacheFlags
@ -4011,14 +3997,6 @@ PermissionOverwrite
.. autoclass:: PermissionOverwrite
:members:
ShardInfo
~~~~~~~~~~~
.. attributetable:: ShardInfo
.. autoclass:: ShardInfo()
:members:
SystemChannelFlags
~~~~~~~~~~~~~~~~~~~~
@ -4069,10 +4047,6 @@ The following exceptions are thrown by the library.
.. autoexception:: ConnectionClosed
.. autoexception:: PrivilegedIntentsRequired
.. autoexception:: InteractionResponded
.. autoexception:: discord.opus.OpusError
.. autoexception:: discord.opus.OpusNotLoaded
@ -4088,8 +4062,6 @@ Exception Hierarchy
- :exc:`InvalidData`
- :exc:`LoginFailure`
- :exc:`ConnectionClosed`
- :exc:`PrivilegedIntentsRequired`
- :exc:`InteractionResponded`
- :exc:`GatewayNotFound`
- :exc:`HTTPException`
- :exc:`Forbidden`

37
docs/conf.py

@ -50,7 +50,7 @@ autodoc_typehints = 'none'
# napoleon_attr_annotations = False
extlinks = {
'issue': ('https://github.com/Rapptz/discord.py/issues/%s', 'GH-'),
'issue': ('https://github.com/dolfies/discord.py-self/issues/%s', 'GH-'),
}
# Links used for cross-referencing stuff in other documentation
@ -80,8 +80,8 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = 'discord.py'
copyright = '2015-present, Rapptz'
project = 'discord.py-self'
#copyright = ''
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@ -106,7 +106,6 @@ branch = 'master' if version.endswith('a') else 'v' + version
# Usually you set "language" from the command line for these cases.
language = None
locale_dirs = ['locale/']
gettext_compact = False
# There are two options for replacing |today|: either, you set today to some
@ -146,7 +145,6 @@ pygments_style = 'friendly'
# Nitpicky mode options
nitpick_ignore_files = [
"migrating_to_async",
"migrating",
"whats_new",
]
@ -175,7 +173,6 @@ html_experimental_html5_writer = True
html_theme = 'basic'
html_context = {
'discord_invite': 'https://discord.gg/r3sSKJJ',
'discord_extensions': [
('discord.ext.commands', 'ext/commands'),
('discord.ext.tasks', 'ext/tasks'),
@ -183,10 +180,9 @@ html_context = {
}
resource_links = {
'discord': 'https://discord.gg/r3sSKJJ',
'issues': 'https://github.com/Rapptz/discord.py/issues',
'discussions': 'https://github.com/Rapptz/discord.py/discussions',
'examples': f'https://github.com/Rapptz/discord.py/tree/{branch}/examples',
'issues': 'https://github.com/dolfies/discord.py-self/issues',
'discussions': 'https://github.com/dolfies/discord.py-self/discussions',
'examples': f'https://github.com/dolfies/discord.py-self/tree/{branch}/examples',
}
# Theme options are theme-specific and customize the look and feel of a theme
@ -252,10 +248,10 @@ html_static_path = ['_static']
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
html_show_sphinx = False
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
html_show_copyright = False
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
@ -309,8 +305,8 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'discord.py.tex', 'discord.py Documentation',
'Rapptz', 'manual'),
('index', 'discord.py-self.tex', 'discord.py-self Documentation',
'Dolfies', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@ -339,8 +335,8 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'discord.py', 'discord.py Documentation',
['Rapptz'], 1)
('index', 'discord.py-self', 'discord.py-self Documentation',
['Dolfies'], 1)
]
# If true, show URL addresses after external links.
@ -353,8 +349,8 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'discord.py', 'discord.py Documentation',
'Rapptz', 'discord.py', 'One line description of project.',
('index', 'discord.py=self', 'discord.py-self Documentation',
'Dolfies', 'discord.py-self', 'One line description of project.',
'Miscellaneous'),
]
@ -371,7 +367,4 @@ texinfo_documents = [
#texinfo_no_detailmenu = False
def setup(app):
if app.config.language == 'ja':
app.config.intersphinx_mapping['py'] = ('https://docs.python.org/ja/3', None)
app.config.html_context['discord_invite'] = 'https://discord.gg/nXzj3dg'
app.config.resource_links['discord'] = 'https://discord.gg/nXzj3dg'
pass

96
docs/discord.rst

@ -1,96 +0,0 @@
:orphan:
.. _discord-intro:
Creating a Bot Account
========================
In order to work with the library and the Discord API in general, we must first create a Discord Bot account.
Creating a Bot account is a pretty straightforward process.
1. Make sure you're logged on to the `Discord website <https://discord.com>`_.
2. Navigate to the `application page <https://discord.com/developers/applications>`_
3. Click on the "New Application" button.
.. image:: /images/discord_create_app_button.png
:alt: The new application button.
4. Give the application a name and click "Create".
.. image:: /images/discord_create_app_form.png
:alt: The new application form filled in.
5. Create a Bot User by navigating to the "Bot" tab and clicking "Add Bot".
- Click "Yes, do it!" to continue.
.. image:: /images/discord_create_bot_user.png
:alt: The Add Bot button.
6. Make sure that **Public Bot** is ticked if you want others to invite your bot.
- You should also make sure that **Require OAuth2 Code Grant** is unchecked unless you
are developing a service that needs it. If you're unsure, then **leave it unchecked**.
.. image:: /images/discord_bot_user_options.png
:alt: How the Bot User options should look like for most people.
7. Copy the token using the "Copy" button.
- **This is not the Client Secret at the General Information page.**
.. warning::
It should be worth noting that this token is essentially your bot's
password. You should **never** share this with someone else. In doing so,
someone can log in to your bot and do malicious things, such as leaving
servers, ban all members inside a server, or pinging everyone maliciously.
The possibilities are endless, so **do not share this token.**
If you accidentally leaked your token, click the "Regenerate" button as soon
as possible. This revokes your old token and re-generates a new one.
Now you need to use the new token to login.
And that's it. You now have a bot account and you can login with that token.
.. _discord_invite_bot:
Inviting Your Bot
-------------------
So you've made a Bot User but it's not actually in any server.
If you want to invite your bot you must create an invite URL for it.
1. Make sure you're logged on to the `Discord website <https://discord.com>`_.
2. Navigate to the `application page <https://discord.com/developers/applications>`_
3. Click on your bot's page.
4. Go to the "OAuth2" tab.
.. image:: /images/discord_oauth2.png
:alt: How the OAuth2 page should look like.
5. Tick the "bot" checkbox under "scopes".
.. image:: /images/discord_oauth2_scope.png
:alt: The scopes checkbox with "bot" ticked.
6. Tick the permissions required for your bot to function under "Bot Permissions".
- Please be aware of the consequences of requiring your bot to have the "Administrator" permission.
- Bot owners must have 2FA enabled for certain actions and permissions when added in servers that have Server-Wide 2FA enabled. Check the `2FA support page <https://support.discord.com/hc/en-us/articles/219576828-Setting-up-Two-Factor-Authentication>`_ for more information.
.. image:: /images/discord_oauth2_perms.png
:alt: The permission checkboxes with some permissions checked.
7. Now the resulting URL can be used to add your bot to a server. Copy and paste the URL into your browser, choose a server to invite the bot to, and click "Authorize".
.. note::
The person adding the bot needs "Manage Server" permissions to do so.
If you want to generate this URL dynamically at run-time inside your bot and using the
:class:`discord.Permissions` interface, you can use :func:`discord.utils.oauth_url`.

8
docs/ext/commands/api.rst

@ -44,14 +44,6 @@ Bot
.. automethod:: Bot.listen(name=None)
:decorator:
AutoShardedBot
~~~~~~~~~~~~~~~~
.. attributetable:: discord.ext.commands.AutoShardedBot
.. autoclass:: discord.ext.commands.AutoShardedBot
:members:
Prefix Helpers
----------------

2
docs/faq.rst

@ -253,7 +253,7 @@ this together we can do the following: ::
How do I run something in the background?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
`Check the background_task.py example. <https://github.com/Rapptz/discord.py/blob/master/examples/background_task.py>`_
`Check the background_task.py example. <https://github.com/dolfies/discord.py-self/blob/master/examples/background_task.py>`_
How do I get a specific model?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

BIN
docs/images/discord_bot_tab.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

BIN
docs/images/discord_bot_user_options.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/images/discord_create_app_button.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

BIN
docs/images/discord_create_app_form.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
docs/images/discord_create_bot_user.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/images/discord_oauth2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/images/discord_oauth2_perms.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save