Skip to content

Commit 635417a

Browse files
committed
Add base of extension
1 parent 21d04ca commit 635417a

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed

twitchio/ext/oauth_relay/__init__.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""MIT License
2+
3+
Copyright (c) 2025 - Present PythonistaGuild
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
"""
23+
24+
__title__ = "twitchio-ext-oauth-relay"
25+
__author__ = "PythonistaGuild"
26+
__license__ = "MIT"
27+
__copyright__ = "Copyright 2025-Present (c) PythonistaGuild"
28+
__version__ = "0.1.0"
29+
30+
31+
from .oauth import OAuthRelay as OAuthRelay

twitchio/ext/oauth_relay/oauth.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""MIT License
2+
3+
Copyright (c) 2025 - Present PythonistaGuild
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import asyncio
27+
import logging
28+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, TypedDict
29+
30+
import aiohttp
31+
32+
import twitchio
33+
import twitchio.backoff
34+
35+
36+
if TYPE_CHECKING:
37+
from twitchio.authentication.payloads import UserTokenPayload, ValidateTokenPayload
38+
39+
40+
LOGGER: logging.Logger = logging.getLogger(__name__)
41+
42+
43+
class OAuthPayload(TypedDict):
44+
code: str
45+
grant_type: Literal["authorization_code"]
46+
redirect_uri: str
47+
48+
49+
class OAuthRelay:
50+
RELAY_URL: ClassVar[str] = "https://twitchio.id/oauth/connect"
51+
52+
def __init__(self, client: twitchio.Client, *, application_id: str, token: str) -> None:
53+
self.client = client
54+
self._application_id = application_id
55+
self._token = token
56+
57+
self._socket: aiohttp.ClientWebSocketResponse | None = None
58+
self._connected: asyncio.Event = asyncio.Event()
59+
self._backoff = twitchio.backoff.Backoff()
60+
61+
self._listen_task: asyncio.Task[None] | None = asyncio.create_task(self._listen())
62+
self._reconnecting: asyncio.Task[None] | None = None
63+
64+
@property
65+
def connected(self) -> bool:
66+
return self._connected.is_set() and self._socket is not None
67+
68+
@property
69+
def application_id(self) -> str:
70+
return self._application_id
71+
72+
@property
73+
def headers(self) -> dict[str, str]:
74+
return {"Application-ID": self.application_id, "Authorization": self._token}
75+
76+
async def fetch_token(self, code: str, *, redirect: str) -> None:
77+
try:
78+
resp: UserTokenPayload = await self.client._http.user_access_token(code, redirect_uri=redirect)
79+
except twitchio.HTTPException as e:
80+
LOGGER.error("Unable to authorize user via OAuth-Relay: %s", e)
81+
return
82+
83+
try:
84+
validated: ValidateTokenPayload = await self.client._http.validate_token(resp.access_token)
85+
except Exception as e:
86+
LOGGER.error("An error occurred trying to validate token in OAuth-Relay: %s", e)
87+
return
88+
89+
resp._user_id = validated.user_id
90+
resp._user_login = validated.login
91+
92+
self.client.dispatch(event="oauth_authorized", payload=resp)
93+
94+
async def connect(self) -> None:
95+
async with aiohttp.ClientSession(headers=self.headers) as session:
96+
socket = await session.ws_connect(self.RELAY_URL, heartbeat=10)
97+
session.detach()
98+
99+
self._sokcet = socket
100+
self._connected.set()
101+
102+
async def reconnect(self) -> None:
103+
self._connected.clear()
104+
105+
if self._socket:
106+
try:
107+
await self._socket.close()
108+
except Exception:
109+
pass
110+
111+
self._socket = None
112+
while True:
113+
try:
114+
await self.connect()
115+
except Exception:
116+
wait = self._backoff.calculate()
117+
118+
LOGGER.warning("OAuth-Relay trying to reconnect in %d seconds.", wait)
119+
await asyncio.sleep(wait)
120+
else:
121+
break
122+
123+
LOGGER.info("Successfully reconnected to OAuth-Relay websocket.")
124+
125+
async def _listen(self) -> None:
126+
while True:
127+
await self._connected.wait()
128+
if not self._socket:
129+
continue
130+
131+
try:
132+
data: OAuthPayload = await self._socket.receive_json()
133+
except Exception:
134+
self._reconnecting = asyncio.create_task(self.reconnect())
135+
continue
136+
137+
try:
138+
code: str = data["code"]
139+
grant: Literal["authorization_code"] = data["grant_type"]
140+
redirect: str = data["redirect_uri"]
141+
except KeyError:
142+
LOGGER.warning("Unrecognized payload received in OAuth-Relay.")
143+
continue
144+
145+
if grant != "authorization_code":
146+
LOGGER.warning("Unrecognized payload received in OAuth-Relay.")
147+
continue
148+
149+
await self.fetch_token(code, redirect=redirect)
150+
151+
async def close(self) -> None:
152+
self._connected.clear()
153+
154+
if self._listen_task:
155+
try:
156+
self._listen_task.cancel()
157+
except Exception:
158+
pass
159+
160+
if self._reconnecting:
161+
try:
162+
self._reconnecting.cancel()
163+
except Exception:
164+
pass
165+
166+
if self._socket:
167+
try:
168+
await self._socket.close()
169+
except Exception:
170+
pass
171+
172+
self._listen_task = None
173+
self._socket = None
174+
self._reconnecting = None
175+
176+
async def __aenter__(self) -> Self:
177+
await self.connect()
178+
return self
179+
180+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
181+
await self.close()

0 commit comments

Comments
 (0)