diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 1b17a04a..6e0d4cea 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -13,6 +13,26 @@ Changelog - Added - :class:`~twitchio.SuspiciousChatUser` model. - Added - :func:`~twitchio.PartialUser.add_suspicious_chat_user` to :class:`~twitchio.PartialUser`. - Added - :func:`~twitchio.PartialUser.remove_suspicious_chat_user` to :class:`~twitchio.PartialUser`. + - Added - :exc:`~twitchio.DeviceCodeFlowException` + - Added - :class:`~twitchio.DeviceCodeRejection` + + - Changes + - Some of the internal token management has been adjusted to support applications using DCF. + +- twitchio.Client + - Additions + - Added - :meth:`twitchio.Client.login_dcf` + - Added - :meth:`twitchio.Client.start_dcf` + - Added - :attr:`twitchio.Client.http` + + - Changes + - The ``client_secret`` passed to :class:`~twitchio.Client` is now optional for DCF support. + - Some methods using deprecated ``asyncio`` methods were updated to use ``inspect``. + +- twitchio.ext.commands.Bot + - Changes + - The ``bot_id`` passed to :class:`~twitchio.ext.commands.Bot` is now optional for DCF support. + 3.2.1 ====== diff --git a/docs/references/enums_etc.rst b/docs/references/enums_etc.rst index d8a0d3dd..f9d67d49 100644 --- a/docs/references/enums_etc.rst +++ b/docs/references/enums_etc.rst @@ -13,6 +13,11 @@ Enums and Payloads .. autoclass:: twitchio.eventsub.TransportMethod() :members: +.. attributetable:: twitchio.DeviceCodeRejection + +.. autoclass:: twitchio.DeviceCodeRejection() + :members: + Websocket Subscription Data ============================ diff --git a/docs/references/exceptions.rst b/docs/references/exceptions.rst index 98735291..8bf89b5f 100644 --- a/docs/references/exceptions.rst +++ b/docs/references/exceptions.rst @@ -9,6 +9,9 @@ Exceptions .. autoclass:: twitchio.HTTPException() :members: +.. autoclass:: twitchio.DeviceCodeFlowException() + :members: + .. autoclass:: twitchio.InvalidTokenException() :members: @@ -27,5 +30,6 @@ Exception Hierarchy - :exc:`TwitchioException` - :exc:`HTTPException` - :exc:`InvalidTokenException` + - :exc:`DeviceCodeFlowException` - :exc:`MessageRejectedError` - :exc:`MissingConduit` \ No newline at end of file diff --git a/examples/device_code_flow/bot.py b/examples/device_code_flow/bot.py new file mode 100644 index 00000000..5bc45a20 --- /dev/null +++ b/examples/device_code_flow/bot.py @@ -0,0 +1,72 @@ +""" +A basic example of using DCF (Device Code Flow) with a commands.Bot and an eventsub subscription +to the authorized users chat, to run commands. + +!note: DCF should only be used when you cannot safely store a client_secert: E.g. a users phone, tv, etc... + +Your application should be set to "Public" in the Twitch Developer Console. +""" +import asyncio +import logging + +import twitchio +from twitchio import eventsub +from twitchio.ext import commands + + +LOGGER: logging.Logger = logging.getLogger(__name__) +CLIENT_ID = "..." + +SCOPES = twitchio.Scopes() +SCOPES.user_read_chat = True +SCOPES.user_write_chat = True + + +class Bot(commands.Bot): + def __init__(self) -> None: + super().__init__(client_id=CLIENT_ID, scopes=SCOPES, prefix="!") + + async def setup_hook(self) -> None: + await self.add_component(MyComponent(self)) + + async def event_ready(self) -> None: + # Usually we would do this in the setup_hook; however DCF deviates from our traditional flow slightly... + # Since we have to wait for the user to authorize, it's safer to subscribe in event_ready... + chat = eventsub.ChatMessageSubscription(broadcaster_user_id=self.bot_id, user_id=self.bot_id) + await self.subscribe_websocket(chat, as_bot=True) + + async def event_message(self, payload: twitchio.ChatMessage) -> None: + await self.process_commands(payload) + + +class MyComponent(commands.Component): + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @commands.command() + async def hi(self, ctx: commands.Context[Bot]) -> None: + await ctx.send(f"Hello {ctx.chatter.mention}!") + + +def main() -> None: + twitchio.utils.setup_logging() + + async def runner() -> None: + async with Bot() as bot: + resp = (await bot.login_dcf()) or {} + device_code = resp.get("device_code") + interval = resp.get("interval", 5) + + # Print URI to visit to authenticate + print(resp.get("verification_uri", "")) + + await bot.start_dcf(device_code=device_code, interval=interval) + + try: + asyncio.run(runner()) + except KeyboardInterrupt: + LOGGER.warning("Shutting down due to KeyboardInterrupt.") + + +if __name__ == "__main__": + main() diff --git a/twitchio/__init__.py b/twitchio/__init__.py index e8a00544..592ae1d1 100644 --- a/twitchio/__init__.py +++ b/twitchio/__init__.py @@ -38,6 +38,7 @@ from .assets import Asset as Asset from .authentication import Scopes as Scopes from .client import * +from .enums import * from .exceptions import * from .http import HTTPAsyncIterator as HTTPAsyncIterator, Route as Route from .models import * diff --git a/twitchio/authentication/oauth.py b/twitchio/authentication/oauth.py index 247b4c72..dfc4f126 100644 --- a/twitchio/authentication/oauth.py +++ b/twitchio/authentication/oauth.py @@ -24,10 +24,14 @@ from __future__ import annotations +import asyncio import secrets import urllib.parse from typing import TYPE_CHECKING, ClassVar +import twitchio + +from ..enums import DeviceCodeRejection from ..http import HTTPClient, Route from ..utils import MISSING from .payloads import * @@ -39,6 +43,8 @@ from ..types_.responses import ( AuthorizationURLResponse, ClientCredentialsResponse, + DeviceCodeFlowResponse, + DeviceCodeTokenResponse, RefreshTokenResponse, UserTokenResponse, ValidateTokenResponse, @@ -53,7 +59,7 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, redirect_uri: str | None = None, scopes: Scopes | None = None, session: aiohttp.ClientSession = MISSING, @@ -66,6 +72,27 @@ def __init__( self.scopes = scopes async def validate_token(self, token: str, /) -> ValidateTokenPayload: + """|coro| + + Method which validates the provided token. + + Parameters + ---------- + token: :class:`str` + The token to attempt to validate. + + Returns + ------- + ValidateTokenPayload + The payload received from Twitch if no HTTPException was raised. + + Raises + ------ + HTTPException + An error occurred during a request to Twitch. + HTTPException + Bad or invalid token provided. + """ token = token.removeprefix("Bearer ").removeprefix("OAuth ") headers: dict[str, str] = {"Authorization": f"OAuth {token}"} @@ -108,6 +135,20 @@ async def user_access_token(self, code: str, /, *, redirect_uri: str | None = No return UserTokenPayload(data) async def revoke_token(self, token: str, /) -> None: + """|coro| + + Method to revoke the authorization of a provided token. + + Parameters + ---------- + token: :class:`str` + The token to revoke authorization from. The token will be invalid and cannot be used after revocation. + + Raises + ------ + HTTPException + An error occurred during a request to Twitch. + """ params = self._create_params({"token": token}) route: Route = Route("POST", "/oauth2/revoke", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params) @@ -121,6 +162,57 @@ async def client_credentials_token(self) -> ClientCredentialsPayload: return ClientCredentialsPayload(data) + async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse: + scopes = scopes or self.scopes + if not scopes: + raise ValueError('"scopes" is a required parameter or attribute which is missing.') + + params = self._create_params({"scopes": scopes.urlsafe()}, device_code=True) + route: Route = Route("POST", "/oauth2/device", use_id=True, headers=self.CONTENT_TYPE_HEADER, params=params) + + return await self.request_json(route) + + async def device_code_authorization( + self, + *, + scopes: Scopes | None = None, + device_code: str, + interval: int = 5, + ) -> DeviceCodeTokenResponse: + scopes = scopes or self.scopes + if not scopes: + raise ValueError('"scopes" is a required parameter or attribute which is missing.') + + params = self._create_params( + { + "scopes": scopes.urlsafe(), + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + device_code=True, + ) + + route: Route = Route("POST", "/oauth2/token", use_id=True, params=params) + + while True: + try: + resp = await self.request_json(route) + except twitchio.HTTPException as e: + if e.status != 400: + msg = "Unknown error during Device Code Authorization." + raise twitchio.DeviceCodeFlowException(msg, original=e) from e + + message = e.extra.get("message", "").lower() + + if message != "authorization_pending": + msg = f"An error occurred during Device Code Authorization: {message.upper()}." + raise twitchio.DeviceCodeFlowException(original=e, reason=DeviceCodeRejection(message)) + + await asyncio.sleep(interval) + continue + + return resp + def get_authorization_url( self, *, @@ -163,10 +255,11 @@ def get_authorization_url( payload: AuthorizationURLPayload = AuthorizationURLPayload(data) return payload - def _create_params(self, extra_params: dict[str, str]) -> dict[str, str]: - params = { - "client_id": self.client_id, - "client_secret": self.client_secret, - } + def _create_params(self, extra_params: dict[str, str], *, device_code: bool = False) -> dict[str, str]: + params = {"client_id": self.client_id} + + if not device_code and self.client_secret: + params["client_secret"] = self.client_secret + params.update(extra_params) return params diff --git a/twitchio/authentication/tokens.py b/twitchio/authentication/tokens.py index ef0c60ef..811d8f6a 100644 --- a/twitchio/authentication/tokens.py +++ b/twitchio/authentication/tokens.py @@ -61,11 +61,10 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, redirect_uri: str | None = None, scopes: Scopes | None = None, session: aiohttp.ClientSession = MISSING, - nested_key: str | None = None, client: Client | None = None, ) -> None: super().__init__( @@ -85,7 +84,6 @@ def __init__( self._tokens: TokenMapping = {} self._app_token: str | None = None - self._nested_key: str | None = None self._token_lock: asyncio.Lock = asyncio.Lock() self._has_loaded: bool = False @@ -213,13 +211,19 @@ async def request(self, route: Route) -> RawResponse | str | None: if e.extra.get("message", "").lower() not in ("invalid access token", "invalid oauth token"): raise e - if isinstance(old, str): + if isinstance(old, str) and self.client_secret: payload: ClientCredentialsPayload = await self.client_credentials_token() self._app_token = payload.access_token route.update_headers({"Authorization": f"Bearer {payload.access_token}"}) return await self.request(route) + if isinstance(old, str): + # Will be a DCF token... + # We only expect and will use a single token when DCF is used; the user shouldn't be loading multiples + vals = list(self._tokens.values()) + old = vals[0] + logger.debug('Token for "%s" was invalid or expired. Attempting to refresh token.', old["user_id"]) refresh: RefreshTokenPayload = await self.__isolated.refresh_token(old["refresh"]) logger.debug('Token for "%s" was successfully refreshed.', old["user_id"]) diff --git a/twitchio/client.py b/twitchio/client.py index 11b642b3..70d344d0 100644 --- a/twitchio/client.py +++ b/twitchio/client.py @@ -25,6 +25,7 @@ from __future__ import annotations import asyncio +import inspect import logging import math from collections import defaultdict @@ -68,6 +69,7 @@ from .types_.conduits import ShardUpdateRequest from .types_.eventsub import ShardStatus, SubscriptionCreateTransport, SubscriptionResponse, _SubscriptionData from .types_.options import AutoClientOptions, ClientOptions, WaitPredicateT + from .types_.responses import DeviceCodeFlowResponse from .types_.tokens import TokenMappingData @@ -128,7 +130,7 @@ def __init__( self, *, client_id: str, - client_secret: str, + client_secret: str | None = None, bot_id: str | None = None, **options: Unpack[ClientOptions], ) -> None: @@ -188,6 +190,18 @@ def adapter(self) -> BaseAdapter[Any]: currently running.""" return self._adapter + @property + def http(self) -> ManagedHTTPClient: + """Property exposing the internal :class:`~twitchio.ManagedHTTPClient` used for requests to the Twitch API. + + .. warning:: + + Altering or changing this class during runtime may have unwanted side-effects. It is exposed for developer + easabilty, especially when OAuth or Device Code Flow methods are required. It is not intended to replace the use + of the built-in methods of the :class:`~twitchio.Client` or other models. + """ + return self._http + async def set_adapter(self, adapter: BaseAdapter[Any]) -> None: """|coro| @@ -392,6 +406,216 @@ async def setup_hook(self) -> None: """ ... + async def login_dcf( + self, + *, + load_token: bool = True, + save_token: bool = True, + scopes: Scopes | None = None, + force_flow: bool = False, + ) -> DeviceCodeFlowResponse | None: + """|coro| + + .. warning:: + + DCF is intended to be used when your application cannot safely store a ``client-secret``. E.g. on a users + device (phone, tv, etc...). + + .. note:: + + This method should be called before :meth:`.start_dcf`. + + Method to initiate a DCF (Device Code Flow) and setup the :class:`~twitchio.Client`. + + If a token has been loaded automcatically via this method, a DCF authorization flow will not be initiated. + You can change this behaviour by setting the ``force_flow`` keyword-only argument to ``True``. This will force the + user to re-authenticate via DCF. + + This method works together with :meth:`.start_dcf` to complete the flow and setup the :class:`~twitchio.Client`. + You should call :meth:`.start_dcf` after this method. + + Parameters + ---------- + load_token: bool + Whether to attempt to load an existing saved token. Defaults to ``True``. + save_token: bool + Whether to save any tokens loaded into the :class:`~twitchio.Client` at close. Defaults to ``True``. + scopes: :class:`~twitchio.Scopes` | ``None`` + A :class:`~twitchio.Scopes` object with the required scopes set for the user to authenticate with. If you pass + these to the :class:`~twitchio.Client` constructor you do not need to pass them here. + force_flow: bool + Wtheer to force the user to authenticate, even when an existing token is found and valid. Useful when you + require new scopes or for testing. Defaults to ``False``. + + Returns + ------- + DeviceCodeFlowResponse + A dict with the keys: ``device_code``, ``expires_in``, ``interval``, ``user_code`` and ``verification_uri``. + None + The Device Code Flow was not initiated; E.g. an existing token was loaded and ``force_flow`` was ``False``. + + Raises + ------ + RuntimeError + Invalid ``client_id`` was passed to the :class:`~twitchio.Client`. + RuntimeError + You cannot use a ``client_secret`` with DCF. + HTTPException + An error was raised making a request to Twitch. + """ + if self._login_called: + return + + self._login_called = True + self._save_tokens = save_token + + if not self._http.client_id: + raise RuntimeError('Expected a valid "client_id", instead received: %s', self._http.client_id) + + if self._http.client_secret: + raise RuntimeError('A "client_secret" cannot be used with Device Code Flows.') + + self._http._app_token = None + + if load_token and not force_flow: + async with self._http._token_lock: + await self.load_tokens() + else: + self._http._has_loaded = True + + await self._setup() + + if not self._http._tokens: + return await self._http.device_code_flow(scopes=scopes) + + async def start_dcf( + self, + *, + device_code: str | None = None, + interval: int = 5, + timeout: int | None = 90, + scopes: Scopes | None = None, + block: bool = True, + ) -> None: + """|coro| + + .. warning:: + + DCF is intended to be used when your application cannot safely store a ``client-secret``. E.g. on a users + device (phone, tv, etc...). + + + .. note:: + + The :meth:`.login_dcf` method must be called before this method. :meth:`.login_dcf` provides a response payload + and other useful data that can be used with :meth:`.start_dcf`. + + + Method to start the :class:`~twitchio.Client` with DCF (Device Code Flow). + + Unlike :meth:`.start` this method must be called after :meth:`.login_dcf` and does not directly call this method + itself. + + Unlike :meth:`.start` this method can be used to run the :class:`~twitchio.Client` with or without asynchronous + blocking behaviours. However due to the design of ``DCF`` the :meth:`.login_dcf` method must still be called first. + + When the ``device_code`` parameter is ``None`` (default), this method will not wait for an authorization response + from Twitch. However, a token must be loaded prior to this method being called + (usually automatically in :meth:`.login_dcf`), else a :exc:`RuntimeError` will be raised. + + Parameters + ---------- + device_code: :class:`str` | ``None`` + The device code received as a response from :meth:`.login_dcf`. If :meth:`.login_dcf` loaded a saved token, you + can safely disregard this parameter, unless you are forcing a user to reauthenticate. + interval: :class:`int` + An :class:`int` as seconds, passed to determine how long we should wait before checking the users authentication + status in the DCF. This can be changed however the provided interval in the response from :meth:`.login_dcf` is + usually preferred. Defaults to ``5``. + timeout: :class:`int` | ``None`` + An :class:`int` as seconds before this method will timeout waiting for a user to complete the DCF. Could be + ``None`` to disable timeout. Defaults to ``90``. If a timeout occurs a :exc:`TimeoutError` will be raised. + scopes: :class:`~twitchio.Scopes` | ``None`` + A :class:`~twitchio.Scopes` that will be granted by the user during the DCF. This should be the same scopes passed + to :meth:`.login_dcf`. If scopes are assigned or passed to the :class:`~twitchio.Client` you do not need to pass + scopes here or in :meth:`.login_dcf`. Defaults to ``None`` which means you would need to pass scopes to the client. + block: :class:`bool` + A bool indicating whether to run the :class:`~twitchio.Client` in a asynchronously blocking loop. This is the + default and same behaviour as :meth:`.start`. When set to ``False``, your :class:`~twitchio.Client` will be logged + in and can be used standalone. Defaults to ``True``. + + Raises + ------ + RuntimeError + :meth:`.login_dcf` must be called before this method. + RuntimeError + A token and refresh pair must be loaded prior to calling this method, or you must pass the ``device_code`` parameter. + RuntimeError + A valid :class:`~twitchio.User` could not be fetched with the token received. + DeviceCodeFlowException + An exception was raised during a request to Twitch for DCF. Check the device code is valid. + HTTPException + An exception was raised during a request to Twitch. + TimeoutError + Timed-out waiting for the user to complete the DCF. + """ + if not self._login_called: + raise RuntimeError('Client failed to start: "login_dcf" must be called before "start_dcf".') + + self.__waiter.clear() + + try: + mapping = list(self._http._tokens.values()) + pair = mapping[0] + token = pair["token"] + refresh = pair["refresh"] + user_id = pair["user_id"] + except (IndexError, KeyError): + token = "" + refresh = "" + user_id = "" + + if device_code: + async with asyncio.timeout(timeout): + resp = await self._http.device_code_authorization(device_code=device_code, interval=interval, scopes=scopes) + + token = resp["access_token"] + refresh = resp["refresh_token"] + + validated = await self.add_token(token=token, refresh=refresh) + user_id = validated.user_id + + if not token or not refresh: + raise RuntimeError( + "Unable to start Client: No DCF token pair was able to be loaded. Try force running the flow." + ) + + # Technically a User Token, however this will allow similar default behaviours since DCF should be bound to a + # ...single user. + self._http._app_token = token + + user = await self.fetch_user(id=user_id) + if not user: + raise RuntimeError("Unable to fetch associated user with DCF token.") + + self._bot_id = user.id + self._owner_id = user.id + self._user = user + self._owner = user + + # Event Ready will act more similarly to setup_hook with DCF setups since we have to wait for the user to respond + self.dispatch("ready") + self._ready_event.set() + + if not block: + return + + try: + await self.__waiter.wait() + finally: + self._ready_event.clear() + await self.close() + async def login(self, *, token: str | None = None, load_tokens: bool = True, save_tokens: bool = True) -> None: """|coro| @@ -884,7 +1108,7 @@ def add_listener(self, listener: Callable[..., Coroutine[Any, Any, None]], *, ev if name == "event_": raise ValueError('Listener and event names cannot be named "event_".') - if not asyncio.iscoroutinefunction(listener): + if not inspect.iscoroutinefunction(listener): raise TypeError("Listeners and Events must be coroutines.") self._listeners[name].add(listener) @@ -3746,3 +3970,19 @@ async def subscribe_webhook(self, *args: Any, **kwargs: Any) -> Any: AutoClient does not implement this method. """ raise NotImplementedError("AutoClient does not implement this method.") + + async def start_dcf(self, *args: Any, **kwargs: Any) -> Any: + """ + .. important:: + + AutoClient does not implement this method. + """ + raise NotImplementedError("AutoClient does not implement this method.") + + async def login_dcf(self, *args: Any, **kwargs: Any) -> Any: + """ + .. important:: + + AutoClient does not implement this method. + """ + raise NotImplementedError("AutoClient does not implement this method.") diff --git a/twitchio/enums.py b/twitchio/enums.py new file mode 100644 index 00000000..82e8ee51 --- /dev/null +++ b/twitchio/enums.py @@ -0,0 +1,46 @@ +""" +MIT License + +Copyright (c) 2017 - Present PythonistaGuild + +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 enum + + +__all__ = ("DeviceCodeRejection",) + + +class DeviceCodeRejection(enum.Enum): + """An enum respresenting the reason a DCF (Device Code Flow) failed. + + Attributes + ---------- + UNKNOWN + The reason is unknown. Twitch likely did not provide one or the exception was a 5xx status code. + INVALID_REFRESH_TOKEN + The refresh used was invalid or expired. DCF refresh tokens can only be used once and last ``30`` days. + INVALID_DEVICE_CODE + The provided device code was not valid or the user has already authenticated with this code. + """ + + UNKNOWN = "unknown" + INVALID_REFRESH_TOKEN = "invalid refresh token" + INVALID_DEVICE_CODE = "invalid device code" diff --git a/twitchio/exceptions.py b/twitchio/exceptions.py index ba20ef3a..dab0bd49 100644 --- a/twitchio/exceptions.py +++ b/twitchio/exceptions.py @@ -26,8 +26,11 @@ from typing import TYPE_CHECKING, Any +from .enums import DeviceCodeRejection + __all__ = ( + "DeviceCodeFlowException", "HTTPException", "InvalidTokenException", "MessageRejectedError", @@ -84,6 +87,30 @@ def __init__( super().__init__(msg) +class DeviceCodeFlowException(HTTPException): + """Exception raised when an error occurs during a DCF (Device Code Flow). + + This exception inherits from :exc:`~twitchio.HTTPException` and contains additional information. + + Attributes + ---------- + reason: :class:`twitchio.DeviceCodeRejection` + The reason the Device Code Flow failed, as an enum. Could be ``UNKNOWN`` if the reason was not provided by Twitch. + route: :class:`twitchio.Route` | None + An optional :class:`twitchio.Route` supplied to this exception, which contains various information about the + request. + status: :class:`int` + The HTTP response code received from Twitch. E.g. ``404`` or ``409``. + extra: dict[Literal["message"], str] + A dict with a single key named "message", which may contain additional information from Twitch + about why the request failed. + """ + + def __init__(self, msg: str = "", /, *, original: HTTPException, reason: DeviceCodeRejection | None = None) -> None: + self.reason: DeviceCodeRejection = reason or DeviceCodeRejection.UNKNOWN + super().__init__(msg, route=original.route, status=original.status, extra=original.extra) + + class InvalidTokenException(HTTPException): """Exception raised when an token can not be validated or refreshed. diff --git a/twitchio/ext/commands/bot.py b/twitchio/ext/commands/bot.py index c90d0927..ddaf1b44 100644 --- a/twitchio/ext/commands/bot.py +++ b/twitchio/ext/commands/bot.py @@ -24,8 +24,8 @@ from __future__ import annotations -import asyncio import importlib.util +import inspect import logging import sys import types @@ -159,8 +159,8 @@ def __init__( self, *, client_id: str, - client_secret: str, - bot_id: str, + client_secret: str | None = None, + bot_id: str | None = None, owner_id: str | None = None, prefix: PrefixT, **options: Unpack[BotOptions], @@ -698,7 +698,7 @@ async def load_module(self, name: str, *, package: str | None = None) -> None: del sys.modules[name] raise NoEntryPointError(f'The module "{module}" has no setup coroutine.') from exc - if not asyncio.iscoroutinefunction(entry): + if not inspect.iscoroutinefunction(entry): del sys.modules[name] raise TypeError(f'The module "{module}"\'s setup function is not a coroutine.') diff --git a/twitchio/http.py b/twitchio/http.py index e445ff62..64cf61b8 100644 --- a/twitchio/http.py +++ b/twitchio/http.py @@ -36,8 +36,9 @@ import aiohttp +from twitchio.exceptions import HTTPException + from . import __version__ -from .exceptions import HTTPException from .models.analytics import ExtensionAnalytics, GameAnalytics from .models.bits import ExtensionTransaction from .models.channel_points import CustomRewardRedemption diff --git a/twitchio/types_/responses.py b/twitchio/types_/responses.py index d4b4a968..df644ebe 100644 --- a/twitchio/types_/responses.py +++ b/twitchio/types_/responses.py @@ -138,6 +138,22 @@ class AuthorizationURLResponse(TypedDict): state: str +class DeviceCodeFlowResponse(TypedDict): + device_code: str + expires_in: int + interval: int + user_code: str + verification_uri: str + + +class DeviceCodeTokenResponse(TypedDict): + access_token: str + expires_in: int + refresh_token: str + scope: list[str] + token_type: str + + OAuthResponses: TypeAlias = ( RefreshTokenResponse | ValidateTokenResponse | ClientCredentialsResponse | UserTokenResponse | AuthorizationURLResponse )