From 7ec8a104561b3467d5f6d0d44d0885a0df0afddc Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 19:27:08 -0400 Subject: [PATCH 001/154] Remove Japanese README --- README.ja.rst | 113 -------------------------------------------------- 1 file changed, 113 deletions(-) delete mode 100644 README.ja.rst diff --git a/README.ja.rst b/README.ja.rst deleted file mode 100644 index 11deab899..000000000 --- a/README.ja.rst +++ /dev/null @@ -1,113 +0,0 @@ -discord.py -========== - -.. image:: https://discord.com/api/guilds/336642139381301249/embed.png - :target: https://discord.gg/nXzj3dg - :alt: Discordサーバーの招待 -.. image:: https://img.shields.io/pypi/v/discord.py.svg - :target: https://pypi.python.org/pypi/discord.py - :alt: PyPIのバージョン情報 -.. image:: https://img.shields.io/pypi/pyversions/discord.py.svg - :target: https://pypi.python.org/pypi/discord.py - :alt: PyPIのサポートしているPythonのバージョン - -discord.py は機能豊富かつモダンで使いやすい、非同期処理にも対応したDiscord用のAPIラッパーです。 - -主な特徴 -------------- - -- ``async`` と ``await`` を使ったモダンなPythonらしいAPI。 -- 適切なレート制限処理 -- メモリと速度の両方を最適化。 - -インストール -------------- - -**Python 3.8 以降のバージョンが必須です** - -完全な音声サポートなしでライブラリをインストールする場合は次のコマンドを実行してください: - -.. code:: sh - - # Linux/OS X - python3 -m pip install -U discord.py - - # Windows - py -3 -m pip install -U discord.py - -音声サポートが必要なら、次のコマンドを実行しましょう: - -.. code:: sh - - # Linux/OS X - python3 -m pip install -U discord.py[voice] - - # Windows - py -3 -m pip install -U discord.py[voice] - - -開発版をインストールしたいのならば、次の手順に従ってください: - -.. code:: sh - - $ git clone https://github.com/Rapptz/discord.py - $ cd discord.py - $ python3 -m pip install -U .[voice] - - -オプションパッケージ -~~~~~~~~~~~~~~~~~~~~~~ - -* PyNaCl (音声サポート用) - -Linuxで音声サポートを導入するには、前述のコマンドを実行する前にお気に入りのパッケージマネージャー(例えば ``apt`` や ``dnf`` など)を使って以下のパッケージをインストールする必要があります: - -* libffi-dev (システムによっては ``libffi-devel``) -* python-dev (例えばPython 3.6用の ``python3.6-dev``) - -簡単な例 --------------- - -.. code:: py - - import discord - - class MyClient(discord.Client): - async def on_ready(self): - print('Logged on as', self.user) - - async def on_message(self, message): - # don't respond to ourselves - if message.author == self.user: - return - - if message.content == 'ping': - await message.channel.send('pong') - - client = MyClient() - client.run('token') - -Botの例 -~~~~~~~~~~~~~ - -.. code:: py - - import discord - from discord.ext import commands - - bot = commands.Bot(command_prefix='>') - - @bot.command() - async def ping(ctx): - await ctx.send('pong') - - bot.run('token') - -examplesディレクトリに更に多くのサンプルがあります。 - -リンク ------- - -- `ドキュメント `_ -- `公式Discordサーバー `_ -- `Discord API `_ From e3667857615357a1a418db0d97c58042624f4085 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 19:27:14 -0400 Subject: [PATCH 002/154] Update setup.py --- setup.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/setup.py b/setup.py index 9ffd24ceb..a84d4f13c 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup +from setuptools import setup, find_packages import re requirements = [] @@ -41,30 +41,22 @@ extras_require = { 'sphinxcontrib-websupport', ], 'speed': [ + 'aiohttp[speedups]' 'orjson>=3.5.4', ] } -packages = [ - 'discord', - 'discord.types', - 'discord.ui', - 'discord.webhook', - 'discord.ext.commands', - 'discord.ext.tasks', -] - -setup(name='discord.py', - author='Rapptz', - url='https://github.com/Rapptz/discord.py', +setup(name='discord.py-self', + author='Dolfies', + url='https://github.com/dolfies/discord.py-self', project_urls={ - "Documentation": "https://discordpy.readthedocs.io/en/latest/", - "Issue tracker": "https://github.com/Rapptz/discord.py/issues", + "Documentation": "https://dolf.ml/discord.py-self", + "Issue tracker": "https://github.com/dolfies/discord.py-self/issues", }, version=version, - packages=packages, + packages=find_packages(), license='MIT', - description='A Python wrapper for the Discord API', + description='A Python wrapper for the Discord user API', long_description=readme, long_description_content_type="text/x-rst", include_package_data=True, @@ -83,6 +75,6 @@ setup(name='discord.py', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Utilities', - 'Typing :: Typed', +# 'Typing :: Typed', ] ) From 3819573ffa11e9b2ddb0b073a9603e66f846ddab Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 19:39:19 -0400 Subject: [PATCH 003/154] Sync .github folder --- .github/CONTRIBUTING.md | 11 +++----- .github/ISSUE_TEMPLATE/bug_report.yml | 36 ++++++++------------------- .github/ISSUE_TEMPLATE/config.yml | 12 ++++----- .github/PULL_REQUEST_TEMPLATE.md | 7 +++--- 4 files changed, 23 insertions(+), 43 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c31..9753d8927 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,12 +6,7 @@ The following is a set of guidelines for contributing to the repository. These a ## This is too much to read! I want to ask a question! -Generally speaking questions are better suited in our resources below. - -- The official support server: https://discord.gg/r3sSKJJ -- The Discord API server under #python_discord-py: https://discord.gg/discord-api -- [The FAQ in the documentation](https://discordpy.readthedocs.io/en/latest/faq.html) -- [StackOverflow's `discord.py` tag](https://stackoverflow.com/questions/tagged/discord.py) +Generally speaking questions are better suited in the repo discussions. Please try your best not to ask questions in our issue tracker. Most of them don't belong there unless they provide value to a larger audience. @@ -26,7 +21,7 @@ Please be aware of the following things when filing bug reports. - Guidance on **how to reproduce the issue**. Ideally, this should have a small code sample that allows us to run and see the issue for ourselves to debug. **Please make sure that the token is not displayed**. If you cannot provide a code snippet, then let us know what the steps were, how often it happens, etc. - Tell us **what you expected to happen**. That way we can meet that expectation. - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? - - Tell us **information about your environment**. What version of discord.py are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. + - Tell us **information about your environment**. What version of discord.py-self are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. @@ -36,7 +31,7 @@ Submitting a pull request is fairly simple, just make sure it focuses on a singl ### Git Commit Guidelines -- Use present tense (e.g. "Add feature" not "Added feature") +- Try to use present tense (e.g. "Add feature" not "Added feature") - Limit all lines to 72 characters or less. - Reference issues or pull requests outside of the first line. - Please use the shorthand `#123` and not the full URL. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index eabf5c071..034658e4d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -5,10 +5,7 @@ body: - type: markdown attributes: value: > - Thanks for taking the time to fill out a bug. - If you want real-time support, consider joining our Discord at https://discord.gg/r3sSKJJ instead. - - Please note that this form is for bugs only! + Thanks for taking the time to fill out a bug report! - type: input attributes: label: Summary @@ -19,35 +16,27 @@ body: attributes: label: Reproduction Steps description: > - What you did to make it happen. + How did you make it happen? validations: required: true - type: textarea attributes: - label: Minimal Reproducible Code + label: Code description: > - A short snippet of code that showcases the bug. + Relevant code that shows the bug. render: python - type: textarea attributes: label: Expected Results description: > - What did you expect to happen? + What is supposed to happen? validations: required: true - type: textarea attributes: label: Actual Results description: > - What actually happened? - validations: - required: true - - type: input - attributes: - label: Intents - description: > - What intents are you using for your bot? - This is the `discord.Intents` class you pass to the client. + What is actually happening? validations: required: true - type: textarea @@ -55,24 +44,21 @@ body: label: System Information description: > Run `python -m discord -v` and paste this information below. - - This command required v1.1.0 or higher of the library. If this errors out then show some basic - information involving your system such as operating system and Python version. validations: required: true - type: checkboxes attributes: label: Checklist description: > - Let's make sure you've properly done due dilligence when reporting this issue! + Let's make sure this issue is valid! options: - label: I have searched the open issues for duplicates. required: true - - label: I have shown the entire traceback, if possible. + - label: I have shared the entire traceback. required: true - - label: I have removed my token from display, if visible. + - label: I am using a user token (and it isn't visible in the code). required: true - type: textarea attributes: - label: Additional Context - description: If there is anything else to say, please do so here. + label: Additional Information + description: Put any extra context, weird configurations, or other important info here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7934e4a85..8b71d28e9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Ask a question - about: Ask questions and discuss with other users of the library. - url: https://github.com/Rapptz/discord.py/discussions - - name: Discord Server - about: Use our official Discord server to ask for help and questions as well. - url: https://discord.gg/r3sSKJJ + - name: Get help + url: https://github.com/dolfies/discord.py-self/discussions/new?category=help + about: Ask a question on our help page (*not* for bug reports). + - name: Suggest a feature + url: https://github.com/dolfies/discord.py-self/discussions/new?category=ideas + about: Suggest new features for the library! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 55941f4e1..18a34d101 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,13 +2,12 @@ -## Checklist - +## General Info - [ ] If code changes were made then they have been tested. - - [ ] I have updated the documentation to reflect the changes. -- [ ] This PR fixes an issue. + +- [ ] This PR fixes an issue (please put issue # in summary). - [ ] This PR adds something new (e.g. new method or parameters). - [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed) - [ ] This PR is **not** a code change (e.g. documentation, README, ...) From f7ef23b444f7d8ce83f610d8e8579e7c54900dfa Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 19:49:03 -0400 Subject: [PATCH 004/154] Re-add workflow (oops) --- .github/workflows/python-publish.yml | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 000000000..77ab0cb86 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,32 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries + +name: Upload Python Package + +on: + workflow_dispatch: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* From 4743b959ec6d20cc6e8e18ff77c0d7b84aaf3fd8 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 19:49:19 -0400 Subject: [PATCH 005/154] Update inbuild examples --- discord/__main__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/discord/__main__.py b/discord/__main__.py index 513b0cb38..51fee43cd 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -57,7 +57,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: @@ -214,8 +214,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})') @@ -268,7 +267,6 @@ def add_newbot_args(subparser): parser.add_argument('name', help='the bot project name') parser.add_argument('directory', help='the directory to place it in (default: .)', nargs='?', default=Path.cwd()) parser.add_argument('--prefix', help='the bot prefix (default: $)', default='$', metavar='') - parser.add_argument('--sharded', help='whether to use AutoShardedBot', action='store_true') parser.add_argument('--no-git', help='do not create a .gitignore file', action='store_true', dest='no_git') def add_newcog_args(subparser): From b37a07f18fbdb79a895db3cc3ab5d60628a92428 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 20:07:41 -0400 Subject: [PATCH 006/154] Remove py.typed for now --- discord/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 discord/py.typed diff --git a/discord/py.typed b/discord/py.typed deleted file mode 100644 index e69de29bb..000000000 From 9c5e4882efa1ed9d9226dc90b44fed364db85a5b Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 1 Nov 2021 20:37:54 -0400 Subject: [PATCH 007/154] Initial de-botting --- discord/__init__.py | 14 +- discord/abc.py | 2 - discord/client.py | 205 +++----------- discord/errors.py | 56 +--- discord/ext/commands/bot.py | 35 +-- discord/flags.py | 493 -------------------------------- discord/gateway.py | 87 +++--- discord/guild.py | 80 +----- discord/shard.py | 546 ------------------------------------ discord/state.py | 170 +---------- discord/template.py | 4 - 11 files changed, 103 insertions(+), 1589 deletions(-) delete mode 100644 discord/shard.py diff --git a/discord/__init__.py b/discord/__init__.py index 1e74cf910..9a6f6e97f 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -2,15 +2,15 @@ Discord API Wrapper ~~~~~~~~~~~~~~~~~~~ -A basic wrapper for the Discord API. +A basic wrapper for the Discord user API. :copyright: (c) 2015-present Rapptz :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' @@ -47,7 +47,6 @@ from . import utils, opus, abc, ui from .enums import * from .embeds import * from .mentions import * -from .shard import * from .player import * from .webhook import * from .voice_client import * @@ -61,14 +60,13 @@ from .components import * from .threads 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=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index fd2dc4bb9..a2ca1e489 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1633,8 +1633,6 @@ class Connectable(Protocol): Connects to voice and creates a :class:`VoiceClient` to establish your connection to the voice server. - This requires :attr:`Intents.voice_states`. - Parameters ----------- timeout: :class:`float` diff --git a/discord/client.py b/discord/client.py index b6198d109..f2389d640 100644 --- a/discord/client.py +++ b/discord/client.py @@ -44,7 +44,6 @@ from .enums import ChannelType from .mentions import AllowedMentions from .errors import * from .enums import Status, VoiceRegion -from .flags import ApplicationFlags, Intents from .gateway import * from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient @@ -133,29 +132,15 @@ class Client: Proxy URL. proxy_auth: Optional[:class:`aiohttp.BasicAuth`] An object that represents proxy HTTP Basic Authorization. - shard_id: Optional[:class:`int`] - Integer starting at ``0`` and less than :attr:`.shard_count`. - shard_count: Optional[:class:`int`] - The total number of shards. - application_id: :class:`int` - The client's application ID. - intents: :class:`Intents` - The intents that you want to enable for the session. This is a way of - disabling and enabling certain gateway events from triggering and being sent. - If not given, defaults to a regularly constructed :class:`Intents` class. - - .. versionadded:: 1.5 member_cache_flags: :class:`MemberCacheFlags` Allows for finer control over how the library caches members. - If not given, defaults to cache as much as possible with the - currently selected intents. + If not given, defaults to cache as much as possible. .. versionadded:: 1.5 chunk_guilds_at_startup: :class:`bool` Indicates if :func:`.on_ready` should be delayed to chunk all guilds at start-up if necessary. This operation is incredibly slow for large - amounts of guilds. The default is ``True`` if :attr:`Intents.members` - is ``True``. + amounts of guilds. The default is ``True``. .. versionadded:: 1.5 status: Optional[:class:`.Status`] @@ -171,11 +156,6 @@ class Client: WebSocket in the case of not receiving a HEARTBEAT_ACK. Useful if processing the initial packets take too long to the point of disconnecting you. The default timeout is 60 seconds. - guild_ready_timeout: :class:`float` - The maximum number of seconds to wait for the GUILD_CREATE stream to end before - preparing the member cache and firing READY. The default timeout is 2 seconds. - - .. versionadded:: 1.4 assume_unsync_clock: :class:`bool` Whether to assume the system clock is unsynced. This applies to the ratelimit handling code. If this is set to ``True``, the default, then the library uses the time to reset @@ -206,12 +186,10 @@ class Client: loop: Optional[asyncio.AbstractEventLoop] = None, **options: Any, ): - # self.ws is set in the connect method + # Set in the connect method self.ws: DiscordWebSocket = None # type: ignore self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop self._listeners: Dict[str, List[Tuple[asyncio.Future, Callable[..., bool]]]] = {} - self.shard_id: Optional[int] = options.get('shard_id') - self.shard_count: Optional[int] = options.get('shard_count') connector: Optional[aiohttp.BaseConnector] = options.pop('connector', None) proxy: Optional[str] = options.pop('proxy', None) @@ -229,7 +207,6 @@ class Client: self._enable_debug_events: bool = options.pop('enable_debug_events', False) self._connection: ConnectionState = self._get_state(**options) - self._connection.shard_count = self.shard_count self._closed: bool = False self._ready: asyncio.Event = asyncio.Event() self._connection._get_websocket = self._get_websocket @@ -237,11 +214,11 @@ class Client: if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False - _log.warning("PyNaCl is not installed, voice will NOT be supported") + _log.warning('PyNaCl is not installed, voice will NOT be supported.') - # internals + # Internals - def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket: + def _get_websocket(self, guild_id: Optional[int] = None) -> DiscordWebSocket: return self.ws def _get_state(self, **options: Any) -> ConnectionState: @@ -305,13 +282,7 @@ class Client: @property def private_channels(self) -> List[PrivateChannel]: - """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on. - - .. note:: - - This returns only up to 128 most recent private channels due to an internal working - on how Discord deals with private channels. - """ + """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on.""" return self._connection.private_channels @property @@ -322,26 +293,6 @@ class Client: """ return self._connection.voice_clients - @property - def application_id(self) -> Optional[int]: - """Optional[:class:`int`]: The client's application ID. - - If this is not passed via ``__init__`` then this is retrieved - through the gateway when an event contains the data. Usually - after :func:`~discord.on_connect` is called. - - .. versionadded:: 2.0 - """ - return self._connection.application_id - - @property - def application_flags(self) -> ApplicationFlags: - """:class:`~discord.ApplicationFlags`: The client's application flags. - - .. versionadded:: 2.0 - """ - return self._connection.application_flags # type: ignore - def is_ready(self) -> bool: """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready.is_set() @@ -363,7 +314,7 @@ class Client: return asyncio.create_task(wrapped, name=f'discord.py: {event_name}') def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: - _log.debug('Dispatching event %s', event) + _log.debug('Dispatching event %s.', event) method = 'on_' + event listeners = self._listeners.get(event) @@ -414,49 +365,51 @@ class Client: print(f'Ignoring exception in {event_method}', file=sys.stderr) traceback.print_exc() - # hooks + # Hooks - async def _call_before_identify_hook(self, shard_id: Optional[int], *, initial: bool = False) -> None: - # This hook is an internal hook that actually calls the public one. + async def _call_before_identify_hook(self, *, initial: bool = False) -> None: + # This hook is an internal hook that actually calls the public one # It allows the library to have its own hook without stepping on the - # toes of those who need to override their own hook. - await self.before_identify_hook(shard_id, initial=initial) + # toes of those who need to override their own hook + await self.before_identify_hook(initial=initial) - async def before_identify_hook(self, shard_id: Optional[int], *, initial: bool = False) -> None: + async def before_identify_hook(self, *, initial: bool = False) -> None: """|coro| A hook that is called before IDENTIFYing a session. This is useful if you wish to have more control over the synchronization of multiple IDENTIFYing clients. - The default implementation sleeps for 5 seconds. + The default implementation does nothing. .. versionadded:: 1.4 Parameters ------------ - shard_id: :class:`int` - The shard ID that requested being IDENTIFY'd initial: :class:`bool` Whether this IDENTIFY is the first initial IDENTIFY. """ - if not initial: - await asyncio.sleep(5.0) + pass - # login state management + # Login state management async def login(self, token: str) -> None: """|coro| Logs in the client with the specified credentials. + .. warning:: + + Logging on with a user token is unfortunately against the Discord + `Terms of Service `_ + and doing so might potentially get your account banned. + Use this at your own risk. Parameters ----------- token: :class:`str` - The authentication token. Do not prefix this token with - anything as the library will do it for you. + The authentication token. Raises ------ @@ -468,7 +421,7 @@ class Client: passing status code. """ - _log.info('logging in using static token') + _log.info('Logging in using static token.') data = await self.http.static_login(token.strip()) self._connection.user = ClientUser(state=self._connection, data=data) @@ -486,8 +439,8 @@ class Client: reconnect: :class:`bool` If we should attempt reconnecting, either due to internet failure or a specific failure on Discord's part. Certain - disconnects that lead to bad state will not be handled (such as - invalid sharding payloads or bad tokens). + disconnects that lead to bad state will not be handled + (such as bad tokens). Raises ------- @@ -501,7 +454,6 @@ class Client: backoff = ExponentialBackoff() ws_params = { 'initial': True, - 'shard_id': self.shard_id, } while not self.is_closed(): try: @@ -526,7 +478,7 @@ class Client: if not reconnect: await self.close() if isinstance(exc, ConnectionClosed) and exc.code == 1000: - # clean close, don't re-raise this + # Clean close, don't re-raise this return raise @@ -539,12 +491,10 @@ class Client: continue # We should only get this when an unhandled close code happens, - # such as a clean disconnect (1000) or a bad state (bad token, no sharding, etc) - # sometimes, discord sends us 1000 for unknown reasons so we should reconnect + # such as a clean disconnect (1000) or a bad state (bad token, etc) + # Sometimes, discord sends us 1000 for unknown reasons so we should reconnect # regardless and rely on is_closed instead if isinstance(exc, ConnectionClosed): - if exc.code == 4014: - raise PrivilegedIntentsRequired(exc.shard_id) from None if exc.code != 1000: await self.close() raise @@ -553,8 +503,8 @@ class Client: _log.exception("Attempting a reconnect in %.2fs", retry) await asyncio.sleep(retry) # Always try to RESUME the connection - # If the connection is not RESUME-able then the gateway will invalidate the session. - # This is apparently what the official Discord client does. + # If the connection is not RESUME-able then the gateway will invalidate the session + # This is apparently what the official Discord client does ws_params.update(sequence=self.ws.sequence, resume=True, session=self.ws.session_id) async def close(self) -> None: @@ -571,7 +521,7 @@ class Client: try: await voice.disconnect(force=True) except Exception: - # if an error happens during disconnects, disregard it. + # If an error happens during disconnects, disregard it pass if self.ws is not None and self.ws.open: @@ -665,7 +615,7 @@ class Client: # I am unsure why this gets raised here but suppress it anyway return None - # properties + # Properties def is_closed(self) -> bool: """:class:`bool`: Indicates if the websocket connection is closed.""" @@ -686,7 +636,7 @@ class Client: # ConnectionState._activity is typehinted as ActivityPayload, we're passing Dict[str, Any] self._connection._activity = value.to_dict() # type: ignore else: - raise TypeError('activity must derive from BaseActivity.') + raise TypeError('activity must derive from BaseActivity') @property def status(self): @@ -706,7 +656,7 @@ class Client: elif isinstance(value, Status): self._connection._status = str(value) else: - raise TypeError('status must derive from Status.') + raise TypeError('status must derive from Status') @property def allowed_mentions(self) -> Optional[AllowedMentions]: @@ -723,15 +673,7 @@ class Client: else: raise TypeError(f'allowed_mentions must be AllowedMentions not {value.__class__!r}') - @property - def intents(self) -> Intents: - """:class:`~discord.Intents`: The intents configured for this connection. - - .. versionadded:: 1.5 - """ - return self._connection.intents - - # helpers/getters + # Helpers/Getters @property def users(self) -> List[User]: @@ -900,7 +842,7 @@ class Client: for guild in self.guilds: yield from guild.members - # listeners/waiters + # Listeners/Waiters async def wait_until_ready(self) -> None: """|coro| @@ -1013,7 +955,7 @@ class Client: listeners.append((future, check)) return asyncio.wait_for(future, timeout) - # event registration + # Event registration def event(self, coro: Coro) -> Coro: """A decorator that registers an event to listen to. @@ -1198,8 +1140,7 @@ class Client: .. note:: - Using this, you will **not** receive :attr:`.Guild.channels`, :attr:`.Guild.members`, - :attr:`.Member.activity` and :attr:`.Member.voice` per :class:`.Member`. + Using this, you will **not** receive :attr:`.Guild.channels` and :attr:`.Guild.members`. .. note:: @@ -1237,8 +1178,6 @@ class Client: Creates a :class:`.Guild`. - Bot accounts in more than 10 guilds are not allowed to create guilds. - Parameters ---------- name: :class:`str` @@ -1410,26 +1349,6 @@ class Client: return Widget(state=self._connection, data=data) - async def application_info(self) -> AppInfo: - """|coro| - - Retrieves the bot's application information. - - Raises - ------- - :exc:`.HTTPException` - Retrieving the information failed somehow. - - Returns - -------- - :class:`.AppInfo` - The bot's application information. - """ - data = await self.http.application_info() - if 'rpc_origins' not in data: - data['rpc_origins'] = None - return AppInfo(self._connection, data) - async def fetch_user(self, user_id: int, /) -> User: """|coro| @@ -1439,7 +1358,7 @@ class Client: .. note:: - This method is an API call. If you have :attr:`discord.Intents.members` and member cache enabled, consider :meth:`get_user` instead. + This method is an API call. If you have member cache enabled, consider :meth:`get_user` instead. Parameters ----------- @@ -1598,45 +1517,3 @@ class Client: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) - - def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: - """Registers a :class:`~discord.ui.View` for persistent listening. - - This method should be used for when a view is comprised of components - that last longer than the lifecycle of the program. - - .. versionadded:: 2.0 - - Parameters - ------------ - view: :class:`discord.ui.View` - The view to register for dispatching. - message_id: Optional[:class:`int`] - The message ID that the view is attached to. This is currently used to - refresh the view's state during message update events. If not given - then message update events are not propagated for the view. - - Raises - ------- - TypeError - A view was not passed. - ValueError - The view is not persistent. A persistent view has no timeout - and all their components have an explicitly provided custom_id. - """ - - if not isinstance(view, View): - raise TypeError(f'expected an instance of View not {view.__class__!r}') - - if not view.is_persistent(): - raise ValueError('View is not persistent. Items need to have a custom_id set and View must have no timeout') - - self._connection.store_view(view, message_id) - - @property - def persistent_views(self) -> Sequence[View]: - """Sequence[:class:`.View`]: A sequence of persistent views added to the client. - - .. versionadded:: 2.0 - """ - return self._connection.persistent_views diff --git a/discord/errors.py b/discord/errors.py index bc2398d55..e344c9f8f 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -49,9 +49,6 @@ __all__ = ( 'InvalidData', 'InvalidArgument', 'LoginFailure', - 'ConnectionClosed', - 'PrivilegedIntentsRequired', - 'InteractionResponded', ) @@ -217,61 +214,12 @@ 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}') diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index b4da61001..0c14c5389 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -57,7 +57,6 @@ __all__ = ( 'when_mentioned', 'when_mentioned_or', 'Bot', - 'AutoShardedBot', ) MISSING: Any = discord.utils.MISSING @@ -137,7 +136,7 @@ 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}') @@ -315,24 +314,22 @@ class BotBase(GroupMixin): # type-checker doesn't distinguish between functions and methods return await discord.utils.async_all(f(ctx) for f in data) # type: ignore - async def is_owner(self, user: discord.User) -> bool: + def is_owner(self, user: discord.User) -> bool: """|coro| 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` @@ -344,14 +341,7 @@ class BotBase(GroupMixin): 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. @@ -1086,8 +1076,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 @@ -1104,9 +1093,3 @@ class Bot(BotBase, discord.Client): .. versionadded:: 1.7 """ pass - -class AutoShardedBot(BotBase, discord.AutoShardedClient): - """This is similar to :class:`.Bot` except that it is inherited from - :class:`discord.AutoShardedClient` instead. - """ - pass diff --git a/discord/flags.py b/discord/flags.py index fb468c50b..391ec3b37 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -32,7 +32,6 @@ __all__ = ( 'SystemChannelFlags', 'MessageFlags', 'PublicUserFlags', - 'Intents', 'MemberCacheFlags', 'ApplicationFlags', ) @@ -415,458 +414,6 @@ class PublicUserFlags(BaseFlags): 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` and :attr:`members`. - """ - self = cls.all() - self.presences = False - self.members = 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 `. - - .. 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 << 1 - - @flag_value - def bans(self): - """:class:`bool`: Whether guild ban related events are enabled. - - This corresponds to the following events: - - - :func:`on_member_ban` - - :func:`on_member_unban` - - This does not correspond to any attributes or classes in the library in terms of cache. - """ - return 1 << 2 - - @flag_value - def emojis(self): - """:class:`bool`: Alias of :attr:`.emojis_and_stickers`. - - .. versionchanged:: 2.0 - Changed to an alias. - """ - return 1 << 3 - - @alias_flag_value - def emojis_and_stickers(self): - """:class:`bool`: Whether guild emoji and sticker related events are enabled. - - .. 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 `. - - .. 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) - """ - 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 - - @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) - - @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 - - @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 - - @fill_with_flags() class MemberCacheFlags(BaseFlags): """Controls the library's cache policy when it comes to members. @@ -875,11 +422,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. @@ -944,8 +486,6 @@ 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 @@ -955,43 +495,10 @@ class MemberCacheFlags(BaseFlags): """:class:`bool`: Whether to cache members that joined the guild or are chunked as part of the initial log in flow. - This requires :attr:`Intents.members`. - Members that leave the guild are no longer cached. """ 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 - - def _verify_intents(self, intents: Intents): - if self.voice and not intents.voice_states: - raise ValueError('MemberCacheFlags.voice requires Intents.voice_states') - - if self.joined and not intents.members: - raise ValueError('MemberCacheFlags.joined requires Intents.members') - @property def _voice_only(self): return self.value == 1 diff --git a/discord/gateway.py b/discord/gateway.py index aa0c6ba06..92128893c 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -52,8 +52,7 @@ __all__ = ( 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=True): self.resume = resume self.op = 'RESUME' if resume else 'IDENTIFY' @@ -71,7 +70,6 @@ class GatewayRatelimiter: self.window = 0.0 self.per = per self.lock = asyncio.Lock() - self.shard_id = None def is_ratelimited(self): current = time.time() @@ -101,7 +99,7 @@ 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('WebSocket is ratelimited, waiting %.2f seconds.', delta) await asyncio.sleep(delta) @@ -109,16 +107,14 @@ class KeepAliveHandler(threading.Thread): def __init__(self, *args, **kwargs): ws = kwargs.pop('ws', None) interval = kwargs.pop('interval', None) - shard_id = kwargs.pop('shard_id', None) threading.Thread.__init__(self, *args, **kwargs) self.ws = ws self._main_thread_id = ws.thread_id self.interval = interval self.daemon = True - self.shard_id = shard_id - self.msg = 'Keeping shard ID %s websocket alive with sequence %s.' - self.block_msg = 'Shard ID %s heartbeat blocked for more than %s seconds.' - self.behind_msg = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' + self.msg = 'Keeping websocket alive with sequence %s.' + self.block_msg = 'Heartbeat blocked for more than %s seconds.' + self.behind_msg = 'Can\'t keep up, websocket is %.1fs behind.' self._stop_ev = threading.Event() self._last_ack = time.perf_counter() self._last_send = time.perf_counter() @@ -129,7 +125,7 @@ class KeepAliveHandler(threading.Thread): def run(self): while not self._stop_ev.wait(self.interval): if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - _log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) + _log.warning('Gateway has stopped responding. Closing and restarting.') coro = self.ws.close(4000) f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) @@ -142,7 +138,7 @@ class KeepAliveHandler(threading.Thread): return data = self.get_payload() - _log.debug(self.msg, self.shard_id, data['d']) + _log.debug(self.msg, data['d']) coro = self.ws.send_heartbeat(data) f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) try: @@ -161,7 +157,7 @@ class KeepAliveHandler(threading.Thread): 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) + _log.warning(msg, total) except Exception: self.stop() @@ -185,15 +181,15 @@ 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, **kwargs): super().__init__(*args, **kwargs) self.recent_ack_latencies = deque(maxlen=20) - self.msg = 'Keeping shard ID %s voice websocket alive with timestamp %s.' - self.block_msg = 'Shard ID %s voice heartbeat blocked for more than %s seconds' - self.behind_msg = 'High socket latency, shard ID %s heartbeat is %.1fs behind' + self.msg = 'Keeping voice websocket alive with timestamp %s.' + self.block_msg = 'Voice heartbeat blocked for more than %s seconds.' + self.behind_msg = 'High socket latency, voice websocket is %.1fs behind.' def get_payload(self): return { @@ -300,7 +296,7 @@ class DiscordWebSocket: pass @classmethod - async def from_client(cls, client, *, initial=False, gateway=None, shard_id=None, session=None, sequence=None, resume=False): + async def from_client(cls, client, *, initial=False, gateway=None, session=None, sequence=None, resume=False): """Creates a main websocket for Discord from a :class:`Client`. This is for internal use only. @@ -317,9 +313,6 @@ 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 @@ -386,9 +379,6 @@ class DiscordWebSocket: } } - 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'] = { @@ -398,12 +388,9 @@ class DiscordWebSocket: 'afk': False } - if state._intents is not None: - payload['d']['intents'] = state._intents.value - - await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) + await self.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): """Sends the RESUME packet.""" @@ -417,7 +404,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, /): if type(msg) is bytes: @@ -432,7 +419,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('WebSocket Event: %s.', msg) event = msg.get('t') if event: self._dispatch('socket_event_type', event) @@ -448,12 +435,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: @@ -468,7 +455,7 @@ 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) + 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() @@ -477,13 +464,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 @@ -492,17 +479,13 @@ 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) 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] @@ -574,15 +557,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, /): await self._rate_limiter.block() @@ -598,7 +581,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): # This bypasses the rate limit handling code since it has a higher priority @@ -606,7 +589,7 @@ 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=None, status=None, since=0.0): if activity is not None: @@ -898,10 +881,10 @@ class DiscordVoiceWebSocket: 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 + 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) + raise ConnectionClosed(self.ws, code=self._close_code) async def close(self, code=1000): if self._keep_alive is not None: diff --git a/discord/guild.py b/discord/guild.py index 41545f773..b2132960f 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -339,7 +339,6 @@ class Guild(Hashable): attrs = ( ('id', self.id), ('name', self.name), - ('shard_id', self.shard_id), ('chunked', self.chunked), ('member_count', getattr(self, '_member_count', None)), ) @@ -875,8 +874,7 @@ class Guild(Hashable): .. warning:: - Due to a Discord limitation, in order for this attribute to remain up-to-date and - accurate, it requires :attr:`Intents.members` to be specified. + Due to a Discord limitation, this may not always be up-to-date and accurate. """ return self._member_count @@ -896,14 +894,6 @@ class Guild(Hashable): return False return count == len(self._members) - @property - def shard_id(self) -> int: - """:class:`int`: Returns the shard ID for this guild if applicable.""" - count = self._state.shard_count - if count is None: - return 0 - return (self.id >> 22) % count - @property def created_at(self) -> datetime.datetime: """:class:`datetime.datetime`: Returns the guild's creation time in UTC.""" @@ -1631,60 +1621,6 @@ class Guild(Hashable): return threads - # TODO: Remove Optional typing here when async iterators are refactored - def fetch_members(self, *, limit: int = 1000, after: Optional[SnowflakeTime] = None) -> MemberIterator: - """Retrieves an :class:`.AsyncIterator` that enables receiving the guild's members. In order to use this, - :meth:`Intents.members` must be enabled. - - .. note:: - - This method is an API call. For general usage, consider :attr:`members` instead. - - .. versionadded:: 1.3 - - All parameters are optional. - - Parameters - ---------- - limit: Optional[:class:`int`] - The number of members to retrieve. Defaults to 1000. - Pass ``None`` to fetch all members. Note that this is potentially slow. - after: Optional[Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`]] - Retrieve members after this date or object. - If a datetime is provided, it is recommended to use a UTC aware datetime. - If the datetime is naive, it is assumed to be local time. - - Raises - ------ - ClientException - The members intent is not enabled. - HTTPException - Getting the members failed. - - Yields - ------ - :class:`.Member` - The member with the member data parsed. - - Examples - -------- - - Usage :: - - async for member in guild.fetch_members(limit=150): - print(member.name) - - Flattening into a list :: - - members = await guild.fetch_members(limit=150).flatten() - # members is now a list of Member... - """ - - if not self._state._intents.members: - raise ClientException('Intents.members must be enabled to use this.') - - return MemberIterator(self, limit=limit, after=after) - async def fetch_member(self, member_id: int, /) -> Member: """|coro| @@ -1692,7 +1628,7 @@ class Guild(Hashable): .. note:: - This method is an API call. If you have :attr:`Intents.members` and member cache enabled, consider :meth:`get_member` instead. + This method is an API call. If you have member cache, consider :meth:`get_member` instead. Parameters ----------- @@ -2822,7 +2758,7 @@ class Guild(Hashable): """|coro| Requests all members that belong to this guild. In order to use this, - :meth:`Intents.members` must be enabled. + you must have certain permissions. This is a websocket operation and can be slow. @@ -2836,12 +2772,9 @@ class Guild(Hashable): Raises ------- ClientException - The members intent is not enabled. + Insufficient permissions. """ - if not self._state._intents.members: - raise ClientException('Intents.members must be enabled to use this.') - if not self._state.is_guild_evicted(self): return await self._state.chunk_guild(self, cache=cache) @@ -2891,8 +2824,6 @@ class Guild(Hashable): The query timed out waiting for the members. ValueError Invalid parameters were passed to the function - ClientException - The presences intent is not enabled. Returns -------- @@ -2900,9 +2831,6 @@ class Guild(Hashable): The list of members that have matched the query. """ - if presences and not self._state._intents.presences: - raise ClientException('Intents.presences must be enabled to use this.') - if query is None: if query == '': raise ValueError('Cannot pass empty query string.') diff --git a/discord/shard.py b/discord/shard.py deleted file mode 100644 index edbdebf4f..000000000 --- a/discord/shard.py +++ /dev/null @@ -1,546 +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, TypeVar - -if TYPE_CHECKING: - from .gateway import DiscordWebSocket - from .activity import BaseActivity - from .enums import Status - - EI = TypeVar('EI', bound='EventItem') - -__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: EI, other: EI) -> bool: - if not isinstance(other, EventItem): - return NotImplemented - return self.type < other.type - - def __eq__(self: EI, other: EI) -> 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]: - """Optional[:class:`ShardInfo`]: Gets the shard information at a given shard ID or ``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: 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. - - 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 - ------ - InvalidArgument - 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()) diff --git a/discord/state.py b/discord/state.py index 2534e7aac..89198213f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -49,7 +49,7 @@ from .member import Member from .role import Role from .enums import ChannelType, try_enum, Status from . import utils -from .flags import ApplicationFlags, Intents, MemberCacheFlags +from .flags import MemberCacheFlags from .object import Object from .invite import Invite from .integrations import _integration_factory @@ -164,9 +164,7 @@ class ConnectionState: self.dispatch: Callable = dispatch self.handlers: Dict[str, Callable] = handlers self.hooks: Dict[str, Callable] = hooks - self.shard_count: Optional[int] = None self._ready_task: Optional[asyncio.Task] = None - self.application_id: Optional[int] = utils._get_as_snowflake(options, 'application_id') self.heartbeat_timeout: float = options.get('heartbeat_timeout', 60.0) self.guild_ready_timeout: float = options.get('guild_ready_timeout', 2.0) if self.guild_ready_timeout < 0: @@ -194,37 +192,20 @@ class ConnectionState: else: status = str(status) - intents = options.get('intents', None) - if intents is not None: - if not isinstance(intents, Intents): - raise TypeError(f'intents parameter must be Intent not {type(intents)!r}') - else: - intents = Intents.default() - - if not intents.guilds: - _log.warning('Guilds intent seems to be disabled. This may cause state related issues.') - - self._chunk_guilds: bool = options.get('chunk_guilds_at_startup', intents.members) - - # Ensure these two are set properly - if not intents.members and self._chunk_guilds: - raise ValueError('Intents.members must be enabled to chunk guilds at startup.') + self._chunk_guilds: bool = options.get('chunk_guilds_at_startup', True) cache_flags = options.get('member_cache_flags', None) if cache_flags is None: - cache_flags = MemberCacheFlags.from_intents(intents) + cache_flags = MemberCacheFlags.all() else: if not isinstance(cache_flags, MemberCacheFlags): raise TypeError(f'member_cache_flags parameter must be MemberCacheFlags not {type(cache_flags)!r}') - cache_flags._verify_intents(intents) - self.member_cache_flags: MemberCacheFlags = cache_flags self._activity: Optional[ActivityPayload] = activity self._status: Optional[str] = status - self._intents: Intents = intents - if not intents.members or cache_flags._empty: + if cache_flags._empty: self.store_user = self.create_user # type: ignore self.deref_user = self.deref_user_no_intents # type: ignore @@ -300,12 +281,6 @@ class ConnectionState: u = self.user return u.id if u else None - @property - def intents(self) -> Intents: - ret = Intents.none() - ret.value = self._intents.value - return ret - @property def voice_clients(self) -> List[VoiceProtocol]: return list(self._voice_clients.values()) @@ -460,7 +435,7 @@ class ConnectionState: def _guild_needs_chunking(self, guild: Guild) -> bool: # If presences are enabled then we get back the old guild.large behaviour - return self._chunk_guilds and not guild.chunked and not (self._intents.presences and not guild.large) + return self._chunk_guilds and not guild.chunked and not (True and not guild.large) def _get_guild_channel(self, data: MessagePayload) -> Tuple[Union[Channel, Thread], Optional[Guild]]: channel_id = int(data['channel_id']) @@ -523,7 +498,7 @@ class ConnectionState: try: await asyncio.wait_for(future, timeout=5.0) except asyncio.TimeoutError: - _log.warning('Shard ID %s timed out waiting for chunks for guild_id %s.', guild.shard_id, guild.id) + _log.warning('Timed out waiting for chunks for guild_id %s.', guild.id) if guild.unavailable is False: self.dispatch('guild_available', guild) @@ -1395,136 +1370,3 @@ class ConnectionState: self, *, channel: Union[TextChannel, Thread, DMChannel, GroupChannel, PartialMessageable], data: MessagePayload ) -> Message: return Message(state=self, channel=channel, data=data) - - -class AutoShardedConnectionState(ConnectionState): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.shard_ids: Union[List[int], range] = [] - self.shards_launched: asyncio.Event = asyncio.Event() - - def _update_message_references(self) -> None: - # self._messages won't be None when this is called - for msg in self._messages: # type: ignore - if not msg.guild: - continue - - new_guild = self._get_guild(msg.guild.id) - if new_guild is not None and new_guild is not msg.guild: - channel_id = msg.channel.id - channel = new_guild._resolve_channel(channel_id) or Object(id=channel_id) - # channel will either be a TextChannel, Thread or Object - msg._rebind_cached_references(new_guild, channel) # type: ignore - - async def chunker( - self, - guild_id: int, - query: str = '', - limit: int = 0, - presences: bool = False, - *, - shard_id: Optional[int] = None, - nonce: Optional[str] = None, - ) -> None: - ws = self._get_websocket(guild_id, shard_id=shard_id) - await ws.request_chunks(guild_id, query=query, limit=limit, presences=presences, nonce=nonce) - - async def _delay_ready(self) -> None: - await self.shards_launched.wait() - processed = [] - max_concurrency = len(self.shard_ids) * 2 - current_bucket = [] - while True: - # this snippet of code is basically waiting N seconds - # until the last GUILD_CREATE was sent - try: - guild = await asyncio.wait_for(self._ready_state.get(), timeout=self.guild_ready_timeout) - except asyncio.TimeoutError: - break - else: - if self._guild_needs_chunking(guild): - _log.debug('Guild ID %d requires chunking, will be done in the background.', guild.id) - if len(current_bucket) >= max_concurrency: - try: - await utils.sane_wait_for(current_bucket, timeout=max_concurrency * 70.0) - except asyncio.TimeoutError: - fmt = 'Shard ID %s failed to wait for chunks from a sub-bucket with length %d' - _log.warning(fmt, guild.shard_id, len(current_bucket)) - finally: - current_bucket = [] - - # Chunk the guild in the background while we wait for GUILD_CREATE streaming - future = asyncio.ensure_future(self.chunk_guild(guild)) - current_bucket.append(future) - else: - future = self.loop.create_future() - future.set_result([]) - - processed.append((guild, future)) - - guilds = sorted(processed, key=lambda g: g[0].shard_id) - for shard_id, info in itertools.groupby(guilds, key=lambda g: g[0].shard_id): - children, futures = zip(*info) - # 110 reqs/minute w/ 1 req/guild plus some buffer - timeout = 61 * (len(children) / 110) - try: - await utils.sane_wait_for(futures, timeout=timeout) - except asyncio.TimeoutError: - _log.warning( - 'Shard ID %s failed to wait for chunks (timeout=%.2f) for %d guilds', shard_id, timeout, len(guilds) - ) - for guild in children: - if guild.unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) - - self.dispatch('shard_ready', shard_id) - - # remove the state - try: - del self._ready_state - except AttributeError: - pass # already been deleted somehow - - # regular users cannot shard so we won't worry about it here. - - # clear the current task - self._ready_task = None - - # dispatch the event - self.call_handlers('ready') - self.dispatch('ready') - - def parse_ready(self, data) -> None: - if not hasattr(self, '_ready_state'): - self._ready_state = asyncio.Queue() - - self.user = user = ClientUser(state=self, data=data['user']) - # self._users is a list of Users, we're setting a ClientUser - self._users[user.id] = user # type: ignore - - if self.application_id is None: - try: - application = data['application'] - except KeyError: - pass - else: - self.application_id = utils._get_as_snowflake(application, 'id') - self.application_flags = ApplicationFlags._from_value(application['flags']) - - for guild_data in data['guilds']: - self._add_guild_from_data(guild_data) - - if self._messages: - self._update_message_references() - - self.dispatch('connect') - self.dispatch('shard_connect', data['__shard_id__']) - - if self._ready_task is None: - self._ready_task = asyncio.create_task(self._delay_ready()) - - def parse_resumed(self, data) -> None: - self.dispatch('resumed') - self.dispatch('shard_resumed', data['__shard_id__']) diff --git a/discord/template.py b/discord/template.py index 30af3a4d9..455073937 100644 --- a/discord/template.py +++ b/discord/template.py @@ -52,10 +52,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 From 72f126a321e25a933113d5463e96247409a19a03 Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 2 Nov 2021 10:21:09 -0400 Subject: [PATCH 008/154] I can't python --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a84d4f13c..9d1e59b3b 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ extras_require = { 'sphinxcontrib-websupport', ], 'speed': [ - 'aiohttp[speedups]' + 'aiohttp[speedups]', 'orjson>=3.5.4', ] } From 3429980d0c32d610f41ffc67bf1bf921086e829f Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 15:10:08 -0400 Subject: [PATCH 009/154] Migrate --- discord/__init__.py | 4 +- discord/abc.py | 48 ++- discord/activity.py | 33 +- discord/appinfo.py | 3 +- discord/channel.py | 239 +++++++++---- discord/client.py | 255 +++++++++++-- discord/components.py | 12 - discord/errors.py | 10 +- discord/flags.py | 53 +++ discord/gateway.py | 194 +++++----- discord/guild.py | 23 +- discord/interactions.py | 767 ---------------------------------------- 12 files changed, 628 insertions(+), 1013 deletions(-) delete mode 100644 discord/interactions.py diff --git a/discord/__init__.py b/discord/__init__.py index 9a6f6e97f..e3dacd63a 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord.py-self' __author__ = 'Dolfies' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.0.0a' +__version__ = '2.0.0a1' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -67,6 +67,6 @@ class _VersionInfo(NamedTuple): 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=1) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index a2ca1e489..806406685 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -26,6 +26,7 @@ from __future__ import annotations import copy import asyncio +from datetime import datetime from typing import ( Any, Callable, @@ -70,7 +71,7 @@ if TYPE_CHECKING: from datetime import datetime 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 @@ -78,7 +79,7 @@ if TYPE_CHECKING: from .channel import CategoryChannel from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable + from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable, VocalGuildChannel from .threads import Thread from .enums import InviteTarget from .ui.view import View @@ -92,6 +93,7 @@ if TYPE_CHECKING: PartialMessageableChannel = Union[TextChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] SnowflakeTime = Union["Snowflake", datetime] + ConnectableChannel = Union[VocalGuildChannel, PrivateChannel, User] MISSING = utils.MISSING @@ -146,6 +148,8 @@ class User(Snowflake, Protocol): The avatar asset the user has. 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__ = () @@ -1025,7 +1029,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, @@ -1232,11 +1236,10 @@ class Messageable: files=None, stickers=None, delete_after=None, - nonce=None, + nonce=MISSING, allowed_mentions=None, reference=None, mention_author=None, - view=None, ): """|coro| @@ -1270,7 +1273,7 @@ class Messageable: 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, @@ -1297,8 +1300,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. @@ -1332,7 +1333,7 @@ class Messageable: content = str(content) if content is not None else None if embed is not None and embeds is not None: - raise InvalidArgument('cannot pass both embed and embeds parameter to send()') + raise InvalidArgument('Cannot pass both embed and embeds parameter to send()') if embed is not None: embed = embed.to_dict() @@ -1363,16 +1364,11 @@ class Messageable: except AttributeError: raise InvalidArgument('reference parameter must be Message, MessageReference, or PartialMessage') from None - if view: - if not hasattr(view, '__discord_ui_view__'): - raise InvalidArgument(f'view parameter must be View not {view.__class__!r}') - - components = view.to_components() - else: - components = None + if nonce is MISSING: + nonce = utils.time_snowflake(datetime.utcnow()) if file is not None and files is not None: - raise InvalidArgument('cannot pass both file and files parameter to send()') + raise InvalidArgument('Cannot pass both file and files parameter to send()') if file is not None: if not isinstance(file, File): @@ -1390,7 +1386,6 @@ class Messageable: nonce=nonce, message_reference=reference, stickers=stickers, - components=components, ) finally: file.close() @@ -1615,6 +1610,9 @@ class Connectable(Protocol): __slots__ = () _state: ConnectionState + async def _get_channel(self) -> ConnectableChannel: + return self + def _get_voice_client_key(self) -> Tuple[int, str]: raise NotImplementedError @@ -1627,6 +1625,7 @@ class Connectable(Protocol): timeout: float = 60.0, reconnect: bool = True, cls: Callable[[Client, Connectable], T] = VoiceClient, + _channel: Optional[Connectable] = None ) -> T: """|coro| @@ -1662,15 +1661,15 @@ class Connectable(Protocol): key_id, _ = self._get_voice_client_key() state = self._state + channel = await self._get_channel() if state._get_voice_client(key_id): - raise ClientException('Already connected to a voice channel.') + raise ClientException('Already connected to a voice channel') - client = state._get_client() - 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) @@ -1680,8 +1679,7 @@ class Connectable(Protocol): try: await voice.disconnect(force=True) except Exception: - # we don't care if disconnect failed because connection failed - pass - raise # re-raise + pass # We don't care if disconnect failed because connection failed + raise # Re-raise return voice diff --git a/discord/activity.py b/discord/activity.py index 512053777..2f8ebf294 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -738,14 +738,14 @@ class CustomActivity(BaseActivity): The emoji to pass to the activity, if any. """ - __slots__ = ('name', 'emoji', 'state') + __slots__ = ('name', 'emoji') def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): super().__init__(**extra) self.name: Optional[str] = name - self.state: Optional[str] = extra.pop('state', None) - if self.name == 'Custom Status': - self.name = self.state + state = extra.pop('state', None) + if self.name == 'Custom Activity': + self.name = state self.emoji: Optional[PartialEmoji] if emoji is None: @@ -768,18 +768,11 @@ 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, - } - + o = { + 'type': ActivityType.custom.value, + 'state': self.name, + 'name': 'Custom Status', + } if self.emoji: o['emoji'] = self.emoji.to_dict() return o @@ -830,12 +823,12 @@ def create_activity(data: Optional[ActivityPayload]) -> Optional[ActivityTypes]: except KeyError: return Activity(**data) else: - # we removed the name key from data already - return CustomActivity(name=name, **data) # type: ignore + # 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 - return Streaming(**data) # type: ignore + # 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) diff --git a/discord/appinfo.py b/discord/appinfo.py index de1f7a73f..7eda5ee51 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -46,8 +46,7 @@ __all__ = ( class AppInfo: - """Represents the application info for the bot provided by Discord. - + """Represents application info for an application/bot. Attributes ------------- diff --git a/discord/channel.py b/discord/channel.py index be3315cf1..1effb9623 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -24,7 +24,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import time import asyncio from typing import ( Any, @@ -44,6 +43,7 @@ from typing import ( import datetime import discord.abc +from .calls import PrivateCall, GroupCall from .permissions import PermissionOverwrite, Permissions from .enums import ChannelType, StagePrivacyLevel, try_enum, VoiceRegion, VideoQualityMode from .mixins import Hashable @@ -71,7 +71,7 @@ if TYPE_CHECKING: from .types.threads import ThreadArchiveDuration 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 @@ -89,9 +89,13 @@ if TYPE_CHECKING: from .types.snowflake import SnowflakeList -async def _single_delete_strategy(messages: Iterable[Message]): - for m in messages: - await m.delete() +async def _delete_messages(state, channel_id, messages): + delete_message = state.http.delete_message + for msg in messages: + try: + await delete_message(channel_id, msg.id) + except NotFound: + pass class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): @@ -366,15 +370,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. Parameters ----------- @@ -383,12 +385,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. """ @@ -398,16 +396,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): if len(messages) == 0: 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) + await _delete_messages(self._state, self.id, messages) async def purge( self, @@ -418,7 +407,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, ) -> List[Message]: """|coro| @@ -426,10 +414,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. Examples --------- @@ -458,10 +444,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. Raises ------- @@ -479,45 +461,27 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): 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) + if count == 50: + to_delete = ret[-50:] + await _delete_messages(state, 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 _delete_messages(state, channel_id, to_delete) return ret @@ -1707,7 +1671,7 @@ class StoreChannel(discord.abc.GuildChannel, Hashable): DMC = TypeVar('DMC', bound='DMChannel') -class DMChannel(discord.abc.Messageable, Hashable): +class DMChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord direct message channel. .. container:: operations @@ -1748,9 +1712,22 @@ class DMChannel(discord.abc.Messageable, Hashable): 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) -> None: + return self._state.http.ring(self.id) + def __str__(self) -> str: if self.recipient: return f'Direct Message with {self.recipient}' @@ -1769,6 +1746,11 @@ class DMChannel(discord.abc.Messageable, Hashable): 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: """:class:`ChannelType`: The channel's Discord type.""" @@ -1832,8 +1814,15 @@ class DMChannel(discord.abc.Messageable, Hashable): return PartialMessage(channel=self, id=message_id) + async def connect(self, *, ring=True, **kwargs): + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + await super().connect(**kwargs) -class GroupChannel(discord.abc.Messageable, Hashable): + +class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): """Represents a Discord group channel. .. container:: operations @@ -1892,9 +1881,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) -> None: + 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 @@ -1907,6 +1909,11 @@ class GroupChannel(discord.abc.Messageable, Hashable): def __repr__(self) -> str: return f'' + @property + def call(self) -> Optional[PrivateCall]: + """Optional[:class:`PrivateCall`]: The channel's currently active call.""" + return self._state._calls.get(self.id) + @property def type(self) -> ChannelType: """:class:`ChannelType`: The channel's Discord type.""" @@ -1960,6 +1967,110 @@ class GroupChannel(discord.abc.Messageable, Hashable): return base + async def connect(self, *, ring=True, **kwargs) -> ConnectReturn: + await self._get_channel() + call = self.call + if call is None and ring: + await self._initial_ring() + await super().connect(**kwargs) + + 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): + """|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| diff --git a/discord/client.py b/discord/client.py index f2389d640..3ba1395c4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -33,17 +33,16 @@ from typing import Any, Callable, Coroutine, Dict, Generator, List, Optional, Se import aiohttp -from .user import User, ClientUser +from .user import User, ClientUser, Profile, Note from .invite import Invite from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji from .channel import _threaded_channel_factory, PartialMessageable -from .enums import ChannelType +from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * -from .enums import Status, VoiceRegion from .gateway import * from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient @@ -143,6 +142,16 @@ class Client: amounts of guilds. The default is ``True``. .. versionadded:: 1.5 + guild_subscription_options: :class:`GuildSubscriptionOptions` + Allows for control over the library's auto-subscribing. + If not given, defaults to off. + + .. versionadded:: 1.9 + request_guilds :class:`bool` + Whether to request guilds at startup (behaves similarly to the old + guild_subscriptions option). Defaults to True. + + .. versionadded:: 1.10 status: Optional[:class:`.Status`] A status to start your presence with upon logging on to Discord. activity: Optional[:class:`.BaseActivity`] @@ -198,7 +207,8 @@ class Client: self.http: HTTPClient = HTTPClient(connector, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, loop=self.loop) self._handlers: Dict[str, Callable] = { - 'ready': self._handle_ready + 'ready': self._handle_ready, + 'connect': self._handle_connect } self._hooks: Dict[str, Callable] = { @@ -209,8 +219,6 @@ class Client: self._connection: ConnectionState = self._get_state(**options) self._closed: bool = False self._ready: asyncio.Event = asyncio.Event() - self._connection._get_websocket = self._get_websocket - self._connection._get_client = lambda: self if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -218,16 +226,21 @@ class Client: # Internals - def _get_websocket(self, guild_id: Optional[int] = None) -> DiscordWebSocket: - return self.ws - def _get_state(self, **options: Any) -> ConnectionState: return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, - hooks=self._hooks, http=self.http, loop=self.loop, **options) + hooks=self._hooks, http=self.http, loop=self.loop, + client=self, **options) def _handle_ready(self) -> None: self._ready.set() + def _handle_connect(self) -> None: + state = self._connection + activity = create_activity(state._activity) + status = try_enum(Status, state._status) + if status is not None or activity is not None: + self.loop.create_task(self.change_presence(activity=activity, status=status)) + @property def latency(self) -> float: """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. @@ -424,6 +437,7 @@ class Client: _log.info('Logging in using static token.') data = await self.http.static_login(token.strip()) + self._state.analytics_token = data.get('') self._connection.user = ClientUser(state=self._connection, data=data) async def connect(self, *, reconnect: bool = True) -> None: @@ -546,11 +560,6 @@ class Client: """|coro| A shorthand coroutine for :meth:`login` + :meth:`connect`. - - Raises - ------- - TypeError - An unexpected keyword argument was received. """ await self.login(token) await self.connect(reconnect=reconnect) @@ -621,6 +630,11 @@ class Client: """:class:`bool`: Indicates if the websocket connection is closed.""" return self._closed + @property + def voice_client(self) -> Optional[VoiceProtocol]: + """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with private calls, if any.""" + return self._connection._get_voice_client(self.user.id) + @property def activity(self) -> Optional[ActivityTypes]: """Optional[:class:`.BaseActivity`]: The activity being used upon @@ -991,6 +1005,7 @@ class Client: *, activity: Optional[BaseActivity] = None, status: Optional[Status] = None, + afk: bool = False ): """|coro| @@ -1004,9 +1019,6 @@ class Client: game = discord.Game("with the API") await client.change_presence(status=discord.Status.idle, activity=game) - .. versionchanged:: 2.0 - Removed the ``afk`` keyword-only parameter. - Parameters ---------- activity: Optional[:class:`.BaseActivity`] @@ -1014,6 +1026,10 @@ class Client: status: Optional[:class:`.Status`] Indicates what status to change to. If ``None``, then :attr:`.Status.online` is used. + afk: Optional[:class:`bool`] + Indicates if you are going AFK. This allows the Discord + client to know how to handle push notifications better + for you in case you are actually idle and not lying. Raises ------ @@ -1030,7 +1046,14 @@ class Client: else: status_str = str(status) - await self.ws.change_presence(activity=activity, status=status_str) + await self.ws.change_presence(activity=activity, status=status_str, afk=afk) + + # TODO: do the same for custom status and check which comes first + if status: + try: + await self._connection.user.edit_settings(status=status_enum) + except Exception: # Not essential to actually changing status... + pass for guild in self._connection.guilds: me = guild.me @@ -1044,12 +1067,51 @@ class Client: me.status = status + async def change_voice_state( + self, + *, + channel: Optional[PrivateChannel], + self_mute: bool = False, + self_deaf: bool = False, + self_video: bool = False, + preferred_region: Optional[VoiceRegion] = MISSING + ) -> None: + """|coro| + + Changes client's voice state in the guild. + + .. versionadded:: 1.4 + + Parameters + ----------- + channel: Optional[:class:`VoiceChannel`] + Channel the client wants to join. Use ``None`` to disconnect. + self_mute: :class:`bool` + Indicates if the client should be self-muted. + self_deaf: :class:`bool` + Indicates if the client should be self-deafened. + self_video: :class:`bool` + Indicates if the client is using video. Untested & unconfirmed + (do not use). + preferred_region: Optional[:class:`VoiceRegion`] + The preferred region to connect to. + """ + ws = self._state._get_websocket(self.id) + channel_id = channel.id if channel else None + + if preferred_region is None or channel_id is None: + region = None + else: + region = str(preferred_region) if preferred_region else str(state.preferred_region) + + await ws.voice_state(None, channel_id, self_mute, self_deaf, self_video, region) + # Guild stuff def fetch_guilds( self, *, - limit: Optional[int] = 100, + limit: Optional[int] = None, before: SnowflakeTime = None, after: SnowflakeTime = None ) -> GuildIterator: @@ -1069,12 +1131,12 @@ class Client: Usage :: - async for guild in client.fetch_guilds(limit=150): + async for guild in client.fetch_guilds(): print(guild.name) Flattening into a list :: - guilds = await client.fetch_guilds(limit=150).flatten() + guilds = await client.fetch_guilds().flatten() # guilds is now a list of Guild... All parameters are optional. @@ -1083,9 +1145,8 @@ class Client: ----------- limit: Optional[:class:`int`] The number of guilds to retrieve. - If ``None``, it retrieves every guild you have access to. Note, however, - that this would make it a slow operation. - Defaults to ``100``. + If ``None``, it retrieves every guild you have access to. + Defaults to ``None``. before: Union[:class:`.abc.Snowflake`, :class:`datetime.datetime`] Retrieves guilds before this date or object. If a datetime is provided, it is recommended to use a UTC aware datetime. @@ -1131,7 +1192,7 @@ class Client: """ code = utils.resolve_template(code) data = await self.http.get_template(code) - return Template(data=data, state=self._connection) # type: ignore + return Template(data=data, state=self._connection) # type: ignore async def fetch_guild(self, guild_id: int, /) -> Guild: """|coro| @@ -1182,9 +1243,6 @@ class Client: ---------- name: :class:`str` The name of the guild. - region: :class:`.VoiceRegion` - The region for the voice communication server. - Defaults to :attr:`.VoiceRegion.us_west`. icon: Optional[:class:`bytes`] The :term:`py:bytes-like object` representing the icon. See :meth:`.ClientUser.edit` for more details on what is expected. @@ -1211,12 +1269,10 @@ class Client: else: icon_base64 = None - region_value = str(region) - if code: - data = await self.http.create_from_template(code, name, region_value, icon_base64) + data = await self.http.create_from_template(code, name, icon_base64) else: - data = await self.http.create_guild(name, region_value, icon_base64) + data = await self.http.create_guild(name, icon_base64) return Guild(data=data, state=self._connection) async def fetch_stage_instance(self, channel_id: int, /) -> StageInstance: @@ -1317,6 +1373,38 @@ class Client: invite_id = utils.resolve_invite(invite) await self.http.delete_invite(invite_id) + async def accept_invite(self, invite: Union[Invite, str]) -> Guild: + """|coro| + + Accepts an invite and joins a guild. + + .. versionadded:: 1.9 + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The Discord invite ID, URL (must be a discord.gg URL), or :class:`.Invite`. + + Raises + ------ + :exc:`.HTTPException` + Joining the guild failed. + + Returns + ------- + :class:`.Guild` + The guild joined. This is not the same guild that is + added to cache. + """ + + if not isinstance(invite, Invite): + invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False) + + data = await self.http.join_guild(invite.code, guild_id=invite.guild.id, channel_id=invite.channel.id, channel_type=invite.channel.type.value) + return Guild(data=data['guild'], state=self._connection) + + use_invite = accept_invite + # Miscellaneous stuff async def fetch_widget(self, guild_id: int, /) -> Widget: @@ -1346,15 +1434,14 @@ class Client: The guild's widget. """ data = await self.http.get_widget(guild_id) - return Widget(state=self._connection, data=data) async def fetch_user(self, user_id: int, /) -> User: """|coro| Retrieves a :class:`~discord.User` based on their ID. - You do not have to share any guilds with the user to get this information, - however many operations do require that you do. + You do not have to share any guilds with the user to get + this information, however many operations do require that you do. .. note:: @@ -1380,6 +1467,57 @@ class Client: data = await self.http.get_user(user_id) return User(state=self._connection, data=data) + + async def fetch_user_profile( + self, user_id: int, *, with_mutuals: bool = True, fetch_note: bool = True + ) -> Profile: + """|coro| + + Gets an arbitrary user's profile. + + You must share a guild or be friends with this user to + get this information. + + Parameters + ------------ + user_id: :class:`int` + The ID of the user to fetch their profile for. + with_mutuals: :class:`bool` + Whether to fetch mutual guilds and friends. + This fills in :attr:`mutual_guilds` & :attr:`mutual_friends`. + fetch_note: :class:`bool` + Whether to pre-fetch the user's note. + + Raises + ------- + :exc:`.NotFound` + A user with this ID does not exist. + :exc:`.Forbidden` + Not allowed to fetch this profile. + :exc:`.HTTPException` + Fetching the profile failed. + + Returns + -------- + :class:`.Profile` + The profile of the user. + """ + + state = self._connection + data = await self.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) + + if with_mutuals: + data['mutual_friends'] = await self.http.get_mutual_friends(user_id) + + profile = Profile(state, data) + + if fetch_note: + await profile.note.fetch() + + return profile + + fetch_profile = fetch_user_profile + async def fetch_channel(self, channel_id: int, /) -> Union[GuildChannel, PrivateChannel, Thread]: """|coro| @@ -1490,6 +1628,51 @@ class Client: data = await self.http.list_premium_sticker_packs() return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] + async def fetch_notes(self) -> List[Note]: + """|coro| + + Retrieves a list of :class:`Note` objects representing all your notes. + + Raises + ------- + :exc:`.HTTPException` + Retreiving the notes failed. + + Returns + -------- + List[:class:`Note`] + All your notes. + """ + state = self._connection + data = await self.http.get_notes() + return [Note(state, int(id), note=note) for id, note in data.items()] + + async def fetch_note(self, user_id: int) -> Note: + """|coro| + + Retrieves a :class:`Note` for the specified user ID. + + Parameters + ----------- + user_id: :class:`int` + The ID of the user to fetch the note for. + + Raises + ------- + :exc:`.HTTPException` + Retreiving the note failed. + + Returns + -------- + :class:`Note` + The note you requested. + """ + try: + data = await self.http.get_note(user_id) + except NotFound: + data = {'note': 0} + return Note(self._connection, int(user_id), note=data['note']) + async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| diff --git a/discord/components.py b/discord/components.py index 74c7be3d0..491716076 100644 --- a/discord/components.py +++ b/discord/components.py @@ -132,11 +132,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 @@ -205,11 +200,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 @@ -269,8 +259,6 @@ class SelectMenu(Component): class SelectOption: """Represents a select menu's option. - These can be created by users. - .. versionadded:: 2.0 Attributes diff --git a/discord/errors.py b/discord/errors.py index e344c9f8f..4210947fa 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -80,7 +80,7 @@ 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) @@ -111,13 +111,14 @@ 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: Dict[any, any] + The raw error JSON. """ def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]): @@ -126,6 +127,7 @@ class HTTPException(DiscordException): 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') @@ -195,7 +197,7 @@ class InvalidArgument(ClientException): 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. @@ -203,6 +205,8 @@ class LoginFailure(ClientException): pass +LoginFailure = AuthFailure + class ConnectionClosed(ClientException): """Exception that's raised when the gateway connection is diff --git a/discord/flags.py b/discord/flags.py index 391ec3b37..08e99faa6 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -34,6 +34,7 @@ __all__ = ( 'PublicUserFlags', 'MemberCacheFlags', 'ApplicationFlags', + 'GuildSubscriptionOptions', ) FV = TypeVar('FV', bound='flag_value') @@ -573,3 +574,55 @@ class ApplicationFlags(BaseFlags): def embedded(self): """:class:`bool`: Returns ``True`` if the application is embedded within the Discord client.""" return 1 << 17 + + +class GuildSubscriptionOptions: + """Controls the library's auto-subscribing feature. + + Subscribing refers to abusing the member sidebar to scrape all* guild + members. However, you can only request 200 members per OPCode 14. + + Once you send a proper OPCode 14, Discord responds with a + GUILD_MEMBER_LIST_UPDATE. You then also get subsequent GUILD_MEMBER_LIST_UPDATEs + that act (kind of) like GUILD_MEMBER_UPDATE/ADD/REMOVEs. + + *Discord doesn't provide offline members for "large" guilds. + *As this is dependent on the member sidebar, guilds that don't have + a channel (of any type, surprisingly) that @everyone or some other + role everyone has can't access don't get the full online member list. + + To construct an object you can pass keyword arguments denoting the options + and their values. If you don't pass a value, the default is used. + """ + + def __init__( + self, *, auto_subscribe: bool = True, concurrent_guilds: int = 2, max_online: int = 6000 + ) -> None: + if concurrent_guilds < 1: + raise TypeError('concurrent_guilds must be positive') + if max_online < 1: + raise TypeError('max_online must be positive') + + self.auto_subscribe = auto_subscribe + self.concurrent_guilds = concurrent_guilds + self.max_online = max_online + + def __repr__(self) -> str: + return ' GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` that subscribes every guild. Not recommended in the slightest.""" + return cls(max_online=10000000) + + @classmethod + def default(cls) -> GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` with default values.""" + return cls() + + @classmethod + def disabled(cls) -> GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` with subscribing disabled.""" + return cls(auto_subscribe=False) + + off = disabled diff --git a/discord/gateway.py b/discord/gateway.py index 92128893c..dd1107a8c 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -103,61 +103,59 @@ class GatewayRatelimiter: await asyncio.sleep(delta) -class KeepAliveHandler(threading.Thread): - def __init__(self, *args, **kwargs): - ws = kwargs.pop('ws', None) - interval = kwargs.pop('interval', None) - threading.Thread.__init__(self, *args, **kwargs) +class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome + def __init__(self, *, ws, interval=None): self.ws = ws - self._main_thread_id = ws.thread_id self.interval = interval - self.daemon = True - self.msg = 'Keeping websocket alive with sequence %s.' + self.heartbeat_timeout = self.ws._max_heartbeat_timeout + + self.msg = 'Keeping websocket alive.' self.block_msg = 'Heartbeat blocked for more than %s seconds.' self.behind_msg = 'Can\'t keep up, websocket is %.1fs behind.' - self._stop_ev = threading.Event() - self._last_ack = time.perf_counter() + self.not_responding_msg = 'Gateway has stopped responding. Closing and restarting.' + self.no_stop_msg = 'An error occurred while stopping the gateway. Ignoring.' + + self._stop_ev = asyncio.Event() self._last_send = time.perf_counter() self._last_recv = time.perf_counter() + self._last_ack = time.perf_counter() self.latency = float('inf') - self.heartbeat_timeout = ws._max_heartbeat_timeout - def run(self): - while not self._stop_ev.wait(self.interval): + async def run(self): + while True: + try: + await asyncio.wait_for(self._stop_ev.wait(), timeout=self.interval) + except asyncio.TimeoutError: + pass + else: + return + if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - _log.warning('Gateway has stopped responding. Closing and restarting.') - 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, 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 + # 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, 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() @@ -167,9 +165,12 @@ class KeepAliveHandler(threading.Thread): def get_payload(self): return { 'op': self.ws.HEARTBEAT, - 'd': self.ws.sequence + 'd': self.ws.sequence, } + def start(self): + self.ws.loop.create_task(self.run()) + def stop(self): self._stop_ev.set() @@ -181,15 +182,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.latency) + log.warning(self.behind_msg, self.latency) + class VoiceKeepAliveHandler(KeepAliveHandler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.recent_ack_latencies = deque(maxlen=20) - self.msg = 'Keeping voice websocket alive with timestamp %s.' - self.block_msg = 'Voice heartbeat blocked for more than %s seconds.' - self.behind_msg = 'High socket latency, voice websocket is %.1fs behind.' + self.msg = 'Keeping voice websocket alive.' + self.block_msg = 'Voice heartbeat blocked for more than %s seconds' + self.behind_msg = 'High socket latency, heartbeat is %.1fs behind' + self.not_responding_msg = 'Voice gateway has stopped responding. Closing and restarting.' + self.no_stop_msg = 'An error occurred while stopping the voice gateway. Ignoring.' def get_payload(self): return { @@ -203,10 +207,9 @@ class VoiceKeepAliveHandler(KeepAliveHandler): self._last_recv = ack_time self.latency = ack_time - self._last_send self.recent_ack_latencies.append(self.latency) + if self.latency > 10: + log.warning(self.behind_msg, self.latency) -class DiscordClientWebSocketResponse(aiohttp.ClientWebSocketResponse): - async def close(self, *, code: int = 4000, message: bytes = b'') -> bool: - return await super().close(code=code, message=message) class DiscordWebSocket: """Implements a WebSocket for Discord's gateway v6. @@ -241,7 +244,11 @@ 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. + ACCESS_DM + Send only. Tracking. + GUILD_SUBSCRIBE + Send only. Subscribes you to guilds/guild members. Might respond with GUILD_MEMBER_LIST_UPDATE. gateway The gateway we are currently connected to. token @@ -261,6 +268,8 @@ class DiscordWebSocket: HELLO = 10 HEARTBEAT_ACK = 11 GUILD_SYNC = 12 + ACCESS_DM = 13 + GUILD_SUBSCRIBE = 14 def __init__(self, socket, *, loop): self.socket = socket @@ -316,6 +325,10 @@ class DiscordWebSocket: 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 @@ -323,9 +336,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: @@ -366,31 +379,30 @@ class DiscordWebSocket: 'op': self.IDENTIFY, 'd': { 'token': self.token, - 'properties': { - '$os': sys.platform, - '$browser': 'discord.py', - '$device': 'discord.py', - '$referrer': '', - '$referring_domain': '' + 'capabilities': 125, + 'properties': self._super_properties, + 'presence': { + 'status': 'online', + 'since': 0, + 'activities': [], + 'afk': False }, - 'compress': True, - 'large_threshold': 250, - 'v': 3 + 'compress': False, + 'client_state': { + 'guild_hashes': {}, + 'highest_last_message_id': '0', + 'read_state_version': 0, + 'user_guild_settings_version': -1 + } } } - state = self._connection - if state._activity is not None or state._status is not None: - payload['d']['presence'] = { - 'status': state._status, - 'game': state._activity, - 'since': 0, - 'afk': False - } + if not self._zlib_enabled: + payload['d']['compress'] = True await self.call_hooks('before_identify', initial=self._initial_identify) await self.send_as_json(payload) - _log.info('Gateway has sent the IDENTIFY payload.') + log.info('Gateway has sent the IDENTIFY payload.') async def resume(self): """Sends the RESUME packet.""" @@ -419,7 +431,7 @@ class DiscordWebSocket: self.log_receive(msg) msg = utils._from_json(msg) - _log.debug('WebSocket Event: %s.', msg) + _log.debug('Gateway event: %s.', msg) event = msg.get('t') if event: self._dispatch('socket_event_type', event) @@ -456,7 +468,7 @@ class DiscordWebSocket: if op == self.HELLO: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = KeepAliveHandler(ws=self, interval=interval) - # send a heartbeat immediately + # Send a heartbeat immediately await self.send_as_json(self._keep_alive.get_payload()) self._keep_alive.start() return @@ -480,21 +492,22 @@ class DiscordWebSocket: self.sequence = msg['s'] self.session_id = data['session_id'] _log.info('Connected to Gateway: %s (Session ID: %s).', - ', '.join(trace), self.session_id) + ', '.join(trace), self.session_id) elif event == 'RESUMED': self._trace = trace = data.get('_trace', []) _log.info('Gateway has successfully RESUMED session %s under trace %s.', - self.session_id, ', '.join(trace)) + 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: @@ -616,40 +629,63 @@ class DiscordWebSocket: _log.debug('Sending "%s" to change status', sent) await self.send(sent) - async def request_chunks(self, guild_id, query=None, *, limit, user_ids=None, presences=False, nonce=None): + 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.REQUEST_MEMBERS, + 'op': self.GUILD_SUBSCRIBE, 'd': { 'guild_id': guild_id, - 'presences': presences, - 'limit': limit } } - if nonce: - payload['d']['nonce'] = nonce + 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 - if user_ids: - payload['d']['user_ids'] = user_ids + await self.send_as_json(payload) - if query is not None: - payload['d']['query'] = query + async def request_chunks(self, guild_ids, query=None, *, limit=None, user_ids=None, presences=True, nonce=None): + payload = { + 'op': self.REQUEST_MEMBERS, + 'd': { + 'guild_id': guild_ids, + 'query': query, + 'limit': limit, + 'presences': presences, + 'user_ids': user_ids, + } + } + if nonce: + payload['d']['nonce'] = nonce await self.send_as_json(payload) - async def voice_state(self, guild_id, channel_id, self_mute=False, self_deaf=False): + async def voice_state(self, guild_id=None, channel_id=None, self_mute=False, self_deaf=False, self_video=False, *, preferred_region=None): payload = { 'op': self.VOICE_STATE, 'd': { 'guild_id': guild_id, 'channel_id': channel_id, 'self_mute': self_mute, - 'self_deaf': self_deaf + '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 close(self, code=4000): diff --git a/discord/guild.py b/discord/guild.py index b2132960f..10bc3aea2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2850,8 +2850,14 @@ class Guild(Hashable): ) async def change_voice_state( - self, *, channel: Optional[VocalGuildChannel], self_mute: bool = False, self_deaf: bool = False - ): + self, + *, + channel: Optional[VocalGuildChannel], + self_mute: bool = False, + self_deaf: bool = False, + self_video: bool = False, + preferred_region: Optional[VoiceRegion] = MISSING + ) -> None: """|coro| Changes client's voice state in the guild. @@ -2866,7 +2872,18 @@ class Guild(Hashable): Indicates if the client should be self-muted. self_deaf: :class:`bool` Indicates if the client should be self-deafened. + self_video: :class:`bool` + Indicates if the client is using video. Untested & unconfirmed + (do not use). + preferred_region: Optional[:class:`VoiceRegion`] + The preferred region to connect to. """ ws = self._state._get_websocket(self.id) channel_id = channel.id if channel else None - await ws.voice_state(self.id, channel_id, self_mute, self_deaf) + + if preferred_region is None or channel_id is None: + region = None + else: + region = str(preferred_region) if preferred_region else str(state.preferred_region) + + await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, region) diff --git a/discord/interactions.py b/discord/interactions.py deleted file mode 100644 index b89d49f53..000000000 --- a/discord/interactions.py +++ /dev/null @@ -1,767 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -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, Dict, List, Optional, TYPE_CHECKING, Tuple, Union -import asyncio - -from . import utils -from .enums import try_enum, InteractionType, InteractionResponseType -from .errors import InteractionResponded, HTTPException, ClientException -from .channel import PartialMessageable, ChannelType - -from .user import User -from .member import Member -from .message import Message, Attachment -from .object import Object -from .permissions import Permissions -from .webhook.async_ import async_context, Webhook, handle_message_parameters - -__all__ = ( - 'Interaction', - 'InteractionMessage', - 'InteractionResponse', -) - -if TYPE_CHECKING: - from .types.interactions import ( - Interaction as InteractionPayload, - InteractionData, - ) - from .guild import Guild - from .state import ConnectionState - from .file import File - from .mentions import AllowedMentions - from aiohttp import ClientSession - from .embeds import Embed - from .ui.view import View - from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable - from .threads import Thread - - InteractionChannel = Union[ - VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable - ] - -MISSING: Any = utils.MISSING - - -class Interaction: - """Represents a Discord interaction. - - An interaction happens when a user does an action that needs to - be notified. Current examples are slash commands and components. - - .. versionadded:: 2.0 - - Attributes - ----------- - id: :class:`int` - The interaction's ID. - type: :class:`InteractionType` - The interaction type. - guild_id: Optional[:class:`int`] - The guild ID the interaction was sent from. - channel_id: Optional[:class:`int`] - The channel ID the interaction was sent from. - application_id: :class:`int` - The application ID that the interaction was for. - user: Optional[Union[:class:`User`, :class:`Member`]] - The user or member that sent the interaction. - message: Optional[:class:`Message`] - The message that sent this interaction. - token: :class:`str` - The token to continue the interaction. These are valid - for 15 minutes. - data: :class:`dict` - The raw interaction data. - """ - - __slots__: Tuple[str, ...] = ( - 'id', - 'type', - 'guild_id', - 'channel_id', - 'data', - 'application_id', - 'message', - 'user', - 'token', - 'version', - '_permissions', - '_state', - '_session', - '_original_message', - '_cs_response', - '_cs_followup', - '_cs_channel', - ) - - def __init__(self, *, data: InteractionPayload, state: ConnectionState): - self._state: ConnectionState = state - self._session: ClientSession = state.http._HTTPClient__session - self._original_message: Optional[InteractionMessage] = None - self._from_data(data) - - def _from_data(self, data: InteractionPayload): - self.id: int = int(data['id']) - self.type: InteractionType = try_enum(InteractionType, data['type']) - self.data: Optional[InteractionData] = data.get('data') - self.token: str = data['token'] - self.version: int = data['version'] - self.channel_id: Optional[int] = utils._get_as_snowflake(data, 'channel_id') - self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') - self.application_id: int = int(data['application_id']) - - self.message: Optional[Message] - try: - self.message = Message(state=self._state, channel=self.channel, data=data['message']) # type: ignore - except KeyError: - self.message = None - - self.user: Optional[Union[User, Member]] = None - self._permissions: int = 0 - - # TODO: there's a potential data loss here - if self.guild_id: - guild = self.guild or Object(id=self.guild_id) - try: - member = data['member'] # type: ignore - except KeyError: - pass - else: - self.user = Member(state=self._state, guild=guild, data=member) # type: ignore - self._permissions = int(member.get('permissions', 0)) - else: - try: - self.user = User(state=self._state, data=data['user']) - except KeyError: - pass - - @property - def guild(self) -> Optional[Guild]: - """Optional[:class:`Guild`]: The guild the interaction was sent from.""" - return self._state and self._state._get_guild(self.guild_id) - - @utils.cached_slot_property('_cs_channel') - def channel(self) -> Optional[InteractionChannel]: - """Optional[Union[:class:`abc.GuildChannel`, :class:`PartialMessageable`, :class:`Thread`]]: The channel the interaction was sent from. - - Note that due to a Discord limitation, DM channels are not resolved since there is - no data to complete them. These are :class:`PartialMessageable` instead. - """ - guild = self.guild - channel = guild and guild._resolve_channel(self.channel_id) - if channel is None: - if self.channel_id is not None: - type = ChannelType.text if self.guild_id is not None else ChannelType.private - return PartialMessageable(state=self._state, id=self.channel_id, type=type) - return None - return channel - - @property - def permissions(self) -> Permissions: - """:class:`Permissions`: The resolved permissions of the member in the channel, including overwrites. - - In a non-guild context where this doesn't apply, an empty permissions object is returned. - """ - return Permissions(self._permissions) - - @utils.cached_slot_property('_cs_response') - def response(self) -> InteractionResponse: - """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. - - A response can only be done once. If secondary messages need to be sent, consider using :attr:`followup` - instead. - """ - return InteractionResponse(self) - - @utils.cached_slot_property('_cs_followup') - def followup(self) -> Webhook: - """:class:`Webhook`: Returns the follow up webhook for follow up interactions.""" - payload = { - 'id': self.application_id, - 'type': 3, - 'token': self.token, - } - return Webhook.from_state(data=payload, state=self._state) - - async def original_message(self) -> InteractionMessage: - """|coro| - - Fetches the original interaction response message associated with the interaction. - - If the interaction response was :meth:`InteractionResponse.send_message` then this would - return the message that was sent using that response. Otherwise, this would return - the message that triggered the interaction. - - Repeated calls to this will return a cached value. - - Raises - ------- - HTTPException - Fetching the original response message failed. - ClientException - The channel for the message could not be resolved. - - Returns - -------- - InteractionMessage - The original interaction response message. - """ - - if self._original_message is not None: - return self._original_message - - # TODO: fix later to not raise? - channel = self.channel - if channel is None: - raise ClientException('Channel for message could not be resolved') - - adapter = async_context.get() - data = await adapter.get_original_interaction_response( - application_id=self.application_id, - token=self.token, - session=self._session, - ) - state = _InteractionMessageState(self, self._state) - message = InteractionMessage(state=state, channel=channel, data=data) # type: ignore - self._original_message = message - return message - - async def edit_original_message( - self, - *, - content: Optional[str] = MISSING, - embeds: List[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - file: File = MISSING, - files: List[File] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - ) -> InteractionMessage: - """|coro| - - Edits the original interaction response message. - - This is a lower level interface to :meth:`InteractionMessage.edit` in case - you do not want to fetch the message and save an HTTP request. - - This method is also the only way to edit the original message if - the message sent was ephemeral. - - Parameters - ------------ - content: Optional[:class:`str`] - The content to edit the message with or ``None`` to clear it. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - file: :class:`File` - The file to upload. This cannot be mixed with ``files`` parameter. - files: List[:class:`File`] - A list of files to send with the content. This cannot be mixed with the - ``file`` parameter. - 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. - - Raises - ------- - HTTPException - Editing the message failed. - Forbidden - Edited a message that is not yours. - TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` - ValueError - The length of ``embeds`` was invalid. - - Returns - -------- - :class:`InteractionMessage` - The newly edited message. - """ - - previous_mentions: Optional[AllowedMentions] = self._state.allowed_mentions - params = handle_message_parameters( - content=content, - file=file, - files=files, - embed=embed, - embeds=embeds, - view=view, - allowed_mentions=allowed_mentions, - previous_allowed_mentions=previous_mentions, - ) - adapter = async_context.get() - data = await adapter.edit_original_interaction_response( - self.application_id, - self.token, - session=self._session, - payload=params.payload, - multipart=params.multipart, - files=params.files, - ) - - # The message channel types should always match - message = InteractionMessage(state=self._state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): - self._state.store_view(view, message.id) - return message - - async def delete_original_message(self) -> None: - """|coro| - - Deletes the original interaction response message. - - This is a lower level interface to :meth:`InteractionMessage.delete` in case - you do not want to fetch the message and save an HTTP request. - - Raises - ------- - HTTPException - Deleting the message failed. - Forbidden - Deleted a message that is not yours. - """ - adapter = async_context.get() - await adapter.delete_original_interaction_response( - self.application_id, - self.token, - session=self._session, - ) - - -class InteractionResponse: - """Represents a Discord interaction response. - - This type can be accessed through :attr:`Interaction.response`. - - .. versionadded:: 2.0 - """ - - __slots__: Tuple[str, ...] = ( - '_responded', - '_parent', - ) - - def __init__(self, parent: Interaction): - self._parent: Interaction = parent - self._responded: bool = False - - def is_done(self) -> bool: - """:class:`bool`: Indicates whether an interaction response has been done before. - - An interaction can only be responded to once. - """ - return self._responded - - async def defer(self, *, ephemeral: bool = False) -> None: - """|coro| - - Defers the interaction response. - - This is typically used when the interaction is acknowledged - and a secondary action will be done later. - - Parameters - ----------- - ephemeral: :class:`bool` - Indicates whether the deferred message will eventually be ephemeral. - This only applies for interactions of type :attr:`InteractionType.application_command`. - - Raises - ------- - HTTPException - Deferring the interaction failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - defer_type: int = 0 - data: Optional[Dict[str, Any]] = None - parent = self._parent - if parent.type is InteractionType.component: - defer_type = InteractionResponseType.deferred_message_update.value - elif parent.type is InteractionType.application_command: - defer_type = InteractionResponseType.deferred_channel_message.value - if ephemeral: - data = {'flags': 64} - - if defer_type: - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, parent.token, session=parent._session, type=defer_type, data=data - ) - self._responded = True - - async def pong(self) -> None: - """|coro| - - Pongs the ping interaction. - - This should rarely be used. - - Raises - ------- - HTTPException - Ponging the interaction failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - parent = self._parent - if parent.type is InteractionType.ping: - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, parent.token, session=parent._session, type=InteractionResponseType.pong.value - ) - self._responded = True - - async def send_message( - self, - content: Optional[Any] = None, - *, - embed: Embed = MISSING, - embeds: List[Embed] = MISSING, - view: View = MISSING, - tts: bool = False, - ephemeral: bool = False, - ) -> None: - """|coro| - - Responds to this interaction by sending a message. - - Parameters - ----------- - content: Optional[:class:`str`] - The content of the message to send. - embeds: List[:class:`Embed`] - A list of embeds to send with the content. Maximum of 10. This cannot - be mixed with the ``embed`` parameter. - embed: :class:`Embed` - The rich embed for the content to send. This cannot be mixed with - ``embeds`` parameter. - tts: :class:`bool` - Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` - The view to send with the message. - ephemeral: :class:`bool` - Indicates if the message should only be visible to the user who started the interaction. - If a view is sent with an ephemeral message and it has no timeout set then the timeout - is set to 15 minutes. - - Raises - ------- - HTTPException - Sending the message failed. - TypeError - You specified both ``embed`` and ``embeds``. - ValueError - The length of ``embeds`` was invalid. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - payload: Dict[str, Any] = { - 'tts': tts, - } - - if embed is not MISSING and embeds is not MISSING: - raise TypeError('cannot mix embed and embeds keyword arguments') - - if embed is not MISSING: - embeds = [embed] - - if embeds: - if len(embeds) > 10: - raise ValueError('embeds cannot exceed maximum of 10 elements') - payload['embeds'] = [e.to_dict() for e in embeds] - - if content is not None: - payload['content'] = str(content) - - if ephemeral: - payload['flags'] = 64 - - if view is not MISSING: - payload['components'] = view.to_components() - - parent = self._parent - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - type=InteractionResponseType.channel_message.value, - data=payload, - ) - - if view is not MISSING: - if ephemeral and view.timeout is None: - view.timeout = 15 * 60.0 - - self._parent._state.store_view(view) - - self._responded = True - - async def edit_message( - self, - *, - content: Optional[Any] = MISSING, - embed: Optional[Embed] = MISSING, - embeds: List[Embed] = MISSING, - attachments: List[Attachment] = MISSING, - view: Optional[View] = MISSING, - ) -> None: - """|coro| - - Responds to this interaction by editing the original message of - a component interaction. - - Parameters - ----------- - content: Optional[:class:`str`] - The new content to replace the message with. ``None`` removes the content. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - attachments: List[:class:`Attachment`] - A list of attachments to keep in the message. If ``[]`` is passed - then all attachments are removed. - view: Optional[:class:`~discord.ui.View`] - The updated view to update this message with. If ``None`` is passed then - the view is removed. - - Raises - ------- - HTTPException - Editing the message failed. - TypeError - You specified both ``embed`` and ``embeds``. - InteractionResponded - This interaction has already been responded to before. - """ - if self._responded: - raise InteractionResponded(self._parent) - - parent = self._parent - msg = parent.message - state = parent._state - message_id = msg.id if msg else None - if parent.type is not InteractionType.component: - return - - payload = {} - if content is not MISSING: - if content is None: - payload['content'] = None - else: - payload['content'] = str(content) - - if embed is not MISSING and embeds is not MISSING: - raise TypeError('cannot mix both embed and embeds keyword arguments') - - if embed is not MISSING: - if embed is None: - embeds = [] - else: - embeds = [embed] - - if embeds is not MISSING: - payload['embeds'] = [e.to_dict() for e in embeds] - - if attachments is not MISSING: - payload['attachments'] = [a.to_dict() for a in attachments] - - if view is not MISSING: - state.prevent_view_updates_for(message_id) - if view is None: - payload['components'] = [] - else: - payload['components'] = view.to_components() - - adapter = async_context.get() - await adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - type=InteractionResponseType.message_update.value, - data=payload, - ) - - if view and not view.is_finished(): - state.store_view(view, message_id) - - self._responded = True - - -class _InteractionMessageState: - __slots__ = ('_parent', '_interaction') - - def __init__(self, interaction: Interaction, parent: ConnectionState): - self._interaction: Interaction = interaction - self._parent: ConnectionState = parent - - def _get_guild(self, guild_id): - return self._parent._get_guild(guild_id) - - def store_user(self, data): - return self._parent.store_user(data) - - def create_user(self, data): - return self._parent.create_user(data) - - @property - def http(self): - return self._parent.http - - def __getattr__(self, attr): - return getattr(self._parent, attr) - - -class InteractionMessage(Message): - """Represents the original interaction response message. - - This allows you to edit or delete the message associated with - the interaction response. To retrieve this object see :meth:`Interaction.original_message`. - - This inherits from :class:`discord.Message` with changes to - :meth:`edit` and :meth:`delete` to work. - - .. versionadded:: 2.0 - """ - - __slots__ = () - _state: _InteractionMessageState - - async def edit( - self, - content: Optional[str] = MISSING, - embeds: List[Embed] = MISSING, - embed: Optional[Embed] = MISSING, - file: File = MISSING, - files: List[File] = MISSING, - view: Optional[View] = MISSING, - allowed_mentions: Optional[AllowedMentions] = None, - ) -> InteractionMessage: - """|coro| - - Edits the message. - - Parameters - ------------ - content: Optional[:class:`str`] - The content to edit the message with or ``None`` to clear it. - embeds: List[:class:`Embed`] - A list of embeds to edit the message with. - embed: Optional[:class:`Embed`] - The embed to edit the message with. ``None`` suppresses the embeds. - This should not be mixed with the ``embeds`` parameter. - file: :class:`File` - The file to upload. This cannot be mixed with ``files`` parameter. - files: List[:class:`File`] - A list of files to send with the content. This cannot be mixed with the - ``file`` parameter. - 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. - - Raises - ------- - HTTPException - Editing the message failed. - Forbidden - Edited a message that is not yours. - TypeError - You specified both ``embed`` and ``embeds`` or ``file`` and ``files`` - ValueError - The length of ``embeds`` was invalid. - - Returns - --------- - :class:`InteractionMessage` - The newly edited message. - """ - return await self._state._interaction.edit_original_message( - content=content, - embeds=embeds, - embed=embed, - file=file, - files=files, - view=view, - allowed_mentions=allowed_mentions, - ) - - async def delete(self, *, delay: Optional[float] = None) -> None: - """|coro| - - Deletes the message. - - Parameters - ----------- - delay: Optional[:class:`float`] - If provided, the number of seconds to wait before deleting the message. - The waiting is done in the background and deletion failures are ignored. - - Raises - ------ - Forbidden - You do not have proper permissions to delete the message. - NotFound - The message was deleted already. - HTTPException - Deleting the message failed. - """ - - if delay is not None: - - async def inner_call(delay: float = delay): - await asyncio.sleep(delay) - try: - await self._state._interaction.delete_original_message() - except HTTPException: - pass - - asyncio.create_task(inner_call()) - else: - await self._state._interaction.delete_original_message() From fe8795e92cd4f69f7c91b52b5ed5182c8bc8923d Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 17:24:06 -0400 Subject: [PATCH 010/154] Re-add context properties --- discord/context_properties.py | 254 ++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 discord/context_properties.py diff --git a/discord/context_properties.py b/discord/context_properties.py new file mode 100644 index 000000000..b60e33c52 --- /dev/null +++ b/discord/context_properties.py @@ -0,0 +1,254 @@ +""" +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, Optional + +from .types.snowflake import Snowflake + +__all__ = ( + 'ContextProperties', +) + + +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, any] = 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).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_accept_invite_page_blank(cls) -> ContextProperties: + data = { + 'location': 'Accept Invite Page' + } + 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, channel_id: Snowflake, channel_type: int + ) -> ContextProperties: + data = { + 'location': 'Accept Invite Page', + 'location_guild_id': str(guild_id), + 'location_channel_id': str(channel_id), + 'location_channel_type': int(channel_type) + } + return cls(data) + + @classmethod + def _from_join_guild_popup( + cls, *, guild_id: Snowflake, channel_id: Snowflake, channel_type: int + ) -> ContextProperties: + data = { + 'location': 'Join Guild', + 'location_guild_id': str(guild_id), + 'location_channel_id': str(channel_id), + 'location_channel_type': int(channel_type) + } + return cls(data) + + @classmethod + def _from_invite_embed( + cls, *, guild_id: Snowflake, channel_id: Snowflake, message_id: Snowflake, channel_type: int + ) -> ContextProperties: + data = { + 'location': 'Invite Button Embed', + 'location_guild_id': str(guild_id), + 'location_channel_id': str(channel_id), + 'location_channel_type': int(channel_type), + 'location_message_id': str(message_id) + } + return cls(data) + + @property + def location(self) -> Optional[str]: + return self._data.get('location') + + @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]: + data = self._data.get('location_channel_type') + if data is not None: + return data + + @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') + + def __repr__(self) -> str: + return ''.format(self) + + def __eq__(self, other) -> bool: + return isinstance(other, ContextProperties) and self.value == other.value + + def __ne__(self, other) -> bool: + return not self.__eq__(other) From da587ba29671db7678b43e3f25404d7a62ade31c Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 17:24:39 -0400 Subject: [PATCH 011/154] Migrate guild (except for subscribe()) --- discord/guild.py | 183 +++++++++++++++++++++++------------------------ 1 file changed, 88 insertions(+), 95 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 10bc3aea2..52e4c19ee 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -150,17 +150,12 @@ class Guild(Hashable): All stickers that the guild owns. .. versionadded:: 2.0 - region: :class:`VoiceRegion` - The region the guild belongs on. There is a chance that the region - will be a :class:`str` if the value is not recognised by the enumerator. afk_timeout: :class:`int` The timeout to get sent to the AFK channel. - afk_channel: Optional[:class:`VoiceChannel`] - The channel that denotes the AFK channel. ``None`` if it doesn't exist. id: :class:`int` The guild's ID. owner_id: :class:`int` - The guild owner's ID. Use :attr:`Guild.owner` instead. + The guild owner's ID. unavailable: :class:`bool` Indicates if the guild is unavailable. If this is ``True`` then the reliability of other attributes outside of :attr:`Guild.id` is slim and they might @@ -274,6 +269,8 @@ class Guild(Hashable): '_public_updates_channel_id', '_stage_instances', '_threads', + '_online_count', + '_subscribing' ) _PREMIUM_GUILD_LIMITS: ClassVar[Dict[Optional[int], _GuildLimit]] = { @@ -285,10 +282,12 @@ class Guild(Hashable): } def __init__(self, *, data: GuildPayload, state: ConnectionState): + self._roles: Dict[int, Role] = {} self._channels: Dict[int, GuildChannel] = {} self._members: Dict[int, Member] = {} self._voice_states: Dict[int, VoiceState] = {} self._threads: Dict[int, Thread] = {} + self._stage_instances: Dict[int, StageInstance] = {} self._state: ConnectionState = state self._from_data(data) @@ -349,7 +348,7 @@ class Guild(Hashable): user_id = int(data['user_id']) channel = self.get_channel(channel_id) try: - # check if we should remove the voice state from cache + # Check if we should remove the voice state from cache if channel is None: after = self._voice_states.pop(user_id) else: @@ -358,7 +357,7 @@ class Guild(Hashable): before = copy.copy(after) after._update(data, channel) except KeyError: - # if we're here then we're getting added into the cache + # If we're here then add it into the cache after = VoiceState(data=data, channel=channel) before = VoiceState(data=data, channel=None) self._voice_states[user_id] = after @@ -373,59 +372,63 @@ class Guild(Hashable): return member, before, after def _add_role(self, role: Role, /) -> None: - # roles get added to the bottom (position 1, pos 0 is @everyone) - # so since self.roles has the @everyone role, we can't increment - # its position because it's stuck at position 0. Luckily x += False - # is equivalent to adding 0. So we cast the position to a bool and - # increment it. for r in self._roles.values(): r.position += not r.is_default() self._roles[role.id] = role def _remove_role(self, role_id: int, /) -> Role: - # this raises KeyError if it fails.. role = self._roles.pop(role_id) - # since it didn't, we can change the positions now - # basically the same as above except we only decrement - # the position if we're above the role we deleted. for r in self._roles.values(): r.position -= r.position > role.position return role def _from_data(self, guild: GuildPayload) -> None: - # according to Stan, this is always available even if the guild is unavailable - # I don't have this guarantee when someone updates the guild. - member_count = guild.get('member_count', None) + member_count = guild.get('member_count') if member_count is not None: self._member_count: int = member_count + self.id: int = int(guild['id']) self.name: str = guild.get('name') - self.region: VoiceRegion = try_enum(VoiceRegion, guild.get('region')) self.verification_level: VerificationLevel = try_enum(VerificationLevel, guild.get('verification_level')) self.default_notifications: NotificationLevel = try_enum( NotificationLevel, guild.get('default_message_notifications') ) self.explicit_content_filter: ContentFilter = try_enum(ContentFilter, guild.get('explicit_content_filter', 0)) self.afk_timeout: int = guild.get('afk_timeout') - self._icon: Optional[str] = guild.get('icon') - self._banner: Optional[str] = guild.get('banner') self.unavailable: bool = guild.get('unavailable', False) - self.id: int = int(guild['id']) - self._roles: Dict[int, Role] = {} - state = self._state # speed up attribute access + + state = self._state # Speed up attribute access + for r in guild.get('roles', []): role = Role(guild=self, data=r, state=state) self._roles[role.id] = role + for c in guild.get('channels', []): + factory, _ = _guild_channel_factory(c['type']) + if factory: + self._add_channel(factory(guild=self, data=c, state=state)) + + for t in guild.get('threads', []): + self._add_thread(Thread(guild=self, state=self._state, data=t)) + + for s in guild.get('stage_instances', []): + stage_instance = StageInstance(guild=self, data=s, state=state) + self._stage_instances[stage_instance.id] = stage_instance + + for vs in guild.get('voice_states', []): + self._update_voice_state(vs, int(vs['channel_id'])) + self.mfa_level: MFALevel = guild.get('mfa_level') self.emojis: Tuple[Emoji, ...] = tuple(map(lambda d: state.store_emoji(self, d), guild.get('emojis', []))) self.stickers: Tuple[GuildSticker, ...] = tuple( map(lambda d: state.store_sticker(self, d), guild.get('stickers', [])) ) self.features: List[GuildFeature] = guild.get('features', []) + self._icon: Optional[str] = guild.get('icon') + self._banner: Optional[str] = guild.get('banner') self._splash: Optional[str] = guild.get('splash') self._system_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'system_channel_id') self.description: Optional[str] = guild.get('description') @@ -439,54 +442,29 @@ class Guild(Hashable): self._discovery_splash: Optional[str] = guild.get('discovery_splash') self._rules_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'rules_channel_id') self._public_updates_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'public_updates_channel_id') + self._afk_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'afk_channel_id') + self._widget_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'widget_channel_id') self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get('nsfw_level', 0)) + self._online_count = None - self._stage_instances: Dict[int, StageInstance] = {} - for s in guild.get('stage_instances', []): - stage_instance = StageInstance(guild=self, data=s, state=state) - self._stage_instances[stage_instance.id] = stage_instance - - cache_joined = self._state.member_cache_flags.joined - self_id = self._state.self_id - for mdata in guild.get('members', []): - member = Member(data=mdata, guild=self, state=state) - if cache_joined or member.id == self_id: - self._add_member(member) - - self._sync(guild) - self._large: Optional[bool] = None if member_count is None else self._member_count >= 250 - - self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') - self.afk_channel: Optional[VocalGuildChannel] = self.get_channel(utils._get_as_snowflake(guild, 'afk_channel_id')) # type: ignore - - for obj in guild.get('voice_states', []): - self._update_voice_state(obj, int(obj['channel_id'])) - - # TODO: refactor/remove? - def _sync(self, data: GuildPayload) -> None: - try: - self._large = data['large'] - except KeyError: - pass + for mdata in guild.get('merged_members', []): + try: + member = Member(data=mdata, guild=self, state=state) + except KeyError: + continue + self._add_member(member) empty_tuple = tuple() - for presence in data.get('presences', []): - user_id = int(presence['user']['id']) + for presence in guild.get('merged_presences', []): + user_id = int(presence['user_id']) member = self.get_member(user_id) if member is not None: - member._presence_update(presence, empty_tuple) # type: ignore + member._presence_update(presence, empty_tuple) - if 'channels' in data: - channels = data['channels'] - for c in channels: - factory, ch_type = _guild_channel_factory(c['type']) - if factory: - self._add_channel(factory(guild=self, data=c, state=self._state)) # type: ignore + large = None if member_count is None else member_count >= 250 + self._large: Optional[bool] = guild.get('large', large) - if 'threads' in data: - threads = data['threads'] - for thread in threads: - self._add_thread(Thread(guild=self, state=self._state, data=thread)) + self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') @property def channels(self) -> List[GuildChannel]: @@ -543,7 +521,7 @@ class Guild(Hashable): This is essentially used to get the member version of yourself. """ self_id = self._state.user.id - # The self member is *always* cached + # We are *always* cached return self.get_member(self_id) # type: ignore @property @@ -625,7 +603,7 @@ class Guild(Hashable): Returns -------- Optional[Union[:class:`Thread`, :class:`.abc.GuildChannel`]] - The returned channel or thread or ``None`` if not found. + The returned channel, thread, or ``None`` if not found. """ return self._channels.get(channel_id) or self._threads.get(channel_id) @@ -704,6 +682,25 @@ class Guild(Hashable): channel_id = self._public_updates_channel_id return channel_id and self._channels.get(channel_id) # type: ignore + @property + def afk_channel(self) -> Optional[VocalGuildChannel]: + """Optional[:class:`VoiceChannel`]: Returns the guild channel AFK users are moved to. + + If no channel is set, then this returns ``None``. + """ + channel_id = self._afk_channel_id + return channel_id and self._channels.get(channel_id) # type: ignore + + @property + def widget_channel(self) -> Optional[GuildChannel]: + """Optional[:class:`TextChannel`]: Returns the channel the + widget will generate an invite to by default. + + If no channel is set, then this returns ``None``. + """ + channel_id = self._widget_channel_id + return channel_id and self._channels.get(channel_id) # type: ignore + @property def emoji_limit(self) -> int: """:class:`int`: The maximum number of emoji slots this guild has.""" @@ -796,19 +793,6 @@ class Guild(Hashable): return role return None - @property - def self_role(self) -> Optional[Role]: - """Optional[:class:`Role`]: Gets the role associated with this client's user, if any. - - .. versionadded:: 1.6 - """ - self_id = self._state.self_id - for role in self._roles.values(): - tags = role.tags - if tags and tags.bot_id == self_id: - return role - return None - @property def stage_instances(self) -> List[StageInstance]: """List[:class:`StageInstance`]: Returns a :class:`list` of the guild's stage instances that @@ -879,6 +863,13 @@ class Guild(Hashable): """ return self._member_count + @property + def online_count(self) -> Optional[int]: + """Optional[:class:`int`]: Returns the online member count. + This only exists after the first GUILD_MEMBER_LIST_UPDATE. + """ + return self._online_count + @property def chunked(self) -> bool: """:class:`bool`: Returns a boolean indicating if the guild is "chunked". @@ -926,16 +917,10 @@ class Guild(Hashable): then ``None`` is returned. """ - result = None members = self.members + if len(name) > 5 and name[-5] == '#': - # The 5 length is checking to see if #0000 is in the string, - # as a#0000 has a length of 6, the minimum for a potential - # discriminator lookup. potential_discriminator = name[-4:] - - # do the actual lookup and return if found - # if it isn't found then we'll do a full name lookup below. result = utils.get(members, name=name[:-5], discriminator=potential_discriminator) if result is not None: return result @@ -1296,7 +1281,7 @@ class Guild(Hashable): .. note:: - You cannot leave the guild that you own, you must delete it instead + You cannot leave a guild that you own, you must delete it instead via :meth:`delete`. Raises @@ -1346,6 +1331,7 @@ class Guild(Hashable): preferred_locale: str = MISSING, rules_channel: Optional[TextChannel] = MISSING, public_updates_channel: Optional[TextChannel] = MISSING, + features: List[str] = MISSING, ) -> Guild: r"""|coro| @@ -1369,7 +1355,7 @@ class Guild(Hashable): The new name of the guild. description: Optional[:class:`str`] The new description of the guild. Could be ``None`` for no description. - This is only available to guilds that contain ``PUBLIC`` in :attr:`Guild.features`. + This is only available to guilds that contain ``COMMUNITY`` in :attr:`Guild.features`. icon: :class:`bytes` A :term:`py:bytes-like object` representing the icon. Only PNG/JPEG is supported. GIF is only available to guilds that contain ``ANIMATED_ICON`` in :attr:`Guild.features`. @@ -1444,6 +1430,7 @@ class Guild(Hashable): mentioned in :meth:`Client.fetch_guild` and may not have full data. """ + # TODO: see what fields are sent no matter if they're changed or not http = self._state.http if vanity_code is not MISSING: @@ -1992,7 +1979,7 @@ class Guild(Hashable): """ await self._state.http.create_integration(self.id, type, id) - async def integrations(self) -> List[Integration]: + async def integrations(self, *, with_applications=True) -> List[Integration]: """|coro| Returns a list of all integrations attached to the guild. @@ -2002,6 +1989,11 @@ class Guild(Hashable): .. versionadded:: 1.4 + Parameters + ----------- + with_applications: :class:`bool` + Whether to include applications. + Raises ------- Forbidden @@ -2014,7 +2006,7 @@ class Guild(Hashable): List[:class:`Integration`] The list of integrations that are attached to the guild. """ - data = await self._state.http.get_all_integrations(self.id) + data = await self._state.http.get_all_integrations(self.id, with_applications) def convert(d): factory, _ = _integration_factory(d['type']) @@ -2784,7 +2776,7 @@ class Guild(Hashable): *, limit: int = 5, user_ids: Optional[List[int]] = None, - presences: bool = False, + presences: bool = True, cache: bool = True, ) -> List[Member]: """|coro| @@ -2805,7 +2797,7 @@ class Guild(Hashable): a number between 5 and 100. presences: :class:`bool` Whether to request for presences to be provided. This defaults - to ``False``. + to ``True``. .. versionadded:: 1.6 @@ -2878,7 +2870,8 @@ class Guild(Hashable): preferred_region: Optional[:class:`VoiceRegion`] The preferred region to connect to. """ - ws = self._state._get_websocket(self.id) + state = self._state + ws = state._get_websocket(self.id) channel_id = channel.id if channel else None if preferred_region is None or channel_id is None: From 005b3e5b41c68ef6c28c8268e7cfa83c2fb81236 Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 17:24:56 -0400 Subject: [PATCH 012/154] Fix ClientUser typing --- discord/types/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/types/user.py b/discord/types/user.py index fba5aef5b..106f3eea4 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -40,7 +40,7 @@ class User(PartialUser, total=False): bot: bool system: bool mfa_enabled: bool - local: str + locale: str verified: bool email: Optional[str] flags: int From 7ce26215914fe5d8ba09dcd770bde6c0edb0203a Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 17:30:39 -0400 Subject: [PATCH 013/154] Add owner_application_id --- discord/guild.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 52e4c19ee..43b8de25d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -156,6 +156,8 @@ class Guild(Hashable): The guild's ID. owner_id: :class:`int` The guild owner's ID. + owner_application_id: Optional[:class:`int`] + The application ID of the guild owner (if applicable). unavailable: :class:`bool` Indicates if the guild is unavailable. If this is ``True`` then the reliability of other attributes outside of :attr:`Guild.id` is slim and they might @@ -445,7 +447,12 @@ class Guild(Hashable): self._afk_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'afk_channel_id') self._widget_channel_id: Optional[int] = utils._get_as_snowflake(guild, 'widget_channel_id') self.nsfw_level: NSFWLevel = try_enum(NSFWLevel, guild.get('nsfw_level', 0)) - self._online_count = None + self._online_count: Optional[int] = None + self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') + self.owner_application_id: Optional[int] = utils._get_as_snowflake(guild, 'application_id') + + large = None if member_count is None else member_count >= 250 + self._large: Optional[bool] = guild.get('large', large) for mdata in guild.get('merged_members', []): try: @@ -461,11 +468,6 @@ class Guild(Hashable): if member is not None: member._presence_update(presence, empty_tuple) - large = None if member_count is None else member_count >= 250 - self._large: Optional[bool] = guild.get('large', large) - - self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') - @property def channels(self) -> List[GuildChannel]: """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" From bee07329d1a8026afd3ed8c3e10f5990ac94071e Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 17:33:35 -0400 Subject: [PATCH 014/154] Fix a few typos --- discord/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index 3ba1395c4..fcb48566c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -437,7 +437,7 @@ class Client: _log.info('Logging in using static token.') data = await self.http.static_login(token.strip()) - self._state.analytics_token = data.get('') + self._state.analytics_token = data.get('analytics_token') self._connection.user = ClientUser(state=self._connection, data=data) async def connect(self, *, reconnect: bool = True) -> None: @@ -1051,7 +1051,7 @@ class Client: # TODO: do the same for custom status and check which comes first if status: try: - await self._connection.user.edit_settings(status=status_enum) + await self._connection.user.edit_settings(status=status) except Exception: # Not essential to actually changing status... pass @@ -1096,7 +1096,8 @@ class Client: preferred_region: Optional[:class:`VoiceRegion`] The preferred region to connect to. """ - ws = self._state._get_websocket(self.id) + state = self._connection + ws = state._get_websocket(self.id) channel_id = channel.id if channel else None if preferred_region is None or channel_id is None: From efa0b4324e7a554a6d22d225ed0af680f639754d Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 5 Nov 2021 22:14:22 -0400 Subject: [PATCH 015/154] Migrate a few more methods --- discord/guild.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 43b8de25d..558142699 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2691,6 +2691,18 @@ class Guild(Hashable): self, before=before, after=after, limit=limit, oldest_first=oldest_first, user_id=user_id, action_type=action ) + async def ack(self): + """|coro| + + Marks every message in this guild as read. + + Raises + ------- + HTTPException + Acking failed. + """ + return await self._state.http.ack_guild(self.id) + async def widget(self) -> Widget: """|coro| @@ -2882,3 +2894,50 @@ class Guild(Hashable): region = str(preferred_region) if preferred_region else str(state.preferred_region) await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, region) + + async def mute(self, *, duration: Optional[int] = None) -> None: + """|coro| + + Mutes the guild. + + .. versionadded:: 1.9 + + Parameters + ----------- + duration: Optional[:class:`int`] + The duration (in hours) of the mute. Defaults to + ``None`` for an indefinite mute. + + Raises + ------- + HTTPException + Muting failed. + """ + + fields = { + 'muted': True + } + + if duration is not None: + mute_config = { + 'selected_time_window': duration * 3600, + 'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat() + } + fields['mute_config'] = mute_config + + await self._state.http.edit_guild_settings(self.id, **fields) + + async def unmute(self) -> None: + """|coro| + + Unmutes the guild. + + .. versionadded:: 1.9 + + Raises + ------- + HTTPException + Unmuting failed. + """ + + await self._state.http.edit_guild_settings(self.id, muted=False) \ No newline at end of file From d6a4f7150b5ec0a9c74a86ea4b7b36c6d2b4aba0 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 18:11:53 -0400 Subject: [PATCH 016/154] Save custom activity state --- discord/activity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/activity.py b/discord/activity.py index 2f8ebf294..124132afe 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -743,7 +743,7 @@ class CustomActivity(BaseActivity): def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): super().__init__(**extra) self.name: Optional[str] = name - state = extra.pop('state', None) + self.state = state = extra.pop('state', None) if self.name == 'Custom Activity': self.name = state From 2e102c96187b5f253265de8c10b128127fb65d96 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 18:12:12 -0400 Subject: [PATCH 017/154] Fix logging, re-add afk parameter --- discord/gateway.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index dd1107a8c..571265b79 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -131,18 +131,18 @@ class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome return if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - log.warning(self.not_responding_msg) + _log.warning(self.not_responding_msg) try: await self.ws.close(4000) except Exception: - log.exception(self.no_stop_msg) + _log.exception(self.no_stop_msg) finally: self.stop() return data = self.get_payload() - log.debug(self.msg) + _log.debug(self.msg) try: # Block until sending is complete total = 0 @@ -155,7 +155,7 @@ class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome stack = ''.join(traceback.format_stack()) msg = f'{self.block_msg}\nLoop traceback (most recent call last):\n{stack}' - log.warning(msg, total) + _log.warning(msg, total) except Exception: self.stop() @@ -182,7 +182,7 @@ class KeepAliveHandler: # Inspired by enhanced-discord.py/Gnome self._last_ack = ack_time self.latency = ack_time - self._last_send if self.latency > 10: - log.warning(self.behind_msg, self.latency) + _log.warning(self.behind_msg, self.latency) class VoiceKeepAliveHandler(KeepAliveHandler): @@ -208,7 +208,7 @@ class VoiceKeepAliveHandler(KeepAliveHandler): self.latency = ack_time - self._last_send self.recent_ack_latencies.append(self.latency) if self.latency > 10: - log.warning(self.behind_msg, self.latency) + _log.warning(self.behind_msg, self.latency) class DiscordWebSocket: @@ -402,7 +402,7 @@ class DiscordWebSocket: await self.call_hooks('before_identify', initial=self._initial_identify) await self.send_as_json(payload) - log.info('Gateway has sent the IDENTIFY payload.') + _log.info('Gateway has sent the IDENTIFY payload.') async def resume(self): """Sends the RESUME packet.""" @@ -604,10 +604,10 @@ class DiscordWebSocket: if not self._can_handle_close(): raise ConnectionClosed(self.socket) from exc - async def change_presence(self, *, activity=None, status=None, since=0.0): + async def change_presence(self, *, activity=None, status=None, since=0.0, afk=False): if activity is not None: if not isinstance(activity, BaseActivity): - raise InvalidArgument('activity must derive from BaseActivity.') + raise InvalidArgument('activity must derive from BaseActivity') activity = [activity.to_dict()] else: activity = [] @@ -619,7 +619,7 @@ class DiscordWebSocket: 'op': self.PRESENCE, 'd': { 'activities': activity, - 'afk': False, + 'afk': afk, 'since': since, 'status': status } From b975ccfa99f348adce4158b77cb7f265ca1a01d4 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 18:12:25 -0400 Subject: [PATCH 018/154] Migrate http.py --- discord/http.py | 1120 +++++++++++++++++++++++------------------------ 1 file changed, 542 insertions(+), 578 deletions(-) diff --git a/discord/http.py b/discord/http.py index 7a4c2adce..307ede63e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -25,8 +25,10 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import asyncio +from base64 import b64encode import json import logging +from random import choice, getrandbits import sys from typing import ( Any, @@ -47,10 +49,12 @@ from urllib.parse import quote as _uriquote import weakref import aiohttp +from types import snowflake +from .context_properties import ContextProperties +from .enums import RelationshipAction from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument -from .gateway import DiscordClientWebSocketResponse -from . import __version__, utils +from . import utils from .utils import MISSING _log = logging.getLogger(__name__) @@ -108,7 +112,7 @@ async def json_or_text(response: aiohttp.ClientResponse) -> Union[Dict[str, Any] class Route: - BASE: ClassVar[str] = 'https://discord.com/api/v8' + BASE: ClassVar[str] = 'https://discord.com/api/v9' def __init__(self, method: str, path: str, **parameters: Any) -> None: self.path: str = path @@ -118,7 +122,7 @@ class Route: url = url.format_map({k: _uriquote(v) if isinstance(v, str) else v for k, v in parameters.items()}) self.url: str = url - # major parameters: + # Major parameters self.channel_id: Optional[Snowflake] = parameters.get('channel_id') self.guild_id: Optional[Snowflake] = parameters.get('guild_id') self.webhook_id: Optional[Snowflake] = parameters.get('webhook_id') @@ -126,7 +130,7 @@ class Route: @property def bucket(self) -> str: - # the bucket is just method + path w/ major parameters + # TODO: Implement buckets :( return f'{self.channel_id}:{self.guild_id}:{self.path}' @@ -156,6 +160,12 @@ class MaybeUnlock: aiohttp.hdrs.WEBSOCKET = 'websocket' # type: ignore +class _FakeResponse: + def __init__(self, reason: str, status: int) -> None: + self.reason = reason + self.status = status + + class HTTPClient: """Represents an HTTP client sending HTTP requests to the Discord API.""" @@ -170,27 +180,62 @@ class HTTPClient: ) -> None: self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() if loop is None else loop self.connector = connector - self.__session: aiohttp.ClientSession = MISSING # filled in static_login + self.__session: aiohttp.ClientSession = MISSING self._locks: weakref.WeakValueDictionary = weakref.WeakValueDictionary() self._global_over: asyncio.Event = asyncio.Event() self._global_over.set() self.token: Optional[str] = None - self.bot_token: bool = False + self.ack_token: Optional[str] = None self.proxy: Optional[str] = proxy self.proxy_auth: Optional[aiohttp.BasicAuth] = proxy_auth self.use_clock: bool = not unsync_clock - user_agent = 'DiscordBot (https://github.com/Rapptz/discord.py {0}) Python/{1[0]}.{1[1]} aiohttp/{2}' - self.user_agent: str = user_agent.format(__version__, sys.version_info, aiohttp.__version__) + self.user_agent: str = MISSING + self.super_properties: Dict[str, Any] = {} + self.encoded_super_properties: str = MISSING + self._started: bool = False + + def __del__(self) -> None: + session = self.__session + if session: + try: + session.connector._close() + except AttributeError: + pass + + async def startup(self) -> None: + if self._started: + return + self.__session = aiohttp.ClientSession(connector=self.connector) + self.user_agent = ua = await utils._get_user_agent(self.__session) + self.client_build_number = bn = await utils._get_build_number(self.__session) + self.browser_version = bv = await utils._get_browser_version(self.__session) + _log.info('Found user agent %s (%s), build number %s.', ua, bv, bn) + self.super_properties = super_properties = { + 'os': 'Windows', + 'browser': 'Chrome', + 'device': '', + 'browser_user_agent': ua, + 'browser_version': bv, + 'os_version': '10', + 'referrer': '', + 'referring_domain': '', + 'referrer_current': '', + 'referring_domain_current': '', + 'release_channel': 'stable', + 'system_locale': 'en-US', + 'client_build_number': bn, + 'client_event_source': None + } + self.encoded_super_properties = b64encode(json.dumps(self.super_properties).encode()).decode('utf-8') + self._started = True - def recreate(self) -> None: - if self.__session.closed: - self.__session = aiohttp.ClientSession( - connector=self.connector, ws_response_class=DiscordClientWebSocketResponse - ) + async def ws_connect(self, url: str, *, compress: int = 0, host: Optional[str] = None) -> Any: + websocket_key = b64encode(bytes(getrandbits(8) for _ in range(16))).decode() # Thank you Discord-S.C.U.M + if not host: + host = url[6:].split('?')[0].rstrip('/') # Removes the 'wss://' and the query params - async def ws_connect(self, url: str, *, compress: int = 0) -> Any: - kwargs = { + kwargs: Dict[str, Any] = { 'proxy_auth': self.proxy_auth, 'proxy': self.proxy, 'max_msg_size': 0, @@ -222,25 +267,46 @@ class HTTPClient: if bucket is not None: self._locks[bucket] = lock - # header creation - headers: Dict[str, str] = { + # Header creation + headers = { + 'Accept': '*/*', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Origin': 'https://discord.com', + 'Pragma': 'no-cache', + 'Referer': 'https://discord.com/channels/@me', + 'Sec-CH-UA': '"Google Chrome";v="{0}", "Chromium";v="{0}", ";Not A Brand";v="99"'.format(self.browser_version.split('.')[0]), + 'Sec-CH-UA-Mobile': '?0', + 'Sec-CH-UA-Platform': '"Windows"', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-origin', 'User-Agent': self.user_agent, + 'X-Debug-Options': 'bugReporterEnabled', + 'X-Super-Properties': self.encoded_super_properties } + # Header modification if self.token is not None: - headers['Authorization'] = 'Bot ' + self.token - # some checking if it's a JSON request + headers['Authorization'] = self.token + + reason = kwargs.pop('reason', None) + if reason: + headers['X-Audit-Log-Reason'] = _uriquote(reason) + if 'json' in kwargs: headers['Content-Type'] = 'application/json' - kwargs['data'] = utils._to_json(kwargs.pop('json')) + kwargs['data'] = utils.to_json(kwargs.pop('json')) - try: - reason = kwargs.pop('reason') - except KeyError: - pass - else: - if reason: - headers['X-Audit-Log-Reason'] = _uriquote(reason, safe='/ ') + if 'context_properties' in kwargs: + context_properties = kwargs.pop('context_properties') + if isinstance(context_properties, ContextProperties): + headers['X-Context-Properties'] = context_properties.value + + if kwargs.pop('super_properties_to_track', False): + headers['X-Track'] = headers.pop('X-Super-Properties') kwargs['headers'] = headers @@ -251,7 +317,6 @@ class HTTPClient: kwargs['proxy_auth'] = self.proxy_auth if not self._global_over.is_set(): - # wait until the global lock is complete await self._global_over.wait() response: Optional[aiohttp.ClientResponse] = None @@ -271,38 +336,36 @@ class HTTPClient: try: async with self.__session.request(method, url, **kwargs) as response: - _log.debug('%s %s with %s has returned %s', method, url, kwargs.get('data'), response.status) - - # even errors have text involved in them so this is safe to call + _log.debug('%s %s with %s has returned %s.', method, url, kwargs.get('data'), response.status) data = await json_or_text(response) - # check if we have rate limit header information + # Check if we have rate limit information remaining = response.headers.get('X-Ratelimit-Remaining') if remaining == '0' and response.status != 429: - # we've depleted our current bucket + # We've depleted our current bucket delta = utils._parse_ratelimit_header(response, use_clock=self.use_clock) _log.debug('A rate limit bucket has been exhausted (bucket: %s, retry: %s).', bucket, delta) maybe_lock.defer() self.loop.call_later(delta, lock.release) - # the request was successful so just return the text/json + # Request was successful so just return the text/json if 300 > response.status >= 200: _log.debug('%s %s has received %s', method, url, data) return data - # we are being rate limited + # Rate limited if response.status == 429: if not response.headers.get('Via') or isinstance(data, str): # Banned by Cloudflare more than likely. raise HTTPException(response, data) - fmt = 'We are being rate limited. Retrying in %.2f seconds. Handled under the bucket "%s"' + fmt = 'We are being rate limited. Retrying in %.2f seconds. Handled under the bucket "%s".' - # sleep a bit + # Sleep a bit retry_after: float = data['retry_after'] _log.warning(fmt, retry_after, bucket) - # check if it's a global rate limit + # Check if it's a global rate limit is_global = data.get('global', False) if is_global: _log.warning('Global rate limit has been hit. Retrying in %.2f seconds.', retry_after) @@ -311,20 +374,19 @@ class HTTPClient: await asyncio.sleep(retry_after) _log.debug('Done sleeping for the rate limit. Retrying...') - # release the global lock now that the - # global rate limit has passed + # Release the global lock now that the rate limit passed if is_global: self._global_over.set() _log.debug('Global rate limit is now over.') continue - # we've received a 500, 502, or 504, unconditional retry + # Unconditional retry if response.status in {500, 502, 504}: await asyncio.sleep(1 + tries * 2) continue - # the usual error cases + # Usual error cases if response.status == 403: raise Forbidden(response, data) elif response.status == 404: @@ -343,7 +405,7 @@ class HTTPClient: raise if response is not None: - # We've run out of retries, raise. + # We've run out of retries, raise if response.status >= 500: raise DiscordServerError(response, data) @@ -362,53 +424,87 @@ class HTTPClient: else: raise HTTPException(resp, 'failed to get asset') - # state management + # State management + + def recreate(self) -> None: + if self.__session and self.__session.closed: + self.__session = aiohttp.ClientSession(connector=self.connector) async def close(self) -> None: if self.__session: await self.__session.close() - # login management + # Login management + + def _token(self, token: str) -> None: + self.token = token + self.ack_token = None + + def get_me(self, with_analytics_token=True) -> user.User: + params = { + 'with_analytics_token': str(with_analytics_token).lower() + } + return self.request(Route('GET', '/users/@me'), params=params) async def static_login(self, token: str) -> user.User: # Necessary to get aiohttp to stop complaining about session creation - self.__session = aiohttp.ClientSession(connector=self.connector, ws_response_class=DiscordClientWebSocketResponse) - old_token = self.token - self.token = token + self.__session = aiohttp.ClientSession(connector=self.connector) + old_token, self.token = self.token, token try: - data = await self.request(Route('GET', '/users/@me')) + data = await self.get_me() except HTTPException as exc: self.token = old_token if exc.status == 401: - raise LoginFailure('Improper token has been passed.') from exc + raise LoginFailure('Improper token has been passed') from exc raise return data - def logout(self) -> Response[None]: - return self.request(Route('POST', '/auth/logout')) + # PM functionality - # Group functionality - - def start_group(self, user_id: Snowflake, recipients: List[int]) -> Response[channel.GroupDMChannel]: + def start_group(self, recipients: SnowflakeList) -> Response[channel.GroupDMChannel]: payload = { 'recipients': recipients, } + props = ContextProperties._from_new_group_dm() # New Group DM button - return self.request(Route('POST', '/users/{user_id}/channels', user_id=user_id), json=payload) + return self.request(Route('POST', '/users/@me/channels'), json=payload, context_properties=props) - def leave_group(self, channel_id) -> Response[None]: + def leave_group(self, channel_id: Snowflake) -> Response[None]: return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id)) - # Message management + def add_group_recipient(self, channel_id: Snowflake, user_id: Snowflake): # TODO: return typings + r = Route('PUT', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def remove_group_recipient(self, channel_id: Snowflake, user_id: Snowflake): # TODO: return typings + r = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) + + def edit_group( + self, channel_id: Snowflake, name: Optional[str] = MISSING, icon: Optional[bytes] = MISSING + ) -> Response[channel.GroupDMChannel]: + payload = {} + if name is not MISSING: + payload['name'] = name + if icon is not MISSING: + payload['icon'] = icon + + return self.request(Route('PATCH', '/channels/{channel_id}', channel_id=channel_id), json=payload) + + def get_private_channels(self) -> Response[List[channel.PrivateChannel]]: + return self.request(Route('GET', '/users/@me/channels')) def start_private_message(self, user_id: Snowflake) -> Response[channel.DMChannel]: payload = { - 'recipient_id': user_id, + 'recipients': [user_id], } + props = ContextProperties._empty() # {} - return self.request(Route('POST', '/users/@me/channels'), json=payload) + return self.request(Route('POST', '/users/@me/channels'), json=payload, context_properties=props) + + # Message management def send_message( self, @@ -418,39 +514,25 @@ class HTTPClient: tts: bool = False, embed: Optional[embed.Embed] = None, embeds: Optional[List[embed.Embed]] = None, - nonce: Optional[str] = None, + nonce: Optional[Union[int, str]] = None, allowed_mentions: Optional[message.AllowedMentions] = None, message_reference: Optional[message.MessageReference] = None, stickers: Optional[List[sticker.StickerItem]] = None, - components: Optional[List[components.Component]] = None, ) -> Response[message.Message]: r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) - payload = {} - + payload = {'tts': tts} if content: payload['content'] = content - - if tts: - payload['tts'] = True - if embed: payload['embeds'] = [embed] - if embeds: payload['embeds'] = embeds - if nonce: payload['nonce'] = nonce - if allowed_mentions: payload['allowed_mentions'] = allowed_mentions - if message_reference: payload['message_reference'] = message_reference - - if components: - payload['components'] = components - if stickers: payload['sticker_ids'] = stickers @@ -472,11 +554,10 @@ class HTTPClient: allowed_mentions: Optional[message.AllowedMentions] = None, message_reference: Optional[message.MessageReference] = None, stickers: Optional[List[sticker.StickerItem]] = None, - components: Optional[List[components.Component]] = None, ) -> Response[message.Message]: form = [] + payload = {'tts': tts} - payload: Dict[str, Any] = {'tts': tts} if content: payload['content'] = content if embed: @@ -489,8 +570,6 @@ class HTTPClient: payload['allowed_mentions'] = allowed_mentions if message_reference: payload['message_reference'] = message_reference - if components: - payload['components'] = components if stickers: payload['sticker_ids'] = stickers @@ -531,11 +610,9 @@ class HTTPClient: allowed_mentions: Optional[message.AllowedMentions] = None, message_reference: Optional[message.MessageReference] = None, stickers: Optional[List[sticker.StickerItem]] = None, - components: Optional[List[components.Component]] = None, ) -> Response[message.Message]: - r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) return self.send_multipart_helper( - r, + Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id), files=files, content=content, tts=tts, @@ -545,24 +622,48 @@ class HTTPClient: allowed_mentions=allowed_mentions, message_reference=message_reference, stickers=stickers, - components=components, ) - def delete_message( - self, channel_id: Snowflake, message_id: Snowflake, *, reason: Optional[str] = None - ) -> Response[None]: - r = Route('DELETE', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) - return self.request(r, reason=reason) + async def ack_message( + self, channel_id: Snowflake, message_id: Snowflake + ): # TODO: response type (simple) + r = Route('POST', '/channels/{channel_id}/messages/{message_id}/ack', channel_id=channel_id, message_id=message_id) + payload = { + 'token': self.ack_token + } + + data = await self.request(r, json=payload) + self.ack_token = data['token'] - def delete_messages( - self, channel_id: Snowflake, message_ids: SnowflakeList, *, reason: Optional[str] = None + def unack_message( + self, channel_id: Snowflake, message_id: Snowflake, *, mention_count: int = 0 ) -> Response[None]: - r = Route('POST', '/channels/{channel_id}/messages/bulk-delete', channel_id=channel_id) + r = Route('POST', '/channels/{channel_id}/messages/{message_id}/ack', channel_id=channel_id, message_id=message_id) payload = { - 'messages': message_ids, + 'manual': True, + 'mention_count': mention_count } - return self.request(r, json=payload, reason=reason) + return self.request(r, json=payload) + + def ack_messages(self, read_states): # TODO: type and implement + payload = { + 'read_states': read_states + } + + return self.request(Route('POST', '/read-states/ack-bulk'), json=payload) + + def ack_guild(self, guild_id: Snowflake) -> Response[None]: + return self.request(Route('POST', '/guilds/{guild_id}/ack', guild_id=guild_id)) + + def unack_something(self, channel_id: Snowflake) -> Response[None]: # TODO: research + return self.request(Route('DELETE', '/channels/{channel_id}/messages/ack', channel_id=channel_id)) + + def delete_message( + self, channel_id: Snowflake, message_id: Snowflake, *, reason: Optional[str] = None + ) -> Response[None]: + r = Route('DELETE', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) + return self.request(r, reason=reason) def edit_message(self, channel_id: Snowflake, message_id: Snowflake, **fields: Any) -> Response[message.Message]: r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) @@ -616,12 +717,12 @@ class HTTPClient: message_id=message_id, emoji=emoji, ) - params: Dict[str, Any] = { 'limit': limit, } if after: params['after'] = after + return self.request(r, params=params) def clear_reactions(self, channel_id: Snowflake, message_id: Snowflake) -> Response[None]: @@ -631,7 +732,6 @@ class HTTPClient: channel_id=channel_id, message_id=message_id, ) - return self.request(r) def clear_single_reaction(self, channel_id: Snowflake, message_id: Snowflake, emoji: str) -> Response[None]: @@ -644,13 +744,19 @@ class HTTPClient: ) return self.request(r) - def get_message(self, channel_id: Snowflake, message_id: Snowflake) -> Response[message.Message]: - r = Route('GET', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id) - return self.request(r) + async def get_message(self, channel_id: Snowflake, message_id: Snowflake) -> Response[message.Message]: + data = await self.logs_from(channel_id, 1, around=message_id) + try: + msg = data[0] + except IndexError: + raise NotFound(_FakeResponse('Not Found', 404), 'message not found') + + if int(msg.get('id')) == message_id: + return msg + raise NotFound(_FakeResponse('Not Found', 404), 'message not found') def get_channel(self, channel_id: Snowflake) -> Response[channel.Channel]: - r = Route('GET', '/channels/{channel_id}', channel_id=channel_id) - return self.request(r) + return self.request(Route('GET', '/channels/{channel_id}', channel_id=channel_id)) def logs_from( self, @@ -663,7 +769,6 @@ class HTTPClient: params: Dict[str, Any] = { 'limit': limit, } - if before is not None: params['before'] = before if after is not None: @@ -674,14 +779,13 @@ class HTTPClient: return self.request(Route('GET', '/channels/{channel_id}/messages', channel_id=channel_id), params=params) def publish_message(self, channel_id: Snowflake, message_id: Snowflake) -> Response[message.Message]: - return self.request( - Route( - 'POST', - '/channels/{channel_id}/messages/{message_id}/crosspost', - channel_id=channel_id, - message_id=message_id, - ) + r = Route( + 'POST', + '/channels/{channel_id}/messages/{message_id}/crosspost', + channel_id=channel_id, + message_id=message_id, ) + return self.request(r) def pin_message(self, channel_id: Snowflake, message_id: Snowflake, reason: Optional[str] = None) -> Response[None]: r = Route( @@ -708,11 +812,7 @@ class HTTPClient: def kick(self, user_id: Snowflake, guild_id: Snowflake, reason: Optional[str] = None) -> Response[None]: r = Route('DELETE', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - if reason: - # thanks aiohttp - r.url = f'{r.url}?reason={_uriquote(reason)}' - - return self.request(r) + return self.request(r, reason=reason) def ban( self, @@ -722,11 +822,11 @@ class HTTPClient: reason: Optional[str] = None, ) -> Response[None]: r = Route('PUT', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) - params = { - 'delete_message_days': delete_message_days, + payload = { + 'delete_message_days': str(delete_message_days), } - return self.request(r, params=params, reason=reason) + return self.request(r, json=payload, reason=reason) def unban(self, user_id: Snowflake, guild_id: Snowflake, *, reason: Optional[str] = None) -> Response[None]: r = Route('DELETE', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) @@ -754,17 +854,26 @@ class HTTPClient: def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]: return self.request(Route('PATCH', '/users/@me'), json=payload) - def change_my_nickname( + def edit_me( self, guild_id: Snowflake, - nickname: str, + nickname: Optional[str] = MISSING, + avatar: Optional[bytes] = MISSING, *, reason: Optional[str] = None, - ) -> Response[member.Nickname]: - r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) - payload = { - 'nick': nickname, - } + ) -> Response[member.MemberWithUser]: + payload = {} + if nickname is not MISSING: + payload['nick'] = nickname + if avatar is not MISSING: + r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) + payload['avatar'] = avatar + else: + r = choice(( + Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id), + Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) + )) + return self.request(r, json=payload, reason=reason) def change_nickname( @@ -774,18 +883,19 @@ class HTTPClient: nickname: str, *, reason: Optional[str] = None, - ) -> Response[member.Member]: + ) -> Response[member.MemberWithUser]: r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) payload = { 'nick': nickname, } + return self.request(r, json=payload, reason=reason) - def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: + def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) return self.request(r, json=payload) - def edit_voice_state(self, guild_id: Snowflake, user_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: + def edit_voice_state(self, guild_id: Snowflake, user_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) return self.request(r, json=payload) @@ -795,7 +905,7 @@ class HTTPClient: user_id: Snowflake, *, reason: Optional[str] = None, - **fields: Any, + **fields: Any, # TODO: Is this cheating ) -> Response[member.MemberWithUser]: r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) return self.request(r, json=fields, reason=reason) @@ -807,10 +917,10 @@ class HTTPClient: channel_id: Snowflake, *, reason: Optional[str] = None, - **options: Any, + **options: Any, # TODO: Is this cheating ) -> Response[channel.Channel]: r = Route('PATCH', '/channels/{channel_id}', channel_id=channel_id) - valid_keys = ( + valid_keys = ( # TODO: Why is this being validated? 'name', 'parent_id', 'topic', @@ -848,12 +958,11 @@ class HTTPClient: channel_type: channel.ChannelType, *, reason: Optional[str] = None, - **options: Any, + **options: Any, # TODO: Is this cheating ) -> Response[channel.GuildChannel]: - payload = { + payload = { # TODO: WTF is happening here?? 'type': channel_type, } - valid_keys = ( 'name', 'parent_id', @@ -873,10 +982,7 @@ class HTTPClient: return self.request(Route('POST', '/guilds/{guild_id}/channels', guild_id=guild_id), json=payload, reason=reason) def delete_channel( - self, - channel_id: Snowflake, - *, - reason: Optional[str] = None, + self, channel_id: Snowflake, *, reason: Optional[str] = None ) -> Response[None]: return self.request(Route('DELETE', '/channels/{channel_id}', channel_id=channel_id), reason=reason) @@ -891,14 +997,16 @@ class HTTPClient: auto_archive_duration: threads.ThreadArchiveDuration, reason: Optional[str] = None, ) -> Response[threads.Thread]: + route = Route( + 'POST', '/channels/{channel_id}/messages/{message_id}/threads', channel_id=channel_id, message_id=message_id + ) payload = { - 'name': name, 'auto_archive_duration': auto_archive_duration, + 'location': choice(('Message', 'Reply Chain Nudge')), + 'name': name, + 'type': 11, } - route = Route( - 'POST', '/channels/{channel_id}/messages/{message_id}/threads', channel_id=channel_id, message_id=message_id - ) return self.request(route, json=payload, reason=reason) def start_thread_without_message( @@ -908,33 +1016,48 @@ class HTTPClient: name: str, auto_archive_duration: threads.ThreadArchiveDuration, type: threads.ThreadType, - invitable: bool = True, + invitable: bool = MISSING, reason: Optional[str] = None, ) -> Response[threads.Thread]: + r = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) payload = { - 'name': name, 'auto_archive_duration': auto_archive_duration, + 'location': None, + 'name': name, 'type': type, - 'invitable': invitable, } + if invitable is not MISSING: + payload['invitable'] = invitable - route = Route('POST', '/channels/{channel_id}/threads', channel_id=channel_id) - return self.request(route, json=payload, reason=reason) + return self.request(r, json=payload, reason=reason) def join_thread(self, channel_id: Snowflake) -> Response[None]: - return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + r = Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id) + params = { + 'location': choice(('Banner', 'Toolbar Overflow', 'Context Menu')) + } - def add_user_to_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: - return self.request( - Route('PUT', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) - ) + return self.request(r, params=params) + + def add_user_to_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: # TODO: Find a way to test private thread stuff + r = Route('PUT', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) + return self.request(r) def leave_thread(self, channel_id: Snowflake) -> Response[None]: - return self.request(Route('DELETE', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + r = Route('DELETE', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id) + params = { + 'location': choice(('Toolbar Overflow', 'Context Menu')) + } + + return self.request(r, params=params) def remove_user_from_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: - route = Route('DELETE', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) - return self.request(route) + r = Route('DELETE', '/channels/{channel_id}/thread-members/{user_id}', channel_id=channel_id, user_id=user_id) + params = { + 'location': 'Context Menu' + } + + return self.request(r, params=params) def get_public_archived_threads( self, channel_id: Snowflake, before: Optional[Snowflake] = None, limit: int = 50 @@ -944,7 +1067,8 @@ class HTTPClient: params = {} if before: params['before'] = before - params['limit'] = limit + if limit and limit != 50: + params['limit'] = limit return self.request(route, params=params) def get_private_archived_threads( @@ -955,7 +1079,8 @@ class HTTPClient: params = {} if before: params['before'] = before - params['limit'] = limit + if limit and limit != 50: + params['limit'] = limit return self.request(route, params=params) def get_joined_private_archived_threads( @@ -965,17 +1090,10 @@ class HTTPClient: params = {} if before: params['before'] = before - params['limit'] = limit + if limit and limit != 50: + params['limit'] = limit return self.request(route, params=params) - def get_active_threads(self, guild_id: Snowflake) -> Response[threads.ThreadPaginationPayload]: - route = Route('GET', '/guilds/{guild_id}/threads/active', guild_id=guild_id) - return self.request(route) - - def get_thread_members(self, channel_id: Snowflake) -> Response[List[threads.ThreadMember]]: - route = Route('GET', '/channels/{channel_id}/thread-members', channel_id=channel_id) - return self.request(route) - # Webhook management def create_webhook( @@ -1010,12 +1128,12 @@ class HTTPClient: webhook_channel_id: Snowflake, reason: Optional[str] = None, ) -> Response[None]: + r = Route('POST', '/channels/{channel_id}/followers', channel_id=channel_id) payload = { 'webhook_channel_id': str(webhook_channel_id), } - return self.request( - Route('POST', '/channels/{channel_id}/followers', channel_id=channel_id), json=payload, reason=reason - ) + + return self.request(r, json=payload, reason=reason) # Guild management @@ -1024,20 +1142,27 @@ class HTTPClient: limit: int, before: Optional[Snowflake] = None, after: Optional[Snowflake] = None, + with_counts: bool = True ) -> Response[List[guild.Guild]]: - params: Dict[str, Any] = { - 'limit': limit, + params = { + 'with_counts': with_counts } - + if limit and limit != 200: + params['limit'] = limit if before: params['before'] = before if after: params['after'] = after - return self.request(Route('GET', '/users/@me/guilds'), params=params) + return self.request(Route('GET', '/users/@me/guilds'), params=params, super_properties_to_track=True) + + def leave_guild(self, guild_id: Snowflake, lurking: bool = False) -> Response[None]: + r = Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id) + payload = { + 'lurking': lurking + } - def leave_guild(self, guild_id: Snowflake) -> Response[None]: - return self.request(Route('DELETE', '/users/@me/guilds/{guild_id}', guild_id=guild_id)) + return self.request(r, json=payload) def get_guild(self, guild_id: Snowflake) -> Response[guild.Guild]: return self.request(Route('GET', '/guilds/{guild_id}', guild_id=guild_id)) @@ -1045,20 +1170,22 @@ class HTTPClient: def delete_guild(self, guild_id: Snowflake) -> Response[None]: return self.request(Route('DELETE', '/guilds/{guild_id}', guild_id=guild_id)) - def create_guild(self, name: str, region: str, icon: Optional[str]) -> Response[guild.Guild]: + def create_guild( + self, name: str, icon: Optional[str] = None, *, template: str = '2TffvPucqHkN' + ) -> Response[guild.Guild]: payload = { 'name': name, - 'region': region, + 'icon': icon, + 'system_channel_id': None, + 'channels': [], + 'guild_template_code': template # API go brrr } - if icon: - payload['icon'] = icon return self.request(Route('POST', '/guilds'), json=payload) def edit_guild(self, guild_id: Snowflake, *, reason: Optional[str] = None, **fields: Any) -> Response[guild.Guild]: - valid_keys = ( + valid_keys = ( # TODO: is this necessary? 'name', - 'region', 'icon', 'afk_timeout', 'owner_id', @@ -1077,11 +1204,13 @@ class HTTPClient: 'public_updates_channel_id', 'preferred_locale', ) - payload = {k: v for k, v in fields.items() if k in valid_keys} return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=payload, reason=reason) + def edit_guild_settings(self, guild_id: Snowflake, **fields): # TODO: type and add more than just muting + return self.request(Route('PATCH', '/users/@me/guilds/{guild_id}/settings', guild_id=guild_id), json=fields) + def get_template(self, code: str) -> Response[template.Template]: return self.request(Route('GET', '/guilds/templates/{code}', code=code)) @@ -1095,25 +1224,24 @@ class HTTPClient: return self.request(Route('PUT', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) def edit_template(self, guild_id: Snowflake, code: str, payload) -> Response[template.Template]: + r = Route('PATCH', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code) valid_keys = ( 'name', 'description', ) payload = {k: v for k, v in payload.items() if k in valid_keys} - return self.request( - Route('PATCH', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code), json=payload - ) + + return self.request(r, json=payload) def delete_template(self, guild_id: Snowflake, code: str) -> Response[None]: return self.request(Route('DELETE', '/guilds/{guild_id}/templates/{code}', guild_id=guild_id, code=code)) - def create_from_template(self, code: str, name: str, region: str, icon: Optional[str]) -> Response[guild.Guild]: + def create_from_template(self, code: str, name: str, icon: Optional[str]) -> Response[guild.Guild]: payload = { 'name': name, - 'region': region, + 'icon': icon, } - if icon: - payload['icon'] = icon + return self.request(Route('POST', '/guilds/templates/{code}', code=code), json=payload) def get_bans(self, guild_id: Snowflake) -> Response[List[guild.Ban]]: @@ -1126,24 +1254,15 @@ class HTTPClient: return self.request(Route('GET', '/guilds/{guild_id}/vanity-url', guild_id=guild_id)) def change_vanity_code(self, guild_id: Snowflake, code: str, *, reason: Optional[str] = None) -> Response[None]: - payload: Dict[str, Any] = {'code': code} + payload = { + 'code': code + } + return self.request(Route('PATCH', '/guilds/{guild_id}/vanity-url', guild_id=guild_id), json=payload, reason=reason) def get_all_guild_channels(self, guild_id: Snowflake) -> Response[List[guild.GuildChannel]]: return self.request(Route('GET', '/guilds/{guild_id}/channels', guild_id=guild_id)) - def get_members( - self, guild_id: Snowflake, limit: int, after: Optional[Snowflake] - ) -> Response[List[member.MemberWithUser]]: - params: Dict[str, Any] = { - 'limit': limit, - } - if after: - params['after'] = after - - r = Route('GET', '/guilds/{guild_id}/members', guild_id=guild_id) - return self.request(r, params=params) - def get_member(self, guild_id: Snowflake, member_id: Snowflake) -> Response[member.MemberWithUser]: return self.request(Route('GET', '/guilds/{guild_id}/members/{member_id}', guild_id=guild_id, member_id=member_id)) @@ -1158,7 +1277,7 @@ class HTTPClient: ) -> Response[guild.GuildPrune]: payload: Dict[str, Any] = { 'days': days, - 'compute_prune_count': 'true' if compute_prune_count else 'false', + 'compute_prune_count': str(compute_prune_count).lower(), } if roles: payload['include_roles'] = ', '.join(roles) @@ -1262,8 +1381,9 @@ class HTTPClient: payload = { 'name': name, 'image': image, - 'roles': roles or [], } + if roles: + payload['roles'] = roles r = Route('POST', '/guilds/{guild_id}/emojis', guild_id=guild_id) return self.request(r, json=payload, reason=reason) @@ -1283,38 +1403,55 @@ class HTTPClient: guild_id: Snowflake, emoji_id: Snowflake, *, - payload: Dict[str, Any], + payload: Dict[str, Any], # TODO: Is this cheating? reason: Optional[str] = None, ) -> Response[emoji.Emoji]: r = Route('PATCH', '/guilds/{guild_id}/emojis/{emoji_id}', guild_id=guild_id, emoji_id=emoji_id) return self.request(r, json=payload, reason=reason) - def get_all_integrations(self, guild_id: Snowflake) -> Response[List[integration.Integration]]: + def get_member_verification( + self, guild_id: Snowflake, *, with_guild: bool = False, invite: str = MISSING + ): # TODO: return type + params = { + 'with_guild': str(with_guild).lower(), + } + if invite is not MISSING: + params['invite_code'] = invite + + return self.request(Route('GET', '/guilds/{guild_id}/member-verification', guild_id=guild_id), params=params) + + def accept_member_verification(self, guild_id: Snowflake, **payload) -> Response[None]: # payload is the same as the above return type + return self.request(Route('PUT', '/guilds/{guild_id}/requests/@me', guild_id=guild_id), json=payload) + + def get_all_integrations( + self, guild_id: Snowflake, include_applications: bool = True + ) -> Response[List[integration.Integration]]: r = Route('GET', '/guilds/{guild_id}/integrations', guild_id=guild_id) + params = { + 'include_applications': str(include_applications).lower(), + } - return self.request(r) + return self.request(r, params=params) def create_integration(self, guild_id: Snowflake, type: integration.IntegrationType, id: int) -> Response[None]: + r = Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id) payload = { 'type': type, 'id': id, } - r = Route('POST', '/guilds/{guild_id}/integrations', guild_id=guild_id) return self.request(r, json=payload) def edit_integration(self, guild_id: Snowflake, integration_id: Snowflake, **payload: Any) -> Response[None]: r = Route( 'PATCH', '/guilds/{guild_id}/integrations/{integration_id}', guild_id=guild_id, integration_id=integration_id ) - return self.request(r, json=payload) def sync_integration(self, guild_id: Snowflake, integration_id: Snowflake) -> Response[None]: r = Route( 'POST', '/guilds/{guild_id}/integrations/{integration_id}/sync', guild_id=guild_id, integration_id=integration_id ) - return self.request(r) def delete_integration( @@ -1323,7 +1460,6 @@ class HTTPClient: r = Route( 'DELETE', '/guilds/{guild_id}/integrations/{integration_id}', guild_id=guild_id, integration_id=integration_id ) - return self.request(r, reason=reason) def get_audit_logs( @@ -1335,7 +1471,10 @@ class HTTPClient: user_id: Optional[Snowflake] = None, action_type: Optional[AuditLogAction] = None, ) -> Response[audit_log.AuditLog]: - params: Dict[str, Any] = {'limit': limit} + r = Route('GET', '/guilds/{guild_id}/audit-logs', guild_id=guild_id) + params = { + 'limit': limit + } if before: params['before'] = before if after: @@ -1345,7 +1484,6 @@ class HTTPClient: if action_type: params['action_type'] = action_type - r = Route('GET', '/guilds/{guild_id}/audit-logs', guild_id=guild_id) return self.request(r, params=params) def get_widget(self, guild_id: Snowflake) -> Response[widget.Widget]: @@ -1356,6 +1494,23 @@ class HTTPClient: # Invite management + def accept_invite( + self, + invite_id: str, + guild_id: Snowflake, + channel_id: Snowflake, + channel_type: int, + message_id: Snowflake = MISSING, + ): # TODO: response type + if message_id is not MISSING: + context_properties = ContextProperties._from_invite_embed(guild_id=guild_id, channel_id=channel_id, channel_type=channel_type, message_id=message_id) # Invite Button Embed + else: + context_properties = choice(( # Join Guild, Accept Invite Page + ContextProperties._from_accept_invite_page(guild_id=guild_id, channel_id=channel_id, channel_type=channel_type), + ContextProperties._from_join_guild_popup(guild_id=guild_id, channel_id=channel_id, channel_type=channel_type) + )) + return self.request(Route('POST', '/invites/{invite_id}', invite_id=invite_id), context_properties=context_properties, json={}) + def create_invite( self, channel_id: Snowflake, @@ -1376,13 +1531,10 @@ class HTTPClient: 'temporary': temporary, 'unique': unique, } - if target_type: payload['target_type'] = target_type - if target_user_id: payload['target_user_id'] = target_user_id - if target_application_id: payload['target_application_id'] = str(target_application_id) @@ -1392,9 +1544,11 @@ class HTTPClient: self, invite_id: str, *, with_counts: bool = True, with_expiration: bool = True ) -> Response[invite.Invite]: params = { - 'with_counts': int(with_counts), - 'with_expiration': int(with_expiration), + 'inputValue': invite_id, + 'with_counts': str(with_counts), + 'with_expiration': str(with_expiration), } + return self.request(Route('GET', '/invites/{invite_id}', invite_id=invite_id), params=params) def invites_from(self, guild_id: Snowflake) -> Response[List[invite.Invite]]: @@ -1503,7 +1657,30 @@ class HTTPClient: ) -> Response[member.MemberWithUser]: return self.edit_member(guild_id=guild_id, user_id=user_id, channel_id=channel_id, reason=reason) + def ring(self, channel_id: Snowflake, *recipients: Snowflake) -> Response[None]: + payload = { + 'recipients': recipients or None + } + + return self.request(Route('POST', '/channels/{channel_id}/call/ring', channel_id=channel_id), json=payload) + + def stop_ringing(self, channel_id: Snowflake, *recipients: Snowflake) -> Response[None]: + r = Route('POST', '/channels/{channel_id}/call/stop-ringing', channel_id=channel_id) + payload = { + 'recipients': recipients + } + + return self.request(r, json=payload) + + def change_call_voice_region(self, channel_id: int, voice_region: str): # TODO: return type + payload = { + 'region': voice_region + } + + return self.request(Route('PATCH', '/channels/{channel_id}/call', channel_id=channel_id), json=payload) + # Stage instance management + # TODO: Check all :( def get_stage_instance(self, channel_id: Snowflake) -> Response[channel.StageInstance]: return self.request(Route('GET', '/stage-instances/{channel_id}', channel_id=channel_id)) @@ -1519,406 +1696,193 @@ class HTTPClient: return self.request(Route('POST', '/stage-instances'), json=payload, reason=reason) def edit_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = None, **payload: Any) -> Response[None]: + r = Route('PATCH', '/stage-instances/{channel_id}', channel_id=channel_id) valid_keys = ( 'topic', 'privacy_level', ) payload = {k: v for k, v in payload.items() if k in valid_keys} - return self.request( - Route('PATCH', '/stage-instances/{channel_id}', channel_id=channel_id), json=payload, reason=reason - ) + return self.request(r, json=payload, reason=reason) def delete_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] = None) -> Response[None]: return self.request(Route('DELETE', '/stage-instances/{channel_id}', channel_id=channel_id), reason=reason) - # Application commands (global) - - def get_global_commands(self, application_id: Snowflake) -> Response[List[interactions.ApplicationCommand]]: - return self.request(Route('GET', '/applications/{application_id}/commands', application_id=application_id)) - - def get_global_command( - self, application_id: Snowflake, command_id: Snowflake - ) -> Response[interactions.ApplicationCommand]: - r = Route( - 'GET', - '/applications/{application_id}/commands/{command_id}', - application_id=application_id, - command_id=command_id, - ) - return self.request(r) - - def upsert_global_command(self, application_id: Snowflake, payload) -> Response[interactions.ApplicationCommand]: - r = Route('POST', '/applications/{application_id}/commands', application_id=application_id) - return self.request(r, json=payload) - - def edit_global_command( - self, - application_id: Snowflake, - command_id: Snowflake, - payload: interactions.EditApplicationCommand, - ) -> Response[interactions.ApplicationCommand]: - valid_keys = ( - 'name', - 'description', - 'options', - ) - payload = {k: v for k, v in payload.items() if k in valid_keys} # type: ignore - r = Route( - 'PATCH', - '/applications/{application_id}/commands/{command_id}', - application_id=application_id, - command_id=command_id, - ) - return self.request(r, json=payload) + # Relationships + + def get_relationships(self): # TODO: return type + return self.request(Route('GET', '/users/@me/relationships')) + + def remove_relationship(self, user_id: Snowflake, *, action: RelationshipAction) -> Response[None]: + r = Route('DELETE', '/users/@me/relationships/{user_id}', user_id=user_id) + if action is RelationshipAction.deny_request: # User Profile, Friends, DM Channel + context_properties = choice(( + ContextProperties._from_friends_page(), ContextProperties._from_user_profile(), + ContextProperties._from_dm_channel() + )) + elif action is RelationshipAction.unfriend: # Friends, ContextMenu, User Profile, DM Channel + context_properties = choice(( + ContextProperties._from_context_menu(), ContextProperties._from_user_profile(), + ContextProperties._from_friends_page(), ContextProperties._from_dm_channel() + )) + elif action == RelationshipAction.unblock: # Friends, ContextMenu, User Profile, DM Channel, NONE + context_properties = choice(( + ContextProperties._from_context_menu(), ContextProperties._from_user_profile(), + ContextProperties._from_friends_page(), ContextProperties._from_dm_channel(), None + )) + elif action == RelationshipAction.remove_pending_request: # Friends + context_properties = ContextProperties._from_friends_page() + + return self.request(r, context_properties=context_properties) + + def add_relationship( + self, user_id: Snowflake, type: int = MISSING, *, action: RelationshipAction + ): # TODO: return type + r = Route('PUT', '/users/@me/relationships/{user_id}', user_id=user_id) + if action is RelationshipAction.accept_request: # User Profile, Friends, DM Channel + context_properties = choice(( + ContextProperties._from_friends_page(), + ContextProperties._from_user_profile(), + ContextProperties._from_dm_channel() + )) + elif action is RelationshipAction.block: # Friends, ContextMenu, User Profile, DM Channel. + context_properties = choice(( + ContextProperties._from_context_menu(), + ContextProperties._from_user_profile(), + ContextProperties._from_friends_page(), + ContextProperties._from_dm_channel() + )) + elif action is RelationshipAction.send_friend_request: # ContextMenu, User Profile, DM Channel + context_properties = choice(( + ContextProperties._from_context_menu(), + ContextProperties._from_user_profile(), + ContextProperties._from_dm_channel() + )) + kwargs = { + 'context_properties': context_properties + } + if type: + kwargs['json'] = {'type': type} - def delete_global_command(self, application_id: Snowflake, command_id: Snowflake) -> Response[None]: - r = Route( - 'DELETE', - '/applications/{application_id}/commands/{command_id}', - application_id=application_id, - command_id=command_id, - ) - return self.request(r) + return self.request(r, **kwargs) - def bulk_upsert_global_commands( - self, application_id: Snowflake, payload - ) -> Response[List[interactions.ApplicationCommand]]: - r = Route('PUT', '/applications/{application_id}/commands', application_id=application_id) - return self.request(r, json=payload) + def send_friend_request(self, username, discriminator): # TODO: return type + r = Route('POST', '/users/@me/relationships') + context_properties = choice(( # Friends, Group DM + ContextProperties._from_add_friend_page, + ContextProperties._from_group_dm + )) + payload = { + 'username': username, + 'discriminator': int(discriminator) + } - # Application commands (guild) + return self.request(r, json=payload, context_properties=context_properties) - def get_guild_commands( - self, application_id: Snowflake, guild_id: Snowflake - ) -> Response[List[interactions.ApplicationCommand]]: - r = Route( - 'GET', - '/applications/{application_id}/guilds/{guild_id}/commands', - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) - - def get_guild_command( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - ) -> Response[interactions.ApplicationCommand]: - r = Route( - 'GET', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', - application_id=application_id, - guild_id=guild_id, - command_id=command_id, - ) - return self.request(r) + def change_friend_nickname(self, user_id, nickname): + payload = { + 'nickname': nickname + } - def upsert_guild_command( - self, - application_id: Snowflake, - guild_id: Snowflake, - payload: interactions.EditApplicationCommand, - ) -> Response[interactions.ApplicationCommand]: - r = Route( - 'POST', - '/applications/{application_id}/guilds/{guild_id}/commands', - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r, json=payload) + return self.request(Route('PATCH', '/users/@me/relationships/{user_id}', user_id=user_id), json=payload) - def edit_guild_command( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - payload: interactions.EditApplicationCommand, - ) -> Response[interactions.ApplicationCommand]: - valid_keys = ( - 'name', - 'description', - 'options', - ) - payload = {k: v for k, v in payload.items() if k in valid_keys} # type: ignore - r = Route( - 'PATCH', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', - application_id=application_id, - guild_id=guild_id, - command_id=command_id, - ) - return self.request(r, json=payload) + # Misc - def delete_guild_command( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - ) -> Response[None]: - r = Route( - 'DELETE', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}', - application_id=application_id, - guild_id=guild_id, - command_id=command_id, - ) - return self.request(r) + async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: + # The gateway URL hasn't changed for over 5 years + # And, the official clients aren't GETting it anymore, sooooo... + self.zlib = zlib + if zlib: + value = 'wss://gateway.discord.gg?encoding={0}&v=9&compress=zlib-stream' + else: + value = 'wss://gateway.discord.gg?encoding={0}&v=9' - def bulk_upsert_guild_commands( - self, - application_id: Snowflake, - guild_id: Snowflake, - payload: List[interactions.EditApplicationCommand], - ) -> Response[List[interactions.ApplicationCommand]]: - r = Route( - 'PUT', - '/applications/{application_id}/guilds/{guild_id}/commands', - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r, json=payload) + return value.format(encoding) - # Interaction responses + def get_user(self, user_id: Snowflake) -> Response[user.User]: + return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) - def _edit_webhook_helper( - self, - route: Route, - file: Optional[File] = None, - content: Optional[str] = None, - embeds: Optional[List[embed.Embed]] = None, - allowed_mentions: Optional[message.AllowedMentions] = None, - ): + def get_user_profile(self, user_id: Snowflake, *, with_mutual_guilds: bool = True): # TODO: return type + params = { + 'with_mutual_guilds': str(with_mutual_guilds).lower() + } - payload: Dict[str, Any] = {} - if content: - payload['content'] = content - if embeds: - payload['embeds'] = embeds - if allowed_mentions: - payload['allowed_mentions'] = allowed_mentions + return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) - form: List[Dict[str, Any]] = [ - { - 'name': 'payload_json', - 'value': utils._to_json(payload), - } - ] + def get_mutual_friends(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) - if file: - form.append( - { - 'name': 'file', - 'value': file.fp, - 'filename': file.filename, - 'content_type': 'application/octet-stream', - } - ) + def get_notes(self): # TODO: return type + return self.request(Route('GET', '/users/@me/notes')) - return self.request(route, form=form, files=[file] if file else None) + def get_note(self, user_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) - def create_interaction_response( - self, - interaction_id: Snowflake, - token: str, - *, - type: InteractionResponseType, - data: Optional[interactions.InteractionApplicationCommandCallbackData] = None, - ) -> Response[None]: - r = Route( - 'POST', - '/interactions/{interaction_id}/{interaction_token}/callback', - interaction_id=interaction_id, - interaction_token=token, - ) - payload: Dict[str, Any] = { - 'type': type, + def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: + payload = { + 'note': note or '' } - if data is not None: - payload['data'] = data + return self.request(Route('PUT', '/users/@me/notes/{user_id}', user_id=user_id), json=payload) - return self.request(r, json=payload) + def change_hypesquad_house(self, house_id: int) -> Response[None]: + payload = { + 'house_id': house_id + } - def get_original_interaction_response( - self, - application_id: Snowflake, - token: str, - ) -> Response[message.Message]: - r = Route( - 'GET', - '/webhooks/{application_id}/{interaction_token}/messages/@original', - application_id=application_id, - interaction_token=token, - ) - return self.request(r) + return self.request(Route('POST', '/hypesquad/online'), json=payload) - def edit_original_interaction_response( - self, - application_id: Snowflake, - token: str, - file: Optional[File] = None, - content: Optional[str] = None, - embeds: Optional[List[embed.Embed]] = None, - allowed_mentions: Optional[message.AllowedMentions] = None, - ) -> Response[message.Message]: - r = Route( - 'PATCH', - '/webhooks/{application_id}/{interaction_token}/messages/@original', - application_id=application_id, - interaction_token=token, - ) - return self._edit_webhook_helper(r, file=file, content=content, embeds=embeds, allowed_mentions=allowed_mentions) + def leave_hypesquad_house(self) -> Response[None]: + return self.request(Route('DELETE', '/hypesquad/online')) - def delete_original_interaction_response(self, application_id: Snowflake, token: str) -> Response[None]: - r = Route( - 'DELETE', - '/webhooks/{application_id}/{interaction_token}/messages/@original', - application_id=application_id, - interaction_token=token, - ) - return self.request(r) + def get_settings(self): # TODO: return type + return self.request(Route('GET', '/users/@me/settings')) - def create_followup_message( - self, - application_id: Snowflake, - token: str, - files: List[File] = [], - content: Optional[str] = None, - tts: bool = False, - embeds: Optional[List[embed.Embed]] = None, - allowed_mentions: Optional[message.AllowedMentions] = None, - ) -> Response[message.Message]: - r = Route( - 'POST', - '/webhooks/{application_id}/{interaction_token}', - application_id=application_id, - interaction_token=token, - ) - return self.send_multipart_helper( - r, - content=content, - files=files, - tts=tts, - embeds=embeds, - allowed_mentions=allowed_mentions, - ) - - def edit_followup_message( - self, - application_id: Snowflake, - token: str, - message_id: Snowflake, - file: Optional[File] = None, - content: Optional[str] = None, - embeds: Optional[List[embed.Embed]] = None, - allowed_mentions: Optional[message.AllowedMentions] = None, - ) -> Response[message.Message]: - r = Route( - 'PATCH', - '/webhooks/{application_id}/{interaction_token}/messages/{message_id}', - application_id=application_id, - interaction_token=token, - message_id=message_id, - ) - return self._edit_webhook_helper(r, file=file, content=content, embeds=embeds, allowed_mentions=allowed_mentions) + def edit_settings(self, **payload): # TODO: return type, is this cheating? + return self.request(Route('PATCH', '/users/@me/settings'), json=payload) - def delete_followup_message(self, application_id: Snowflake, token: str, message_id: Snowflake) -> Response[None]: - r = Route( - 'DELETE', - '/webhooks/{application_id}/{interaction_token}/messages/{message_id}', - application_id=application_id, - interaction_token=token, - message_id=message_id, - ) - return self.request(r) + def get_applications(self, *, with_team_applications: bool = True) -> Response[List[appinfo.AppInfo]]: + params = { + 'with_team_applications': str(with_team_applications).lower() + } - def get_guild_application_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - ) -> Response[List[interactions.GuildApplicationCommandPermissions]]: - r = Route( - 'GET', - '/applications/{application_id}/guilds/{guild_id}/commands/permissions', - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r) + return self.request(Route('GET', '/applications'), params=params, super_properties_to_track=True) - def get_application_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - ) -> Response[interactions.GuildApplicationCommandPermissions]: - r = Route( - 'GET', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', - application_id=application_id, - guild_id=guild_id, - command_id=command_id, - ) - return self.request(r) + def get_my_application(self, app_id: Snowflake) -> Response[appinfo.AppInfo]: + return self.request(Route('GET', '/applications/{app_id}', app_id=app_id), super_properties_to_track=True) - def edit_application_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - command_id: Snowflake, - payload: interactions.BaseGuildApplicationCommandPermissions, - ) -> Response[None]: - r = Route( - 'PUT', - '/applications/{application_id}/guilds/{guild_id}/commands/{command_id}/permissions', - application_id=application_id, - guild_id=guild_id, - command_id=command_id, - ) - return self.request(r, json=payload) + def get_app_entitlements(self, app_id: Snowflake): # TODO: return type + r = Route('GET', '/users/@me/applications/{app_id}/entitlements', app_id=app_id) + return self.request(r, super_properties_to_track=True) - def bulk_edit_guild_application_command_permissions( - self, - application_id: Snowflake, - guild_id: Snowflake, - payload: List[interactions.PartialGuildApplicationCommandPermissions], - ) -> Response[None]: - r = Route( - 'PUT', - '/applications/{application_id}/guilds/{guild_id}/commands/permissions', - application_id=application_id, - guild_id=guild_id, - ) - return self.request(r, json=payload) + def get_app_skus( + self, app_id: Snowflake, *, localize: bool = False, with_bundled_skus: bool = True + ): # TODO: return type + r = Route('GET', '/applications/{app_id}/skus', app_id=app_id) + params = { + 'localize': str(localize).lower(), + 'with_bundled_skus': str(with_bundled_skus).lower() + } - # Misc + return self.request(r, params=params, super_properties_to_track=True) - def application_info(self) -> Response[appinfo.AppInfo]: - return self.request(Route('GET', '/oauth2/applications/@me')) + def get_app_whitelist(self, app_id): + return self.request(Route('GET', '/oauth2/applications/{app_id}/allowlist', app_id=app_id), super_properties_to_track=True) - async def get_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> str: - try: - data = await self.request(Route('GET', '/gateway')) - except HTTPException as exc: - raise GatewayNotFound() from exc - if zlib: - value = '{0}?encoding={1}&v=9&compress=zlib-stream' - else: - value = '{0}?encoding={1}&v=9' - return value.format(data['url'], encoding) + def get_teams(self): # TODO: return type + return self.request(Route('GET', '/teams'), super_properties_to_track=True) - async def get_bot_gateway(self, *, encoding: str = 'json', zlib: bool = True) -> Tuple[int, str]: - try: - data = await self.request(Route('GET', '/gateway/bot')) - except HTTPException as exc: - raise GatewayNotFound() from exc + def get_team(self, team_id: Snowflake): # TODO: return type + return self.request(Route('GET', '/teams/{team_id}', team_id=team_id), super_properties_to_track=True) - if zlib: - value = '{0}?encoding={1}&v=9&compress=zlib-stream' - else: - value = '{0}?encoding={1}&v=9' - return data['shards'], value.format(data['url'], encoding) + def mobile_report( + self, guild_id: Snowflake, channel_id: Snowflake, message_id: Snowflake, reason: str + ): # TODO: return type + payload = { + 'guild_id': guild_id, + 'channel_id': channel_id, + 'message_id': message_id, + 'reason': reason + } - def get_user(self, user_id: Snowflake) -> Response[user.User]: - return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) + return self.request(Route('POST', '/report'), json=payload) \ No newline at end of file From 78ccafc35fe03f5abecb01ab479a263ec5e18b37 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 19:11:37 -0400 Subject: [PATCH 019/154] Migrate iterators.py --- discord/iterators.py | 66 +++----------------------------------------- 1 file changed, 4 insertions(+), 62 deletions(-) diff --git a/discord/iterators.py b/discord/iterators.py index f725d527e..83eef3348 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -38,7 +38,6 @@ __all__ = ( 'HistoryIterator', 'AuditLogIterator', 'GuildIterator', - 'MemberIterator', ) if TYPE_CHECKING: @@ -504,9 +503,7 @@ class GuildIterator(_AsyncIterator['Guild']): 100 guilds, update the ``before`` parameter to the oldest guild received. Guilds will be returned in order by time. If `after` is specified, it returns the ``limit`` oldest guilds after ``after``, - sorted with newest first. For filling over 100 guilds, update the ``after`` - parameter to the newest guild received, If guilds are not reversed, they - will be out of order (99-0, 199-100, so on) + sorted with newest first. Not that if both ``before`` and ``after`` are specified, ``before`` is ignored by the guilds endpoint. @@ -524,7 +521,6 @@ class GuildIterator(_AsyncIterator['Guild']): """ def __init__(self, bot, limit, before=None, after=None): - if isinstance(before, datetime.datetime): before = Object(id=time_snowflake(before, high=False)) if isinstance(after, datetime.datetime): @@ -560,8 +556,8 @@ class GuildIterator(_AsyncIterator['Guild']): def _get_retrieve(self): l = self.limit - if l is None or l > 100: - r = 100 + if l is None or l > 200: + r = 200 else: r = l self.retrieve = r @@ -575,7 +571,7 @@ class GuildIterator(_AsyncIterator['Guild']): async def fill_guilds(self): if self._get_retrieve(): data = await self._retrieve_guilds(self.retrieve) - if self.limit is None or len(data) < 100: + if self.limit is None or len(data) < 200: self.limit = 0 if self._filter: @@ -609,60 +605,6 @@ class GuildIterator(_AsyncIterator['Guild']): return data -class MemberIterator(_AsyncIterator['Member']): - def __init__(self, guild, limit=1000, after=None): - - if isinstance(after, datetime.datetime): - after = Object(id=time_snowflake(after, high=True)) - - self.guild = guild - self.limit = limit - self.after = after or OLDEST_OBJECT - - self.state = self.guild._state - self.get_members = self.state.http.get_members - self.members = asyncio.Queue() - - async def next(self) -> Member: - if self.members.empty(): - await self.fill_members() - - try: - return self.members.get_nowait() - except asyncio.QueueEmpty: - raise NoMoreItems() - - def _get_retrieve(self): - l = self.limit - if l is None or l > 1000: - r = 1000 - else: - r = l - self.retrieve = r - return r > 0 - - async def fill_members(self): - if self._get_retrieve(): - after = self.after.id if self.after else None - data = await self.get_members(self.guild.id, self.retrieve, after) - if not data: - # no data, terminate - return - - if len(data) < 1000: - self.limit = 0 # terminate loop - - self.after = Object(id=int(data[-1]['user']['id'])) - - for element in reversed(data): - await self.members.put(self.create_member(element)) - - def create_member(self, data): - from .member import Member - - return Member(data=data, guild=self.guild, state=self.state) - - class ArchivedThreadIterator(_AsyncIterator['Thread']): def __init__( self, From 1ddf526af10cc80384e407b500e7e93159e73d6e Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 19:11:47 -0400 Subject: [PATCH 020/154] Migrate invite.py --- discord/invite.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index d02fa6808..206d299d8 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -347,6 +347,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_id: Optional[int] = data.get('message_id') expires_at = data.get('expires_at', None) self.expires_at: Optional[datetime.datetime] = parse_time(expires_at) if expires_at else None @@ -367,7 +368,9 @@ class Invite(Hashable): ) @classmethod - def from_incomplete(cls: Type[I], *, state: ConnectionState, data: InvitePayload) -> I: + def from_incomplete( + cls: Type[I], *, state: ConnectionState, data: InvitePayload, message_id: Optional[int] = None + ) -> I: guild: Optional[Union[Guild, PartialInviteGuild]] try: guild_data = data['guild'] @@ -378,16 +381,17 @@ class Invite(Hashable): 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 + if message_id is not None: + data['message_id'] = message_id + return cls(state=state, data=data, guild=guild, channel=channel) @classmethod @@ -453,6 +457,33 @@ class Invite(Hashable): """:class:`str`: A property that retrieves the invite URL.""" return self.BASE + '/' + self.code + async def use(self) -> Guild: + """|coro| + + Uses the invite (joins the guild) + + .. versionadded:: 1.9 + + Raises + ------ + :exc:`.HTTPException` + Joining the guild failed. + + Returns + ------- + :class:`.Guild` + The guild joined. This is not the same guild that is + added to cache. + """ + + state = self._state + data = await state.http.join_guild(self.code, self.guild.id, self.channel.id, self.channel.type.value, self._message_id) + + Guild = state.Guild # Circular import + return Guild(data=data['guild'], state=state) + + accept = use + async def delete(self, *, reason: Optional[str] = None): """|coro| From 6f32b18f2bc644044ff0bccfc6e3bf40a4420eb3 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 19:12:11 -0400 Subject: [PATCH 021/154] Fix broken import --- discord/guild.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/guild.py b/discord/guild.py index 558142699..5af2da176 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import copy +from datetime import datetime, timedelta import unicodedata from typing import ( Any, From 6ee2fa6d855113335308901f4a3b9103021769ad Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 6 Nov 2021 19:16:07 -0400 Subject: [PATCH 022/154] Solve circular import issue in a smarter way :) --- discord/invite.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/invite.py b/discord/invite.py index 206d299d8..25b530378 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -478,9 +478,7 @@ class Invite(Hashable): state = self._state data = await state.http.join_guild(self.code, self.guild.id, self.channel.id, self.channel.type.value, self._message_id) - - Guild = state.Guild # Circular import - return Guild(data=data['guild'], state=state) + return state.Guild(data=data['guild'], state=state) # Circular import accept = use From 2c2564c4ee4fb9675b0bb8267ca3f0499b2a81cb Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 17:21:51 -0500 Subject: [PATCH 023/154] Add missing import --- discord/abc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/abc.py b/discord/abc.py index 806406685..9b83aeb43 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -79,7 +79,7 @@ if TYPE_CHECKING: from .channel import CategoryChannel from .embeds import Embed from .message import Message, MessageReference, PartialMessage - from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable, VocalGuildChannel + from .channel import DMChannel, GroupChannel, PartialMessageable, PrivateChannel, TextChannel, VocalGuildChannel from .threads import Thread from .enums import InviteTarget from .ui.view import View From 3e4ad5d651bd74c234805eb184ba8d9e09258beb Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 17:35:49 -0500 Subject: [PATCH 024/154] Migrate user.py, refactor a few things --- discord/client.py | 83 +++++- discord/user.py | 732 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 782 insertions(+), 33 deletions(-) diff --git a/discord/client.py b/discord/client.py index fcb48566c..e0c28dac1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -39,7 +39,7 @@ from .template import Template from .widget import Widget from .guild import Guild from .emoji import Emoji -from .channel import _threaded_channel_factory, PartialMessageable +from .channel import _private_channel_factory, _threaded_channel_factory, GroupChannel, PartialMessageable from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * @@ -55,7 +55,6 @@ from .backoff import ExponentialBackoff from .webhook import Webhook from .iterators import GuildIterator from .appinfo import AppInfo -from .ui.view import View from .stage_instance import StageInstance from .threads import Thread from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory @@ -323,7 +322,6 @@ class Client: def _schedule_event(self, coro: Callable[..., Coroutine[Any, Any, Any]], event_name: str, *args: Any, **kwargs: Any) -> asyncio.Task: wrapped = self._run_event(coro, event_name, *args, **kwargs) - # Schedules the task return asyncio.create_task(wrapped, name=f'discord.py: {event_name}') def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: @@ -402,7 +400,6 @@ class Client: initial: :class:`bool` Whether this IDENTIFY is the first initial IDENTIFY. """ - pass # Login state management @@ -1607,15 +1604,28 @@ class Client: """ data = await self.http.get_sticker(sticker_id) cls, _ = _sticker_factory(data['type']) # type: ignore - return cls(state=self._connection, data=data) # type: ignore + return cls(state=self._connection, data=data) # type: ignore - async def fetch_premium_sticker_packs(self) -> List[StickerPack]: + async def fetch_sticker_packs( + self, *, country='US', locale='en-US', *, payment_source_id: int = MISSING + ) -> List[StickerPack]: """|coro| - Retrieves all available premium sticker packs. + Retrieves all available default sticker packs. .. versionadded:: 2.0 + Parameters + ----------- + country: :class:`str` + ISO 3166 country code to fetch the sticker packs for. + Defaults to ``US``. + locale: :class:`str` + ISO 639 language code the name and description should be in. + Defaults to ``en-US``. + payment_source_id: :class:`int` + Unknown. + Raises ------- :exc:`.HTTPException` @@ -1626,7 +1636,7 @@ class Client: List[:class:`.StickerPack`] All available premium sticker packs. """ - data = await self.http.list_premium_sticker_packs() + data = await self.http.list_premium_sticker_packs(country, locale, payment_source_id) return [StickerPack(state=self._connection, data=pack) for pack in data['sticker_packs']] async def fetch_notes(self) -> List[Note]: @@ -1645,7 +1655,7 @@ class Client: All your notes. """ state = self._connection - data = await self.http.get_notes() + data = await state.http.get_notes() return [Note(state, int(id), note=note) for id, note in data.items()] async def fetch_note(self, user_id: int) -> Note: @@ -1668,11 +1678,28 @@ class Client: :class:`Note` The note you requested. """ - try: - data = await self.http.get_note(user_id) - except NotFound: - data = {'note': 0} - return Note(self._connection, int(user_id), note=data['note']) + note = Note(self._connection, int(user_id)) + await note.fetch() + return note + + async def fetch_private_channels(self) -> List[PrivateChannel]: + """|coro + + Retrieves all your private channels. + + Raises + ------- + :exc:`.HTTPException` + Retreiving your private channels failed. + + Returns + -------- + List[:class:`PrivateChannel`] + All your private channels. + """ + channels = await self._state.http.get_private_channels() + state = self._connection + return [_private_channel_factory(data['type'])(me=self.user, data=data, state=state) for data in channels] async def create_dm(self, user: Snowflake) -> DMChannel: """|coro| @@ -1701,3 +1728,31 @@ class Client: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) + + async def create_group(self, *recipients) -> GroupChannel: + r"""|coro| + + Creates a group direct message with the recipients + provided. These recipients must be have a relationship + of type :attr:`RelationshipType.friend`. + + Parameters + ----------- + \*recipients: :class:`User` + An argument :class:`list` of :class:`User` to have in + your group. + + Raises + ------- + HTTPException + Failed to create the group direct message. + + Returns + ------- + :class:`GroupChannel` + The new group channel. + """ + users = [str(u.id) for u in recipients] + state = self._connection + data = await state.http.start_group(users) + return GroupChannel(me=self.user, data=data, state=state) diff --git a/discord/user.py b/discord/user.py index 57c032ac5..b1db6d003 100644 --- a/discord/user.py +++ b/discord/user.py @@ -24,20 +24,28 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING +from copy import copy +from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, Union import discord.abc +from types.snowflake import Snowflake from .asset import Asset from .colour import Colour -from .enums import DefaultAvatar +from .enums import DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags +from .errors import ClientException, NotFound from .flags import PublicUserFlags -from .utils import snowflake_time, _bytes_to_base64_data, MISSING +from .object import Object +from .relationship import Relationship +from .settings import Settings +from .utils import _bytes_to_base64_data, cached_slot_property, parse_time, snowflake_time, MISSING if TYPE_CHECKING: from datetime import datetime + from .call import Call from .channel import DMChannel from .guild import Guild + from .member import VoiceState from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload @@ -52,6 +60,251 @@ __all__ = ( BU = TypeVar('BU', bound='BaseUser') +class Note: + """Represents a Discord note.""" + __slots__ = ('_state', '_note', '_user_id', '_user') + + def __init__( + self, state: ConnectionState, user_id: int, *, user: BaseUser = MISSING, note: Optional[str] = MISSING + ) -> None: + self._state = state + self._user_id = user_id + self._note = note + if user is not MISSING: + self._user: Union[User, Object] = 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) -> Union[User, Object]: + """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'' + else: + base += '>' + return base + + def __len__(self) -> int: + try: + return len(self._note) + except TypeError: + return 0 + + def __eq__(self, other) -> bool: + try: + return isinstance(other, Note) and self._note == other._note + except TypeError: + return False + + def __ne__(self, other) -> bool: + return not self.__eq__(other) + + def __bool__(self) -> bool: + try: + return bool(self._note) + except TypeError: + return False + + +class Profile: + """Represents a Discord profile. + + Attributes + ---------- + flags: :class:`int` + The user's flags. Will be its own class (like public_flags) in the future. + bio: Optional[:class:`str`] + The user's "about me" field. Could be ``None``. + user: :class:`User` + The user the profile represents (with banner/accent_colour). + premium_since: Optional[:class:`datetime.datetime`] + A datetime object denoting how long a user has been premium (had Nitro). + Could be ``None``. + connected_accounts: Optional[List[:class:`dict`]] + The connected accounts that show up on the profile. + These are currently just the raw json, but this will change in the future. + note: :class:`Note` + Represents the note on the profile. + """ + + __slots__ = ( + '_state', + 'user', + 'flags', + 'bio', + 'premium_since', + 'connected_accounts', + 'note', + 'mutual_guilds', + 'mutual_friends', + ) + + def __init__(self, state: ConnectionState, data) -> None: # Type data + self._state = state + + user = data['user'] + self.flags: int = user.pop('flags', 0) # TODO: figure them all out and parse them + self.bio: Optional[str] = user.pop('bio') or None + self.user: User = User(data=user, state=state) + + self.premium_since: datetime = parse_time(data['premium_since']) + self.connected_accounts: List[dict] = data['connected_accounts'] # TODO: parse these + + self.note: Note = Note(state, self.user.id, user=self.user) + + if 'mutual_guilds' in data: + self.mutual_guilds: List[Guild] = self._parse_mutual_guilds(data['mutual_guilds']) + if 'mutual_friends' in data: # TODO: maybe make Relationships + self.mutual_friends: List[User] = self._parse_mutual_friends(data['mutual_friends']) + + def __str__(self) -> str: + return '{0.name}#{0.discriminator}'.format(self.user) + + def __repr__(self) -> str: + return ''.format(self) + + def _parse_mutual_guilds(self, mutual_guilds) -> List[Guild]: + state = self._state + + def get_guild(guild) -> Optional[Guild]: + return state._get_guild(int(guild['id'])) + + return list(filter(None, map(get_guild, mutual_guilds))) + + def _parse_mutual_friends(self, mutual_friends) -> List[User]: + state = self._state + return [state.store_user(friend) for friend in mutual_friends] + + @property + def nitro(self) -> bool: + return self.premium_since is not None + + premium = nitro + + def _has_flag(self, o) -> bool: + v = o.value + return (self.flags & v) == v + + @property + def staff(self) -> bool: + return self._has_flag(UserFlags.staff) + + @property + def partner(self) -> bool: + return self._has_flag(UserFlags.partner) + + @property + def bug_hunter(self) -> bool: + return self._has_flag(UserFlags.bug_hunter) + + @property + def early_supporter(self) -> bool: + return self._has_flag(UserFlags.early_supporter) + + @property + def hypesquad(self) -> bool: + return self._has_flag(UserFlags.hypesquad) + + @property + def hypesquad_house(self) -> HypeSquadHouse: + return self.hypesquad_houses[0] + + @property + def hypesquad_houses(self) -> List[HypeSquadHouse]: + flags = (UserFlags.hypesquad_bravery, UserFlags.hypesquad_brilliance, UserFlags.hypesquad_balance) + return [house for house, flag in zip(HypeSquadHouse, flags) if self._has_flag(flag)] + + @property + def team_user(self) -> bool: + return self._has_flag(UserFlags.team_user) + + @property + def system(self) -> bool: + return self._has_flag(UserFlags.system) + + class _UserTag: __slots__ = () id: int @@ -139,8 +392,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.""" @@ -305,7 +564,11 @@ 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` @@ -315,38 +578,112 @@ class ClientUser(BaseUser): verified: :class:`bool` Specifies if the user's email is verified. + 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:`str`] 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. """ __slots__ = ('locale', '_flags', 'verified', 'mfa_enabled', '__weakref__') if TYPE_CHECKING: verified: bool + email: Optional[str] + phone: Optional[int] locale: Optional[str] - mfa_enabled: bool _flags: int + mfa_enabled: bool + premium: bool + premium_type: Optional[PremiumType] + bio: Optional[str] 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'' + 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.email = data.get('email') + self.phone = data.get('phone') self.locale = data.get('locale') 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 + + def get_relationship(self, user_id: int) -> Relationship: + """Retrieves the :class:`Relationship` if applicable. - async def edit(self, *, username: str = MISSING, avatar: bytes = MISSING) -> ClientUser: + 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[Settings]: + """Optional[:class:`Settings`]: 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. @@ -358,43 +695,265 @@ 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. 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. - avatar: :class:`bytes` + 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. - InvalidArgument - Wrong image format passed for ``avatar``. + ClientException + 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 ClientException('Password is required') + args['password'] = password if avatar is not MISSING: - payload['avatar'] = _bytes_to_base64_data(avatar) + if avatar is not None: + 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 ClientException('`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 ClientException('`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 ClientException('`house` parameter was not a HypeSquadHouse') + else: + 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) -> Settings: + """|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:`Settings` + The current settings for your account. + """ + data = await self._state.http.get_settings() + return Settings(data=data, state=self._state) + + async def edit_settings(self, **kwargs) -> Settings: # TODO: I really wish I didn't have to do this... + """|coro| + + Edits the client user's settings. + + 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:`str` + 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:`.Settings` + The client user's updated settings. + """ + 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 + + theme = kwargs.pop('theme', None) + if theme: + payload['theme'] = theme.value + + payload.update(kwargs) + + state = self._state + data = await state.http.edit_settings(**payload) + state.settings = settings = Settings(data=data, state=self._state) + return settings + -class User(BaseUser, discord.abc.Messageable): +class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): """Represents a Discord user. .. container:: operations @@ -422,7 +981,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` @@ -451,6 +1010,12 @@ class User(BaseUser, discord.abc.Messageable): self._stored = False return self + def _get_voice_client_key(self) -> Union[int, str]: + return self._state.user.id, 'self_id' + + def _get_voice_state_pair(self) -> Union[int, int]: + return self._state.user.id, self.dm_channel.id + async def _get_channel(self) -> DMChannel: ch = await self.create_dm() return ch @@ -464,6 +1029,15 @@ class User(BaseUser, discord.abc.Messageable): """ return self._state._get_private_channel_by_user(self.id) + @property + def call(self) -> Optional[Call]: + return getattr(self.dm_channel, 'call', None) + + @property + def relationship(self): + """Optional[:class:`Relationship`]: Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise.""" + return self._state.user.get_relationship(self.id) + @property def mutual_guilds(self) -> List[Guild]: """List[:class:`Guild`]: The guilds that the user shares with the client. @@ -476,6 +1050,15 @@ class User(BaseUser, discord.abc.Messageable): """ return [guild for guild in self._state._guilds.values() if guild.get_member(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| @@ -496,3 +1079,114 @@ 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 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) -> bool: + """|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 + ) -> Profile: + """|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:`Profile` + The profile of the user. + """ + user_id = self.id + state = self._state + data = await state.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) + + if with_mutuals: + data['mutual_friends'] = await self.http.get_mutual_friends(user_id) + + profile = Profile(state, data) + + if fetch_note: + await profile.note.fetch() + + return profile From 1ad6c07054f7e9815c8fd0ad2eaf5ef3d852c0d0 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 17:36:21 -0500 Subject: [PATCH 025/154] Add _private_channel_factory --- discord/channel.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 1effb9623..b9d87165c 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1884,7 +1884,7 @@ class GroupChannel(discord.abc.Messageable, discord.abc.Connectable, Hashable): def _get_voice_client_key(self) -> Tuple[int, str]: return self.me.id, 'self_id' - def _get_voice_state_pair(self) Tuple[int, int]: + def _get_voice_state_pair(self) -> Tuple[int, int]: return self.me.id, self.id async def _get_channel(self): @@ -2168,14 +2168,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): From 6645f87d333df25592f27a56732512eca524fb43 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 17:37:07 -0500 Subject: [PATCH 026/154] Remove bot-only stuff from message.py --- discord/message.py | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/discord/message.py b/discord/message.py index 304c807da..debf808e8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -937,7 +937,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 @@ -1003,7 +1003,7 @@ 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): return self.content if self.type is MessageType.recipient_add: @@ -1072,10 +1072,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: @@ -1093,14 +1093,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: @@ -1156,7 +1153,6 @@ class Message(Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., ) -> Message: ... @@ -1170,7 +1166,6 @@ class Message(Hashable): suppress: bool = ..., delete_after: Optional[float] = ..., allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., ) -> Message: ... @@ -1183,7 +1178,6 @@ class Message(Hashable): suppress: bool = MISSING, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[View] = MISSING, ) -> Message: """|coro| @@ -1228,9 +1222,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 ------- @@ -1279,19 +1270,9 @@ class Message(Hashable): if attachments is not MISSING: payload['attachments'] = [a.to_dict() for a in attachments] - if view is not MISSING: - self._state.prevent_view_updates_for(self.id) - if view: - payload['components'] = view.to_components() - else: - payload['components'] = [] - data = await self._state.http.edit_message(self.channel.id, self.id, **payload) 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) @@ -1534,10 +1515,11 @@ class Message(Hashable): self.id, name=name, auto_archive_duration=auto_archive_duration or default_auto_archive_duration, + location='Message', ) return Thread(guild=self.guild, state=self._state, data=data) - async def reply(self, content: Optional[str] = None, **kwargs) -> Message: + 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 From 856663066529d3d0753851b8db6e510ab623ed02 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 17:38:06 -0500 Subject: [PATCH 027/154] Migrate member --- discord/http.py | 65 +++++++++++++++++----------------- discord/member.py | 88 +++++++++++++++++++++++++++++++---------------- 2 files changed, 89 insertions(+), 64 deletions(-) diff --git a/discord/http.py b/discord/http.py index 307ede63e..5a47fd0fc 100644 --- a/discord/http.py +++ b/discord/http.py @@ -854,17 +854,25 @@ class HTTPClient: def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]: return self.request(Route('PATCH', '/users/@me'), json=payload) + def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload + r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) + return self.request(r, json=payload) + + def edit_voice_state(self, guild_id: Snowflake, user_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload + r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) + return self.request(r, json=payload) + def edit_me( self, guild_id: Snowflake, - nickname: Optional[str] = MISSING, - avatar: Optional[bytes] = MISSING, *, + nick: Optional[str] = MISSING, + avatar: Optional[bytes] = MISSING, reason: Optional[str] = None, ) -> Response[member.MemberWithUser]: payload = {} - if nickname is not MISSING: - payload['nick'] = nickname + if nick is not MISSING: + payload['nick'] = nick if avatar is not MISSING: r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) payload['avatar'] = avatar @@ -876,29 +884,6 @@ class HTTPClient: return self.request(r, json=payload, reason=reason) - def change_nickname( - self, - guild_id: Snowflake, - user_id: Snowflake, - nickname: str, - *, - reason: Optional[str] = None, - ) -> Response[member.MemberWithUser]: - r = Route('PATCH', '/guilds/{guild_id}/members/{user_id}', guild_id=guild_id, user_id=user_id) - payload = { - 'nick': nickname, - } - - return self.request(r, json=payload, reason=reason) - - def edit_my_voice_state(self, guild_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload - r = Route('PATCH', '/guilds/{guild_id}/voice-states/@me', guild_id=guild_id) - return self.request(r, json=payload) - - def edit_voice_state(self, guild_id: Snowflake, user_id: Snowflake, payload: Dict[str, Any]) -> Response[None]: # TODO: remove payload - r = Route('PATCH', '/guilds/{guild_id}/voice-states/{user_id}', guild_id=guild_id, user_id=user_id) - return self.request(r, json=payload) - def edit_member( self, guild_id: Snowflake, @@ -995,6 +980,7 @@ class HTTPClient: *, name: str, auto_archive_duration: threads.ThreadArchiveDuration, + location: str = MISSING, reason: Optional[str] = None, ) -> Response[threads.Thread]: route = Route( @@ -1002,10 +988,13 @@ class HTTPClient: ) payload = { 'auto_archive_duration': auto_archive_duration, - 'location': choice(('Message', 'Reply Chain Nudge')), 'name': name, 'type': 11, } + if location is MISSING: + payload['location'] = choice(('Message', 'Reply Chain Nudge')) + else: + payload['location'] = location return self.request(route, json=payload, reason=reason) @@ -1056,7 +1045,7 @@ class HTTPClient: params = { 'location': 'Context Menu' } - + return self.request(r, params=params) def get_public_archived_threads( @@ -1301,16 +1290,24 @@ class HTTPClient: def get_sticker(self, sticker_id: Snowflake) -> Response[sticker.Sticker]: return self.request(Route('GET', '/stickers/{sticker_id}', sticker_id=sticker_id)) - def list_premium_sticker_packs(self) -> Response[sticker.ListPremiumStickerPacks]: - return self.request(Route('GET', '/sticker-packs')) + def list_premium_sticker_packs( + self, country: str = 'US', locale: str = 'en-US', payment_source_id: int = MISSING + ) -> Response[sticker.ListPremiumStickerPacks]: + params = { + 'country_code': country, + 'locale': locale, + } + if payment_source_id is not MISSING: + params['payment_source_id'] = payment_source_id + + return self.request(Route('GET', '/sticker-packs'), params=params) def get_all_guild_stickers(self, guild_id: Snowflake) -> Response[List[sticker.GuildSticker]]: return self.request(Route('GET', '/guilds/{guild_id}/stickers', guild_id=guild_id)) def get_guild_sticker(self, guild_id: Snowflake, sticker_id: Snowflake) -> Response[sticker.GuildSticker]: - return self.request( - Route('GET', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) - ) + r = Route('GET', '/guilds/{guild_id}/stickers/{sticker_id}', guild_id=guild_id, sticker_id=sticker_id) + return self.request(r) def create_guild_sticker( self, guild_id: Snowflake, payload: sticker.CreateGuildSticker, file: File, reason: str diff --git a/discord/member.py b/discord/member.py index a8c868d87..7c666c71d 100644 --- a/discord/member.py +++ b/discord/member.py @@ -39,7 +39,7 @@ 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 RelationshipAction, Status, try_enum from .colour import Colour from .object import Object @@ -60,7 +60,7 @@ if TYPE_CHECKING: UserWithMember as UserWithMemberPayload, ) from .types.user import User as UserPayload - from .abc import Snowflake + from .abc import Connectable, Snowflake from .state import ConnectionState from .message import Message from .role import Role @@ -76,8 +76,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` @@ -107,7 +111,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. """ @@ -140,7 +144,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[Connectable] = channel def __repr__(self) -> str: attrs = [ @@ -157,26 +161,26 @@ class VoiceState: 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 @@ -201,7 +205,7 @@ M = TypeVar('M', bound='Member') @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`. @@ -359,10 +363,6 @@ 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: MemberPayload) -> None: # the nickname change is optional, # if it isn't in the payload then it didn't change @@ -389,7 +389,7 @@ class Member(discord.abc.Messageable, _UserTag): if len(user) > 1: return self._update_inner_user(user) - return None + return def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: u = self._user @@ -448,11 +448,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 @@ -644,6 +642,7 @@ class Member(discord.abc.Messageable, _UserTag): roles: List[discord.abc.Snowflake] = MISSING, voice_channel: Optional[VocalGuildChannel] = MISSING, reason: Optional[str] = None, + avatar: Optional[bytes] = MISSING, ) -> Optional[Member]: """|coro| @@ -667,6 +666,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. @@ -691,6 +697,9 @@ class Member(discord.abc.Messageable, _UserTag): voice_channel: Optional[:class:`VoiceChannel`] The voice channel to move the member to. Pass ``None`` to kick them from voice. + avatar: Optional[:class:`bytes`] + The member's new guild avatar. Pass ``None`` to remove the avatar. + You can only change your own guild avatar. reason: Optional[:class:`str`] The reason for editing this member. Shows up on the audit log. @@ -713,11 +722,14 @@ class Member(discord.abc.Messageable, _UserTag): payload: Dict[str, Any] = {} 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 me and payload: + data = await http.edit_me(**payload) + payload = {} if deafen is not MISSING: payload['deaf'] = deafen @@ -749,6 +761,8 @@ class Member(discord.abc.Messageable, _UserTag): if payload: data = await http.edit_member(guild_id, self.id, reason=reason, **payload) + + if data: return Member(data=data, guild=self.guild, state=self._state) async def request_to_speak(self) -> None: @@ -906,3 +920,17 @@ class Member(discord.abc.Messageable, _UserTag): The role or ``None`` if not found in the member's roles. """ return self.guild.get_role(role_id) if self._roles.has(role_id) else None + + 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) From 9b2b32d2c32118f59e531fe93c5ccaf3c76e8412 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 22:31:01 -0500 Subject: [PATCH 028/154] Migrate voice redesign --- discord/gateway.py | 46 +++-- discord/recorder.py | 49 +++++ discord/voice_client.py | 432 ++++++++++++++++++++++++++++++---------- 3 files changed, 408 insertions(+), 119 deletions(-) create mode 100644 discord/recorder.py diff --git a/discord/gateway.py b/discord/gateway.py index 571265b79..6a49b9e0a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -39,6 +39,7 @@ from . import utils from .activity import BaseActivity from .enums import SpeakingState from .errors import ConnectionClosed, InvalidArgument +from .recorder import SSRC _log = logging.getLogger(__name__) @@ -557,10 +558,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 @@ -712,7 +713,7 @@ class DiscordVoiceWebSocket: SESSION_DESCRIPTION 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 @@ -724,7 +725,7 @@ class DiscordVoiceWebSocket: 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. """ IDENTIFY = 0 @@ -753,7 +754,7 @@ class DiscordVoiceWebSocket: pass async def send_as_json(self, data): - _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 @@ -788,7 +789,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 @@ -839,7 +840,7 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) async def received_message(self, msg): - _log.debug('Voice websocket frame received: %s', msg) + _log.debug('Voice gateway event: %s.', msg) op = msg['op'] data = msg.get('d') @@ -849,6 +850,7 @@ class DiscordVoiceWebSocket: self._keep_alive.ack() elif op == self.RESUMED: _log.info('Voice RESUME succeeded.') + self.secret_key = self._connection.secret_key elif op == self.SESSION_DESCRIPTION: self._connection.mode = data['mode'] await self.load_secret_key(data) @@ -856,6 +858,18 @@ class DiscordVoiceWebSocket: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) self._keep_alive.start() + elif op == self.SPEAKING: + state = self._connection + user_id = int(data['user_id']) + speaking = data['speaking'] + ssrc = state._flip_ssrc(user_id) + if ssrc is None: + state._set_ssrc(user_id, SSRC(data['ssrc'], speaking)) + else: + ssrc.speaking = speaking + + #item = state.guild or state._state + #item._update_speaking_status(user_id, speaking) await self._hook(self, msg) @@ -871,23 +885,23 @@ 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.ip = recv[ip_start:ip_end].decode('ascii') state.port = struct.unpack_from('>H', recv, len(recv) - 2)[0] - _log.debug('detected ip: %s port: %s', state.ip, state.port) + _log.debug('Detected ip: %s, port: %s.', state.ip, state.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.ip, state.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): @@ -905,7 +919,7 @@ class DiscordVoiceWebSocket: return sum(heartbeat.recent_ack_latencies) / len(heartbeat.recent_ack_latencies) async def load_secret_key(self, data): - _log.info('received secret key for voice connection') + _log.info('Received secret key for voice connection.') self.secret_key = self._connection.secret_key = data.get('secret_key') await self.speak() await self.speak(False) @@ -916,10 +930,10 @@ 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) + _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) + _log.debug('Voice received %s.', msg) raise ConnectionClosed(self.ws, code=self._close_code) async def close(self, code=1000): diff --git a/discord/recorder.py b/discord/recorder.py new file mode 100644 index 000000000..087c16fac --- /dev/null +++ b/discord/recorder.py @@ -0,0 +1,49 @@ +""" +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. +""" + +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] diff --git a/discord/voice_client.py b/discord/voice_client.py index d382a74d7..c866c310f 100644 --- a/discord/voice_client.py +++ b/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 +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Tuple from . import opus, utils from .backoff import ExponentialBackoff @@ -66,7 +67,7 @@ if TYPE_CHECKING: VoiceServerUpdate as VoiceServerUpdatePayload, SupportedModes, ) - + has_nacl: bool @@ -81,11 +82,9 @@ __all__ = ( 'VoiceClient', ) - - - _log = logging.getLogger(__name__) + class VoiceProtocol: """A class that represents the Discord voice protocol. @@ -195,6 +194,178 @@ class VoiceProtocol: key_id, _ = self.channel._get_voice_client_key() 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) + + @property + def playing(self) -> bool: + return self.is_playing() + + def is_playing(self) -> bool: + """Indicates if we're currently playing audio.""" + return self._player is not None and self._player.is_playing() + + @property + def paused(self) -> bool: + return self.is_paused() + + 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 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 = None + + +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 + + @property + def listening(self) -> bool: + return self.is_playing() + + def is_listening(self) -> bool: + """Indicates if we're currently listening.""" + return self._listener is not None and self._listener.is_listening() + + @property + def paused(self) -> bool: + return self.is_paused() + + 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. @@ -226,7 +397,6 @@ class VoiceClient(VoiceProtocol): secret_key: List[int] ssrc: int - def __init__(self, client: Client, channel: abc.Connectable): if not has_nacl: raise RuntimeError("PyNaCl library needed in order to use voice") @@ -237,7 +407,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 @@ -249,12 +419,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, ...] = ( @@ -263,6 +435,16 @@ class VoiceClient(VoiceProtocol): 'xsalsa20_poly1305', ) + @property + def ssrc(self) -> int: + """:class:`str`: Our ssrc.""" + return self.idrcs.get(self.user.id) + + @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.""" @@ -273,14 +455,7 @@ class VoiceClient(VoiceProtocol): """:class:`ClientUser`: The user connected to voice (i.e. ourselves).""" return self._state.user - 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'] @@ -295,7 +470,10 @@ class VoiceClient(VoiceProtocol): await self.disconnect() else: guild = self.guild - self.channel = channel_id and guild and guild.get_channel(int(channel_id)) # type: ignore + if guild is not None: + self.channel = channel_id and guild.get_channel(int(channel_id)) # type: ignore + else: + self.channel = channel_id and self._state._get_private_channel(int(channel_id)) # type: ignore else: self._voice_state_complete.set() @@ -305,7 +483,9 @@ class VoiceClient(VoiceProtocol): return self.token = data.get('token') - self.server_id = int(data['guild_id']) + 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: @@ -315,44 +495,48 @@ 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) + if self.guild: + await self.guild.change_voice_state(channel=self.channel) + else: + await self._state.client.change_voice_state(channel=self.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).', self.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() @@ -388,11 +572,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: @@ -420,6 +605,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. @@ -448,16 +647,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...') @@ -474,12 +678,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: @@ -490,7 +693,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: @@ -511,20 +714,52 @@ class VoiceClient(VoiceProtocol): Parameters ----------- channel: :class:`abc.Snowflake` - The channel to move to. Must be a voice channel. + The channel to move to. Must be a :class:`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) + + @property + def connected(self) -> bool: + return self.is_connected() def is_connected(self) -> bool: """Indicates if the voice client is connected to voice.""" return self._connected.is_set() - # audio related + # Audio related + + 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): + 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) @@ -541,21 +776,43 @@ 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 _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`. @@ -584,45 +841,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]: @@ -630,19 +852,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 + + @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, *, encode: bool = True) -> None: + def send_audio_packet(self, data: bytes) -> None: """Sends an audio packet composed of the data. You must be connected to play audio. @@ -650,9 +882,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 ------- @@ -661,16 +891,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) From 22dcc8e35280086cdff4f51f7ca23e27ac1dadc8 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 22:31:25 -0500 Subject: [PATCH 029/154] Minor docstring consistency nitpick --- discord/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index e0c28dac1..3699213ee 100644 --- a/discord/client.py +++ b/discord/client.py @@ -1744,12 +1744,12 @@ class Client: Raises ------- - HTTPException + :exc:`.HTTPException` Failed to create the group direct message. Returns ------- - :class:`GroupChannel` + :class:`.GroupChannel` The new group channel. """ users = [str(u.id) for u in recipients] From 0489ad0f46c6cd99fad7006c4cf5158b80d4a484 Mon Sep 17 00:00:00 2001 From: dolfies Date: Mon, 8 Nov 2021 22:31:40 -0500 Subject: [PATCH 030/154] Migrate utils.py --- discord/utils.py | 245 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 241 insertions(+), 4 deletions(-) diff --git a/discord/utils.py b/discord/utils.py index 4360b77a1..88f51a404 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -56,11 +56,18 @@ import functools from inspect import isawaitable as _isawaitable, signature as _signature from operator import attrgetter import json +import logging +import os +import platform import re +import subprocess import sys +import tempfile +from threading import Timer import types import warnings +from .enums import BrowserEnum from .errors import InvalidArgument try: @@ -88,6 +95,8 @@ __all__ = ( DISCORD_EPOCH = 1420070400000 +_log = logging.getLogger(__name__) + class _MissingSentinel: def __eq__(self, other): @@ -119,6 +128,7 @@ class _cached_property: if TYPE_CHECKING: + from aiohttp import ClientSession from functools import cached_property as cached_property from typing_extensions import ParamSpec @@ -273,13 +283,13 @@ 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. 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. @@ -288,7 +298,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', 'application.commands')``. .. versionadded:: 1.7 disable_guild_select: :class:`bool` @@ -302,7 +312,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', 'application.commands')) if permissions is not MISSING: url += f'&permissions={permissions.value}' if guild is not MISSING: @@ -1017,3 +1027,230 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) if style is None: return f'' return f'' + + +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: Optional[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_client_version(session): + try: + request = await session.get('https://discord.com/api/downloads/distributions/app/installers/latest?arch=x86&channel=stable&platform=win', headers={'Accept-Encoding': 'gzip, deflate'}, timeout=7) + url = request.headers['location'] + return url.split('/')[-2] + except (asyncio.TimeoutError, RuntimeError): + _log.warning('Could not fetch client version.') + return '1.0.9003' + + +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', headers={'Accept-Encoding': 'gzip, deflate'}, 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, headers={'Accept-Encoding': 'gzip, deflate'}, timeout=7) + build_file = await build_request.text() + build_index = build_file.find('buildNumber') + 14 + return int(build_file[build_index:build_index + 6]) + except asyncio.TimeoutError: + _log.warning('Could not fetch client build number.') + return 103016 + + +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.warning('Could not fetch user-agent.') + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 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.warning('Could not fetch browser version.') + return '91.0.4472.77' From f41490c8210c3b2c1720fab44d7b8d54630145fd Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:14:15 -0500 Subject: [PATCH 031/154] Rename context_properties.py to tracking.py --- discord/{context_properties.py => tracking.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename discord/{context_properties.py => tracking.py} (100%) diff --git a/discord/context_properties.py b/discord/tracking.py similarity index 100% rename from discord/context_properties.py rename to discord/tracking.py From 8b16792a35bfcc799df95e8705f47ac3d2a9d17b Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:14:32 -0500 Subject: [PATCH 032/154] Migrate enums.py --- discord/enums.py | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index af8ee2b0a..59d69f87a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -55,6 +55,18 @@ __all__ = ( 'InteractionType', 'InteractionResponseType', 'NSFWLevel', + 'RelationshipType', + 'HypeSquadHouse', + 'PremiumType', + 'UserContentFilter', + 'FriendFlags', + 'Theme', + 'StickerAnimationOptions', + 'RelationshipAction', + 'UnavailableGuildType', + 'RequiredActionType', + 'ReportType', + 'BrowserEnum', ) @@ -276,6 +288,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' @@ -295,6 +361,7 @@ class DefaultAvatar(Enum): green = 2 orange = 3 red = 4 + pink = 5 def __str__(self): return self.name @@ -477,6 +544,17 @@ class ActivityType(Enum): return self.value +class HypeSquadHouse(Enum): + bravery = 1 + brilliance = 2 + balance = 3 + + +class PremiumType(Enum): + nitro_classic = 1 + nitro = 2 + + class TeamMembershipState(Enum): invited = 1 accepted = 2 @@ -518,6 +596,48 @@ 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' + 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 From 45d2cd891c3214c3490302c6c8fff3302d9add70 Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:15:52 -0500 Subject: [PATCH 033/154] Migrate settings.py --- discord/settings.py | 201 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 discord/settings.py diff --git a/discord/settings.py b/discord/settings.py new file mode 100644 index 000000000..c9abdee4c --- /dev/null +++ b/discord/settings.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- + +""" +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 typing import Any, Dict, List, Optional, TYPE_CHECKING + +from .enums import FriendFlags, StickerAnimationOptions, Theme, UserContentFilter, try_enum +from .guild_folder import GuildFolder + +if TYPE_CHECKING: + from .guild import Guild + from .state import ConnectionState + + +class Settings: + """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. + locale: :class:`str` + 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. + 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 + animate_stickers: StickerAnimationOptions + 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 + explicit_content_filter: UserContentFilter + friend_source_flags: FriendFlags + gif_auto_play: bool + guild_positions: List[Guild] + inline_attachment_media: bool + inline_embed_media: bool + locale: str + message_display_compact: bool + native_phone_integration_enabled: bool + render_embeds: bool + render_reactions: bool + restricted_guilds: List[Guild] + show_current_game: bool + stream_notifications_enabled: bool + theme: Theme + 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 '' + + 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', + 'inline_attachment_media', + 'inline_embed_media', + 'locale', + '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) + + @property + def animate_stickers(self) -> StickerAnimationOptions: + """Whether or not to animate stickers in the chat.""" + return try_enum(StickerAnimationOptions, self._animate_stickers) + + @property + def explicit_content_filter(self) -> UserContentFilter: + """The filter for explicit content in all messages.""" + return try_enum(UserContentFilter, self._explicit_content_filter) + + @property + def friend_source_flags(self) -> FriendFlags: + """Who can add you as a friend.""" + return FriendFlags._from_dict(self._friend_source_flags) + + @property + def guild_folders(self) -> List[GuildFolder]: + """A list of guild folders.""" + state = self._state + return [GuildFolder(data=folder, state=state) for folder in self._guild_folders] + + @property + def guild_positions(self) -> List[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, self._guild_positions))) + + @property + def restricted_guilds(self) -> List[Guild]: + """A list of guilds that you will not receive DMs from.""" + return list(filter(None, map(self._get_guild, self._restricted_guilds))) + + @property + def theme(self) -> Theme: + """The theme of the Discord UI.""" + return try_enum(Theme, self._theme) + + def _get_guild(self, id: int) -> Optional[Guild]: + return self._state._get_guild(int(id)) From 0d76225d9503dd2f8a19628240f1963ec1c7b2ce Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:16:00 -0500 Subject: [PATCH 034/154] Migrate guild_folder.py --- discord/guild_folder.py | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 discord/guild_folder.py diff --git a/discord/guild_folder.py b/discord/guild_folder.py new file mode 100644 index 000000000..2031295b0 --- /dev/null +++ b/discord/guild_folder.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +""" +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 typing import List, Optional, TYPE_CHECKING + +from .colour import Colour + +if TYPE_CHECKING: + from .guild import Guild + from .state import ConnectionState + from .types.snowflake import Snowflake + + +class GuildFolder: + __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']))) + + def _get_guild(self, id): + return self._state._get_guild(int(id)) + + @property + def colour(self) -> Optional[Colour]: + colour = self._colour + return colour and Colour(colour) + + @property + def color(self) -> Optional[Colour]: + return self.colour + + def __str__(self) -> str: + return self.name or 'None' + + def __repr__(self) -> str: + return f'' + + 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) From fe0087291391be2dc8815f09ff22388d18993794 Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:16:08 -0500 Subject: [PATCH 035/154] Migrate calls.py --- discord/calls.py | 315 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 discord/calls.py diff --git a/discord/calls.py b/discord/calls.py new file mode 100644 index 000000000..4ff671601 --- /dev/null +++ b/discord/calls.py @@ -0,0 +1,315 @@ +""" +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. +""" + +import datetime +from typing import List, Optional, TYPE_CHECKING + +from . import utils +from .enums import VoiceRegion, try_enum +from .errors import ClientException +from .utils import MISSING + +if TYPE_CHECKING: + from .abc import PrivateChannel + from .channel import DMChannel, GroupChannel + 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 + from .voice_client import VoiceProtocol + + +def _running_only(func): + def decorator(self, *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 + + @property + def channel(self) -> PrivateChannel: + r""":class:`PrivateChannel`\: The private channel associated with this message.""" + return self.message.channel + + @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:`User`] + A list of users that are currently being rung to join the call. + region: :class:`VoiceRegion` + The region the call is being hosted at. + """ + + if TYPE_CHECKING: + channel: DMChannel + ringing: List[User] + region: VoiceRegion + + def __init__( + self, + state: ConnectionState, + *, + message_id: Snowflake, + channel_id: Snowflake, + message: 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) + + self._update(**kwargs) + + def _deleteup(self) -> None: + self.ringing = [] + self._ended = True + + def _update( + self, *, ringing: SnowflakeList = {}, region: VoiceRegion = MISSING + ) -> None: + if region is not MISSING: + self.region = try_enum(VoiceRegion, 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 + + @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.voice_state_for(self.channel.me).channel.id == self._channel_id + + @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} + ret = [u for u in recipients if self.voice_state_for(u).channel.id == self._channel_id] + + return ret + + @property + def voice_states(self) -> List[VoiceState]: + """Mapping[:class:`int`, :class:`VoiceState`]: Returns a mapping of user IDs who have voice states in this call.""" + return set(self._voice_states) + + async def fetch_message(self) -> Optional[Message]: + message = await self.channel.fetch_message(self._message_id) + if message is not None and self.message is None: + self.message = message + return message + + @_running_only + async def change_region(self, region) -> None: + """|coro| + + Changes the channel's voice region. + + Parameters + ----------- + region: :class:`VoiceRegion` + A :class:`VoiceRegion` to change the voice region to. + + 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: + channel = self.channel + await self._state.http.ring(channel.id, channel.recipient.id) + + @_running_only + async def stop_ringing(self) -> None: + channel = self.channel + await self._state.http.stop_ringing(channel.id, channel.recipient.id) + + @_running_only + async def join(self, **kwargs) -> VoiceProtocol: + return await self.channel._connect(**kwargs) + + connect = join + + @_running_only + async def leave(self, **kwargs) -> None: + state = self._state + if not (client := state._get_voice_client(self.channel.me.id)): + return + + return await client.disconnect(**kwargs) + + disconnect = leave + + 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:`User`] + A list of users that are currently being rung to join the call. + region: :class:`VoiceRegion` + The region the group call is being hosted in. + """ + + if TYPE_CHECKING: + channel: GroupChannel + + def _update( + self, *, ringing: SnowflakeList = [], region: VoiceRegion = MISSING + ) -> None: + if region is not MISSING: + self.region = try_enum(VoiceRegion, region) + lookup = {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[User]: + """List[:class:`User`]: Returns all users that are currently in this call.""" + ret = [u for u in self.channel.recipients if self.voice_state_for(u).channel.id == self._channel_id] + me = self.channel.me + if self.voice_state_for(me).channel.id == self._channel_id: + 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}) From e1e0bca5f8e9ba300a32cd29b51ff268e7b95db7 Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:16:18 -0500 Subject: [PATCH 036/154] Migrate relationship.py --- discord/relationship.py | 111 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 discord/relationship.py diff --git a/discord/relationship.py b/discord/relationship.py new file mode 100644 index 000000000..65c53ec7b --- /dev/null +++ b/discord/relationship.py @@ -0,0 +1,111 @@ +""" +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 typing import Optional, TYPE_CHECKING + +from .enums import RelationshipAction, RelationshipType, try_enum + +if TYPE_CHECKING: + from .state import ConnectionState + from .user import User + + +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'' + + 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 From 1a0453835ed0a584c0b95ae7629d8ce0db725c5c Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 19:16:46 -0500 Subject: [PATCH 037/154] Minor changes --- discord/http.py | 7 ++----- discord/sticker.py | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/discord/http.py b/discord/http.py index 5a47fd0fc..aba1c328e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -51,20 +51,17 @@ import weakref import aiohttp from types import snowflake -from .context_properties import ContextProperties from .enums import RelationshipAction from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument from . import utils +from .tracking import ContextProperties from .utils import MISSING _log = logging.getLogger(__name__) if TYPE_CHECKING: from .file import File - from .enums import ( - AuditLogAction, - InteractionResponseType, - ) + from .enums import AuditLogAction from .types import ( appinfo, diff --git a/discord/sticker.py b/discord/sticker.py index b0b5c678d..711c2fb38 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -99,7 +99,7 @@ class StickerPack(Hashable): 'stickers', 'name', 'sku_id', - 'cover_sticker_id', + '_cover_sticker_id', 'cover_sticker', 'description', '_banner', @@ -115,8 +115,8 @@ class StickerPack(Hashable): self.stickers: List[StandardSticker] = [StandardSticker(state=self._state, data=sticker) for sticker in stickers] self.name: str = data['name'] self.sku_id: int = int(data['sku_id']) - self.cover_sticker_id: int = int(data['cover_sticker_id']) - self.cover_sticker: StandardSticker = get(self.stickers, id=self.cover_sticker_id) # type: ignore + self._cover_sticker_id: int = int(data['cover_sticker_id']) + self.cover_sticker: StandardSticker = get(self.stickers, id=self._cover_sticker_id) # type: ignore self.description: str = data['description'] self._banner: int = int(data['banner_asset_id']) @@ -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() From 225ca0135a456630616aa40c34b0fe6d7d13ef5d Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 21:33:18 -0500 Subject: [PATCH 038/154] Misc. fixes --- discord/calls.py | 5 ++++- discord/guild_folder.py | 6 ++++-- discord/settings.py | 8 +++++--- discord/tracking.py | 4 +--- discord/user.py | 22 +++++++++++----------- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/discord/calls.py b/discord/calls.py index 4ff671601..6c3254e23 100644 --- a/discord/calls.py +++ b/discord/calls.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ import datetime -from typing import List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING, Union from . import utils from .enums import VoiceRegion, try_enum @@ -313,3 +313,6 @@ class GroupCall(PrivateCall): @_running_only async def stop_ringing(self, *recipients) -> None: await self._state.http.stop_ringing(self._channel_id, *{r.id for r in recipients}) + + +Call = Union[PrivateCall, GroupCall] diff --git a/discord/guild_folder.py b/discord/guild_folder.py index 2031295b0..860b64b71 100644 --- a/discord/guild_folder.py +++ b/discord/guild_folder.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ The MIT License (MIT) @@ -24,6 +22,8 @@ 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 @@ -33,6 +33,8 @@ if TYPE_CHECKING: from .state import ConnectionState from .types.snowflake import Snowflake +__all__ = ('GuildFolder',) + class GuildFolder: __slots__ = ('_state', 'id', 'name', '_colour', 'guilds') diff --git a/discord/settings.py b/discord/settings.py index c9abdee4c..359a53eb1 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ The MIT License (MIT) @@ -24,6 +22,8 @@ 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, Dict, List, Optional, TYPE_CHECKING from .enums import FriendFlags, StickerAnimationOptions, Theme, UserContentFilter, try_enum @@ -33,8 +33,10 @@ if TYPE_CHECKING: from .guild import Guild from .state import ConnectionState +__all__ = ('UserSettings',) + -class Settings: +class UserSettings: """Represents the Discord client settings. Attributes diff --git a/discord/tracking.py b/discord/tracking.py index b60e33c52..2e0297093 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -31,9 +31,7 @@ from typing import Any, Dict, Optional from .types.snowflake import Snowflake -__all__ = ( - 'ContextProperties', -) +__all__ = ('ContextProperties',) class ContextProperties: # Thank you Discord-S.C.U.M diff --git a/discord/user.py b/discord/user.py index b1db6d003..0c9db2f9e 100644 --- a/discord/user.py +++ b/discord/user.py @@ -36,13 +36,13 @@ from .errors import ClientException, NotFound from .flags import PublicUserFlags from .object import Object from .relationship import Relationship -from .settings import Settings +from .settings import UserSettings from .utils import _bytes_to_base64_data, cached_slot_property, parse_time, snowflake_time, MISSING if TYPE_CHECKING: from datetime import datetime - from .call import Call + from .calls import PrivateCall from .channel import DMChannel from .guild import Guild from .member import VoiceState @@ -664,8 +664,8 @@ class ClientUser(BaseUser): return [r.user for r in self._state._relationships.values() if r.type is RelationshipType.blocked] @property - def settings(self) -> Optional[Settings]: - """Optional[:class:`Settings`]: Returns the user's settings.""" + def settings(self) -> Optional[UserSettings]: + """Optional[:class:`UserSettings`]: Returns the user's settings.""" return self._state.settings async def edit( @@ -810,7 +810,7 @@ class ClientUser(BaseUser): return ClientUser(state=self._state, data=data) - async def fetch_settings(self) -> Settings: + async def fetch_settings(self) -> UserSettings: """|coro| Retrieves your settings. @@ -826,13 +826,13 @@ class ClientUser(BaseUser): Returns -------- - :class:`Settings` + :class:`UserSettings` The current settings for your account. """ data = await self._state.http.get_settings() - return Settings(data=data, state=self._state) + return UserSettings(data=data, state=self._state) - async def edit_settings(self, **kwargs) -> Settings: # TODO: I really wish I didn't have to do this... + async def edit_settings(self, **kwargs) -> UserSettings: # TODO: I really wish I didn't have to do this... """|coro| Edits the client user's settings. @@ -910,7 +910,7 @@ class ClientUser(BaseUser): Returns ------- - :class:`.Settings` + :class:`.UserSettings` The client user's updated settings. """ payload = {} @@ -949,7 +949,7 @@ class ClientUser(BaseUser): state = self._state data = await state.http.edit_settings(**payload) - state.settings = settings = Settings(data=data, state=self._state) + state.settings = settings = UserSettings(data=data, state=self._state) return settings @@ -1030,7 +1030,7 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): return self._state._get_private_channel_by_user(self.id) @property - def call(self) -> Optional[Call]: + def call(self) -> Optional[PrivateCall]: return getattr(self.dm_channel, 'call', None) @property From c6e6c22a954acf8e171ddc3b57e7525183adfe5e Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 9 Nov 2021 21:33:27 -0500 Subject: [PATCH 039/154] Migrate state.py --- discord/state.py | 619 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 436 insertions(+), 183 deletions(-) diff --git a/discord/state.py b/discord/state.py index 89198213f..8839aa30a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -25,15 +25,15 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import asyncio -from collections import deque, OrderedDict +from collections import deque import copy import datetime -import itertools import logging from typing import Dict, Optional, TYPE_CHECKING, Union, Callable, Any, List, TypeVar, Coroutine, Sequence, Tuple, Deque import inspect - +import time import os +import random from .guild import Guild from .activity import BaseActivity @@ -46,18 +46,19 @@ from .channel import * from .channel import _channel_factory from .raw_models import * from .member import Member +from .relationship import Relationship from .role import Role -from .enums import ChannelType, try_enum, Status +from .enums import ChannelType, RequiredActionType, Status, try_enum, UnavailableGuildType, VoiceRegion from . import utils -from .flags import MemberCacheFlags +from .flags import GuildSubscriptionOptions, MemberCacheFlags from .object import Object from .invite import Invite from .integrations import _integration_factory -from .interactions import Interaction -from .ui.view import ViewStore, View from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker +from .settings import UserSettings + if TYPE_CHECKING: from .abc import PrivateChannel @@ -67,6 +68,8 @@ if TYPE_CHECKING: from .voice_client import VoiceProtocol from .client import Client from .gateway import DiscordWebSocket + from .calls import Call + from .member import VoiceState from .types.activity import Activity as ActivityPayload from .types.channel import DMChannel as DMChannelPayload @@ -75,6 +78,7 @@ if TYPE_CHECKING: from .types.sticker import GuildSticker as GuildStickerPayload from .types.guild import Guild as GuildPayload from .types.message import Message as MessagePayload + from .types.voice import GuildVoiceState T = TypeVar('T') CS = TypeVar('CS', bound='ConnectionState') @@ -136,7 +140,7 @@ async def logging_coroutine(coroutine: Coroutine[Any, Any, T], *, info: str) -> try: await coroutine except Exception: - _log.exception('Exception occurred during %s', info) + _log.exception('Exception occurred during %s.', info) class ConnectionState: @@ -153,10 +157,12 @@ class ConnectionState: hooks: Dict[str, Callable], http: HTTPClient, loop: asyncio.AbstractEventLoop, + client: Client, **options: Any, ) -> None: self.loop: asyncio.AbstractEventLoop = loop self.http: HTTPClient = http + self.client = client self.max_messages: Optional[int] = options.get('max_messages', 1000) if self.max_messages is not None and self.max_messages <= 0: self.max_messages = 1000 @@ -166,9 +172,6 @@ class ConnectionState: self.hooks: Dict[str, Callable] = hooks self._ready_task: Optional[asyncio.Task] = None self.heartbeat_timeout: float = options.get('heartbeat_timeout', 60.0) - self.guild_ready_timeout: float = options.get('guild_ready_timeout', 2.0) - if self.guild_ready_timeout < 0: - raise ValueError('guild_ready_timeout cannot be negative') allowed_mentions = options.get('allowed_mentions') @@ -193,6 +196,16 @@ class ConnectionState: status = str(status) self._chunk_guilds: bool = options.get('chunk_guilds_at_startup', True) + self._request_guilds = options.get('request_guilds', True) + + subscription_options = options.get('guild_subscription_options') + if subscription_options is None: + subscription_options = GuildSubscriptionOptions.off() + else: + if not isinstance(subscription_options, GuildSubscriptionOptions): + raise TypeError(f'subscription_options parameter must be GuildSubscriptionOptions not {type(subscription_options)!r}') + self._subscription_options = subscription_options + self._subscribe_guilds = subscription_options.auto_subscribe cache_flags = options.get('member_cache_flags', None) if cache_flags is None: @@ -216,33 +229,38 @@ class ConnectionState: self.clear() - def clear(self, *, views: bool = True) -> None: + def clear(self) -> None: self.user: Optional[ClientUser] = None + self.settings: Optional[UserSettings] = None + self.analytics_token: Optional[str] = None # Originally, this code used WeakValueDictionary to maintain references to the - # global user mapping. + # global user mapping # However, profiling showed that this came with two cons: # 1. The __weakref__ slot caused a non-trivial increase in memory - # 2. The performance of the mapping caused store_user to be a bottleneck. + # 2. The performance of the mapping caused store_user to be a bottleneck # Since this is undesirable, a mapping is now used instead with stored # references now using a regular dictionary with eviction being done - # using __del__. Testing this for memory leaks led to no discernable leaks, - # though more testing will have to be done. + # using __del__ + # Testing this for memory leaks led to no discernable leaks self._users: Dict[int, User] = {} self._emojis: Dict[int, Emoji] = {} self._stickers: Dict[int, GuildSticker] = {} self._guilds: Dict[int, Guild] = {} - if views: - self._view_store: ViewStore = ViewStore(self) + self._queued_guilds: Dict[int, Guild] = {} + self._unavailable_guilds: Dict[int, UnavailableGuildType] = {} + self._calls: Dict[int, Call] = {} + self._call_message_cache: List[Message] = [] # Hopefully this won't be a memory leak self._voice_clients: Dict[int, VoiceProtocol] = {} + self._voice_states: Dict[int, VoiceState] = {} - # LRU of max size 128 - self._private_channels: OrderedDict[int, PrivateChannel] = OrderedDict() - # extra dict to look up private channels by user id + self._private_channels: Dict[int, PrivateChannel] = {} self._private_channels_by_user: Dict[int, DMChannel] = {} + self._last_private_channel: tuple = (None, None) + if self.max_messages is not None: self._messages: Optional[Deque[Message]] = deque(maxlen=self.max_messages) else: @@ -276,6 +294,10 @@ class ConnectionState: else: await coro(*args, **kwargs) + @property + def ws(self): + return self.client.ws + @property def self_id(self) -> Optional[int]: u = self.user @@ -285,8 +307,33 @@ class ConnectionState: def voice_clients(self) -> List[VoiceProtocol]: return list(self._voice_clients.values()) + def _update_voice_state(self, data: GuildVoiceState, channel_id: int) -> Tuple[User, VoiceState, VoiceState]: + user_id = int(data['user_id']) + user = self.get_user(user_id) + channel = self._get_private_channel(channel_id) + + try: + # Check if we should remove the voice state from cache + if channel is None: + after = self._voice_states.pop(user_id) + else: + after = self._voice_states[user_id] + + before = copy.copy(after) + after._update(data, channel) + except KeyError: + # if we're here then add it into the cache + after = VoiceState(data=data, channel=channel) + before = VoiceState(data=data, channel=None) + self._voice_states[user_id] = after + + return user, before, after + + def _voice_state_for(self, user_id: int) -> Optional[VoiceState]: + return self._voice_states.get(user_id) + def _get_voice_client(self, guild_id: Optional[int]) -> Optional[VoiceProtocol]: - # the keys of self._voice_clients are ints + # The keys of self._voice_clients are ints return self._voice_clients.get(guild_id) # type: ignore def _add_voice_client(self, guild_id: int, voice: VoiceProtocol) -> None: @@ -302,7 +349,15 @@ class ConnectionState: def store_user(self, data: UserPayload) -> User: user_id = int(data['id']) try: - return self._users[user_id] + user = self._users[user_id] + # We use the data available to us since we + # might not have events for that user + # However, the data may only have an ID + try: + user._update(data) + except KeyError: + pass + return user except KeyError: user = User(state=self, data=data) if user.discriminator != '0000': @@ -317,14 +372,14 @@ class ConnectionState: return User(state=self, data=data) def deref_user_no_intents(self, user_id: int) -> None: - return + pass def get_user(self, id: Optional[int]) -> Optional[User]: - # the keys of self._users are ints + # The keys of self._users are ints return self._users.get(id) # type: ignore def store_emoji(self, guild: Guild, data: EmojiPayload) -> Emoji: - # the id will be present here + # The id will be present here emoji_id = int(data['id']) # type: ignore self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self, data=data) return emoji @@ -334,23 +389,16 @@ class ConnectionState: self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: Optional[int] = None) -> None: - self._view_store.add_view(view, message_id) - - def prevent_view_updates_for(self, message_id: int) -> Optional[View]: - return self._view_store.remove_message_tracking(message_id) - - @property - def persistent_views(self) -> Sequence[View]: - return self._view_store.persistent_views - @property def guilds(self) -> List[Guild]: return list(self._guilds.values()) def _get_guild(self, guild_id: Optional[int]) -> Optional[Guild]: - # the keys of self._guilds are ints - return self._guilds.get(guild_id) # type: ignore + # The keys of self._guilds are ints + guild = self._guilds.get(guild_id) # type: ignore + if guild is None: + guild = self._queued_guilds.get(guild_id) # type: ignore + return guild def _add_guild(self, guild: Guild) -> None: self._guilds[guild.id] = guild @@ -386,29 +434,39 @@ class ConnectionState: def private_channels(self) -> List[PrivateChannel]: return list(self._private_channels.values()) - def _get_private_channel(self, channel_id: Optional[int]) -> Optional[PrivateChannel]: + async def access_private_channel(self, channel_id: int) -> None: + if not self._get_accessed_private_channel(channel_id): + await self._access_private_channel(channel_id) + self._set_accessed_private_channel(channel_id) + + async def _access_private_channel(self, channel_id: int) -> None: + if (ws := self.ws) is None: + return + try: - # the keys of self._private_channels are ints - value = self._private_channels[channel_id] # type: ignore - except KeyError: - return None - else: - self._private_channels.move_to_end(channel_id) # type: ignore - return value + await ws.access_dm(channel_id) + except Exception as exc: + _log.warning('Sending ACCESS_DM failed for channel %s, (%s).', channel_id, exc) + + def _set_accessed_private_channel(self, channel_id): + self._last_private_channel = (channel_id, time.time()) + + def _get_accessed_private_channel(self, channel_id): + timestamp, existing_id = self._last_private_channel + return existing_id == channel_id and int(time.time() - timestamp) < random.randrange(120000, 420000) + + def _get_private_channel(self, channel_id: Optional[int]) -> Optional[PrivateChannel]: + # The keys of self._private_channels are ints + return self._private_channels.get(channel_id) # type: ignore def _get_private_channel_by_user(self, user_id: Optional[int]) -> Optional[DMChannel]: - # the keys of self._private_channels are ints + # The keys of self._private_channels are ints return self._private_channels_by_user.get(user_id) # type: ignore def _add_private_channel(self, channel: PrivateChannel) -> None: channel_id = channel.id self._private_channels[channel_id] = channel - if len(self._private_channels) > 128: - _, to_remove = self._private_channels.popitem(last=False) - if isinstance(to_remove, DMChannel) and to_remove.recipient: - self._private_channels_by_user.pop(to_remove.recipient.id, None) - if isinstance(channel, DMChannel) and channel.recipient: self._private_channels_by_user[channel.recipient.id] = channel @@ -428,71 +486,77 @@ class ConnectionState: def _get_message(self, msg_id: Optional[int]) -> Optional[Message]: return utils.find(lambda m: m.id == msg_id, reversed(self._messages)) if self._messages else None - def _add_guild_from_data(self, data: GuildPayload) -> Guild: - guild = Guild(data=data, state=self) - self._add_guild(guild) - return guild + def _add_guild_from_data(self, guild: GuildPayload, *, from_ready: bool = False) -> Guild: + guild_id = int(guild['id']) + unavailable = guild.get('unavailable', False) + + if not unavailable: + guild = Guild(data=guild, state=self) + self._add_guild(guild) + return guild + else: + self._unavailable_guilds[guild_id] = UnavailableGuildType.existing if from_ready else UnavailableGuildType.joined + _log.debug('Forcing GUILD_CREATE for unavailable guild %s.' % guild_id) + asyncio.ensure_future(self.request_guild(guild_id), loop=self.loop) def _guild_needs_chunking(self, guild: Guild) -> bool: - # If presences are enabled then we get back the old guild.large behaviour - return self._chunk_guilds and not guild.chunked and not (True and not guild.large) + return self._chunk_guilds and not guild.chunked and any( + guild.me.guild_permissions.kick_members, + guild.me.guild_permissions.manage_roles, + guild.me.guild_permissions.ban_members + ) + + def _guild_needs_subscribing(self, guild): # TODO: rework + return not guild.subscribed and self._subscribe_guilds def _get_guild_channel(self, data: MessagePayload) -> Tuple[Union[Channel, Thread], Optional[Guild]]: channel_id = int(data['channel_id']) try: guild = self._get_guild(int(data['guild_id'])) except KeyError: - channel = DMChannel._from_message(self, channel_id) + channel = self.get_channel(channel_id) guild = None else: channel = guild and guild._resolve_channel(channel_id) return channel or PartialMessageable(state=self, id=channel_id), guild - async def chunker( - self, guild_id: int, query: str = '', limit: int = 0, presences: bool = False, *, nonce: Optional[str] = None + def request_guild(self, guild_id: int) -> None: + return self.ws.request_lazy_guild(guild_id, typing=True, activities=True, threads=True) + + def chunker( + self, guild_id: int, query: str = '', limit: int = 0, presences: bool = True, *, nonce: Optional[str] = None ) -> None: - ws = self._get_websocket(guild_id) # This is ignored upstream - await ws.request_chunks(guild_id, query=query, limit=limit, presences=presences, nonce=nonce) + return self.ws.request_chunks(guild_id, query=query, limit=limit, presences=presences, nonce=nonce) async def query_members(self, guild: Guild, query: str, limit: int, user_ids: List[int], cache: bool, presences: bool): guild_id = guild.id - ws = self._get_websocket(guild_id) - if ws is None: - raise RuntimeError('Somehow do not have a websocket for this guild_id') - request = ChunkRequest(guild.id, self.loop, self._get_guild, cache=cache) self._chunk_requests[request.nonce] = request try: - # start the query operation - await ws.request_chunks( - guild_id, query=query, limit=limit, user_ids=user_ids, presences=presences, nonce=request.nonce + await self.ws.request_chunks( + [guild_id], query=query, limit=limit, user_ids=user_ids, presences=presences, nonce=request.nonce ) return await asyncio.wait_for(request.wait(), timeout=30.0) except asyncio.TimeoutError: - _log.warning('Timed out waiting for chunks with query %r and limit %d for guild_id %d', query, limit, guild_id) + _log.warning('Timed out waiting for chunks with query %r and limit %d for guild_id %d.', query, limit, guild_id) raise async def _delay_ready(self) -> None: try: states = [] - while True: - # this snippet of code is basically waiting N seconds - # until the last GUILD_CREATE was sent - try: - guild = await asyncio.wait_for(self._ready_state.get(), timeout=self.guild_ready_timeout) - except asyncio.TimeoutError: - break - else: - if self._guild_needs_chunking(guild): - future = await self.chunk_guild(guild, wait=False) - states.append((guild, future)) - else: - if guild.unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) + subscribes = [] + for guild in self._guilds.values(): + if self._request_guilds: + await self.request_guild(guild.id) + + if self._guild_needs_chunking(guild): + future = await self.chunk_guild(guild, wait=False) + states.append((guild, future)) + + if self._guild_needs_subscribing(guild): + subscribes.append(guild) for guild, future in states: try: @@ -500,48 +564,92 @@ class ConnectionState: except asyncio.TimeoutError: _log.warning('Timed out waiting for chunks for guild_id %s.', guild.id) - if guild.unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) - - # remove the state - try: - del self._ready_state - except AttributeError: - pass # already been deleted somehow + options = self._subscription_options + ticket = asyncio.Semaphore(options.concurrent_guilds) + await asyncio.gather(*[guild.subscribe(ticket=ticket, max_online=options.max_online) for guild in subscribes]) except asyncio.CancelledError: pass else: - # dispatch the event + # Dispatch the event self.call_handlers('ready') self.dispatch('ready') finally: self._ready_task = None def parse_ready(self, data) -> None: + # Before parsing, we wait for READY_SUPPLEMENTAL + # This has voice state objects, as well as an initial member cache + self._ready_data: dict = data + + def parse_ready_supplemental(self, data) -> None: if self._ready_task is not None: self._ready_task.cancel() - self._ready_state = asyncio.Queue() - self.clear(views=False) - self.user = ClientUser(state=self, data=data['user']) - self.store_user(data['user']) + self.clear() - if self.application_id is None: + # Merge with READY data + extra_data = data + data = self._ready_data + + # Discord bad + for guild_data, guild_extra, merged_members, merged_me, merged_presences in zip( + data.get('guilds', []), + extra_data.get('guilds', []), + extra_data.get('merged_members', []), + data.get('merged_members', []), + extra_data['merged_presences'].get('guilds', []) + ): + guild_data['voice_states'] = guild_extra.get('voice_states', []) + guild_data['merged_members'] = merged_me + guild_data['merged_members'].extend(merged_members) + guild_data['merged_presences'] = merged_presences + # There's also a friends key that has presence data for your friends + # Parsing that would require a redesign of the Relationship class ;-; + + # Self parsing + self.user = ClientUser(state=self, data=data['user']) + user = self.store_user(data['user']) + + # Temp user parsing + temp_users = {user.id: user._to_minimal_user_json()} + for u in data.get('users', []): + u_id = int(u['id']) + temp_users[u_id] = u + + # Guild parsing + for guild_data in data.get('guilds', []): + for member in guild_data['merged_members']: + if 'user' not in member: + member['user'] = temp_users.get(int(member.pop('user_id'))) + self._add_guild_from_data(guild_data, from_ready=True) + + # Relationship parsing + for relationship in data.get('relationships', []): try: - application = data['application'] + r_id = int(relationship['id']) except KeyError: - pass + continue else: - self.application_id = utils._get_as_snowflake(application, 'id') - # flags will always be present here - self.application_flags = ApplicationFlags._from_value(application['flags']) # type: ignore - - for guild_data in data['guilds']: - self._add_guild_from_data(guild_data) - + if 'user' not in relationship: + relationship['user'] = temp_users[int(relationship.pop('user_id'))] + user._relationships[r_id] = Relationship(state=self, data=relationship) + + # Private channel parsing + for pm in data.get('private_channels', []): + factory, _ = _channel_factory(pm['type']) + if 'recipients' not in pm: + pm['recipients'] = [temp_users[int(u_id)] for u_id in pm.pop('recipient_ids')] + self._add_private_channel(factory(me=user, data=pm, state=self)) + + # Extras + region = data.get('geo_ordered_rtc_regions', ['us-west'])[0] + self.preferred_region = try_enum(VoiceRegion, region) + self.settings = UserSettings(data=data.get('user_settings', {}), state=self) + + # We're done + del self._ready_data + self.call_handlers('connect') self.dispatch('connect') self._ready_task = asyncio.create_task(self._delay_ready()) @@ -549,13 +657,20 @@ class ConnectionState: self.dispatch('resumed') def parse_message_create(self, data) -> None: + guild_id = utils._get_as_snowflake(data, 'guild_id') channel, _ = self._get_guild_channel(data) - # channel would be the correct type here + if guild_id in self._unavailable_guilds: # I don't know how I feel about this :( + return + + # Channel will be the correct type here message = Message(channel=channel, data=data, state=self) # type: ignore self.dispatch('message', message) if self._messages is not None: self._messages.append(message) - # we ensure that the channel is either a TextChannel or Thread + if message.call is not None: + self._call_message_cache[message.id] = message + + # We ensure that the channel is either a TextChannel or Thread if channel and channel.__class__ in (TextChannel, Thread): channel.last_message_id = message.id # type: ignore @@ -597,9 +712,6 @@ class ConnectionState: else: self.dispatch('raw_message_edit', raw) - if 'components' in data and self._view_store.is_message_tracked(raw.message_id): - self._view_store.update_from_message(raw.message_id, data['components']) - def parse_message_reaction_add(self, data) -> None: emoji = data['emoji'] emoji_id = utils._get_as_snowflake(emoji, 'id') @@ -673,15 +785,6 @@ class ConnectionState: if reaction: self.dispatch('reaction_clear_emoji', reaction) - def parse_interaction_create(self, data) -> None: - interaction = Interaction(data=data, state=self) - if data['type'] == 3: # interaction component - custom_id = interaction.data['custom_id'] # type: ignore - component_type = interaction.data['component_type'] # type: ignore - self._view_store.dispatch(component_type, custom_id, interaction) - - self.dispatch('interaction', interaction) - def parse_presence_update(self, data) -> None: guild_id = utils._get_as_snowflake(data, 'guild_id') # guild_id won't be None here @@ -791,6 +894,22 @@ class ConnectionState: else: self.dispatch('guild_channel_pins_update', channel, last_pin) + def parse_channel_recipient_add(self, data) -> None: + channel = self._get_private_channel(int(data['channel_id'])) + user = self.store_user(data['user']) + channel.recipients.append(user) + self.dispatch('group_join', channel, user) + + def parse_channel_recipient_remove(self, data) -> None: + channel = self._get_private_channel(int(data['channel_id'])) + user = self.store_user(data['user']) + try: + channel.recipients.remove(user) + except ValueError: + pass + else: + self.dispatch('group_remove', channel, user) + def parse_thread_create(self, data) -> None: guild_id = int(data['guild_id']) guild: Optional[Guild] = self._get_guild(guild_id) @@ -934,7 +1053,7 @@ class ConnectionState: except AttributeError: pass - self.dispatch('member_join', member) + # self.dispatch('member_join', member) def parse_guild_member_remove(self, data) -> None: guild = self._get_guild(int(data['guild_id'])) @@ -948,7 +1067,7 @@ class ConnectionState: member = guild.get_member(user_id) if member is not None: guild._remove_member(member) # type: ignore - self.dispatch('member_remove', member) + # self.dispatch('member_remove', member) else: _log.debug('GUILD_MEMBER_REMOVE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) @@ -981,6 +1100,100 @@ class ConnectionState: guild._add_member(member) _log.debug('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.', user_id) + def parse_guild_sync(self, data) -> None: + print('I noticed you triggered a `GUILD_SYNC`.\nIf you want to share your secrets, please feel free to email me.') + + def parse_guild_member_list_update(self, data) -> None: # Rewrite incoming... + self.dispatch('raw_guild_member_list_update', data) + guild = self._get_guild(int(data['guild_id'])) + if guild is None: + _log.debug('GUILD_MEMBER_LIST_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + return + + ops = data['ops'] + + if data['member_count'] > 0: + guild._member_count = data['member_count'] + + online_count = 0 + for group in data['groups']: + online_count += group['count'] if group['id'] != 'offline' else 0 + guild._online_count = online_count + + for opdata in ops: + op = opdata['op'] + # There are two OPs I'm not parsing. + # INVALIDATE: Usually invalid (hehe). + # DELETE: Sends the index, not the user ID, so I can't do anything with + # it unless I keep a seperate list of the member sidebar (maybe in future). + + if op == 'SYNC': + members = [Member(guild=guild, data=member['member'], state=self) for member in [item for item in opdata.get('items', []) if 'member' in item]] + + member_dict = {str(member.id): member for member in members} + for presence in [item for item in opdata.get('items', []) if 'member' in item]: + presence = presence['member']['presence'] + user = presence['user'] + member_id = user['id'] + member = member_dict.get(member_id) + member._presence_update(presence, user) + + for member in members: + guild._add_member(member) + + if op == 'INSERT': + if 'member' not in opdata['item']: + # Hoisted role INSERT + return + + mdata = opdata['item']['member'] + user = mdata['user'] + user_id = int(user['id']) + + member = guild.get_member(user_id) + if member is not None: # INSERTs are also sent when a user changes range + old_member = Member._copy(member) + member._update(mdata) + user_update = member._update_inner_user(user) + if 'presence' in mdata: + presence = mdata['presence'] + user = presence['user'] + member_id = user['id'] + member._presence_update(presence, user) + if user_update: + self.dispatch('user_update', user_update[0], user_update[1]) + + self.dispatch('member_update', old_member, member) + else: + member = Member(data=mdata, guild=guild, state=self) + guild._add_member(member) + + if op == 'UPDATE': + if 'member' not in opdata['item']: + # Hoisted role UPDATE + return + + mdata = opdata['item']['member'] + user = mdata['user'] + user_id = int(user['id']) + + member = guild.get_member(user_id) + if member is not None: + old_member = Member._copy(member) + member._update(mdata) + user_update = member._update_inner_user(user) + if 'presence' in mdata: + presence = mdata['presence'] + user = presence['user'] + member_id = user['id'] + member._presence_update(presence, user) + if user_update: + self.dispatch('user_update', user_update[0], user_update[1]) + + self.dispatch('member_update', old_member, member) + else: + _log.debug('GUILD_MEMBER_LIST_UPDATE type UPDATE referencing an unknown member ID: %s. Discarding.', user_id) + def parse_guild_emojis_update(self, data) -> None: guild = self._get_guild(int(data['guild_id'])) if guild is None: @@ -1008,15 +1221,12 @@ class ConnectionState: self.dispatch('guild_stickers_update', guild, before_stickers, guild.stickers) def _get_create_guild(self, data): - if data.get('unavailable') is False: - # GUILD_CREATE with unavailable in the response - # usually means that the guild has become available - # and is therefore in the cache - guild = self._get_guild(int(data['id'])) - if guild is not None: - guild.unavailable = False - guild._from_data(data) - return guild + guild = self._get_guild(int(data['id'])) + # Discord being Discord sends a GUILD_CREATE after an OPCode 14 is sent (a la bots) + # However, we want that if we forced a GUILD_CREATE for an unavailable guild + if guild is not None: + guild._from_data(data) + return return self._add_guild_from_data(data) @@ -1034,44 +1244,44 @@ class ConnectionState: return await request.wait() return request.get_future() - async def _chunk_and_dispatch(self, guild, unavailable): - try: - await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) - except asyncio.TimeoutError: - _log.info('Somehow timed out waiting for chunks.') + async def _parse_and_dispatch(self, guild, *, chunk, subscribe) -> None: + self._queued_guilds[guild.id] = guild + + if chunk: + try: + await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) + except asyncio.TimeoutError: + log.info('Somehow timed out waiting for chunks.') - if unavailable is False: - self.dispatch('guild_available', guild) + if subscribe: + await guild.subscribe(max_online=self._subscription_options.max_online) + + self._queued_guilds.pop(guild.id) + + # Dispatch available/join depending on circumstances + if guild.id in self._unavailable_guilds: + type = self._unavailable_guilds.pop(guild.id) + if type is UnavailableGuildType.existing: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) else: self.dispatch('guild_join', guild) - def parse_guild_create(self, data) -> None: - unavailable = data.get('unavailable') - if unavailable is True: - # joined a guild with unavailable == True so.. - return + def parse_guild_create(self, data): + guild_id = int(data['id']) guild = self._get_create_guild(data) - try: - # Notify the on_ready state, if any, that this guild is complete. - self._ready_state.put_nowait(guild) - except AttributeError: - pass - else: - # If we're waiting for the event, put the rest on hold + if guild is None: return - # check if it requires chunking - if self._guild_needs_chunking(guild): - asyncio.create_task(self._chunk_and_dispatch(guild, unavailable)) - return + if self._request_guilds: + asyncio.ensure_future(self.request_guild(guild.id), loop=self.loop) - # Dispatch available if newly available - if unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) + # Chunk/subscribe if needed + needs_chunking, needs_subscribing = self._guild_needs_chunking(guild), self._guild_needs_subscribing(guild) + asyncio.ensure_future(self._parse_and_dispatch(guild, chunk=needs_chunking, subscribe=needs_subscribing), loop=self.loop) def parse_guild_update(self, data) -> None: guild = self._get_guild(int(data['id'])) @@ -1095,7 +1305,7 @@ class ConnectionState: self.dispatch('guild_unavailable', guild) return - # do a cleanup of the messages cache + # Cleanup the message cache if self._messages is not None: self._messages: Optional[Deque[Message]] = deque( (msg for msg in self._messages if msg.guild != guild), maxlen=self.max_messages @@ -1105,11 +1315,6 @@ class ConnectionState: self.dispatch('guild_remove', guild) def parse_guild_ban_add(self, data) -> None: - # we make the assumption that GUILD_BAN_ADD is done - # before GUILD_MEMBER_REMOVE is called - # hence we don't remove it from cache or do anything - # strange with it, the main purpose of this event - # is mainly to dispatch to another event worth listening to for logging guild = self._get_guild(int(data['guild_id'])) if guild is not None: try: @@ -1168,7 +1373,7 @@ class ConnectionState: guild = self._get_guild(guild_id) presences = data.get('presences', []) - # the guild won't be None here + # The guild won't be None here members = [Member(guild=guild, data=member, state=self) for member in data.get('members', [])] # type: ignore _log.debug('Processed a chunk for %s members in guild ID %s.', len(members), guild_id) @@ -1266,24 +1471,43 @@ class ConnectionState: else: _log.debug('STAGE_INSTANCE_DELETE referencing unknown guild ID: %s. Discarding.', data['guild_id']) + def parse_call_create(self, data) -> None: + channel = self._get_private_channel(int(data['channel_id'])) + message = self._call_message_cache.pop((int(data['message_id'])), None) + call = channel._add_call(state=self, message=message, channel=channel, **data) + self._calls[channel.id] = call + self.dispatch('call_create', call) + + def parse_call_update(self, data) -> None: + call = self._calls.get(int(data['channel_id'])) + call._update(**data) + self.dispatch('call_update', call) + + def parse_call_delete(self, data) -> None: + call = self._calls.pop(int(data['channel_id']), None) + if call is not None: + call._deleteup() + self.dispatch('call_delete', call) + def parse_voice_state_update(self, data) -> None: guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) channel_id = utils._get_as_snowflake(data, 'channel_id') + session_id = data['session_id'] flags = self.member_cache_flags # self.user is *always* cached when this is called self_id = self.user.id # type: ignore - if guild is not None: - if int(data['user_id']) == self_id: - voice = self._get_voice_client(guild.id) - if voice is not None: - coro = voice.on_voice_state_update(data) - asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice state update handler')) + if int(data['user_id']) == self_id: + voice = self._get_voice_client(guild.id) + if voice is not None: + coro = voice.on_voice_state_update(data) + asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice state update handler')) + + if guild is not None member, before, after = guild._update_voice_state(data, channel_id) # type: ignore if member is not None: if flags.voice: if channel_id is None and flags._voice_only and member.id != self_id: - # Only remove from cache if we only have the voice flag enabled # Member doesn't meet the Snowflake protocol currently guild._remove_member(member) # type: ignore elif channel_id is not None: @@ -1292,18 +1516,24 @@ class ConnectionState: self.dispatch('voice_state_update', member, before, after) else: _log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) + else: + user, before, after = self._update_voice_state(data) + self.dispatch('voice_state_update', user, before, after) def parse_voice_server_update(self, data) -> None: - try: - key_id = int(data['guild_id']) - except KeyError: - key_id = int(data['channel_id']) + key_id = utils._get_as_snowflake(data, 'guild_id') + if key_id is None: + key_id = self.user.id vc = self._get_voice_client(key_id) if vc is not None: coro = vc.on_voice_server_update(data) asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice server update handler')) + def parse_user_required_action_update(self, data) -> None: + required_action = try_enum(RequiredActionType, data['required_action']) + self.dispatch('required_action_update', required_action) + def parse_typing_start(self, data) -> None: channel, guild = self._get_guild_channel(data) if channel is not None: @@ -1328,6 +1558,29 @@ class ConnectionState: timestamp = datetime.datetime.fromtimestamp(data.get('timestamp'), tz=datetime.timezone.utc) self.dispatch('typing', channel, member, timestamp) + def parse_user_required_action_update(self, data) -> None: + required_action = try_enum(RequiredActionType, data['required_action']) + self.dispatch('required_action_update', required_action) + + def parse_relationship_add(self, data) -> None: + key = int(data['id']) + old = self.user.get_relationship(key) + new = Relationship(state=self, data=data) + self.user._relationships[key] = new + if old is not None: + self.dispatch('relationship_update', old, new) + else: + self.dispatch('relationship_add', new) + + def parse_relationship_remove(self, data) -> None: + key = int(data['id']) + try: + old = self.user._relationships.pop(key) + except KeyError: + pass + else: + self.dispatch('relationship_remove', old) + def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]: if isinstance(channel, TextChannel): return channel.guild.get_member(user_id) From 7b11d8c256ac5446aaba4718fdf66b4520cf7bac Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 11:49:28 -0500 Subject: [PATCH 040/154] Get it starting --- .gitignore | 1 + discord/__init__.py | 3 +- discord/abc.py | 8 - discord/activity.py | 2 +- discord/calls.py | 2 + discord/client.py | 17 +- discord/enums.py | 7 + discord/gateway.py | 11 + discord/guild.py | 41 +-- discord/http.py | 8 +- discord/member.py | 3 +- discord/message.py | 35 ++- discord/recorder.py | 2 + discord/relationship.py | 2 + discord/stage_instance.py | 10 +- discord/state.py | 17 +- discord/threads.py | 8 +- discord/ui/__init__.py | 15 -- discord/ui/button.py | 290 --------------------- discord/ui/item.py | 131 ---------- discord/ui/select.py | 357 ------------------------- discord/ui/view.py | 529 -------------------------------------- discord/user.py | 15 +- 23 files changed, 135 insertions(+), 1379 deletions(-) delete mode 100644 discord/ui/__init__.py delete mode 100644 discord/ui/button.py delete mode 100644 discord/ui/item.py delete mode 100644 discord/ui/select.py delete mode 100644 discord/ui/view.py diff --git a/.gitignore b/.gitignore index b556ebbb9..4500d2782 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ docs/crowdin.py *.jpg *.flac *.mo +*test.py diff --git a/discord/__init__.py b/discord/__init__.py index e3dacd63a..f4149fb14 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -43,7 +43,7 @@ from .template import * from .widget import * from .object import * from .reaction import * -from . import utils, opus, abc, ui +from . import utils, opus, abc from .enums import * from .embeds import * from .mentions import * @@ -55,7 +55,6 @@ from .raw_models import * from .team import * from .sticker import * from .stage_instance import * -from .interactions import * from .components import * from .threads import * diff --git a/discord/abc.py b/discord/abc.py index 9b83aeb43..a696de267 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1167,7 +1167,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1185,7 +1184,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1203,7 +1201,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1221,7 +1218,6 @@ class Messageable: allowed_mentions: AllowedMentions = ..., reference: Union[Message, MessageReference, PartialMessage] = ..., mention_author: bool = ..., - view: View = ..., ) -> Message: ... @@ -1408,7 +1404,6 @@ class Messageable: allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, - components=components, ) finally: for f in files: @@ -1424,12 +1419,9 @@ class Messageable: allowed_mentions=allowed_mentions, message_reference=reference, stickers=stickers, - components=components, ) 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) diff --git a/discord/activity.py b/discord/activity.py index 124132afe..999c46dce 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -738,7 +738,7 @@ class CustomActivity(BaseActivity): The emoji to pass to the activity, if any. """ - __slots__ = ('name', 'emoji') + __slots__ = ('name', 'emoji', 'state') def __init__(self, name: Optional[str], *, emoji: Optional[PartialEmoji] = None, **extra: Any): super().__init__(**extra) diff --git a/discord/calls.py b/discord/calls.py index 6c3254e23..f5317add2 100644 --- a/discord/calls.py +++ b/discord/calls.py @@ -22,6 +22,8 @@ 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 List, Optional, TYPE_CHECKING, Union diff --git a/discord/client.py b/discord/client.py index 3699213ee..9e0a4e914 100644 --- a/discord/client.py +++ b/discord/client.py @@ -44,6 +44,7 @@ from .enums import ChannelType, Status, VoiceRegion, try_enum from .mentions import AllowedMentions from .errors import * from .gateway import * +from .gateway import ConnectionClosed from .activity import ActivityTypes, BaseActivity, create_activity from .voice_client import VoiceClient from .http import HTTPClient @@ -236,7 +237,7 @@ class Client: def _handle_connect(self) -> None: state = self._connection activity = create_activity(state._activity) - status = try_enum(Status, state._status) + status = state._status and try_enum(Status, state._status) if status is not None or activity is not None: self.loop.create_task(self.change_presence(activity=activity, status=status)) @@ -433,9 +434,10 @@ class Client: _log.info('Logging in using static token.') - data = await self.http.static_login(token.strip()) - self._state.analytics_token = data.get('analytics_token') - self._connection.user = ClientUser(state=self._connection, data=data) + state = self._connection + data = await state.http.static_login(token.strip()) + state.analytics_token = data.get('analytics_token', '') + self._connection.user = ClientUser(state=state, data=data) async def connect(self, *, reconnect: bool = True) -> None: """|coro| @@ -803,7 +805,7 @@ class Client: .. note:: To retrieve standard stickers, use :meth:`.fetch_sticker`. - or :meth:`.fetch_premium_sticker_packs`. + or :meth:`.fetch_sticker_packs`. Returns -------- @@ -1041,6 +1043,7 @@ class Client: status_str = 'invisible' status = Status.offline else: + breakpoint() status_str = str(status) await self.ws.change_presence(activity=activity, status=status_str, afk=afk) @@ -1607,7 +1610,7 @@ class Client: return cls(state=self._connection, data=data) # type: ignore async def fetch_sticker_packs( - self, *, country='US', locale='en-US', *, payment_source_id: int = MISSING + self, *, country='US', locale='en-US', payment_source_id: int = MISSING ) -> List[StickerPack]: """|coro| @@ -1697,8 +1700,8 @@ class Client: List[:class:`PrivateChannel`] All your private channels. """ - channels = await self._state.http.get_private_channels() state = self._connection + channels = await state.http.get_private_channels() return [_private_channel_factory(data['type'])(me=self.user, data=data, state=state) for data in channels] async def create_dm(self, user: Snowflake) -> DMChannel: diff --git a/discord/enums.py b/discord/enums.py index 59d69f87a..1d91d1a26 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -367,6 +367,13 @@ class DefaultAvatar(Enum): return self.name +class RelationshipType(Enum): + friend = 1 + blocked = 2 + incoming_request = 3 + outgoing_request = 4 + + class NotificationLevel(Enum, comparable=True): all_messages = 0 only_mentions = 1 diff --git a/discord/gateway.py b/discord/gateway.py index 6a49b9e0a..7d484646a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -689,6 +689,17 @@ class DiscordWebSocket: _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): + payload = { + 'op': self.ACCESS_DM, + 'd': { + 'channel_id': channel_id + } + } + + _log.debug('Sending ACCESS_DM for channel %s.', channel_id) + await self.send_as_json(payload) + async def close(self, code=4000): if self._keep_alive: self._keep_alive.stop() diff --git a/discord/guild.py b/discord/guild.py index 5af2da176..9fc5afabb 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -68,7 +68,7 @@ from .enums import ( from .mixins import Hashable from .user import User from .invite import Invite -from .iterators import AuditLogIterator, MemberIterator +from .iterators import AuditLogIterator from .widget import Widget from .asset import Asset from .flags import SystemChannelFlags @@ -234,7 +234,6 @@ class Guild(Hashable): __slots__ = ( 'afk_timeout', - 'afk_channel', 'name', 'id', 'unavailable', @@ -255,6 +254,7 @@ class Guild(Hashable): 'premium_subscription_count', 'preferred_locale', 'nsfw_level', + 'owner_application_id', '_members', '_channels', '_icon', @@ -265,6 +265,8 @@ class Guild(Hashable): '_large', '_splash', '_voice_states', + '_afk_channel_id', + '_widget_channel_id', '_system_channel_id', '_system_channel_flags', '_discovery_splash', @@ -294,6 +296,13 @@ class Guild(Hashable): self._state: ConnectionState = state self._from_data(data) + # Get it running + @property + def subscribed(self): + return False + async def subscribe(self, *args, **kwargs): + pass + def _add_channel(self, channel: GuildChannel, /) -> None: self._channels[channel.id] = channel @@ -1334,7 +1343,6 @@ class Guild(Hashable): preferred_locale: str = MISSING, rules_channel: Optional[TextChannel] = MISSING, public_updates_channel: Optional[TextChannel] = MISSING, - features: List[str] = MISSING, ) -> Guild: r"""|coro| @@ -1432,7 +1440,6 @@ class Guild(Hashable): The newly updated guild. Note that this has the same limitations as mentioned in :meth:`Client.fetch_guild` and may not have full data. """ - # TODO: see what fields are sent no matter if they're changed or not http = self._state.http @@ -1735,7 +1742,6 @@ class Guild(Hashable): List[:class:`BanEntry`] A list of :class:`BanEntry` objects. """ - data: List[BanPayload] = await self._state.http.get_bans(self.id) return [BanEntry(user=User(state=self._state, data=e['user']), reason=e['reason']) for e in data] @@ -1795,7 +1801,6 @@ class Guild(Hashable): The number of members pruned. If ``compute_prune_count`` is ``False`` then this returns ``None``. """ - if not isinstance(days, int): raise InvalidArgument(f'Expected int for ``days``, received {days.__class__.__name__} instead.') @@ -1850,7 +1855,6 @@ class Guild(Hashable): List[:class:`Webhook`] The webhooks for this guild. """ - from .webhook import Webhook data = await self._state.http.guild_webhooks(self.id) @@ -1887,7 +1891,6 @@ class Guild(Hashable): :class:`int` The number of members estimated to be pruned. """ - if not isinstance(days, int): raise InvalidArgument(f'Expected int for ``days``, received {days.__class__.__name__} instead.') @@ -1919,7 +1922,6 @@ class Guild(Hashable): List[:class:`Invite`] The list of invites that are currently active. """ - data = await self._state.http.invites_from(self.id) result = [] for invite in data: @@ -2257,7 +2259,6 @@ class Guild(Hashable): :class:`Emoji` The created emoji. """ - img = utils._bytes_to_base64_data(image) if roles: role_ids = [role.id for role in roles] @@ -2289,7 +2290,6 @@ class Guild(Hashable): HTTPException An error occurred deleting the emoji. """ - await self._state.http.delete_custom_emoji(self.id, emoji.id, reason=reason) async def fetch_roles(self) -> List[Role]: @@ -2597,14 +2597,12 @@ class Guild(Hashable): The special vanity invite. If ``None`` then the guild does not have a vanity invite set. """ - - # we start with { code: abc } + # We start with { code: abc } payload = await self._state.http.get_vanity_code(self.id) if not payload['code']: - return None + return - # get the vanity URL channel since default channels aren't - # reliable or a thing anymore + # Get the vanity channel & uses data = await self._state.http.get_invite(payload['code']) channel = self.get_channel(int(data['channel']['id'])) @@ -2692,7 +2690,7 @@ class Guild(Hashable): self, before=before, after=after, limit=limit, oldest_first=oldest_first, user_id=user_id, action_type=action ) - async def ack(self): + async def ack(self) -> None: """|coro| Marks every message in this guild as read. @@ -2781,6 +2779,12 @@ class Guild(Hashable): ClientException Insufficient permissions. """ + if not self.me or not any({ + self.me.guild_permissions.kick_members, + self.me.guild_permissions.manage_roles, + self.me.guild_permissions.ban_members + }): + raise ClientException('You don\'t have permission to chunk this guild') if not self._state.is_guild_evicted(self): return await self._state.chunk_guild(self, cache=cache) @@ -2837,7 +2841,6 @@ class Guild(Hashable): List[:class:`Member`] The list of members that have matched the query. """ - if query is None: if query == '': raise ValueError('Cannot pass empty query string.') @@ -2914,7 +2917,6 @@ class Guild(Hashable): HTTPException Muting failed. """ - fields = { 'muted': True } @@ -2940,5 +2942,4 @@ class Guild(Hashable): HTTPException Unmuting failed. """ - await self._state.http.edit_guild_settings(self.id, muted=False) \ No newline at end of file diff --git a/discord/http.py b/discord/http.py index aba1c328e..6305a4b72 100644 --- a/discord/http.py +++ b/discord/http.py @@ -49,7 +49,6 @@ from urllib.parse import quote as _uriquote import weakref import aiohttp -from types import snowflake from .enums import RelationshipAction from .errors import HTTPException, Forbidden, NotFound, LoginFailure, DiscordServerError, GatewayNotFound, InvalidArgument @@ -84,6 +83,7 @@ if TYPE_CHECKING: widget, threads, voice, + snowflake, sticker, ) from .types.snowflake import Snowflake, SnowflakeList @@ -295,7 +295,7 @@ class HTTPClient: if 'json' in kwargs: headers['Content-Type'] = 'application/json' - kwargs['data'] = utils.to_json(kwargs.pop('json')) + kwargs['data'] = utils._to_json(kwargs.pop('json')) if 'context_properties' in kwargs: context_properties = kwargs.pop('context_properties') @@ -444,10 +444,10 @@ class HTTPClient: return self.request(Route('GET', '/users/@me'), params=params) async def static_login(self, token: str) -> user.User: - # Necessary to get aiohttp to stop complaining about session creation - self.__session = aiohttp.ClientSession(connector=self.connector) old_token, self.token = self.token, token + await self.startup() + try: data = await self.get_me() except HTTPException as exc: diff --git a/discord/member.py b/discord/member.py index 7c666c71d..3a8cc9fe5 100644 --- a/discord/member.py +++ b/discord/member.py @@ -205,7 +205,7 @@ M = TypeVar('M', bound='Member') @flatten_user -class Member(discord.abc.Messageable, discord.abc.connectable, _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`. @@ -269,6 +269,7 @@ class Member(discord.abc.Messageable, discord.abc.connectable, _UserTag): '_user', '_state', '_avatar', + '_index', # Member list index ) if TYPE_CHECKING: diff --git a/discord/message.py b/discord/message.py index debf808e8..6b1dad811 100644 --- a/discord/message.py +++ b/discord/message.py @@ -35,6 +35,7 @@ from . import utils from .reaction import Reaction from .emoji import Emoji from .partial_emoji import PartialEmoji +from .calls import CallMessage from .enums import MessageType, ChannelType, try_enum from .errors import InvalidArgument, HTTPException from .components import _component_factory @@ -525,6 +526,9 @@ class Message(Hashable): channel: Union[:class:`TextChannel`, :class:`Thread`, :class:`DMChannel`, :class:`GroupChannel`, :class:`PartialMessageable`] 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 @@ -633,6 +637,7 @@ class Message(Hashable): 'stickers', 'components', 'guild', + 'call', ) if TYPE_CHECKING: @@ -670,6 +675,7 @@ class Message(Hashable): 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.call: Optional[CallMessage] = None try: # if the channel doesn't have a guild attribute, we handle that @@ -700,7 +706,7 @@ class Message(Hashable): # 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'): try: getattr(self, f'_handle_{handler}')(data[handler]) except KeyError: @@ -871,6 +877,23 @@ 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] @@ -1047,6 +1070,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 + + if self.channel.me in self.call.participants: + 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!' diff --git a/discord/recorder.py b/discord/recorder.py index 087c16fac..07f1f842a 100644 --- a/discord/recorder.py +++ b/discord/recorder.py @@ -22,6 +22,8 @@ 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 diff --git a/discord/relationship.py b/discord/relationship.py index 65c53ec7b..a1ceedb2d 100644 --- a/discord/relationship.py +++ b/discord/relationship.py @@ -22,6 +22,8 @@ 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 diff --git a/discord/stage_instance.py b/discord/stage_instance.py index 479e89f2c..346a601a0 100644 --- a/discord/stage_instance.py +++ b/discord/stage_instance.py @@ -103,11 +103,16 @@ class StageInstance(Hashable): def __repr__(self) -> str: return f'' + @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 - return self._state.get_channel(self.channel_id) # type: ignore + # The returned channel will always be a StageChannel or None + return self._state.get_channel(self.channel_id) # type: ignore def is_public(self) -> bool: return self.privacy_level is StagePrivacyLevel.public @@ -138,7 +143,6 @@ class StageInstance(Hashable): HTTPException Editing a stage instance failed. """ - payload = {} if topic is not MISSING: diff --git a/discord/state.py b/discord/state.py index 8839aa30a..ee7773eab 100644 --- a/discord/state.py +++ b/discord/state.py @@ -257,6 +257,7 @@ class ConnectionState: self._voice_clients: Dict[int, VoiceProtocol] = {} self._voice_states: Dict[int, VoiceState] = {} + self._relationships: Dict[int, Relationship] = {} self._private_channels: Dict[int, PrivateChannel] = {} self._private_channels_by_user: Dict[int, DMChannel] = {} self._last_private_channel: tuple = (None, None) @@ -500,11 +501,13 @@ class ConnectionState: asyncio.ensure_future(self.request_guild(guild_id), loop=self.loop) def _guild_needs_chunking(self, guild: Guild) -> bool: - return self._chunk_guilds and not guild.chunked and any( + if not guild.me: # Dear god this will break everything + return False + return self._chunk_guilds and not guild.chunked and any({ guild.me.guild_permissions.kick_members, guild.me.guild_permissions.manage_roles, guild.me.guild_permissions.ban_members - ) + }) def _guild_needs_subscribing(self, guild): # TODO: rework return not guild.subscribed and self._subscribe_guilds @@ -633,7 +636,7 @@ class ConnectionState: else: if 'user' not in relationship: relationship['user'] = temp_users[int(relationship.pop('user_id'))] - user._relationships[r_id] = Relationship(state=self, data=relationship) + self._relationships[r_id] = Relationship(state=self, data=relationship) # Private channel parsing for pm in data.get('private_channels', []): @@ -1251,7 +1254,7 @@ class ConnectionState: try: await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) except asyncio.TimeoutError: - log.info('Somehow timed out waiting for chunks.') + _log.info('Somehow timed out waiting for chunks for guild %s.', guild.id) if subscribe: await guild.subscribe(max_online=self._subscription_options.max_online) @@ -1503,7 +1506,7 @@ class ConnectionState: coro = voice.on_voice_state_update(data) asyncio.create_task(logging_coroutine(coro, info='Voice Protocol voice state update handler')) - if guild is not None + if guild is not None: member, before, after = guild._update_voice_state(data, channel_id) # type: ignore if member is not None: if flags.voice: @@ -1566,7 +1569,7 @@ class ConnectionState: key = int(data['id']) old = self.user.get_relationship(key) new = Relationship(state=self, data=data) - self.user._relationships[key] = new + self._relationships[key] = new if old is not None: self.dispatch('relationship_update', old, new) else: @@ -1575,7 +1578,7 @@ class ConnectionState: def parse_relationship_remove(self, data) -> None: key = int(data['id']) try: - old = self.user._relationships.pop(key) + old = self._relationships.pop(key) except KeyError: pass else: diff --git a/discord/threads.py b/discord/threads.py index 892910d9e..dfc89a1cb 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -681,7 +681,6 @@ class Thread(Messageable, Hashable): List[:class:`ThreadMember`] All thread members in the thread. """ - members = await self._state.http.get_thread_members(self.id) return [ThreadMember(parent=self, data=data) for data in members] @@ -800,3 +799,10 @@ class ThreadMember(Hashable): 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 member represents. If the member + is not cached then this will be ``None``. + """ + return self.parent.guild.get_member(self.id) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py deleted file mode 100644 index 9f5a22811..000000000 --- a/discord/ui/__init__.py +++ /dev/null @@ -1,15 +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 .item import * -from .button import * -from .select import * diff --git a/discord/ui/button.py b/discord/ui/button.py deleted file mode 100644 index fedeac680..000000000 --- a/discord/ui/button.py +++ /dev/null @@ -1,290 +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, Type, 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 .view import View - from ..emoji import Emoji - -B = TypeVar('B', bound='Button') -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: Type[B], button: ButtonComponent) -> B: - 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], ItemCallbackType]: - """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) -> ItemCallbackType: - 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 diff --git a/discord/ui/item.py b/discord/ui/item.py deleted file mode 100644 index 46c529707..000000000 --- a/discord/ui/item.py +++ /dev/null @@ -1,131 +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 - -__all__ = ( - 'Item', -) - -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[[Any, 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, interaction: Interaction) -> 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 diff --git a/discord/ui/select.py b/discord/ui/select.py deleted file mode 100644 index 8479ca157..000000000 --- a/discord/ui/select.py +++ /dev/null @@ -1,357 +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, Type, 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 ..interactions import Interaction -from ..utils import MISSING -from ..components import ( - SelectOption, - SelectMenu, -) - -__all__ = ( - 'Select', - 'select', -) - -if TYPE_CHECKING: - from .view import View - from ..types.components import SelectMenu as SelectMenuPayload - from ..types.interactions import ( - ComponentInteractionData, - ) - -S = TypeVar('S', bound='Select') -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, interaction: Interaction) -> None: - data: ComponentInteractionData = interaction.data # type: ignore - self._selected_values = data.get('values', []) - - @classmethod - def from_component(cls: Type[S], component: SelectMenu) -> S: - 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], ItemCallbackType]: - """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) -> ItemCallbackType: - 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 diff --git a/discord/ui/view.py b/discord/ui/view.py deleted file mode 100644 index 13510eeaf..000000000 --- a/discord/ui/view.py +++ /dev/null @@ -1,529 +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 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, -) - -__all__ = ( - 'View', -) - - -if TYPE_CHECKING: - from ..interactions import Interaction - from ..message import Message - from ..types.components import Component as ComponentPayload - from ..state import ConnectionState - - -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: - __slots__ = ( - 'weights', - ) - - 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 - __view_children_items__: ClassVar[List[ItemCallbackType]] = [] - - def __init_subclass__(cls) -> None: - children: List[ItemCallbackType] = [] - 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__(self, *, timeout: Optional[float] = 180.0): - self.timeout = timeout - self.children: List[Item] = [] - 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) - item._view = self - setattr(self, func.__name__, item) - self.children.append(item) - - 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 - - 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] = {} - 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): - self.__verify_integrity() - - view._start_listening_from_store(self) - 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): - 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(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) - view._dispatch_item(item, 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]) diff --git a/discord/user.py b/discord/user.py index 0c9db2f9e..f8853bfc8 100644 --- a/discord/user.py +++ b/discord/user.py @@ -28,7 +28,6 @@ from copy import copy from typing import Any, Dict, List, Optional, Type, TypeVar, TYPE_CHECKING, Union import discord.abc -from types.snowflake import Snowflake from .asset import Asset from .colour import Colour from .enums import DefaultAvatar, HypeSquadHouse, PremiumType, RelationshipAction, RelationshipType, try_enum, UserFlags @@ -49,6 +48,7 @@ if TYPE_CHECKING: from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload + from .types.snowflake import Snowflake from .types.user import User as UserPayload @@ -597,7 +597,18 @@ class ClientUser(BaseUser): The user's note. Not pre-fetched. """ - __slots__ = ('locale', '_flags', 'verified', 'mfa_enabled', '__weakref__') + __slots__ = ( + 'locale', + '_flags', + 'verified', + 'mfa_enabled', + 'email', + 'phone', + 'premium_type', + 'note', + 'premium', + 'bio', + ) if TYPE_CHECKING: verified: bool From e7d369c2c9418dbc20ab6da1c5d575988bad834d Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 11:51:03 -0500 Subject: [PATCH 041/154] Add _message_id to Invite slots --- discord/invite.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/invite.py b/discord/invite.py index 25b530378..17c0d97e3 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -324,6 +324,7 @@ class Invite(Hashable): 'approximate_presence_count', 'target_application', 'expires_at', + '_message_id', ) BASE = 'https://discord.gg' From f31e48ca1147e93fd80c38f951c0aede70415630 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 12:40:02 -0500 Subject: [PATCH 042/154] Add missing options to User paylod --- discord/types/user.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord/types/user.py b/discord/types/user.py index 106f3eea4..50776d347 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -46,3 +46,8 @@ class User(PartialUser, total=False): flags: int premium_type: PremiumType public_flags: int + banner: Optional[str] + accent_color: Optional[int] + bio: str + analytics_token: str + phone: Optional[str] From c4dc7db710573508ee96465c4c07a7f966a27c02 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 12:40:15 -0500 Subject: [PATCH 043/154] Make phone actually an int --- discord/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/user.py b/discord/user.py index f8853bfc8..ed5f5a9b1 100644 --- a/discord/user.py +++ b/discord/user.py @@ -36,7 +36,7 @@ from .flags import PublicUserFlags from .object import Object from .relationship import Relationship from .settings import UserSettings -from .utils import _bytes_to_base64_data, cached_slot_property, parse_time, snowflake_time, MISSING +from .utils import _bytes_to_base64_data, _get_as_snowflake, cached_slot_property, parse_time, snowflake_time, MISSING if TYPE_CHECKING: from datetime import datetime @@ -636,7 +636,7 @@ class ClientUser(BaseUser): # There's actually an Optional[str] phone field as well but I won't use it self.verified = data.get('verified', False) self.email = data.get('email') - self.phone = data.get('phone') + self.phone = _get_as_snowflake(data, 'phone') self.locale = data.get('locale') self._flags = data.get('flags', 0) self.mfa_enabled = data.get('mfa_enabled', False) From 21727baa074096c6a61baaee333f82b17a403588 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 13:16:58 -0500 Subject: [PATCH 044/154] Add missing guild attributes --- discord/guild.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 9fc5afabb..a8d2aefb1 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -229,6 +229,14 @@ class Guild(Hashable): nsfw_level: :class:`NSFWLevel` The guild's NSFW level. + .. versionadded:: 2.0 + vanity_code: Optional[:class:`str`] + The guild's vanity invite. + + .. versionadded:: 2.0 + premium_progress_bar_enabled: :class:`bool` + Whether the guild has the premium progress bar enabled. + .. versionadded:: 2.0 """ @@ -255,6 +263,8 @@ class Guild(Hashable): 'preferred_locale', 'nsfw_level', 'owner_application_id', + 'vanity_code', + 'premium_progress_bar_enabled', '_members', '_channels', '_icon', @@ -460,6 +470,8 @@ class Guild(Hashable): self._online_count: Optional[int] = None self.owner_id: Optional[int] = utils._get_as_snowflake(guild, 'owner_id') self.owner_application_id: Optional[int] = utils._get_as_snowflake(guild, 'application_id') + self.vanity_code: Optional[str] = guild.get('vanity_url_code') + self.premium_progress_bar_enabled: bool = guild.get('premium_progress_bar_enabled', False) large = None if member_count is None else member_count >= 250 self._large: Optional[bool] = guild.get('large', large) @@ -478,6 +490,9 @@ class Guild(Hashable): if member is not None: member._presence_update(presence, empty_tuple) + def _update_settings(self, data) -> None: + pass # TODO + @property def channels(self) -> List[GuildChannel]: """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" From 1646421cc03cd9d32b3b3f4906d4bd3238072590 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 18:15:32 -0500 Subject: [PATCH 045/154] Implement guild notification settings --- discord/abc.py | 9 +- discord/enums.py | 17 ++- discord/guild.py | 13 ++- discord/http.py | 2 +- discord/settings.py | 269 +++++++++++++++++++++++++++++++++++++++++++- discord/state.py | 37 +++++- discord/tracking.py | 18 ++- discord/user.py | 11 +- 8 files changed, 354 insertions(+), 22 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index a696de267..fa267d8b1 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -54,6 +54,7 @@ from .invite import Invite from .file import File from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem +from .settings import ChannelSettings from . import utils __all__ = ( @@ -82,7 +83,6 @@ if TYPE_CHECKING: from .channel import DMChannel, GroupChannel, PartialMessageable, PrivateChannel, 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, @@ -414,6 +414,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 diff --git a/discord/enums.py b/discord/enums.py index 1d91d1a26..d7bc06932 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -367,7 +367,7 @@ class DefaultAvatar(Enum): return self.name -class RelationshipType(Enum): +class RelationshipType(Enum, comparable=True): friend = 1 blocked = 2 incoming_request = 3 @@ -376,8 +376,15 @@ class RelationshipType(Enum): 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): create = 1 @@ -557,7 +564,7 @@ class HypeSquadHouse(Enum): balance = 3 -class PremiumType(Enum): +class PremiumType(Enum, comparable=True): nitro_classic = 1 nitro = 2 @@ -614,7 +621,7 @@ class ReportType(Enum): return self.value -class RelationshipAction(Enum): +class RelationshipAction(Enum, comparable=True): send_friend_request = 'request' unfriend = 'unfriend' accept_request = 'accept' @@ -624,12 +631,12 @@ class RelationshipAction(Enum): remove_pending_request = 'remove' -class UnavailableGuildType(Enum): +class UnavailableGuildType(Enum, comparable=True): existing = 'ready' joined = 'joined' -class RequiredActionType(Enum): +class RequiredActionType(Enum, comparable=True): verify_phone = 'REQUIRE_VERIFIED_PHONE' verify_email = 'REQUIRE_VERIFIED_EMAIL' captcha = 'REQUIRE_CAPTCHA' diff --git a/discord/guild.py b/discord/guild.py index a8d2aefb1..b88f0e382 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -77,6 +77,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker from .file import File +from .settings import GuildSettings __all__ = ( @@ -237,6 +238,10 @@ class Guild(Hashable): premium_progress_bar_enabled: :class:`bool` Whether the guild has the premium progress bar enabled. + .. versionadded:: 2.0 + notification_settings: :class:`GuildSettings` + The notification settings for the guild. + .. versionadded:: 2.0 """ @@ -265,6 +270,7 @@ class Guild(Hashable): 'owner_application_id', 'vanity_code', 'premium_progress_bar_enabled', + 'notification_settings', '_members', '_channels', '_icon', @@ -304,6 +310,7 @@ class Guild(Hashable): self._threads: Dict[int, Thread] = {} self._stage_instances: Dict[int, StageInstance] = {} self._state: ConnectionState = state + self.notification_settings: Optional[GuildSettings] = None self._from_data(data) # Get it running @@ -476,6 +483,9 @@ class Guild(Hashable): large = None if member_count is None else member_count >= 250 self._large: Optional[bool] = guild.get('large', large) + if (settings := guild.get('settings')) is not None: + self.notification_settings = GuildSettings(state=state, data=settings) + for mdata in guild.get('merged_members', []): try: member = Member(data=mdata, guild=self, state=state) @@ -490,9 +500,6 @@ class Guild(Hashable): if member is not None: member._presence_update(presence, empty_tuple) - def _update_settings(self, data) -> None: - pass # TODO - @property def channels(self) -> List[GuildChannel]: """List[:class:`abc.GuildChannel`]: A list of channels that belongs to this guild.""" diff --git a/discord/http.py b/discord/http.py index 6305a4b72..27b51e21e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1194,7 +1194,7 @@ class HTTPClient: return self.request(Route('PATCH', '/guilds/{guild_id}', guild_id=guild_id), json=payload, reason=reason) - def edit_guild_settings(self, guild_id: Snowflake, **fields): # TODO: type and add more than just muting + def edit_guild_settings(self, guild_id: Snowflake, fields): # TODO: type return self.request(Route('PATCH', '/users/@me/guilds/{guild_id}/settings', guild_id=guild_id), json=fields) def get_template(self, code: str) -> Response[template.Template]: diff --git a/discord/settings.py b/discord/settings.py index 359a53eb1..0514e4531 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -24,16 +24,24 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union -from .enums import FriendFlags, StickerAnimationOptions, Theme, UserContentFilter, try_enum +from .enums import FriendFlags, NotificationLevel, 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 .guild import Guild from .state import ConnectionState + from .tracking import Tracking -__all__ = ('UserSettings',) +__all__ = ( + 'ChannelSettings', + 'GuildSettings', + 'UserSettings', +) class UserSettings: @@ -161,6 +169,11 @@ class UserSettings: else: setattr(self, '_' + key, value) + @property + def tracking(self) -> Optional[Tracking]: + """Returns your tracking settings if available.""" + return self._state.consents + @property def animate_stickers(self) -> StickerAnimationOptions: """Whether or not to animate stickers in the chat.""" @@ -201,3 +214,253 @@ class UserSettings: def _get_guild(self, id: int) -> Optional[Guild]: return self._state._get_guild(int(id)) + + +class MuteConfig: + def __init__(self, muted: bool, config: Dict[str, Union[str, int]]) -> 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'' + + 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: + breakpoint() + 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 + -------- + Optional[:class:`ChannelSettings`] + The new notification settings. This is only returned if something is updated. + """ + payload = {} + + 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 = 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 + ) + + +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 + -------- + Optional[:class:`GuildSettings`] + The new notification settings. This is only returned if something is updated. + """ + payload = {} + + 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 = self._state.http.edit_guild_settings(self._guild_id, payload) + + if data: + return GuildSettings(data=data, state=self._state) diff --git a/discord/state.py b/discord/state.py index ee7773eab..164dc3af5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -58,6 +58,7 @@ from .stage_instance import StageInstance from .threads import Thread, ThreadMember from .sticker import GuildSticker from .settings import UserSettings +from .tracking import Tracking if TYPE_CHECKING: @@ -232,7 +233,11 @@ class ConnectionState: def clear(self) -> None: self.user: Optional[ClientUser] = None self.settings: Optional[UserSettings] = None + self.consents: Optional[Tracking] = None self.analytics_token: Optional[str] = None + self.session_id: Optional[str] = None + self.connected_accounts: Optional[List[dict]] = None + self.preferred_region: Optional[VoiceRegion] = None # Originally, this code used WeakValueDictionary to maintain references to the # global user mapping @@ -591,9 +596,8 @@ class ConnectionState: self.clear() - # Merge with READY data - extra_data = data - data = self._ready_data + extra_data, data = data, self._ready_data + guild_settings = data.get('user_guild_settings', {}).get('entries', []) # Discord bad for guild_data, guild_extra, merged_members, merged_me, merged_presences in zip( @@ -603,6 +607,11 @@ class ConnectionState: data.get('merged_members', []), extra_data['merged_presences'].get('guilds', []) ): + guild_data['settings'] = utils.find( + lambda i: i['guild_id'] == guild_data['id'], + guild_settings, + ) or {'guild_id': guild_data['id']} + guild_data['voice_states'] = guild_extra.get('voice_states', []) guild_data['merged_members'] = merged_me guild_data['merged_members'].extend(merged_members) @@ -646,9 +655,12 @@ class ConnectionState: self._add_private_channel(factory(me=user, data=pm, state=self)) # Extras + self.session_id = data.get('session_id') + self.analytics_token = data.get('analytics_token') region = data.get('geo_ordered_rtc_regions', ['us-west'])[0] self.preferred_region = try_enum(VoiceRegion, region) - self.settings = UserSettings(data=data.get('user_settings', {}), state=self) + self.settings = settings = UserSettings(data=data.get('user_settings', {}), state=self) + self.consents = Tracking(data.get('consents', {})) # We're done del self._ready_data @@ -656,7 +668,7 @@ class ConnectionState: self.dispatch('connect') self._ready_task = asyncio.create_task(self._delay_ready()) - def parse_resumed(self, data) -> None: + def parse_resumed(self, _) -> None: self.dispatch('resumed') def parse_message_create(self, data) -> None: @@ -818,6 +830,19 @@ class ConnectionState: if ref: ref._update(data) + def parse_user_settings_update(self, data) -> None: + new_settings = self.settings + old_settings = copy.copy(new_settings) + new_settings._update(data) + self.dispatch('settings_update', old_settings, new_settings) + + def parse_user_guild_settings_update(self, data) -> None: + guild = self.get_guild(int(data['guild_id'])) + new_settings = guild.notification_settings + old_settings = copy.copy(new_settings) + new_settings._update(data) + self.dispatch('guild_settings_update', old_settings, new_settings) + def parse_invite_create(self, data) -> None: invite = Invite.from_gateway(state=self, data=data) self.dispatch('invite_create', invite) @@ -841,7 +866,7 @@ class ConnectionState: if channel_type is ChannelType.group: channel = self._get_private_channel(channel_id) old_channel = copy.copy(channel) - # the channel is a GroupChannel + # The channel is a GroupChannel channel._update_group(data) # type: ignore self.dispatch('private_channel_update', old_channel, channel) return diff --git a/discord/tracking.py b/discord/tracking.py index 2e0297093..b67c89c04 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -31,7 +31,10 @@ from typing import Any, Dict, Optional from .types.snowflake import Snowflake -__all__ = ('ContextProperties',) +__all__ = ( + 'ContextProperties', + 'Tracking', +) class ContextProperties: # Thank you Discord-S.C.U.M @@ -250,3 +253,16 @@ class ContextProperties: # Thank you Discord-S.C.U.M 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. + """ + + def __init__(self, data: Dict[str, Any]): # TODO: rest of the values + self.personalization = data.get('personalization', {}).get('consented', False) diff --git a/discord/user.py b/discord/user.py index ed5f5a9b1..65843eeed 100644 --- a/discord/user.py +++ b/discord/user.py @@ -644,6 +644,11 @@ class ClientUser(BaseUser): self.premium_type = try_enum(PremiumType, data.get('premium_type', None)) self.bio = data.get('bio') or None + @property + def connected_accounts(self) -> Optional[List[dict]]: + """Optional[List[:class:`dict`]]: Returns a list of all linked accounts for this user if available.""" + return self._state._connected_accounts + def get_relationship(self, user_id: int) -> Relationship: """Retrieves the :class:`Relationship` if applicable. @@ -848,6 +853,9 @@ class ClientUser(BaseUser): 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` @@ -960,8 +968,7 @@ class ClientUser(BaseUser): state = self._state data = await state.http.edit_settings(**payload) - state.settings = settings = UserSettings(data=data, state=self._state) - return settings + return UserSettings(data=data, state=self._state) class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): From 6c021e4d56bbfbedaa7adfac76d36e795fced6f8 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 18:16:29 -0500 Subject: [PATCH 046/154] Remove now useless mute() and unmute() methods --- discord/guild.py | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index b88f0e382..49e8c0089 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2920,48 +2920,3 @@ class Guild(Hashable): region = str(preferred_region) if preferred_region else str(state.preferred_region) await ws.voice_state(self.id, channel_id, self_mute, self_deaf, self_video, region) - - async def mute(self, *, duration: Optional[int] = None) -> None: - """|coro| - - Mutes the guild. - - .. versionadded:: 1.9 - - Parameters - ----------- - duration: Optional[:class:`int`] - The duration (in hours) of the mute. Defaults to - ``None`` for an indefinite mute. - - Raises - ------- - HTTPException - Muting failed. - """ - fields = { - 'muted': True - } - - if duration is not None: - mute_config = { - 'selected_time_window': duration * 3600, - 'end_time': (datetime.utcnow() + timedelta(hours=duration)).isoformat() - } - fields['mute_config'] = mute_config - - await self._state.http.edit_guild_settings(self.id, **fields) - - async def unmute(self) -> None: - """|coro| - - Unmutes the guild. - - .. versionadded:: 1.9 - - Raises - ------- - HTTPException - Unmuting failed. - """ - await self._state.http.edit_guild_settings(self.id, muted=False) \ No newline at end of file From 1f51b7b1b54c1e8f6e7c3219e41b7009e2a5d6a2 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 18:26:20 -0500 Subject: [PATCH 047/154] Try to get docs to build --- docs/api.rst | 13 +------------ docs/conf.py | 28 ++++++++++++++-------------- docs/ext/commands/api.rst | 10 +--------- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 0a9ba5cc2..427554f3e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -48,14 +48,6 @@ Client .. automethod:: Client.fetch_guilds :async-for: -AutoShardedClient -~~~~~~~~~~~~~~~~~~ - -.. attributetable:: AutoShardedClient - -.. autoclass:: AutoShardedClient - :members: - Application Info ------------------ @@ -3491,10 +3483,7 @@ Guild .. autoclass:: Guild() :members: - :exclude-members: fetch_members, audit_logs - - .. automethod:: fetch_members - :async-for: + :exclude-members: audit_logs .. automethod:: audit_logs :async-for: diff --git a/docs/conf.py b/docs/conf.py index 03f69c195..88de74c8f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,7 +79,7 @@ source_suffix = '.rst' master_doc = 'index' # General information about the project. -project = 'discord.py' +project = 'discord.py-self' copyright = '2015-present, Rapptz' # The version info for the project you're documenting, acts as replacement for @@ -159,7 +159,7 @@ html_experimental_html5_writer = True html_theme = 'basic' html_context = { - 'discord_invite': 'https://discord.gg/r3sSKJJ', + 'discord_invite': 'https://discord.gg/NONE', 'discord_extensions': [ ('discord.ext.commands', 'ext/commands'), ('discord.ext.tasks', 'ext/tasks'), @@ -167,10 +167,10 @@ 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', + 'discord': 'https://discord.gg/NONE', + '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 @@ -293,8 +293,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.tex', 'discord.py-self Documentation', + 'Dolfies', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -323,8 +323,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. @@ -337,8 +337,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'), ] @@ -357,5 +357,5 @@ texinfo_documents = [ 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' + app.config.html_context['discord_invite'] = 'https://discord.gg/NONE' + app.config.resource_links['discord'] = 'https://discord.gg/NONE' diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index e96315145..53f965d60 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -34,7 +34,7 @@ Bot .. automethod:: Bot.command(*args, **kwargs) :decorator: - + .. automethod:: Bot.event() :decorator: @@ -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 ---------------- From 9b9ee194c8143d5c3dfd4e27385a6a0317f6e4d8 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 20:46:03 -0500 Subject: [PATCH 048/154] Remove useless whitespace --- discord/errors.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/discord/errors.py b/discord/errors.py index 4210947fa..5c83fed97 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -35,8 +35,6 @@ if TYPE_CHECKING: except ModuleNotFoundError: _ResponseType = ClientResponse - from .interactions import Interaction - __all__ = ( 'DiscordException', 'ClientException', @@ -48,6 +46,7 @@ __all__ = ( 'DiscordServerError', 'InvalidData', 'InvalidArgument', + 'AuthFailure', 'LoginFailure', ) @@ -57,7 +56,6 @@ class DiscordException(Exception): Ideally speaking, this could be caught to handle any exceptions raised from this library. """ - pass @@ -66,19 +64,16 @@ class ClientException(DiscordException): These are usually for exceptions that happened due to user input. """ - pass class NoMoreItems(DiscordException): """Exception that is raised when an async iteration operation has no more items.""" - 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.' super().__init__(message) @@ -120,7 +115,6 @@ class HTTPException(DiscordException): json: Dict[any, any] 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 @@ -153,7 +147,6 @@ class Forbidden(HTTPException): Subclass of :exc:`HTTPException` """ - pass @@ -162,7 +155,6 @@ class NotFound(HTTPException): Subclass of :exc:`HTTPException` """ - pass @@ -173,7 +165,6 @@ class DiscordServerError(HTTPException): .. versionadded:: 1.5 """ - pass @@ -181,7 +172,6 @@ class InvalidData(ClientException): """Exception that's raised when the library encounters unknown or invalid data from Discord. """ - pass @@ -193,7 +183,6 @@ class InvalidArgument(ClientException): ``TypeError`` except inherited from :exc:`ClientException` and thus :exc:`DiscordException`. """ - pass @@ -202,9 +191,9 @@ class AuthFailure(ClientException): fails to log you in from improper credentials or some other misc. failure. """ - pass + LoginFailure = AuthFailure @@ -219,7 +208,6 @@ class ConnectionClosed(ClientException): reason: :class:`str` The reason provided for the closure. """ - def __init__(self, socket: ClientWebSocketResponse, *, code: Optional[int] = None): # This exception is just the same exception except # reconfigured to subclass ClientException for users From c5aeb64fa7a0e1f5d1dd418cf44ba071a6f9a515 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 20:46:32 -0500 Subject: [PATCH 049/154] Try to get docs building v2 --- docs/api.rst | 204 ++++++++------------------------------------------- 1 file changed, 30 insertions(+), 174 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 427554f3e..00646ae48 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -196,16 +196,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. @@ -214,16 +204,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 @@ -236,28 +216,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,7 +314,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. If the ``channel`` is a :class:`TextChannel` 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` @@ -365,7 +327,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:: @@ -388,7 +350,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. If this occurs increase the :class:`max_messages ` 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` @@ -405,7 +367,7 @@ to handle it, which defaults to print a traceback and ignoring the exception. If this occurs increase the :class:`max_messages ` 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`] @@ -418,7 +380,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` @@ -431,7 +393,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` @@ -457,7 +419,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` @@ -483,7 +445,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` @@ -498,7 +460,7 @@ 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:: @@ -518,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` @@ -550,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` @@ -561,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` @@ -573,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` @@ -584,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 @@ -596,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. @@ -647,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` @@ -656,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. @@ -667,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``. @@ -681,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. @@ -694,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 @@ -714,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. @@ -728,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. @@ -739,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. @@ -752,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. @@ -763,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. @@ -774,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. @@ -785,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. @@ -796,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` @@ -806,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` @@ -821,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` @@ -837,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 @@ -856,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` @@ -868,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` @@ -887,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` @@ -901,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` @@ -915,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` @@ -924,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` @@ -935,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` @@ -948,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 @@ -965,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. @@ -980,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` @@ -1019,7 +932,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` @@ -1032,7 +945,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` @@ -1051,7 +964,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` @@ -1071,7 +984,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` @@ -4068,14 +3981,6 @@ PermissionOverwrite .. autoclass:: PermissionOverwrite :members: -ShardInfo -~~~~~~~~~~~ - -.. attributetable:: ShardInfo - -.. autoclass:: ShardInfo() - :members: - SystemChannelFlags ~~~~~~~~~~~~~~~~~~~~ @@ -4100,52 +4005,6 @@ PublicUserFlags .. autoclass:: PublicUserFlags() :members: -.. _discord_ui_kit: - -Bot UI Kit -------------- - -The library has helpers to help create component-based UIs. - -View -~~~~~~~ - -.. attributetable:: discord.ui.View - -.. autoclass:: discord.ui.View - :members: - -Item -~~~~~~~ - -.. attributetable:: discord.ui.Item - -.. autoclass:: discord.ui.Item - :members: - -Button -~~~~~~~ - -.. attributetable:: discord.ui.Button - -.. autoclass:: discord.ui.Button - :members: - :inherited-members: - -.. autofunction:: discord.ui.button - -Select -~~~~~~~ - -.. attributetable:: discord.ui.Select - -.. autoclass:: discord.ui.Select - :members: - :inherited-members: - -.. autofunction:: discord.ui.select - - Exceptions ------------ @@ -4176,8 +4035,6 @@ The following exceptions are thrown by the library. .. autoexception:: ConnectionClosed -.. autoexception:: PrivilegedIntentsRequired - .. autoexception:: InteractionResponded .. autoexception:: discord.opus.OpusError @@ -4196,7 +4053,6 @@ Exception Hierarchy - :exc:`InvalidArgument` - :exc:`LoginFailure` - :exc:`ConnectionClosed` - - :exc:`PrivilegedIntentsRequired` - :exc:`InteractionResponded` - :exc:`NoMoreItems` - :exc:`GatewayNotFound` From a9b26e906279aa175264a4beb914de746933dfe6 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 10 Nov 2021 20:58:37 -0500 Subject: [PATCH 050/154] Actually get docs building --- discord/errors.py | 1 + docs/api.rst | 35 ----------------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/discord/errors.py b/discord/errors.py index 5c83fed97..3d236e793 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -48,6 +48,7 @@ __all__ = ( 'InvalidArgument', 'AuthFailure', 'LoginFailure', + 'ConnectionClosed', ) diff --git a/docs/api.rst b/docs/api.rst index 00646ae48..6bc1db630 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3435,30 +3435,6 @@ Integration .. autoclass:: StreamIntegration() :members: -Interaction -~~~~~~~~~~~~ - -.. attributetable:: Interaction - -.. autoclass:: Interaction() - :members: - -InteractionResponse -~~~~~~~~~~~~~~~~~~~~ - -.. attributetable:: InteractionResponse - -.. autoclass:: InteractionResponse() - :members: - -InteractionMessage -~~~~~~~~~~~~~~~~~~~ - -.. attributetable:: InteractionMessage - -.. autoclass:: InteractionMessage() - :members: - Member ~~~~~~ @@ -3885,14 +3861,6 @@ SelectOption .. autoclass:: SelectOption :members: -Intents -~~~~~~~~~~ - -.. attributetable:: Intents - -.. autoclass:: Intents - :members: - MemberCacheFlags ~~~~~~~~~~~~~~~~~~ @@ -4035,8 +4003,6 @@ The following exceptions are thrown by the library. .. autoexception:: ConnectionClosed -.. autoexception:: InteractionResponded - .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -4053,7 +4019,6 @@ Exception Hierarchy - :exc:`InvalidArgument` - :exc:`LoginFailure` - :exc:`ConnectionClosed` - - :exc:`InteractionResponded` - :exc:`NoMoreItems` - :exc:`GatewayNotFound` - :exc:`HTTPException` From b96d31aedcb2834e18b179a0607b9f9b0d63196d Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 12 Nov 2021 20:52:22 -0500 Subject: [PATCH 051/154] Add a few things to docs --- discord/__init__.py | 3 ++ discord/client.py | 4 +-- discord/errors.py | 2 +- discord/settings.py | 1 - docs/api.rst | 77 ++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index f4149fb14..f31dc8592 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -57,6 +57,9 @@ from .sticker import * from .stage_instance import * from .components import * from .threads import * +from .relationship import * +from .guild_folder import * +from .settings import * class _VersionInfo(NamedTuple): diff --git a/discord/client.py b/discord/client.py index 9e0a4e914..ee5db7595 100644 --- a/discord/client.py +++ b/discord/client.py @@ -147,7 +147,7 @@ class Client: If not given, defaults to off. .. versionadded:: 1.9 - request_guilds :class:`bool` + request_guilds: :class:`bool` Whether to request guilds at startup (behaves similarly to the old guild_subscriptions option). Defaults to True. @@ -1686,7 +1686,7 @@ class Client: return note async def fetch_private_channels(self) -> List[PrivateChannel]: - """|coro + """|coro| Retrieves all your private channels. diff --git a/discord/errors.py b/discord/errors.py index 3d236e793..80c964f53 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -113,7 +113,7 @@ class HTTPException(DiscordException): The status code of the HTTP request. code: :class:`int` The Discord specific error code for the failure. - json: Dict[any, any] + json: :class:`dict` The raw error JSON. """ def __init__(self, response: _ResponseType, message: Optional[Union[str, Dict[str, Any]]]): diff --git a/discord/settings.py b/discord/settings.py index 0514e4531..26f53769d 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -258,7 +258,6 @@ class ChannelSettings: self._update(data) def _update(self, data: Dict[str, Any]) -> None: - breakpoint() self._channel_id = int(data['channel_id']) self.collapsed = data.get('collapsed', False) diff --git a/docs/api.rst b/docs/api.rst index 6bc1db630..4da3ade20 100644 --- a/docs/api.rst +++ b/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 --------------------- @@ -186,8 +186,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() @@ -308,10 +307,10 @@ 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`. @@ -462,7 +461,9 @@ to handle it, which defaults to print a traceback and ignoring the exception. - .. 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 @@ -2013,7 +2014,8 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.name` - - :attr:`~AuditLogDiff.type` (always set to ``1`` if so) + - :attr:`~AuditLogDif + .. attribute:: verified_bot_developerf.type` (always set to ``1`` if so) .. attribute:: emoji_create @@ -2375,6 +2377,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. @@ -3308,6 +3315,33 @@ User .. automethod:: typing :async-with: +Profile +~~~~~~~~ + +.. attributetable:: Profile + +.. autoclass:: Profile() + :members: + :inherited-members: + +Relationship +~~~~~~~~~~~~~ + +.. attributetable:: Relationship + +.. autoclass:: Relationship() + :members: + :inherited-members: + +UserSettings +~~~~~~~~~~~~~ + +.. attributetable:: UserSettings + +.. autoclass:: UserSettings() + :members: + :inherited-members: + Attachment ~~~~~~~~~~~ @@ -3416,6 +3450,33 @@ Guild :type: :class:`User` +GuildFolder +~~~~~~~~~~~~ + +.. attributetable:: GuildFolder + +.. autoclass:: GuildFolder() + :members: + :inherited-members: + +GuildSettings +~~~~~~~~~~~~~~ + +.. attributetable:: GuildSettings + +.. autoclass:: GuildSettings() + :members: + :inherited-members: + +ChannelSettings +~~~~~~~~~~~~~~~~ + +.. attributetable:: ChannelSettings + +.. autoclass:: ChannelSettings() + :members: + :inherited-members: + Integration ~~~~~~~~~~~~ From a9fa3ea862adc22c0315abea21a87e54beccc0b1 Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 12 Nov 2021 20:54:39 -0500 Subject: [PATCH 052/154] Add missing import --- discord/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/channel.py b/discord/channel.py index b9d87165c..d21ae97ab 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -51,7 +51,7 @@ from .object import Object from . import utils from .utils import MISSING from .asset import Asset -from .errors import ClientException, InvalidArgument +from .errors import ClientException, InvalidArgument, NotFound from .stage_instance import StageInstance from .threads import Thread from .iterators import ArchivedThreadIterator From ad3904d65a03c7691441b8e874ed3283f85daa8c Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 12 Nov 2021 20:55:29 -0500 Subject: [PATCH 053/154] Fix user.py __all__ --- discord/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/user.py b/discord/user.py index 65843eeed..71100ac1f 100644 --- a/discord/user.py +++ b/discord/user.py @@ -55,6 +55,7 @@ if TYPE_CHECKING: __all__ = ( 'User', 'ClientUser', + 'Profile', ) BU = TypeVar('BU', bound='BaseUser') From bcdfa36bf9567911c289e9e4ba31a2aaa94694dd Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 13 Nov 2021 14:34:39 -0500 Subject: [PATCH 054/154] Debotify docs --- docs/discord.rst | 96 -- docs/faq.rst | 2 +- docs/images/discord_bot_tab.png | Bin 9430 -> 0 bytes docs/images/discord_bot_user_options.png | Bin 48479 -> 0 bytes docs/images/discord_create_app_button.png | Bin 1580 -> 0 bytes docs/images/discord_create_app_form.png | Bin 12216 -> 0 bytes docs/images/discord_create_bot_user.png | Bin 16587 -> 0 bytes docs/images/discord_oauth2.png | Bin 29649 -> 0 bytes docs/images/discord_oauth2_perms.png | Bin 52706 -> 0 bytes docs/images/discord_oauth2_scope.png | Bin 31714 -> 0 bytes docs/images/discord_privileged_intents.png | Bin 51849 -> 0 bytes docs/index.rst | 17 +- docs/intents.rst | 192 ---- docs/intro.rst | 20 +- docs/logging.rst | 2 +- docs/migrating.rst | 1207 +------------------- docs/migrating_to_async.rst | 322 ------ docs/token.rst | 24 + docs/version_guarantees.rst | 2 + 19 files changed, 101 insertions(+), 1783 deletions(-) delete mode 100644 docs/discord.rst delete mode 100644 docs/images/discord_bot_tab.png delete mode 100644 docs/images/discord_bot_user_options.png delete mode 100644 docs/images/discord_create_app_button.png delete mode 100644 docs/images/discord_create_app_form.png delete mode 100644 docs/images/discord_create_bot_user.png delete mode 100644 docs/images/discord_oauth2.png delete mode 100644 docs/images/discord_oauth2_perms.png delete mode 100644 docs/images/discord_oauth2_scope.png delete mode 100644 docs/images/discord_privileged_intents.png delete mode 100644 docs/intents.rst delete mode 100644 docs/migrating_to_async.rst create mode 100644 docs/token.rst diff --git a/docs/discord.rst b/docs/discord.rst deleted file mode 100644 index ac12417f0..000000000 --- a/docs/discord.rst +++ /dev/null @@ -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 `_. -2. Navigate to the `application page `_ -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 `_. -2. Navigate to the `application page `_ -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 `_ 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`. diff --git a/docs/faq.rst b/docs/faq.rst index 3f46d2a97..9cf7a2a3f 100644 --- a/docs/faq.rst +++ b/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. `_ +`Check the background_task.py example. `_ How do I get a specific model? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/images/discord_bot_tab.png b/docs/images/discord_bot_tab.png deleted file mode 100644 index 835682448e3fc42afb5a6480b12e9c559e04acdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9430 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 diff --git a/docs/images/discord_bot_user_options.png b/docs/images/discord_bot_user_options.png deleted file mode 100644 index 4091bd88e0043121f99b8afbaee51f8b02ca0e62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48479 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

ii#qTfPUtWjxN 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�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 RGzJ5fjD(_iwWwj>{|Aej^$q|4 diff --git a/docs/images/discord_create_app_button.png b/docs/images/discord_create_app_button.png deleted file mode 100644 index e6d06a7a2fb48c275386b16f3480acd1569b94f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1580 zcmbtU`!^c~6i#Q2S4dkKsaL8-8}Vv2B&XE-5wEsUQyU?r!8*^3Fi|DcYu=H{L3A4> z>M=#iq}nbtHH4z|ekBbjhH9+p6&7dv1J<)2zWbf;oO|y5&OP^hSyw!rl@!z!004jz z#>D|E-`5gNw}cxvihq88w#7BZJS|G+xTG9(5{rz-Y!;zK?@`vTw60W-!i z5)CD-?Zil{_3fk^Be`Ii)%y)`T~CvtYX@zwV+T&{nRlJ++KXTk_j%kAZ;i$|oana* zNIdVYDHd+Q+Ux}O#}8wTn^x01gUawp#JOCsy}?a?Grfe_y@WP|hncY8dd+FVWCw%V z#I`!gOB#|hd4ha}35vqxH}z?iZooV;7B_8U-8(9iHrAVOfy57#aUgcQL3Ku(5`Lsq zNN0@{4|CS?ca82|TJhezf4DgYUYL4wFP8+1bu|!fU2AbI`(|3cfn{S3C39swab&*F zXN2bn3)PEDa4)llsAO>{T9_DzuR(ZPsrtlmmUnR1XT}w8h1cf1eq0&v=bR^Cc??GS zgg;{WZda0#FcwL<&axvU@|`I;ajABuHEZln#SCXzyZdxSj8VXFd)`ig#Tc%$Z91!X z>B%O-rX_Z$xAHb?%k&>}=zURsZk>#gs#|Y=Pe-gJCAYhrqNO6Jhlo2-Oi>&1P5 zRhJF4r)wV5Su?-S?Oly0d4&v>zRy`FS4J=mp%JWsYP5&Y?w7jxem#l5%Wu8SXS!EK_am7opv1PEb#R__RpL%tB$iXje7dT> z`hY^-$SzX_b`b^~q-$muo zM5*@Me4hx|$*MW;JB3<9dYxz)JGL%9G>!Bk@M8`7ABP|=nMb`Wj7aO+zs*#UC0Net z87aY`Q~@o*9LXjnoEtWBV!RL3JqOM+dN1krqSrH8D-#73@0V7`z+Jr@^U$}w6xw33 zg4P&C7Tu+7E0{~q!u*le>IDMc;kBg55I|F5;@q_6D!Bq)c7H0@XvPnA?>NVJmTh m8CD_s>raZuf16*>*TKnF271TUCX}QJ55PEjIyBk^rv43t1nkEE diff --git a/docs/images/discord_create_app_form.png b/docs/images/discord_create_app_form.png deleted file mode 100644 index a8afd4fa638705ce402c7dddc077533838b25928..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12216 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 z 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.~O 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 )9dT=4! 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 diff --git a/docs/images/discord_oauth2_scope.png b/docs/images/discord_oauth2_scope.png deleted file mode 100644 index 9e2cdeff0760f6f498a0743e5acf0b4aad9860c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31714 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

cgG~wgRF%?yCr@?ZyF>(gyDD69 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 Tuj_vS|5P7oJuH9l?9KlIyuJ*i diff --git a/docs/index.rst b/docs/index.rst index e1af5163f..34e6e52b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,16 +1,11 @@ -.. discord.py documentation master file, created by - sphinx-quickstart on Fri Aug 21 05:43:30 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to discord.py +Welcome to discord.py-self =========================== .. image:: /images/snake.svg .. image:: /images/snake_dark.svg -discord.py is a modern, easy to use, feature-rich, and async ready API wrapper -for Discord. +discord.py-self is a modern, easy to use, feature-rich, and async ready API wrapper +for the Discord user APIs. **Features:** @@ -26,16 +21,18 @@ Getting started Is this your first time using the library? This is the place to get started! - **First steps:** :doc:`intro` | :doc:`quickstart` | :doc:`logging` -- **Working with Discord:** :doc:`discord` | :doc:`intents` +- **Working with Discord:** :doc:`token` - **Examples:** Many examples are available in the :resource:`repository `. +**Obligatory note:** +Automating user accounts is against the Discord ToS. If what you are trying to do is accomplishable with a bot account, please use one. + Getting help -------------- If you're having trouble with something, these resources might help. - Try the :doc:`faq` first, it's got answers to all common questions. -- Ask us and hang out with us in our :resource:`Discord ` server. - If you're looking for something specific, try the :ref:`index ` or :ref:`searching `. - Report bugs in the :resource:`issue tracker `. - Ask in our :resource:`GitHub discussions page `. diff --git a/docs/intents.rst b/docs/intents.rst deleted file mode 100644 index a9708aafa..000000000 --- a/docs/intents.rst +++ /dev/null @@ -1,192 +0,0 @@ -:orphan: - -.. currentmodule:: discord -.. versionadded:: 1.5 -.. _intents_primer: - -A Primer to Gateway Intents -============================= - -In version 1.5 comes the introduction of :class:`Intents`. This is a radical change in how bots are written. An intent basically allows a bot to subscribe to specific buckets of events. The events that correspond to each intent is documented in the individual attribute of the :class:`Intents` documentation. - -These intents are passed to the constructor of :class:`Client` or its subclasses (:class:`AutoShardedClient`, :class:`~.AutoShardedBot`, :class:`~.Bot`) with the ``intents`` argument. - -If intents are not passed, then the library defaults to every intent being enabled except the privileged intents, currently :attr:`Intents.members` and :attr:`Intents.presences`. - -What intents are needed? --------------------------- - -The intents that are necessary for your bot can only be dictated by yourself. Each attribute in the :class:`Intents` class documents what :ref:`events ` it corresponds to and what kind of cache it enables. - -For example, if you want a bot that functions without spammy events like presences or typing then we could do the following: - -.. code-block:: python3 - :emphasize-lines: 7,9,10 - - import discord - intents = discord.Intents.default() - intents.typing = False - intents.presences = False - - # Somewhere else: - # client = discord.Client(intents=intents) - # or - # from discord.ext import commands - # bot = commands.Bot(command_prefix='!', intents=intents) - -Note that this doesn't enable :attr:`Intents.members` since it's a privileged intent. - -Another example showing a bot that only deals with messages and guild information: - -.. code-block:: python3 - :emphasize-lines: 7,9,10 - - import discord - intents = discord.Intents(messages=True, guilds=True) - # If you also want reaction events enable the following: - # intents.reactions = True - - # Somewhere else: - # client = discord.Client(intents=intents) - # or - # from discord.ext import commands - # bot = commands.Bot(command_prefix='!', intents=intents) - -.. _privileged_intents: - -Privileged Intents ---------------------- - -With the API change requiring bot authors to specify intents, some intents were restricted further and require more manual steps. These intents are called **privileged intents**. - -A privileged intent is one that requires you to go to the developer portal and manually enable it. To enable privileged intents do the following: - -1. Make sure you're logged on to the `Discord website `_. -2. Navigate to the `application page `_. -3. Click on the bot you want to enable privileged intents for. -4. Navigate to the bot tab on the left side of the screen. - - .. image:: /images/discord_bot_tab.png - :alt: The bot tab in the application page. - -5. Scroll down to the "Privileged Gateway Intents" section and enable the ones you want. - - .. image:: /images/discord_privileged_intents.png - :alt: The privileged gateway intents selector. - -.. warning:: - - Enabling privileged intents when your bot is in over 100 guilds requires going through `bot verification `_. If your bot is already verified and you would like to enable a privileged intent you must go through `Discord support `_ and talk to them about it. - -.. note:: - - Even if you enable intents through the developer portal, you still have to enable the intents - through code as well. - -Do I need privileged intents? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This is a quick checklist to see if you need specific privileged intents. - -.. _need_presence_intent: - -Presence Intent -+++++++++++++++++ - -- Whether you use :attr:`Member.status` at all to track member statuses. -- Whether you use :attr:`Member.activity` or :attr:`Member.activities` to check member's activities. - -.. _need_members_intent: - -Member Intent -+++++++++++++++ - -- Whether you track member joins or member leaves, corresponds to :func:`on_member_join` and :func:`on_member_remove` events. -- Whether you want to track member updates such as nickname or role changes. -- Whether you want to track user updates such as usernames, avatars, discriminators, etc. -- Whether you want to request the guild member list through :meth:`Guild.chunk` or :meth:`Guild.fetch_members`. -- Whether you want high accuracy member cache under :attr:`Guild.members`. - -.. _intents_member_cache: - -Member Cache -------------- - -Along with intents, Discord now further restricts the ability to cache members and expects bot authors to cache as little as is necessary. However, to properly maintain a cache the :attr:`Intents.members` intent is required in order to track the members who left and properly evict them. - -To aid with member cache where we don't need members to be cached, the library now has a :class:`MemberCacheFlags` flag to control the member cache. The documentation page for the class goes over the specific policies that are possible. - -It should be noted that certain things do not need a member cache since Discord will provide full member information if possible. For example: - -- :func:`on_message` will have :attr:`Message.author` be a member even if cache is disabled. -- :func:`on_voice_state_update` will have the ``member`` parameter be a member even if cache is disabled. -- :func:`on_reaction_add` will have the ``user`` parameter be a member when in a guild even if cache is disabled. -- :func:`on_raw_reaction_add` will have :attr:`RawReactionActionEvent.member` be a member when in a guild even if cache is disabled. -- The reaction add events do not contain additional information when in direct messages. This is a Discord limitation. -- The reaction removal events do not have member information. This is a Discord limitation. - -Other events that take a :class:`Member` will require the use of the member cache. If absolute accuracy over the member cache is desirable, then it is advisable to have the :attr:`Intents.members` intent enabled. - -.. _retrieving_members: - -Retrieving Members --------------------- - -If the cache is disabled or you disable chunking guilds at startup, we might still need a way to load members. The library offers a few ways to do this: - -- :meth:`Guild.query_members` - - Used to query members by a prefix matching nickname or username. - - This can also be used to query members by their user ID. - - This uses the gateway and not the HTTP. -- :meth:`Guild.chunk` - - This can be used to fetch the entire member list through the gateway. -- :meth:`Guild.fetch_member` - - Used to fetch a member by ID through the HTTP API. -- :meth:`Guild.fetch_members` - - used to fetch a large number of members through the HTTP API. - -It should be noted that the gateway has a strict rate limit of 120 requests per 60 seconds. - -Troubleshooting ------------------- - -Some common issues relating to the mandatory intent change. - -Where'd my members go? -~~~~~~~~~~~~~~~~~~~~~~~~ - -Due to an :ref:`API change ` Discord is now forcing developers who want member caching to explicitly opt-in to it. This is a Discord mandated change and there is no way to bypass it. In order to get members back you have to explicitly enable the :ref:`members privileged intent ` and change the :attr:`Intents.members` attribute to true. - -For example: - -.. code-block:: python3 - :emphasize-lines: 3,6,8,9 - - import discord - intents = discord.Intents.default() - intents.members = True - - # Somewhere else: - # client = discord.Client(intents=intents) - # or - # from discord.ext import commands - # bot = commands.Bot(command_prefix='!', intents=intents) - -Why does ``on_ready`` take so long to fire? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -As part of the API change regarding intents, Discord also changed how members are loaded in the beginning. Originally the library could request 75 guilds at once and only request members from guilds that have the :attr:`Guild.large` attribute set to ``True``. With the new intent changes, Discord mandates that we can only send 1 guild per request. This causes a 75x slowdown which is further compounded by the fact that *all* guilds, not just large guilds are being requested. - -There are a few solutions to fix this. - -The first solution is to request the privileged presences intent along with the privileged members intent and enable both of them. This allows the initial member list to contain online members just like the old gateway. Note that we're still limited to 1 guild per request but the number of guilds we request is significantly reduced. - -The second solution is to disable member chunking by setting ``chunk_guilds_at_startup`` to ``False`` when constructing a client. Then, when chunking for a guild is necessary you can use the various techniques to :ref:`retrieve members `. - -To illustrate the slowdown caused by the API change, take a bot who is in 840 guilds and 95 of these guilds are "large" (over 250 members). - -Under the original system this would result in 2 requests to fetch the member list (75 guilds, 20 guilds) roughly taking 60 seconds. With :attr:`Intents.members` but not :attr:`Intents.presences` this requires 840 requests, with a rate limit of 120 requests per 60 seconds means that due to waiting for the rate limit it totals to around 7 minutes of waiting for the rate limit to fetch all the members. With both :attr:`Intents.members` and :attr:`Intents.presences` we mostly get the old behaviour so we're only required to request for the 95 guilds that are large, this is slightly less than our rate limit so it's close to the original timing to fetch the member list. - -Unfortunately due to this change being required from Discord there is nothing that the library can do to mitigate this. - -If you truly dislike the direction Discord is going with their API, you can contact them via `support `_. diff --git a/docs/intro.rst b/docs/intro.rst index 12a73c70a..1d4b8563e 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -7,14 +7,14 @@ Introduction ============== -This is the documentation for discord.py, a library for Python to aid -in creating applications that utilise the Discord API. +This is the documentation for discord.py-self, a library for Python to aid +in creating self-bots that utilise the Discord API. Prerequisites --------------- -discord.py works with Python 3.8 or higher. Support for earlier versions of Python -is not provided. Python 2.7 or lower is not supported. Python 3.7 or lower is not supported. +discord.py-self works with Python 3.8 or higher. Support for earlier versions of Python +is not provided. .. _installing: @@ -24,16 +24,16 @@ Installing You can get the library directly from PyPI: :: - python3 -m pip install -U discord.py + python3 -m pip install -U discord.py-self If you are using Windows, then the following should be used instead: :: - py -3 -m pip install -U discord.py + py -3 -m pip install -U discord.py-self -To get voice support, you should use ``discord.py[voice]`` instead of ``discord.py``, e.g. :: +To get voice support, you should use ``discord.py-self[voice]`` instead of ``discord.py``, e.g. :: - python3 -m pip install -U discord.py[voice] + python3 -m pip install -U discord.py-self[voice] On Linux environments, installing voice requires getting the following dependencies: @@ -84,7 +84,7 @@ However, for the quick and dirty: .. code-block:: shell - $ pip install -U discord.py + $ pip install -U discord.py-self Congratulations. You now have a virtual environment all set up. @@ -109,5 +109,5 @@ A quick example to showcase how events work: print(f'Message from {messsage.author}: {message.content}') client = MyClient() - client.run('my token goes here') + client.run('token') diff --git a/docs/logging.rst b/docs/logging.rst index 535a373f3..27a370cf3 100644 --- a/docs/logging.rst +++ b/docs/logging.rst @@ -6,7 +6,7 @@ Setting Up Logging =================== -*discord.py* logs errors and debug information via the :mod:`logging` python +*discord.py-self* logs errors and debug information via the :mod:`logging` python module. It is strongly recommended that the logging module is configured, as no errors or warnings will be output if it is not set up. Configuration of the ``logging`` module can be as simple as:: diff --git a/docs/migrating.rst b/docs/migrating.rst index baf97160f..c7d8c91cf 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -1,1172 +1,77 @@ .. currentmodule:: discord -.. _migrating_1_0: +.. _migrating: -Migrating to v1.0 -====================== +Migrating to this library +========================== -v1.0 is one of the biggest breaking changes in the library due to a complete -redesign. +This library is designed to be compatible with discord.py. +However, the user and bot APIs are *not* the same. -The amount of changes are so massive and long that for all intents and purposes, it is a completely -new library. +Most things bots can do, users can (in some capacity) as well. -Part of the redesign involves making things more easy to use and natural. Things are done on the -:ref:`models ` instead of requiring a :class:`Client` instance to do any work. +However, a number of things have been removed. +For example: +- `Intents`: While the gateway technically accepts Intents for user accounts (and even modifies payloads to be a little more like bot payloads), it leads to breakage. Additionally, it's a giant waving red flag to Discord. +- `Shards`: The concept doesn't exist and is unneeded for users. +- `Guild.fetch_members`: The `/guilds/:id/members` and `/guilds/:id/members/search` endpoints instantly phone-lock your account. For more information about guild members, please read their respective section below. -Python Version Change ------------------------ +Additionally, existing payloads and headers have been heavily changed to match the Discord client. -In order to make development easier and also to allow for our dependencies to upgrade to allow usage of 3.7 or higher, -the library had to remove support for Python versions lower than 3.5.3, which essentially means that **support for Python 3.4 -is dropped**. +`guild.members` +---------------- +Since the concept of Intents (mostly) doesn't exist for user accounts; you just get all events, right? +Well, yes but actually no. -Major Model Changes ---------------------- +For 80% of things, events are identical to bot events. However, other than the quite large amount of new events, not all events work the same. -Below are major model changes that have happened in v1.0 +The biggest example of this are the events `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. -Snowflakes are int -~~~~~~~~~~~~~~~~~~~~ +Bots +~~~~~ +For bots (with the member intent), it's simple. They request all guild members with an OPCode 8 (chunk the guild), and receive respective `GUILD_MEMBER_*` events, that are then parsed by the library and dispatched to users. +If the bot has the presence intent, it even gets an initial member cache in the `GUILD_CREATE` event. -Before v1.0, all snowflakes (the ``id`` attribute) were strings. This has been changed to :class:`int`. +Users +~~~~~~ +Users, however, do not work like this. +If you have one of kick members, ban members, or manage roles, you can request all guild members the same way bots do. The client uses this in various areas of guild settings. -Quick example: :: +But, here's the twist: users do not receive `GUILD_MEMBER_*` reliably. +They receive them in certain circumstances, but they're usually rare and nothing to be relied on. +If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library). The client almost never uses the :func:`Guild.fetch_member` endpoint. +However, the maximum amount of members you can get with this method is 100 per request. - # before - ch = client.get_channel('84319995256905728') - if message.author.id == '80528701850124288': - ... +But, you may be thinking, how does the member list work? Why can't you just utilize that? This is where it gets complicated. +First, let's make sure we understand a few things: +- The API doesn't differentiate between offline and invisible members (for a good reason). +- The concept of a member list is not per-guild, it's per-channel. This makes sense if you think about it, since the member list only shows users that have access to a specific channel. +- The member list is always up-to-date. +- If a server has >1k members, the member list does **not** have offline members. - # after - ch = client.get_channel(84319995256905728) - if message.author.id == 80528701850124288: - ... +The member list uses OPCode 14, and the `GUILD_MEMBER_LIST_UPDATE` event. -This change allows for fewer errors when using the Copy ID feature in the official client since you no longer have -to wrap it in quotes and allows for optimisation opportunities by allowing ETF to be used instead of JSON internally. +One more thing you need to understand, is that the member list is lazily loaded. You subscribe to 100 member ranges, and can subscribe to 2 per-request (needs more testing). So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds). +Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE`s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). +These can be parsed and dispatched as `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. -Server is now Guild -~~~~~~~~~~~~~~~~~~~~~ +You may have already noticed a few problems with this: +1. You'll get spammed with `member_add/remove`s whenever someone changes ranges. +2. For guilds with >1k members you don't receive offline members. So, you won't know if an offline member is kicked, or an invisible member joins/leaves. You also won't know if someone came online or joined. Or, if someone went offline or left. -The official API documentation calls the "Server" concept a "Guild" instead. In order to be more consistent with the -API documentation when necessary, the model has been renamed to :class:`Guild` and all instances referring to it has -been changed as well. +#1 is solveable with a bit of parsing, but #2 is a huge problem. +If you have the permissions to request all guild members, you can combine that with member list scraping and get a *decent* local member cache. However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. -A list of changes is as follows: +Additionally, there are more caveats: +1. `GUILD_MEMBER_LIST_UPDATE` removes provide an index, not a user ID. The index starts at 0 from the top of the member list and includes hoisted roles. +2. For large servers, you get ratelimited pretty fast, so scraping can take over half an hour. +3. The scraping has to happen every time the bot starts. This not only slows things down, but *may* make Discord suspicious. +4. Remember that member lists are per-channel? Well, that means you can only subscribe all members that can *see* the channel you're subscribing too. -+-------------------------------+----------------------------------+ -| Before | After | -+-------------------------------+----------------------------------+ -| ``Message.server`` | :attr:`Message.guild` | -+-------------------------------+----------------------------------+ -| ``Channel.server`` | :attr:`.GuildChannel.guild` | -+-------------------------------+----------------------------------+ -| ``Client.servers`` | :attr:`Client.guilds` | -+-------------------------------+----------------------------------+ -| ``Client.get_server`` | :meth:`Client.get_guild` | -+-------------------------------+----------------------------------+ -| ``Emoji.server`` | :attr:`Emoji.guild` | -+-------------------------------+----------------------------------+ -| ``Role.server`` | :attr:`Role.guild` | -+-------------------------------+----------------------------------+ -| ``Invite.server`` | :attr:`Invite.guild` | -+-------------------------------+----------------------------------+ -| ``Member.server`` | :attr:`Member.guild` | -+-------------------------------+----------------------------------+ -| ``Permissions.manage_server`` | :attr:`Permissions.manage_guild` | -+-------------------------------+----------------------------------+ -| ``VoiceClient.server`` | :attr:`VoiceClient.guild` | -+-------------------------------+----------------------------------+ -| ``Client.create_server`` | :meth:`Client.create_guild` | -+-------------------------------+----------------------------------+ +#1 is again solveable with a bit of parsing. There's not much you can do about #2 and #3. But, to solve #4, you *can* subscribe to multiple channels. Although, that will probably have problems of its own. -.. _migrating_1_0_model_state: - -Models are Stateful -~~~~~~~~~~~~~~~~~~~~~ - -As mentioned earlier, a lot of functionality was moved out of :class:`Client` and -put into their respective :ref:`model `. - -A list of these changes is enumerated below. - -+---------------------------------------+------------------------------------------------------------------------------+ -| Before | After | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.add_reaction`` | :meth:`Message.add_reaction` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.add_roles`` | :meth:`Member.add_roles` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.ban`` | :meth:`Member.ban` or :meth:`Guild.ban` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.change_nickname`` | :meth:`Member.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.clear_reactions`` | :meth:`Message.clear_reactions` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.create_channel`` | :meth:`Guild.create_text_channel` and :meth:`Guild.create_voice_channel` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.create_custom_emoji`` | :meth:`Guild.create_custom_emoji` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.create_invite`` | :meth:`abc.GuildChannel.create_invite` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.create_role`` | :meth:`Guild.create_role` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_channel`` | :meth:`abc.GuildChannel.delete` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_channel_permissions`` | :meth:`abc.GuildChannel.set_permissions` with ``overwrite`` set to ``None`` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_custom_emoji`` | :meth:`Emoji.delete` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_invite`` | :meth:`Invite.delete` or :meth:`Client.delete_invite` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_message`` | :meth:`Message.delete` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_messages`` | :meth:`TextChannel.delete_messages` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_role`` | :meth:`Role.delete` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.delete_server`` | :meth:`Guild.delete` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_channel`` | :meth:`TextChannel.edit` or :meth:`VoiceChannel.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_channel_permissions`` | :meth:`abc.GuildChannel.set_permissions` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_custom_emoji`` | :meth:`Emoji.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_message`` | :meth:`Message.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_profile`` | :meth:`ClientUser.edit` (you get this from :attr:`Client.user`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_role`` | :meth:`Role.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.edit_server`` | :meth:`Guild.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.estimate_pruned_members`` | :meth:`Guild.estimate_pruned_members` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_all_emojis`` | :attr:`Client.emojis` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_bans`` | :meth:`Guild.bans` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_invite`` | :meth:`Client.fetch_invite` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_message`` | :meth:`abc.Messageable.fetch_message` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_reaction_users`` | :meth:`Reaction.users` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.get_user_info`` | :meth:`Client.fetch_user` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.invites_from`` | :meth:`abc.GuildChannel.invites` or :meth:`Guild.invites` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.join_voice_channel`` | :meth:`VoiceChannel.connect` (see :ref:`migrating_1_0_voice`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.kick`` | :meth:`Guild.kick` or :meth:`Member.kick` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.leave_server`` | :meth:`Guild.leave` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.logs_from`` | :meth:`abc.Messageable.history` (see :ref:`migrating_1_0_async_iter`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.move_channel`` | :meth:`TextChannel.edit` or :meth:`VoiceChannel.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.move_member`` | :meth:`Member.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.move_role`` | :meth:`Role.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.pin_message`` | :meth:`Message.pin` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.pins_from`` | :meth:`abc.Messageable.pins` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.prune_members`` | :meth:`Guild.prune_members` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.purge_from`` | :meth:`TextChannel.purge` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.remove_reaction`` | :meth:`Message.remove_reaction` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.remove_roles`` | :meth:`Member.remove_roles` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.replace_roles`` | :meth:`Member.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.send_file`` | :meth:`abc.Messageable.send` (see :ref:`migrating_1_0_sending_messages`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.send_message`` | :meth:`abc.Messageable.send` (see :ref:`migrating_1_0_sending_messages`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.send_typing`` | :meth:`abc.Messageable.trigger_typing` (use :meth:`abc.Messageable.typing`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.server_voice_state`` | :meth:`Member.edit` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.start_private_message`` | :meth:`User.create_dm` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.unban`` | :meth:`Guild.unban` or :meth:`Member.unban` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.unpin_message`` | :meth:`Message.unpin` | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.wait_for_message`` | :meth:`Client.wait_for` (see :ref:`migrating_1_0_wait_for`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.wait_for_reaction`` | :meth:`Client.wait_for` (see :ref:`migrating_1_0_wait_for`) | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.wait_until_login`` | Removed | -+---------------------------------------+------------------------------------------------------------------------------+ -| ``Client.wait_until_ready`` | No change | -+---------------------------------------+------------------------------------------------------------------------------+ - -Property Changes -~~~~~~~~~~~~~~~~~~ - -In order to be a bit more consistent, certain things that were properties were changed to methods instead. - -The following are now methods instead of properties (requires parentheses): - -- :meth:`Role.is_default` -- :meth:`Client.is_ready` -- :meth:`Client.is_closed` - -Dict Value Change -~~~~~~~~~~~~~~~~~~~~~ - -Prior to v1.0 some aggregating properties that retrieved models would return "dict view" objects. - -As a consequence, when the dict would change size while you would iterate over it, a RuntimeError would -be raised and crash the task. To alleviate this, the "dict view" objects were changed into lists. - -The following views were changed to a list: - -- :attr:`Client.guilds` -- :attr:`Client.users` (new in v1.0) -- :attr:`Client.emojis` (new in v1.0) -- :attr:`Guild.channels` -- :attr:`Guild.text_channels` (new in v1.0) -- :attr:`Guild.voice_channels` (new in v1.0) -- :attr:`Guild.emojis` -- :attr:`Guild.members` - -Voice State Changes -~~~~~~~~~~~~~~~~~~~~~ - -Earlier, in v0.11.0 a :class:`VoiceState` class was added to refer to voice states along with a -:attr:`Member.voice` attribute to refer to it. - -However, it was transparent to the user. In an effort to make the library save more memory, the -voice state change is now more visible. - -The only way to access voice attributes is via the :attr:`Member.voice` attribute. Note that if -the member does not have a voice state this attribute can be ``None``. - -Quick example: :: - - # before - member.deaf - member.voice.voice_channel - - # after - if member.voice: # can be None - member.voice.deaf - member.voice.channel - - -User and Member Type Split -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -In v1.0 to save memory, :class:`User` and :class:`Member` are no longer inherited. Instead, they are "flattened" -by having equivalent properties that map out to the functional underlying :class:`User`. Thus, there is no functional -change in how they are used. However this breaks :func:`isinstance` checks and thus is something to keep in mind. - -These memory savings were accomplished by having a global :class:`User` cache, and as a positive consequence you -can now easily fetch a :class:`User` by their ID by using the new :meth:`Client.get_user`. You can also get a list -of all :class:`User` your client can see with :attr:`Client.users`. - -.. _migrating_1_0_channel_split: - -Channel Type Split -~~~~~~~~~~~~~~~~~~~~~ - -Prior to v1.0, channels were two different types, ``Channel`` and ``PrivateChannel`` with a ``is_private`` -property to help differentiate between them. - -In order to save memory the channels have been split into 4 different types: - -- :class:`TextChannel` for guild text channels. -- :class:`VoiceChannel` for guild voice channels. -- :class:`DMChannel` for DM channels with members. -- :class:`GroupChannel` for Group DM channels with members. - -With this split came the removal of the ``is_private`` attribute. You should now use :func:`isinstance`. - -The types are split into two different :ref:`discord_api_abcs`: - -- :class:`abc.GuildChannel` for guild channels. -- :class:`abc.PrivateChannel` for private channels (DMs and group DMs). - -So to check if something is a guild channel you would do: :: - - isinstance(channel, discord.abc.GuildChannel) - -And to check if it's a private channel you would do: :: - - isinstance(channel, discord.abc.PrivateChannel) - -Of course, if you're looking for only a specific type you can pass that too, e.g. :: - - isinstance(channel, discord.TextChannel) - -With this type split also came event changes, which are enumerated in :ref:`migrating_1_0_event_changes`. - - -Miscellaneous Model Changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -There were lots of other things added or removed in the models in general. - -They will be enumerated here. - -**Removed** - -- :meth:`Client.login` no longer accepts email and password logins. - - - Use a token and ``bot=False``. - -- ``Client.get_all_emojis`` - - - Use :attr:`Client.emojis` instead. - -- ``Client.messages`` - - - Use read-only :attr:`Client.cached_messages` instead. - -- ``Client.wait_for_message`` and ``Client.wait_for_reaction`` are gone. - - - Use :meth:`Client.wait_for` instead. - -- ``Channel.voice_members`` - - - Use :attr:`VoiceChannel.members` instead. - -- ``Channel.is_private`` - - - Use ``isinstance`` instead with one of the :ref:`discord_api_abcs` instead. - - e.g. ``isinstance(channel, discord.abc.GuildChannel)`` will check if it isn't a private channel. - -- ``Client.accept_invite`` - - - There is no replacement for this one. This functionality is deprecated API wise. - -- ``Guild.default_channel`` / ``Server.default_channel`` and ``Channel.is_default`` - - - The concept of a default channel was removed from Discord. - See `#329 `_. - -- ``Message.edited_timestamp`` - - - Use :attr:`Message.edited_at` instead. - -- ``Message.timestamp`` - - - Use :attr:`Message.created_at` instead. - -- ``Colour.to_tuple()`` - - - Use :meth:`Colour.to_rgb` instead. - -- ``Permissions.view_audit_logs`` - - - Use :attr:`Permissions.view_audit_log` instead. - -- ``Member.game`` - - - Use :attr:`Member.activities` instead. - -- ``Guild.role_hierarchy`` / ``Server.role_hierarchy`` - - - Use :attr:`Guild.roles` instead. Note that while sorted, it is in the opposite order - of what the old ``Guild.role_hierarchy`` used to be. - -**Changed** - -- :attr:`Member.avatar_url` and :attr:`User.avatar_url` now return the default avatar if a custom one is not set. -- :attr:`Message.embeds` is now a list of :class:`Embed` instead of :class:`dict` objects. -- :attr:`Message.attachments` is now a list of :class:`Attachment` instead of :class:`dict` object. -- :attr:`Guild.roles` is now sorted through hierarchy. The first element is always the ``@everyone`` role. - -**Added** - -- :class:`Attachment` to represent a discord attachment. -- :class:`CategoryChannel` to represent a channel category. -- :attr:`VoiceChannel.members` for fetching members connected to a voice channel. -- :attr:`TextChannel.members` for fetching members that can see the channel. -- :attr:`Role.members` for fetching members that have the role. -- :attr:`Guild.text_channels` for fetching text channels only. -- :attr:`Guild.voice_channels` for fetching voice channels only. -- :attr:`Guild.categories` for fetching channel categories only. -- :attr:`TextChannel.category` and :attr:`VoiceChannel.category` to get the category a channel belongs to. -- :meth:`Guild.by_category` to get channels grouped by their category. -- :attr:`Guild.chunked` to check member chunking status. -- :attr:`Guild.explicit_content_filter` to fetch the content filter. -- :attr:`Guild.shard_id` to get a guild's Shard ID if you're sharding. -- :attr:`Client.users` to get all visible :class:`User` instances. -- :meth:`Client.get_user` to get a :class:`User` by ID. -- :meth:`User.avatar_url_as` to get an avatar in a specific size or format. -- :meth:`Guild.vanity_invite` to fetch the guild's vanity invite. -- :meth:`Guild.audit_logs` to fetch the guild's audit logs. -- :attr:`Message.webhook_id` to fetch the message's webhook ID. -- :attr:`Message.activity` and :attr:`Message.application` for Rich Presence related information. -- :meth:`TextChannel.is_nsfw` to check if a text channel is NSFW. -- :meth:`Colour.from_rgb` to construct a :class:`Colour` from RGB tuple. -- :meth:`Guild.get_role` to get a role by its ID. - -.. _migrating_1_0_sending_messages: - -Sending Messages ------------------- - -One of the changes that were done was the merger of the previous ``Client.send_message`` and ``Client.send_file`` -functionality into a single method, :meth:`~abc.Messageable.send`. - -Basically: :: - - # before - await client.send_message(channel, 'Hello') - - # after - await channel.send('Hello') - -This supports everything that the old ``send_message`` supported such as embeds: :: - - e = discord.Embed(title='foo') - await channel.send('Hello', embed=e) - -There is a caveat with sending files however, as this functionality was expanded to support multiple -file attachments, you must now use a :class:`File` pseudo-namedtuple to upload a single file. :: - - # before - await client.send_file(channel, 'cool.png', filename='testing.png', content='Hello') - - # after - await channel.send('Hello', file=discord.File('cool.png', 'testing.png')) - -This change was to facilitate multiple file uploads: :: - - my_files = [ - discord.File('cool.png', 'testing.png'), - discord.File(some_fp, 'cool_filename.png'), - ] - - await channel.send('Your images:', files=my_files) - -.. _migrating_1_0_async_iter: - -Asynchronous Iterators ------------------------- - -Prior to v1.0, certain functions like ``Client.logs_from`` would return a different type if done in Python 3.4 or 3.5+. - -In v1.0, this change has been reverted and will now return a singular type meeting an abstract concept called -:class:`AsyncIterator`. - -This allows you to iterate over it like normal: :: - - async for message in channel.history(): - print(message) - -Or turn it into a list: :: - - messages = await channel.history().flatten() - for message in messages: - print(message) - -A handy aspect of returning :class:`AsyncIterator` is that it allows you to chain functions together such as -:meth:`AsyncIterator.map` or :meth:`AsyncIterator.filter`: :: - - async for m_id in channel.history().filter(lambda m: m.author == client.user).map(lambda m: m.id): - print(m_id) - -The functions passed to :meth:`AsyncIterator.map` or :meth:`AsyncIterator.filter` can be either coroutines or regular -functions. - -You can also get single elements a la :func:`discord.utils.find` or :func:`discord.utils.get` via -:meth:`AsyncIterator.get` or :meth:`AsyncIterator.find`: :: - - my_last_message = await channel.history().get(author=client.user) - -The following return :class:`AsyncIterator`: - -- :meth:`abc.Messageable.history` -- :meth:`Guild.audit_logs` -- :meth:`Reaction.users` - -.. _migrating_1_0_event_changes: - -Event Changes --------------- - -A lot of events have gone through some changes. - -Many events with ``server`` in the name were changed to use ``guild`` instead. - -Before: - -- ``on_server_join`` -- ``on_server_remove`` -- ``on_server_update`` -- ``on_server_role_create`` -- ``on_server_role_delete`` -- ``on_server_role_update`` -- ``on_server_emojis_update`` -- ``on_server_available`` -- ``on_server_unavailable`` - -After: - -- :func:`on_guild_join` -- :func:`on_guild_remove` -- :func:`on_guild_update` -- :func:`on_guild_role_create` -- :func:`on_guild_role_delete` -- :func:`on_guild_role_update` -- :func:`on_guild_emojis_update` -- :func:`on_guild_available` -- :func:`on_guild_unavailable` - - -The :func:`on_voice_state_update` event has received an argument change. - -Before: :: - - async def on_voice_state_update(before, after) - -After: :: - - async def on_voice_state_update(member, before, after) - -Instead of two :class:`Member` objects, the new event takes one :class:`Member` object and two :class:`VoiceState` objects. - -The :func:`on_guild_emojis_update` event has received an argument change. - -Before: :: - - async def on_guild_emojis_update(before, after) - -After: :: - - async def on_guild_emojis_update(guild, before, after) - -The first argument is now the :class:`Guild` that the emojis were updated from. - -The :func:`on_member_ban` event has received an argument change as well: - -Before: :: - - async def on_member_ban(member) - -After: :: - - async def on_member_ban(guild, user) - -As part of the change, the event can either receive a :class:`User` or :class:`Member`. To help in the cases that have -:class:`User`, the :class:`Guild` is provided as the first parameter. - -The ``on_channel_`` events have received a type level split (see :ref:`migrating_1_0_channel_split`). - -Before: - -- ``on_channel_delete`` -- ``on_channel_create`` -- ``on_channel_update`` - -After: - -- :func:`on_guild_channel_delete` -- :func:`on_guild_channel_create` -- :func:`on_guild_channel_update` -- :func:`on_private_channel_delete` -- :func:`on_private_channel_create` -- :func:`on_private_channel_update` - -The ``on_guild_channel_`` events correspond to :class:`abc.GuildChannel` being updated (i.e. :class:`TextChannel` -and :class:`VoiceChannel`) and the ``on_private_channel_`` events correspond to :class:`abc.PrivateChannel` being -updated (i.e. :class:`DMChannel` and :class:`GroupChannel`). - -.. _migrating_1_0_voice: - -Voice Changes ---------------- - -Voice sending has gone through a complete redesign. - -In particular: - -- Connection is done through :meth:`VoiceChannel.connect` instead of ``Client.join_voice_channel``. -- You no longer create players and operate on them (you no longer store them). -- You instead request :class:`VoiceClient` to play an :class:`AudioSource` via :meth:`VoiceClient.play`. -- There are different built-in :class:`AudioSource`\s. - - - :class:`FFmpegPCMAudio` is the equivalent of ``create_ffmpeg_player`` - -- create_ffmpeg_player/create_stream_player/create_ytdl_player have all been removed. - - - The goal is to create :class:`AudioSource` instead. - -- Using :meth:`VoiceClient.play` will not return an ``AudioPlayer``. - - - Instead, it's "flattened" like :class:`User` -> :class:`Member` is. - -- The ``after`` parameter now takes a single parameter (the error). - -Basically: - -Before: :: - - vc = await client.join_voice_channel(channel) - player = vc.create_ffmpeg_player('testing.mp3', after=lambda: print('done')) - player.start() - - player.is_playing() - player.pause() - player.resume() - player.stop() - # ... - -After: :: - - vc = await channel.connect() - vc.play(discord.FFmpegPCMAudio('testing.mp3'), after=lambda e: print('done', e)) - vc.is_playing() - vc.pause() - vc.resume() - vc.stop() - # ... - -With the changed :class:`AudioSource` design, you can now change the source that the :class:`VoiceClient` is -playing at runtime via :attr:`VoiceClient.source`. - -For example, you can add a :class:`PCMVolumeTransformer` to allow changing the volume: :: - - vc.source = discord.PCMVolumeTransformer(vc.source) - vc.source.volume = 0.6 - -An added benefit of the redesign is that it will be much more resilient towards reconnections: - -- The voice websocket will now automatically re-connect and re-do the handshake when disconnected. -- The initial connect handshake will now retry up to 5 times so you no longer get as many ``asyncio.TimeoutError``. -- Audio will now stop and resume when a disconnect is found. - - - This includes changing voice regions etc. - - -.. _migrating_1_0_wait_for: - -Waiting For Events --------------------- - -Prior to v1.0, the machinery for waiting for an event outside of the event itself was done through two different -functions, ``Client.wait_for_message`` and ``Client.wait_for_reaction``. One problem with one such approach is that it did -not allow you to wait for events outside of the ones provided by the library. - -In v1.0 the concept of waiting for another event has been generalised to work with any event as :meth:`Client.wait_for`. - -For example, to wait for a message: :: - - # before - msg = await client.wait_for_message(author=message.author, channel=message.channel) - - # after - def pred(m): - return m.author == message.author and m.channel == message.channel - - msg = await client.wait_for('message', check=pred) - -To facilitate multiple returns, :meth:`Client.wait_for` returns either a single argument, no arguments, or a tuple of -arguments. - -For example, to wait for a reaction: :: - - reaction, user = await client.wait_for('reaction_add', check=lambda r, u: u.id == 176995180300206080) - - # use user and reaction - -Since this function now can return multiple arguments, the ``timeout`` parameter will now raise a :exc:`asyncio.TimeoutError` -when reached instead of setting the return to ``None``. For example: - -.. code-block:: python3 - - def pred(m): - return m.author == message.author and m.channel == message.channel - - try: - - msg = await client.wait_for('message', check=pred, timeout=60.0) - except asyncio.TimeoutError: - await channel.send('You took too long...') - else: - await channel.send('You said {0.content}, {0.author}.'.format(msg)) - -Upgraded Dependencies ------------------------ - -Following v1.0 of the library, we've updated our requirements to :doc:`aiohttp ` v2.0 or higher. - -Since this is a backwards incompatible change, it is recommended that you see the -`changes `_ -and the :doc:`aio:migration_to_2xx` pages for details on the breaking changes in -:doc:`aiohttp `. - -Of the most significant for common users is the removal of helper functions such as: - -- ``aiohttp.get`` -- ``aiohttp.post`` -- ``aiohttp.delete`` -- ``aiohttp.patch`` -- ``aiohttp.head`` -- ``aiohttp.put`` -- ``aiohttp.request`` - -It is recommended that you create a session instead: :: - - async with aiohttp.ClientSession() as sess: - async with sess.get('url') as resp: - # work with resp - -Since it is better to not create a session for every request, you should store it in a variable and then call -``session.close`` on it when it needs to be disposed. - -Sharding ----------- - -The library has received significant changes on how it handles sharding and now has sharding as a first-class citizen. - -If using a Bot account and you want to shard your bot in a single process then you can use the :class:`AutoShardedClient`. - -This class allows you to use sharding without having to launch multiple processes or deal with complicated IPC. - -It should be noted that **the sharded client does not support user accounts**. This is due to the changes in connection -logic and state handling. - -Usage is as simple as doing: :: - - client = discord.AutoShardedClient() - -instead of using :class:`Client`. - -This will launch as many shards as your bot needs using the ``/gateway/bot`` endpoint, which allocates about 1000 guilds -per shard. - -If you want more control over the sharding you can specify ``shard_count`` and ``shard_ids``. :: - - # launch 10 shards regardless - client = discord.AutoShardedClient(shard_count=10) - - # launch specific shard IDs in this process - client = discord.AutoShardedClient(shard_count=10, shard_ids=(1, 2, 5, 6)) - -For users of the command extension, there is also :class:`~ext.commands.AutoShardedBot` which behaves similarly. - -Connection Improvements -------------------------- - -In v1.0, the auto reconnection logic has been powered up significantly. - -:meth:`Client.connect` has gained a new keyword argument, ``reconnect`` that defaults to ``True`` which controls -the reconnect logic. When enabled, the client will automatically reconnect in all instances of your internet going -offline or Discord going offline with exponential back-off. - -:meth:`Client.run` and :meth:`Client.start` gains this keyword argument as well, but for most cases you will not -need to specify it unless turning it off. - -.. _migrating_1_0_commands: - -Command Extension Changes --------------------------- - -Due to the :ref:`migrating_1_0_model_state` changes, some of the design of the extension module had to -undergo some design changes as well. - -Context Changes -~~~~~~~~~~~~~~~~~ - -In v1.0, the :class:`.Context` has received a lot of changes with how it's retrieved and used. - -The biggest change is that ``pass_context=True`` no longer exists, :class:`.Context` is always passed. Ergo: - -.. code-block:: python3 - - # before - @bot.command() - async def foo(): - await bot.say('Hello') - - # after - @bot.command() - async def foo(ctx): - await ctx.send('Hello') - -The reason for this is because :class:`~ext.commands.Context` now meets the requirements of :class:`abc.Messageable`. This -makes it have similar functionality to :class:`TextChannel` or :class:`DMChannel`. Using :meth:`~.Context.send` -will either DM the user in a DM context or send a message in the channel it was in, similar to the old ``bot.say`` -functionality. The old helpers have been removed in favour of the new :class:`abc.Messageable` interface. See -:ref:`migrating_1_0_removed_helpers` for more information. - -Since the :class:`~ext.commands.Context` is now passed by default, several shortcuts have been added: - -**New Shortcuts** - -- :attr:`ctx.author ` is a shortcut for ``ctx.message.author``. -- :attr:`ctx.guild ` is a shortcut for ``ctx.message.guild``. -- :attr:`ctx.channel ` is a shortcut for ``ctx.message.channel``. -- :attr:`ctx.me ` is a shortcut for ``ctx.message.guild.me`` or ``ctx.bot.user``. -- :attr:`ctx.voice_client ` is a shortcut for ``ctx.message.guild.voice_client``. - -**New Functionality** - -- :meth:`.Context.reinvoke` to invoke a command again. - - - This is useful for bypassing cooldowns. -- :attr:`.Context.valid` to check if a context can be invoked with :meth:`.Bot.invoke`. -- :meth:`.Context.send_help` to show the help command for an entity using the new :class:`~.ext.commands.HelpCommand` system. - - - This is useful if you want to show the user help if they misused a command. - -Subclassing Context -++++++++++++++++++++ - -In v1.0, there is now the ability to subclass :class:`~ext.commands.Context` and use it instead of the default -provided one. - -For example, if you want to add some functionality to the context: - -.. code-block:: python3 - - class MyContext(commands.Context): - @property - def secret(self): - return 'my secret here' - -Then you can use :meth:`~ext.commands.Bot.get_context` inside :func:`on_message` with combination with -:meth:`~ext.commands.Bot.invoke` to use your custom context: - -.. code-block:: python3 - - class MyBot(commands.Bot): - async def on_message(self, message): - ctx = await self.get_context(message, cls=MyContext) - await self.invoke(ctx) - -Now inside your commands you will have access to your custom context: - -.. code-block:: python3 - - @bot.command() - async def secret(ctx): - await ctx.send(ctx.secret) - -.. _migrating_1_0_removed_helpers: - -Removed Helpers -+++++++++++++++++ - -With the new :class:`.Context` changes, a lot of message sending helpers have been removed. - -For a full list of changes, see below: - -+-----------------+------------------------------------------------------------+ -| Before | After | -+-----------------+------------------------------------------------------------+ -| ``Bot.say`` | :meth:`.Context.send` | -+-----------------+------------------------------------------------------------+ -| ``Bot.upload`` | :meth:`.Context.send` | -+-----------------+------------------------------------------------------------+ -| ``Bot.whisper`` | ``ctx.author.send`` | -+-----------------+------------------------------------------------------------+ -| ``Bot.type`` | :meth:`.Context.typing` or :meth:`.Context.trigger_typing` | -+-----------------+------------------------------------------------------------+ -| ``Bot.reply`` | No replacement. | -+-----------------+------------------------------------------------------------+ - -Command Changes -~~~~~~~~~~~~~~~~~ - -As mentioned earlier, the first command change is that ``pass_context=True`` no longer -exists, so there is no need to pass this as a parameter. - -Another change is the removal of ``no_pm=True``. Instead, use the new :func:`~ext.commands.guild_only` built-in -check. - -The ``commands`` attribute of :class:`~ext.commands.Bot` and :class:`~ext.commands.Group` have been changed from a -dictionary to a set that does not have aliases. To retrieve the previous dictionary behaviour, use ``all_commands`` instead. - -Command instances have gained new attributes and properties: - -1. :attr:`~ext.commands.Command.signature` to get the signature of the command. -2. :attr:`~.Command.usage`, an attribute to override the default signature. -3. :attr:`~.Command.root_parent` to get the root parent group of a subcommand. - -For :class:`~ext.commands.Group` and :class:`~ext.commands.Bot` the following changed: - -- Changed :attr:`~.GroupMixin.commands` to be a :class:`set` without aliases. - - - Use :attr:`~.GroupMixin.all_commands` to get the old :class:`dict` with all commands. - -Check Changes -~~~~~~~~~~~~~~~ - -Prior to v1.0, :func:`~ext.commands.check`\s could only be synchronous. As of v1.0 checks can now be coroutines. - -Along with this change, a couple new checks were added. - -- :func:`~ext.commands.guild_only` replaces the old ``no_pm=True`` functionality. -- :func:`~ext.commands.is_owner` uses the :meth:`Client.application_info` endpoint by default to fetch owner ID. - - - This is actually powered by a different function, :meth:`~ext.commands.Bot.is_owner`. - - You can set the owner ID yourself by setting :attr:`.Bot.owner_id`. - -- :func:`~ext.commands.is_nsfw` checks if the channel the command is in is a NSFW channel. - - - This is powered by the new :meth:`TextChannel.is_nsfw` method. - -Event Changes -~~~~~~~~~~~~~~~ - -All command extension events have changed. - -Before: :: - - on_command(command, ctx) - on_command_completion(command, ctx) - on_command_error(error, ctx) - -After: :: - - on_command(ctx) - on_command_completion(ctx) - on_command_error(ctx, error) - -The extraneous ``command`` parameter in :func:`.on_command` and :func:`.on_command_completion` -have been removed. The :class:`~ext.commands.Command` instance was not kept up-to date so it was incorrect. In order to get -the up to date :class:`~ext.commands.Command` instance, use the :attr:`.Context.command` -attribute. - -The error handlers, either :meth:`.Command.error` or :func:`.on_command_error`, -have been re-ordered to use the :class:`~ext.commands.Context` as its first parameter to be consistent with other events -and commands. - -HelpFormatter and Help Command Changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The ``HelpFormatter`` class has been removed. It has been replaced with a :class:`~.commands.HelpCommand` class. This class now stores all the command handling and processing of the help command. - -The help command is now stored in the :attr:`.Bot.help_command` attribute. As an added extension, you can disable the help command completely by assigning the attribute to ``None`` or passing it at ``__init__`` as ``help_command=None``. - -The new interface allows the help command to be customised through special methods that can be overridden. - -- :meth:`.HelpCommand.send_bot_help` - - Called when the user requested for help with the entire bot. -- :meth:`.HelpCommand.send_cog_help` - - Called when the user requested for help with a specific cog. -- :meth:`.HelpCommand.send_group_help` - - Called when the user requested for help with a :class:`~.commands.Group` -- :meth:`.HelpCommand.send_command_help` - - Called when the user requested for help with a :class:`~.commands.Command` -- :meth:`.HelpCommand.get_destination` - - Called to know where to send the help messages. Useful for deciding whether to DM or not. -- :meth:`.HelpCommand.command_not_found` - - A function (or coroutine) that returns a presentable no command found string. -- :meth:`.HelpCommand.subcommand_not_found` - - A function (or coroutine) that returns a string when a subcommand is not found. -- :meth:`.HelpCommand.send_error_message` - - A coroutine that gets passed the result of :meth:`.HelpCommand.command_not_found` and :meth:`.HelpCommand.subcommand_not_found`. - - By default it just sends the message. But you can, for example, override it to put it in an embed. -- :meth:`.HelpCommand.on_help_command_error` - - The :ref:`error handler ` for the help command if you want to add one. -- :meth:`.HelpCommand.prepare_help_command` - - A coroutine that is called right before the help command processing is done. - -Certain subclasses can implement more customisable methods. - -The old ``HelpFormatter`` was replaced with :class:`~.commands.DefaultHelpCommand`\, which implements all of the logic of the old help command. The customisable methods can be found in the accompanying documentation. - -The library now provides a new more minimalistic :class:`~.commands.HelpCommand` implementation that doesn't take as much space, :class:`~.commands.MinimalHelpCommand`. The customisable methods can also be found in the accompanying documentation. - -A frequent request was if you could associate a help command with a cog. The new design allows for dynamically changing of cog through binding it to the :attr:`.HelpCommand.cog` attribute. After this assignment the help command will pretend to be part of the cog and everything should work as expected. When the cog is unloaded then the help command will be "unbound" from the cog. - -For example, to implement a :class:`~.commands.HelpCommand` in a cog, the following snippet can be used. - -.. code-block:: python3 - - class MyHelpCommand(commands.MinimalHelpCommand): - def get_command_signature(self, command): - return '{0.clean_prefix}{1.qualified_name} {1.signature}'.format(self, command) - - class MyCog(commands.Cog): - def __init__(self, bot): - self._original_help_command = bot.help_command - bot.help_command = MyHelpCommand() - bot.help_command.cog = self - - def cog_unload(self): - self.bot.help_command = self._original_help_command - -For more information, check out the relevant :ref:`documentation `. - -Cog Changes -~~~~~~~~~~~~~ - -Cogs have completely been revamped. They are documented in :ref:`ext_commands_cogs` as well. - -Cogs are now required to have a base class, :class:`~.commands.Cog` for future proofing purposes. This comes with special methods to customise some behaviour. - -* :meth:`.Cog.cog_unload` - - This is called when a cog needs to do some cleanup, such as cancelling a task. -* :meth:`.Cog.bot_check_once` - - This registers a :meth:`.Bot.check_once` check. -* :meth:`.Cog.bot_check` - - This registers a regular :meth:`.Bot.check` check. -* :meth:`.Cog.cog_check` - - This registers a check that applies to every command in the cog. -* :meth:`.Cog.cog_command_error` - - This is a special error handler that is called whenever an error happens inside the cog. -* :meth:`.Cog.cog_before_invoke` and :meth:`.Cog.cog_after_invoke` - - A special method that registers a cog before and after invoke hook. More information can be found in :ref:`migrating_1_0_before_after_hook`. - -Those that were using listeners, such as ``on_message`` inside a cog will now have to explicitly mark them as such using the :meth:`.commands.Cog.listener` decorator. - -Along with that, cogs have gained the ability to have custom names through specifying it in the class definition line. More options can be found in the metaclass that facilitates all this, :class:`.commands.CogMeta`. - -An example cog with every special method registered and a custom name is as follows: - -.. code-block:: python3 - - class MyCog(commands.Cog, name='Example Cog'): - def cog_unload(self): - print('cleanup goes here') - - def bot_check(self, ctx): - print('bot check') - return True - - def bot_check_once(self, ctx): - print('bot check once') - return True - - async def cog_check(self, ctx): - print('cog local check') - return await ctx.bot.is_owner(ctx.author) - - async def cog_command_error(self, ctx, error): - print('Error in {0.command.qualified_name}: {1}'.format(ctx, error)) - - async def cog_before_invoke(self, ctx): - print('cog local before: {0.command.qualified_name}'.format(ctx)) - - async def cog_after_invoke(self, ctx): - print('cog local after: {0.command.qualified_name}'.format(ctx)) - - @commands.Cog.listener() - async def on_message(self, message): - pass - - -.. _migrating_1_0_before_after_hook: - -Before and After Invocation Hooks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Commands have gained new before and after invocation hooks that allow you to do an action before and after a command is -run. - -They take a single parameter, :class:`~ext.commands.Context` and they must be a coroutine. - -They are on a global, per-cog, or per-command basis. - -Basically: :: - - - # global hooks: - - @bot.before_invoke - async def before_any_command(ctx): - # do something before a command is called - pass - - @bot.after_invoke - async def after_any_command(ctx): - # do something after a command is called - pass - -The after invocation is hook always called, **regardless of an error in the command**. This makes it ideal for some error -handling or clean up of certain resources such a database connection. - -The per-command registration is as follows: :: - - @bot.command() - async def foo(ctx): - await ctx.send('foo') - - @foo.before_invoke - async def before_foo_command(ctx): - # do something before the foo command is called - pass - - @foo.after_invoke - async def after_foo_command(ctx): - # do something after the foo command is called - pass - -The special cog method for these is :meth:`.Cog.cog_before_invoke` and :meth:`.Cog.cog_after_invoke`, e.g.: - -.. code-block:: python3 - - class MyCog(commands.Cog): - async def cog_before_invoke(self, ctx): - ctx.secret_cog_data = 'foo' - - async def cog_after_invoke(self, ctx): - print('{0.command} is done...'.format(ctx)) - - @commands.command() - async def foo(self, ctx): - await ctx.send(ctx.secret_cog_data) - -To check if a command failed in the after invocation hook, you can use -:attr:`.Context.command_failed`. - -The invocation order is as follows: - -1. Command local before invocation hook -2. Cog local before invocation hook -3. Global before invocation hook -4. The actual command -5. Command local after invocation hook -6. Cog local after invocation hook -7. Global after invocation hook - -Converter Changes -~~~~~~~~~~~~~~~~~~~ - -Prior to v1.0, a converter was a type hint that could be a callable that could be invoked -with a singular argument denoting the argument passed by the user as a string. - -This system was eventually expanded to support a :class:`~ext.commands.Converter` system to -allow plugging in the :class:`~ext.commands.Context` and do more complicated conversions such -as the built-in "discord" converters. - -In v1.0 this converter system was revamped to allow instances of :class:`~ext.commands.Converter` derived -classes to be passed. For consistency, the :meth:`~ext.commands.Converter.convert` method was changed to -always be a coroutine and will now take the two arguments as parameters. - -Essentially, before: :: - - class MyConverter(commands.Converter): - def convert(self): - return self.ctx.message.server.me - -After: :: - - class MyConverter(commands.Converter): - async def convert(self, ctx, argument): - return ctx.me - -The command framework also got a couple new converters: - -- :class:`~ext.commands.clean_content` this is akin to :attr:`Message.clean_content` which scrubs mentions. -- :class:`~ext.commands.UserConverter` will now appropriately convert :class:`User` only. -- ``ChannelConverter`` is now split into two different converters. - - - :class:`~ext.commands.TextChannelConverter` for :class:`TextChannel`. - - :class:`~ext.commands.VoiceChannelConverter` for :class:`VoiceChannel`. +There are a few more pieces of the puzzle: +- There is a `/guilds/:id/roles/:id/member-ids` endpoint that provides up to 100 member IDs for any role other than the default role. You can use :func:`Guild.query_members` to fetch all these members in one go. +- With OPCode 14, you can subscribe to certain member IDs and receive presence updates for them. The limit of IDs per-request is currently unknown, but I have witnessed the client send over 200/request. This may help with the offline members issue. +- Thread member lists do *not* work the same. You just send an OPCode 14 with the thread IDs and receive a `THREAD_MEMBER_LIST_UPDATE` with all the members. The cache then stays updated with `GUILD_MEMBER_UPDATE` and `THREAD_MEMBERS_UPDATE` events. +- OPCode 14 lets you subscribe to multiple channels at once, and you *might* be able to do more than 2 ranges at once. diff --git a/docs/migrating_to_async.rst b/docs/migrating_to_async.rst deleted file mode 100644 index a705f7239..000000000 --- a/docs/migrating_to_async.rst +++ /dev/null @@ -1,322 +0,0 @@ -:orphan: - -.. currentmodule:: discord - -.. _migrating-to-async: - -Migrating to v0.10.0 -====================== - -v0.10.0 is one of the biggest breaking changes in the library due to massive -fundamental changes in how the library operates. - -The biggest major change is that the library has dropped support to all versions prior to -Python 3.4.2. This was made to support :mod:`asyncio`, in which more detail can be seen -:issue:`in the corresponding issue <50>`. To reiterate this, the implication is that -**python version 2.7 and 3.3 are no longer supported**. - -Below are all the other major changes from v0.9.0 to v0.10.0. - -Event Registration --------------------- - -All events before were registered using :meth:`Client.event`. While this is still -possible, the events must be decorated with ``@asyncio.coroutine``. - -Before: - -.. code-block:: python3 - - @client.event - def on_message(message): - pass - -After: - -.. code-block:: python3 - - @client.event - @asyncio.coroutine - def on_message(message): - pass - -Or in Python 3.5+: - -.. code-block:: python3 - - @client.event - async def on_message(message): - pass - -Because there is a lot of typing, a utility decorator (:meth:`Client.async_event`) is provided -for easier registration. For example: - -.. code-block:: python3 - - @client.async_event - def on_message(message): - pass - - -Be aware however, that this is still a coroutine and your other functions that are coroutines must -be decorated with ``@asyncio.coroutine`` or be ``async def``. - -Event Changes --------------- - -Some events in v0.9.0 were considered pretty useless due to having no separate states. The main -events that were changed were the ``_update`` events since previously they had no context on what -was changed. - -Before: - -.. code-block:: python3 - - def on_channel_update(channel): pass - def on_member_update(member): pass - def on_status(member): pass - def on_server_role_update(role): pass - def on_voice_state_update(member): pass - def on_socket_raw_send(payload, is_binary): pass - - -After: - -.. code-block:: python3 - - def on_channel_update(before, after): pass - def on_member_update(before, after): pass - def on_server_role_update(before, after): pass - def on_voice_state_update(before, after): pass - def on_socket_raw_send(payload): pass - -Note that ``on_status`` was removed. If you want its functionality, use :func:`on_member_update`. -See :ref:`discord-api-events` for more information. Other removed events include ``on_socket_closed``, ``on_socket_receive``, and ``on_socket_opened``. - - -Coroutines ------------ - -The biggest change that the library went through is that almost every function in :class:`Client` -was changed to be a `coroutine `_. Functions -that are marked as a coroutine in the documentation must be awaited from or yielded from in order -for the computation to be done. For example... - -Before: - -.. code-block:: python3 - - client.send_message(message.channel, 'Hello') - -After: - -.. code-block:: python3 - - yield from client.send_message(message.channel, 'Hello') - - # or in python 3.5+ - await client.send_message(message.channel, 'Hello') - -In order for you to ``yield from`` or ``await`` a coroutine then your function must be decorated -with ``@asyncio.coroutine`` or ``async def``. - -Iterables ----------- - -For performance reasons, many of the internal data structures were changed into a dictionary to support faster -lookup. As a consequence, this meant that some lists that were exposed via the API have changed into iterables -and not sequences. In short, this means that certain attributes now only support iteration and not any of the -sequence functions. - -The affected attributes are as follows: - -- :attr:`Client.servers` -- :attr:`Client.private_channels` -- :attr:`Server.channels` -- :attr:`Server.members` - -Some examples of previously valid behaviour that is now invalid - -.. code-block:: python3 - - if client.servers[0].name == "test": - # do something - -Since they are no longer :obj:`list`\s, they no longer support indexing or any operation other than iterating. -In order to get the old behaviour you should explicitly cast it to a list. - -.. code-block:: python3 - - servers = list(client.servers) - # work with servers - -.. warning:: - - Due to internal changes of the structure, the order you receive the data in - is not in a guaranteed order. - -Enumerations ------------- - -Due to dropping support for versions lower than Python 3.4.2, the library can now use -:doc:`py:library/enum` in places where it makes sense. - -The common places where this was changed was in the server region, member status, and channel type. - -Before: - -.. code-block:: python3 - - server.region == 'us-west' - member.status == 'online' - channel.type == 'text' - -After: - -.. code-block:: python3 - - server.region == discord.ServerRegion.us_west - member.status = discord.Status.online - channel.type == discord.ChannelType.text - -The main reason for this change was to reduce the use of finicky strings in the API as this -could give users a false sense of power. More information can be found in the :ref:`discord-api-enums` page. - -Properties ------------ - -A lot of function calls that returned constant values were changed into Python properties for ease of use -in format strings. - -The following functions were changed into properties: - -+----------------------------------------+--------------------------------------+ -| Before | After | -+----------------------------------------+--------------------------------------+ -| ``User.avatar_url()`` | :attr:`User.avatar_url` | -+----------------------------------------+--------------------------------------+ -| ``User.mention()`` | :attr:`User.mention` | -+----------------------------------------+--------------------------------------+ -| ``Channel.mention()`` | :attr:`Channel.mention` | -+----------------------------------------+--------------------------------------+ -| ``Channel.is_default_channel()`` | :attr:`Channel.is_default` | -+----------------------------------------+--------------------------------------+ -| ``Role.is_everyone()`` | :attr:`Role.is_everyone` | -+----------------------------------------+--------------------------------------+ -| ``Server.get_default_role()`` | :attr:`Server.default_role` | -+----------------------------------------+--------------------------------------+ -| ``Server.icon_url()`` | :attr:`Server.icon_url` | -+----------------------------------------+--------------------------------------+ -| ``Server.get_default_channel()`` | :attr:`Server.default_channel` | -+----------------------------------------+--------------------------------------+ -| ``Message.get_raw_mentions()`` | :attr:`Message.raw_mentions` | -+----------------------------------------+--------------------------------------+ -| ``Message.get_raw_channel_mentions()`` | :attr:`Message.raw_channel_mentions` | -+----------------------------------------+--------------------------------------+ - -Member Management -------------------- - -Functions that involved banning and kicking were changed. - -+--------------------------------+--------------------------+ -| Before | After | -+--------------------------------+--------------------------+ -| ``Client.ban(server, user)`` | ``Client.ban(member)`` | -+--------------------------------+--------------------------+ -| ``Client.kick(server, user)`` | ``Client.kick(member)`` | -+--------------------------------+--------------------------+ - -.. migrating-renames: - -Renamed Functions -------------------- - -Functions have been renamed. - -+------------------------------------+-------------------------------------------+ -| Before | After | -+------------------------------------+-------------------------------------------+ -| ``Client.set_channel_permissions`` | :meth:`Client.edit_channel_permissions` | -+------------------------------------+-------------------------------------------+ - -All the :class:`Permissions` related attributes have been renamed and the `can_` prefix has been -dropped. So for example, ``can_manage_messages`` has become ``manage_messages``. - -Forced Keyword Arguments -------------------------- - -Since 3.0+ of Python, we can now force questions to take in forced keyword arguments. A keyword argument is when you -explicitly specify the name of the variable and assign to it, for example: ``foo(name='test')``. Due to this support, -some functions in the library were changed to force things to take said keyword arguments. This is to reduce errors of -knowing the argument order and the issues that could arise from them. - -The following parameters are now exclusively keyword arguments: - -- :meth:`Client.send_message` - - ``tts`` -- :meth:`Client.logs_from` - - ``before`` - - ``after`` -- :meth:`Client.edit_channel_permissions` - - ``allow`` - - ``deny`` - -In the documentation you can tell if a function parameter is a forced keyword argument if it is after ``\*,`` -in the function signature. - -.. _migrating-running: - -Running the Client --------------------- - -In earlier versions of discord.py, ``client.run()`` was a blocking call to the main thread -that called it. In v0.10.0 it is still a blocking call but it handles the event loop for you. -However, in order to do that you must pass in your credentials to :meth:`Client.run`. - -Basically, before: - -.. code-block:: python3 - - client.login('token') - client.run() - -After: - -.. code-block:: python3 - - client.run('token') - -.. warning:: - - Like in the older ``Client.run`` function, the newer one must be the one of - the last functions to call. This is because the function is **blocking**. Registering - events or doing anything after :meth:`Client.run` will not execute until the function - returns. - -This is a utility function that abstracts the event loop for you. There's no need for -the run call to be blocking and out of your control. Indeed, if you want control of the -event loop then doing so is quite straightforward: - -.. code-block:: python3 - - import discord - import asyncio - - client = discord.Client() - - @asyncio.coroutine - def main_task(): - yield from client.login('token') - yield from client.connect() - - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(main_task()) - except: - loop.run_until_complete(client.logout()) - finally: - loop.close() - - - diff --git a/docs/token.rst b/docs/token.rst new file mode 100644 index 000000000..8e8a6a44e --- /dev/null +++ b/docs/token.rst @@ -0,0 +1,24 @@ +:orphan: + +.. versionadded:: 2.0 +.. _tokens: + +Tokens +======= + +Tokens are how we authenticate with Discord. + +Regular (and bot) tokens have this format: + +.. image:: /images/token.png + +MFA tokens, however, are just the HMAC prefixed with `mfa.` (as far as I know). + +How to obtain mine +------------------- +To obtain your token from the Discord client, the easiest way is as follows: +1. Open developer tools (CTRL+SHIFT+I). +2. Click the Network tab. +3. Click the XHR tab. +4. Select a request and click the Headers tab. +5. Copy-paste the value in the Authorization header. diff --git a/docs/version_guarantees.rst b/docs/version_guarantees.rst index 7909bd6b5..f4f3738f1 100644 --- a/docs/version_guarantees.rst +++ b/docs/version_guarantees.rst @@ -7,6 +7,8 @@ The library follows a `semantic versioning principle `_ whi The first thing to keep in mind is that breaking changes only apply to **publicly documented functions and classes**. If it's not listed in the documentation here then it is not part of the public API and is thus bound to change. This includes attributes that start with an underscore or functions without an underscore that are not documented. +However, the Discord user API is in constant flux, so sometimes breaking changes may creep in. + .. note:: The examples below are non-exhaustive. From 7067aad6dfa02ddba158a996a56c052bc50d865f Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 13 Nov 2021 15:06:48 -0500 Subject: [PATCH 055/154] I can't doc --- docs/migrating.rst | 49 ++++++++++++++++++++++++++++------------------ docs/token.rst | 5 +++-- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index c7d8c91cf..cebce4747 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -5,23 +5,24 @@ Migrating to this library ========================== -This library is designed to be compatible with discord.py. -However, the user and bot APIs are *not* the same. +| This library is designed to be compatible with discord.py. +| However, the user and bot APIs are *not* the same. Most things bots can do, users can (in some capacity) as well. However, a number of things have been removed. For example: + - `Intents`: While the gateway technically accepts Intents for user accounts (and even modifies payloads to be a little more like bot payloads), it leads to breakage. Additionally, it's a giant waving red flag to Discord. - `Shards`: The concept doesn't exist and is unneeded for users. - `Guild.fetch_members`: The `/guilds/:id/members` and `/guilds/:id/members/search` endpoints instantly phone-lock your account. For more information about guild members, please read their respective section below. Additionally, existing payloads and headers have been heavily changed to match the Discord client. -`guild.members` ----------------- -Since the concept of Intents (mostly) doesn't exist for user accounts; you just get all events, right? -Well, yes but actually no. +Guild Members +-------------- +| Since the concept of Intents (mostly) doesn't exist for user accounts; you just get all events, right? +| Well, yes but actually no. For 80% of things, events are identical to bot events. However, other than the quite large amount of new events, not all events work the same. @@ -29,21 +30,25 @@ The biggest example of this are the events `on_member_add`, `on_member_update`/` Bots ~~~~~ -For bots (with the member intent), it's simple. They request all guild members with an OPCode 8 (chunk the guild), and receive respective `GUILD_MEMBER_*` events, that are then parsed by the library and dispatched to users. -If the bot has the presence intent, it even gets an initial member cache in the `GUILD_CREATE` event. +| For bots (with the member intent), it's simple. +| They request all guild members with an OPCode 8 (chunk the guild), and receive respective `GUILD_MEMBER_*` events, that are then parsed by the library and dispatched to users. +| If the bot has the presence intent, it even gets an initial member cache in the `GUILD_CREATE` event. Users ~~~~~~ -Users, however, do not work like this. -If you have one of kick members, ban members, or manage roles, you can request all guild members the same way bots do. The client uses this in various areas of guild settings. +| Users, however, do not work like this. +| If you have one of kick members, ban members, or manage roles, you can request all guild members the same way bots do. The client uses this in various areas of guild settings. -But, here's the twist: users do not receive `GUILD_MEMBER_*` reliably. -They receive them in certain circumstances, but they're usually rare and nothing to be relied on. -If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library). The client almost never uses the :func:`Guild.fetch_member` endpoint. -However, the maximum amount of members you can get with this method is 100 per request. +| But, here's the twist: users do not receive `GUILD_MEMBER_*` reliably. +| They receive them in certain circumstances, but they're usually rare and nothing to be relied on. +| If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. +This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library). +The client almost never uses the :func:`Guild.fetch_member` endpoint. +| However, the maximum amount of members you can get with this method is 100 per request. But, you may be thinking, how does the member list work? Why can't you just utilize that? This is where it gets complicated. First, let's make sure we understand a few things: + - The API doesn't differentiate between offline and invisible members (for a good reason). - The concept of a member list is not per-guild, it's per-channel. This makes sense if you think about it, since the member list only shows users that have access to a specific channel. - The member list is always up-to-date. @@ -51,18 +56,23 @@ First, let's make sure we understand a few things: The member list uses OPCode 14, and the `GUILD_MEMBER_LIST_UPDATE` event. -One more thing you need to understand, is that the member list is lazily loaded. You subscribe to 100 member ranges, and can subscribe to 2 per-request (needs more testing). So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds). -Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE`s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). -These can be parsed and dispatched as `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. +| One more thing you need to understand, is that the member list is lazily loaded. +You subscribe to 100 member ranges, and can subscribe to 2 per-request (needs more testing). +So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds). +| Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE`s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). +| These can be parsed and dispatched as `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. You may have already noticed a few problems with this: + 1. You'll get spammed with `member_add/remove`s whenever someone changes ranges. 2. For guilds with >1k members you don't receive offline members. So, you won't know if an offline member is kicked, or an invisible member joins/leaves. You also won't know if someone came online or joined. Or, if someone went offline or left. -#1 is solveable with a bit of parsing, but #2 is a huge problem. -If you have the permissions to request all guild members, you can combine that with member list scraping and get a *decent* local member cache. However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. +| #1 is solveable with a bit of parsing, but #2 is a huge problem. +| If you have the permissions to request all guild members, you can combine that with member list scraping and get a *decent* local member cache. +However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. Additionally, there are more caveats: + 1. `GUILD_MEMBER_LIST_UPDATE` removes provide an index, not a user ID. The index starts at 0 from the top of the member list and includes hoisted roles. 2. For large servers, you get ratelimited pretty fast, so scraping can take over half an hour. 3. The scraping has to happen every time the bot starts. This not only slows things down, but *may* make Discord suspicious. @@ -71,6 +81,7 @@ Additionally, there are more caveats: #1 is again solveable with a bit of parsing. There's not much you can do about #2 and #3. But, to solve #4, you *can* subscribe to multiple channels. Although, that will probably have problems of its own. There are a few more pieces of the puzzle: + - There is a `/guilds/:id/roles/:id/member-ids` endpoint that provides up to 100 member IDs for any role other than the default role. You can use :func:`Guild.query_members` to fetch all these members in one go. - With OPCode 14, you can subscribe to certain member IDs and receive presence updates for them. The limit of IDs per-request is currently unknown, but I have witnessed the client send over 200/request. This may help with the offline members issue. - Thread member lists do *not* work the same. You just send an OPCode 14 with the thread IDs and receive a `THREAD_MEMBER_LIST_UPDATE` with all the members. The cache then stays updated with `GUILD_MEMBER_UPDATE` and `THREAD_MEMBERS_UPDATE` events. diff --git a/docs/token.rst b/docs/token.rst index 8e8a6a44e..2eff0e019 100644 --- a/docs/token.rst +++ b/docs/token.rst @@ -14,9 +14,10 @@ Regular (and bot) tokens have this format: MFA tokens, however, are just the HMAC prefixed with `mfa.` (as far as I know). -How to obtain mine -------------------- +How do I obtain mine? +---------------------- To obtain your token from the Discord client, the easiest way is as follows: + 1. Open developer tools (CTRL+SHIFT+I). 2. Click the Network tab. 3. Click the XHR tab. From edcf87c3f75860589175b97e35df6dad398890f8 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 13 Nov 2021 15:54:43 -0500 Subject: [PATCH 056/154] I can't doc v2 --- docs/migrating.rst | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index cebce4747..166099932 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -30,9 +30,11 @@ The biggest example of this are the events `on_member_add`, `on_member_update`/` Bots ~~~~~ -| For bots (with the member intent), it's simple. -| They request all guild members with an OPCode 8 (chunk the guild), and receive respective `GUILD_MEMBER_*` events, that are then parsed by the library and dispatched to users. -| If the bot has the presence intent, it even gets an initial member cache in the `GUILD_CREATE` event. +For bots (with the member intent), it's simple. + +They request all guild members with an OPCode 8 (chunk the guild), and receive respective `GUILD_MEMBER_*` events, that are then parsed by the library and dispatched to users. + +If the bot has the presence intent, it even gets an initial member cache in the `GUILD_CREATE` event. Users ~~~~~~ @@ -41,7 +43,8 @@ Users | But, here's the twist: users do not receive `GUILD_MEMBER_*` reliably. | They receive them in certain circumstances, but they're usually rare and nothing to be relied on. -| If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. + +If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library). The client almost never uses the :func:`Guild.fetch_member` endpoint. | However, the maximum amount of members you can get with this method is 100 per request. @@ -56,15 +59,16 @@ First, let's make sure we understand a few things: The member list uses OPCode 14, and the `GUILD_MEMBER_LIST_UPDATE` event. -| One more thing you need to understand, is that the member list is lazily loaded. +One more thing you need to understand, is that the member list is lazily loaded. You subscribe to 100 member ranges, and can subscribe to 2 per-request (needs more testing). So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds). + | Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE`s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). | These can be parsed and dispatched as `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. You may have already noticed a few problems with this: -1. You'll get spammed with `member_add/remove`s whenever someone changes ranges. +1. You'll get spammed with `member_add\/remove`s whenever someone changes ranges. 2. For guilds with >1k members you don't receive offline members. So, you won't know if an offline member is kicked, or an invisible member joins/leaves. You also won't know if someone came online or joined. Or, if someone went offline or left. | #1 is solveable with a bit of parsing, but #2 is a huge problem. From 069c503b79cb8b3ed23e8d1bf6888079dca97d0d Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 13 Nov 2021 16:15:32 -0500 Subject: [PATCH 057/154] Add token format table --- docs/migrating.rst | 12 ++++++------ docs/token.rst | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index 166099932..5be651898 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -19,7 +19,7 @@ For example: Additionally, existing payloads and headers have been heavily changed to match the Discord client. -Guild Members +Guild members -------------- | Since the concept of Intents (mostly) doesn't exist for user accounts; you just get all events, right? | Well, yes but actually no. @@ -47,7 +47,8 @@ Users If the Discord client ever needs member objects for specific users, it sends an OPCode 8 with the specific user IDs/names. This is why this is recommended if you want to fetch specific members (implemented as :func:`Guild.query_members` in the library). The client almost never uses the :func:`Guild.fetch_member` endpoint. -| However, the maximum amount of members you can get with this method is 100 per request. + +However, the maximum amount of members you can get with this method is 100 per request. But, you may be thinking, how does the member list work? Why can't you just utilize that? This is where it gets complicated. First, let's make sure we understand a few things: @@ -63,17 +64,16 @@ One more thing you need to understand, is that the member list is lazily loaded. You subscribe to 100 member ranges, and can subscribe to 2 per-request (needs more testing). So, to subscribe to all available ranges, you need to spam the gateway quite a bit (especially for large guilds). -| Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE`s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). +| Once you subscribe to a range, you'll receive `GUILD_MEMBER_LIST_UPDATE` s for it whenever someone is added to it (i.e. someone joined the guild, changed their nickname so they moved in the member list alphabetically, came online, etc.), removed from it (i.e. someone left the guild, went offline, changed their nickname so they moved in the member list alphabetically), or updated in it (i.e. someone got their roles changed, or changed their nickname but remained in the same range). | These can be parsed and dispatched as `on_member_add`, `on_member_update`/`on_user_update`, and `on_member_remove`. You may have already noticed a few problems with this: -1. You'll get spammed with `member_add\/remove`s whenever someone changes ranges. +1. You'll get spammed with `member_add/remove` s whenever someone changes ranges. 2. For guilds with >1k members you don't receive offline members. So, you won't know if an offline member is kicked, or an invisible member joins/leaves. You also won't know if someone came online or joined. Or, if someone went offline or left. | #1 is solveable with a bit of parsing, but #2 is a huge problem. -| If you have the permissions to request all guild members, you can combine that with member list scraping and get a *decent* local member cache. -However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. +| If you have the permissions to request all guild members, you can combine that with member list scraping and get a *decent* local member cache. However, because of the nature of this (and the fact that you'll have to request all guild membesr again every so often), accurate events are nearly impossible. Additionally, there are more caveats: diff --git a/docs/token.rst b/docs/token.rst index 2eff0e019..bf6b59edd 100644 --- a/docs/token.rst +++ b/docs/token.rst @@ -1,6 +1,5 @@ :orphan: -.. versionadded:: 2.0 .. _tokens: Tokens @@ -10,9 +9,24 @@ Tokens are how we authenticate with Discord. Regular (and bot) tokens have this format: -.. image:: /images/token.png +.. list-table:: Discord Token + :header-rows: 1 -MFA tokens, however, are just the HMAC prefixed with `mfa.` (as far as I know). + * - + - MjQ1NTU5MDg3NTI0MjE2ODMy + - DulyxA + - brcD2xRAqjACTuMcGPwy4TWVQdg + * - **Decode** + - :func:`base64.b64decode` + - :func:`base64.b64decode` + 1293840000 + - N/A + * - **Output** + - User ID + - Unix TS + - HMAC + + +MFA tokens, however, are just the HMAC prefixed with **mfa.** How do I obtain mine? ---------------------- From ccd971527f40c6cb11ea4cdaaf23c80f2f8e3a35 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 13 Nov 2021 16:53:56 -0500 Subject: [PATCH 058/154] Finish docs? --- discord/flags.py | 21 +- discord/settings.py | 2 +- discord/user.py | 1 + docs/_templates/layout.html | 5 +- docs/api.rst | 61 +- docs/conf.py | 18 +- docs/quickstart.rst | 6 +- docs/whats_new.rst | 1176 +---------------------------------- 8 files changed, 72 insertions(+), 1218 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 08e99faa6..99d2ef2f2 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -577,7 +577,7 @@ class ApplicationFlags(BaseFlags): class GuildSubscriptionOptions: - """Controls the library's auto-subscribing feature. + r"""Controls the library's auto-subscribing feature. Subscribing refers to abusing the member sidebar to scrape all* guild members. However, you can only request 200 members per OPCode 14. @@ -586,8 +586,8 @@ class GuildSubscriptionOptions: GUILD_MEMBER_LIST_UPDATE. You then also get subsequent GUILD_MEMBER_LIST_UPDATEs that act (kind of) like GUILD_MEMBER_UPDATE/ADD/REMOVEs. - *Discord doesn't provide offline members for "large" guilds. - *As this is dependent on the member sidebar, guilds that don't have + \*Discord doesn't provide offline members for "large" guilds. + \*As this is dependent on the member sidebar, guilds that don't have a channel (of any type, surprisingly) that @everyone or some other role everyone has can't access don't get the full online member list. @@ -608,7 +608,7 @@ class GuildSubscriptionOptions: self.max_online = max_online def __repr__(self) -> str: - return ' GuildSubscriptionOptions: @@ -622,7 +622,16 @@ class GuildSubscriptionOptions: @classmethod def disabled(cls) -> GuildSubscriptionOptions: - """A factory method that creates a :class:`GuildSubscriptionOptions` with subscribing disabled.""" + """A factory method that creates a :class:`GuildSubscriptionOptions` with subscribing disabled. + + There is an alias for this called :meth`none`. + """ return cls(auto_subscribe=False) - off = disabled + @classmethod + def off(cls) -> GuildSubscriptionOptions: + """A factory method that creates a :class:`GuildSubscriptionOptions` with subscribing disabled. + + This is an alias of :meth:`disabled`. + """ + return cls(auto_subscribe=False) diff --git a/discord/settings.py b/discord/settings.py index 26f53769d..6cdcdbe91 100644 --- a/discord/settings.py +++ b/discord/settings.py @@ -266,7 +266,7 @@ class ChannelSettings: @property def channel(self) -> Optional[GuildChannel]: - """Optional[:class:`GuildChannel]: Returns the channel these settings are for.""" + """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) diff --git a/discord/user.py b/discord/user.py index 71100ac1f..a98f5bc3f 100644 --- a/discord/user.py +++ b/discord/user.py @@ -56,6 +56,7 @@ __all__ = ( 'User', 'ClientUser', 'Profile', + 'Note', ) BU = TypeVar('BU', bound='BaseUser') diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index d9fe22315..e7775ebd6 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -58,9 +58,8 @@ {#- The main navigation header #}