|
25 | 25 | from __future__ import annotations |
26 | 26 |
|
27 | 27 | import asyncio |
| 28 | +import inspect |
28 | 29 | import logging |
29 | 30 | import math |
30 | 31 | from collections import defaultdict |
|
68 | 69 | from .types_.conduits import ShardUpdateRequest |
69 | 70 | from .types_.eventsub import ShardStatus, SubscriptionCreateTransport, SubscriptionResponse, _SubscriptionData |
70 | 71 | from .types_.options import AutoClientOptions, ClientOptions, WaitPredicateT |
| 72 | + from .types_.responses import DeviceCodeFlowResponse |
71 | 73 | from .types_.tokens import TokenMappingData |
72 | 74 |
|
73 | 75 |
|
@@ -128,7 +130,7 @@ def __init__( |
128 | 130 | self, |
129 | 131 | *, |
130 | 132 | client_id: str, |
131 | | - client_secret: str, |
| 133 | + client_secret: str | None = None, |
132 | 134 | bot_id: str | None = None, |
133 | 135 | **options: Unpack[ClientOptions], |
134 | 136 | ) -> None: |
@@ -188,6 +190,40 @@ def adapter(self) -> BaseAdapter[Any]: |
188 | 190 | currently running.""" |
189 | 191 | return self._adapter |
190 | 192 |
|
| 193 | + @property |
| 194 | + def http(self) -> ManagedHTTPClient: |
| 195 | + """Property exposing the internal :class:`~twitchio.ManagedHTTPClient` used for requests to the Twitch API. |
| 196 | +
|
| 197 | + .. warning:: |
| 198 | +
|
| 199 | + Altering or changing this class during runtime may have unwanted side-effects. It is exposed for developer |
| 200 | + easabilty, especially when OAuth or Device Code Flow methods are required. It is not intended to replace the use |
| 201 | + of the built-in methods of the :class:`~twitchio.Client` or other models. |
| 202 | + """ |
| 203 | + return self._http |
| 204 | + |
| 205 | + async def device_code_flow(self, *, scopes: Scopes | None = None) -> DeviceCodeFlowResponse: |
| 206 | + """|coro| |
| 207 | +
|
| 208 | + .. warning:: |
| 209 | +
|
| 210 | + It's not intended to use DCF when storing a ``client-secret`` is a safe and practical option. |
| 211 | + DCF is intended to be used on user devices where storing your ``client-secret`` is not possible. |
| 212 | +
|
| 213 | + .. note:: |
| 214 | +
|
| 215 | + When using tokens generated through DCF, the only ``EventSub`` transport available is traditional websockets. |
| 216 | + Using ``Conduits`` and ``Webhooks`` are not available as they require a ``App Token``. |
| 217 | +
|
| 218 | + Method which starts a Twitch Device Code Flow. |
| 219 | +
|
| 220 | + The DCF (Device Code Flow) is used to obtain an access/refresh token pair for use on client-side applications where |
| 221 | + storing a ``client-secret`` would be unsafe E.g. a users device (Phone, TV, etc...). |
| 222 | +
|
| 223 | + When using a token |
| 224 | + """ |
| 225 | + return await self._http.device_code_flow(scopes=scopes) |
| 226 | + |
191 | 227 | async def set_adapter(self, adapter: BaseAdapter[Any]) -> None: |
192 | 228 | """|coro| |
193 | 229 |
|
@@ -392,6 +428,87 @@ async def setup_hook(self) -> None: |
392 | 428 | """ |
393 | 429 | ... |
394 | 430 |
|
| 431 | + async def login_dcf( |
| 432 | + self, |
| 433 | + *, |
| 434 | + load_token: bool = True, |
| 435 | + save_token: bool = True, |
| 436 | + scopes: Scopes | None = None, |
| 437 | + force_flow: bool = False, |
| 438 | + ) -> DeviceCodeFlowResponse | None: |
| 439 | + if self._login_called: |
| 440 | + return |
| 441 | + |
| 442 | + self._login_called = True |
| 443 | + self._save_tokens = save_token |
| 444 | + |
| 445 | + if not self._http.client_id: |
| 446 | + raise RuntimeError('Expected a valid "client_id", instead received: %s', self._http.client_id) |
| 447 | + |
| 448 | + self._http._app_token = None |
| 449 | + |
| 450 | + if load_token and not force_flow: |
| 451 | + async with self._http._token_lock: |
| 452 | + await self.load_tokens() |
| 453 | + else: |
| 454 | + self._http._has_loaded = True |
| 455 | + |
| 456 | + await self._setup() |
| 457 | + |
| 458 | + if not self._http._tokens: |
| 459 | + return await self.device_code_flow(scopes=scopes) |
| 460 | + |
| 461 | + async def start_dcf( |
| 462 | + self, |
| 463 | + *, |
| 464 | + device_code: str | None = None, |
| 465 | + interval: int = 5, |
| 466 | + timeout: int | None = 90, |
| 467 | + scopes: Scopes | None = None, |
| 468 | + ) -> None: |
| 469 | + if not self._login_called: |
| 470 | + raise RuntimeError('Client failed to start: "login_dcf" must be called before "start_dcf".') |
| 471 | + |
| 472 | + self.__waiter.clear() |
| 473 | + |
| 474 | + try: |
| 475 | + mapping = list(self._http._tokens.values()) |
| 476 | + pair = mapping[0] |
| 477 | + token = pair["token"] |
| 478 | + refresh = pair["refresh"] |
| 479 | + except (IndexError, KeyError): |
| 480 | + token = "" |
| 481 | + refresh = "" |
| 482 | + |
| 483 | + if device_code: |
| 484 | + async with asyncio.timeout(timeout): |
| 485 | + resp = await self._http.device_code_authorization(device_code=device_code, interval=interval, scopes=scopes) |
| 486 | + |
| 487 | + token = resp["access_token"] |
| 488 | + refresh = resp["refresh_token"] |
| 489 | + |
| 490 | + if not token or not refresh: |
| 491 | + raise RuntimeError( |
| 492 | + "Unable to start Client: No DCF token pair was able to be loaded. Try force running the flow." |
| 493 | + ) |
| 494 | + |
| 495 | + validated = await self.add_token(token=token, refresh=refresh) |
| 496 | + self._http._app_token = token |
| 497 | + |
| 498 | + user = await self.fetch_user(id=validated.user_id) |
| 499 | + if not user: |
| 500 | + raise RuntimeError("Unable to fetch associated user with DCF token.") |
| 501 | + |
| 502 | + self._bot_id = user.id |
| 503 | + self.dispatch("ready") |
| 504 | + self._ready_event.set() |
| 505 | + |
| 506 | + try: |
| 507 | + await self.__waiter.wait() |
| 508 | + finally: |
| 509 | + self._ready_event.clear() |
| 510 | + await self.close() |
| 511 | + |
395 | 512 | async def login(self, *, token: str | None = None, load_tokens: bool = True, save_tokens: bool = True) -> None: |
396 | 513 | """|coro| |
397 | 514 |
|
@@ -884,7 +1001,7 @@ def add_listener(self, listener: Callable[..., Coroutine[Any, Any, None]], *, ev |
884 | 1001 | if name == "event_": |
885 | 1002 | raise ValueError('Listener and event names cannot be named "event_".') |
886 | 1003 |
|
887 | | - if not asyncio.iscoroutinefunction(listener): |
| 1004 | + if not inspect.iscoroutinefunction(listener): |
888 | 1005 | raise TypeError("Listeners and Events must be coroutines.") |
889 | 1006 |
|
890 | 1007 | self._listeners[name].add(listener) |
|
0 commit comments