From 7de78cfcbacb11e0d95ae03575dbcd6835287254 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sat, 2 Apr 2022 18:50:32 -0400 Subject: [PATCH] Implement captcha handling --- discord/__init__.py | 1 + discord/client.py | 9 ++++ discord/handlers.py | 108 ++++++++++++++++++++++++++++++++++++++++++++ discord/http.py | 44 +++++++++++++++--- 4 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 discord/handlers.py diff --git a/discord/__init__.py b/discord/__init__.py index a13551657..6c7616737 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -64,6 +64,7 @@ from .settings import * from .profile import * from .welcome_screen import * from .modal import * +from .handlers import * class _VersionInfo(NamedTuple): diff --git a/discord/client.py b/discord/client.py index 03b936fbe..41323d335 100644 --- a/discord/client.py +++ b/discord/client.py @@ -77,6 +77,7 @@ from .profile import UserProfile from .connections import Connection from .team import Team from .member import _ClientStatus +from .handlers import CaptchaHandler if TYPE_CHECKING: from typing_extensions import Self @@ -188,6 +189,10 @@ class Client: This allows you to check requests the library is using. For more information, check the `aiohttp documentation `_. + .. versionadded:: 2.0 + captcha_handler: Optional[:class:`CaptchaHandler`] + A class that solves captcha challenges. + .. versionadded:: 2.0 Attributes @@ -206,12 +211,16 @@ class Client: proxy_auth: Optional[aiohttp.BasicAuth] = options.pop('proxy_auth', None) unsync_clock: bool = options.pop('assume_unsync_clock', True) http_trace: Optional[aiohttp.TraceConfig] = options.pop('http_trace', None) + captcha_handler: Optional[CaptchaHandler] = options.pop('captcha_handler', None) + if captcha_handler is not None and not isinstance(captcha_handler, CaptchaHandler): + raise TypeError(f'captcha_handler must be CaptchaHandler not {type(captcha_handler)!r}') self.http: HTTPClient = HTTPClient( self.loop, proxy=proxy, proxy_auth=proxy_auth, unsync_clock=unsync_clock, http_trace=http_trace, + captcha_handler=captcha_handler, ) self._handlers: Dict[str, Callable[..., None]] = { diff --git a/discord/handlers.py b/discord/handlers.py new file mode 100644 index 000000000..70064eca2 --- /dev/null +++ b/discord/handlers.py @@ -0,0 +1,108 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from aiohttp import BasicAuth + +# fmt: off +__all__ = ( + 'CaptchaHandler', +) +# fmt: on + + +class CaptchaHandler: + """A class that represents a captcha handler. + + This class allows you to implement a protocol to solve captchas required by Discord. + This is an abstract class. The library provides no concrete implementation. + + These classes are passed to :class:`Client`. + """ + + async def startup(self): + """|coro| + + An abstract method that is called by the library at startup. + + This is meant to provide an async startup method for the handler. + This isn't guaranteed to be run once. + + The default implementation does nothing. + """ + pass + + async def prefetch_token(self, proxy: Optional[str], proxy_auth: Optional[BasicAuth], /) -> None: + """|coro| + + An abstract method that is called a bit before a captcha token is required. + Not guaranteed to be called. + + It's meant to signal the handler to begin preparing for the fetching of a token, if applicable. + Keep in mind that Discord has multiple captcha sitekeys. + + The default implementation does nothing. + + Parameters + ---------- + proxy: Optional[:class:`str`] + The current proxy of the client. + proxy_auth: Optional[:class:`aiohttp.BasicAuth`] + The proxy's auth. + """ + pass + + async def fetch_token( + self, + data: Dict[str, Any], + proxy: Optional[str] = None, + proxy_auth: Optional[BasicAuth] = None, + /, + ) -> str: + """|coro| + + An abstract method that is called to fetch a captcha token. + + If there is no token available, it should wait until one is + generated before returning. + + Parameters + ------------ + data: Dict[:class:`str`, :class:`Any`] + The raw error from Discord containing the captcha info. + proxy: Optional[:class:`str`] + The current proxy of the client. + proxy_auth: Optional[:class:`aiohttp.BasicAuth`] + The proxy's auth. + + Returns + -------- + :class:`str` + A captcha token. + """ + raise NotImplementedError diff --git a/discord/http.py b/discord/http.py index 74c052547..c01e942ff 100644 --- a/discord/http.py +++ b/discord/http.py @@ -41,7 +41,6 @@ from typing import ( Optional, overload, Sequence, - Tuple, TYPE_CHECKING, Type, TypeVar, @@ -60,13 +59,21 @@ from . import utils from .mentions import AllowedMentions from .utils import MISSING +CAPTCHA_VALUES = { + 'incorrect-captcha', + 'response-already-used', + 'captcha-required', + 'invalid-input-response', + 'invalid-response', + 'You need to update your app', # Discord moment +} _log = logging.getLogger(__name__) if TYPE_CHECKING: from typing_extensions import Self - from .abc import Snowflake as abcSnowflake from .channel import TextChannel, DMChannel, GroupChannel, PartialMessageable + from .handlers import CaptchaHandler from .threads import Thread from .file import File from .mentions import AllowedMentions @@ -79,7 +86,6 @@ if TYPE_CHECKING: appinfo, audit_log, channel, - command, emoji, guild, integration, @@ -168,7 +174,7 @@ def handle_message_parameters( if attachments is not MISSING and files is not MISSING: raise TypeError('Cannot mix attachments and files keyword arguments.') - payload = {} + payload: Any = {'tts': tts} if embeds is not MISSING: if len(embeds) > 10: raise ValueError('embeds has a maximum of 10 elements.') @@ -198,7 +204,6 @@ def handle_message_parameters( else: payload['sticker_ids'] = [] - payload['tts'] = tts if avatar_url: payload['avatar_url'] = str(avatar_url) if username: @@ -327,6 +332,7 @@ class HTTPClient: proxy_auth: Optional[aiohttp.BasicAuth] = None, unsync_clock: bool = True, http_trace: Optional[aiohttp.TraceConfig] = None, + captcha_handler: Optional[CaptchaHandler] = None, ) -> None: self.loop: asyncio.AbstractEventLoop = loop self.connector: aiohttp.BaseConnector = connector or MISSING @@ -339,6 +345,7 @@ class HTTPClient: self.proxy_auth: Optional[aiohttp.BasicAuth] = proxy_auth self.http_trace: Optional[aiohttp.TraceConfig] = http_trace self.use_clock: bool = not unsync_clock + self.captcha_handler: Optional[CaptchaHandler] = captcha_handler self.user_agent: str = MISSING self.super_properties: Dict[str, Any] = {} @@ -381,6 +388,10 @@ class HTTPClient: 'client_event_source': None, } self.encoded_super_properties = b64encode(json.dumps(sp).encode()).decode('utf-8') + + if self.captcha_handler is not None: + await self.captcha_handler.startup() + self._started = True async def ws_connect( @@ -421,6 +432,7 @@ class HTTPClient: bucket = route.bucket method = route.method url = route.url + captcha_handler = self.captcha_handler lock = self._locks.get(bucket) if lock is None: @@ -458,9 +470,9 @@ class HTTPClient: if reason: headers['X-Audit-Log-Reason'] = _uriquote(reason) - if 'json' in kwargs: + if payload := kwargs.pop('json', None): headers['Content-Type'] = 'application/json' - kwargs['data'] = utils._to_json(kwargs.pop('json')) + kwargs['data'] = utils._to_json(payload) if 'context_properties' in kwargs: props = kwargs.pop('context_properties') @@ -567,6 +579,24 @@ class HTTPClient: continue raise + # Captcha handling + except HTTPException as e: + try: + captcha_key = data['captcha_key'] # type: ignore - Handled below + except (KeyError, TypeError): + raise + else: + values = [i for i in captcha_key if any(value in i for value in CAPTCHA_VALUES)] + if captcha_handler is None or tries == 4: + raise HTTPException(e.response, {'code': -1, 'message': 'Captcha required'}) from e + elif not values: + raise + else: + previous = payload or {} + previous['captcha_key'] = await captcha_handler.fetch_token(data, self.proxy, self.proxy_auth) # type: ignore - data is json here + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = utils._to_json(previous) + if response is not None: # We've run out of retries, raise if response.status >= 500: