Skip to content

Commit aa5ac20

Browse files
authored
Merge pull request #414 from PythonistaGuild/feature/ws-eventsub
Websocket Eventsub
2 parents c9cbe0a + fe6a9d3 commit aa5ac20

File tree

8 files changed

+699
-27
lines changed

8 files changed

+699
-27
lines changed

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
.. image:: https://img.shields.io/pypi/dm/twitchio?color=black
1717
:target: https://pypi.org/project/twitchio
1818
:alt: PyPI - Downloads
19-
19+
2020

2121
TwitchIO is an asynchronous Python wrapper around the Twitch API and IRC, with a powerful command extension for creating Twitch Chat Bots. TwitchIO covers almost all of the new Twitch API and features support for commands, PubSub, Webhooks, and EventSub.
2222

docs/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ Master
2424
- Fix reconnect loop when Twitch sends a RECONNECT via IRC websocket
2525
- Fix :func:`~twitchio.CustomReward.edit` so it now can enable the reward
2626

27+
- ext.eventsub
28+
- Added websocket support via eventsub.EventSubWSClient
29+
2730
- Other
2831
- [speed] extra
2932
- Added wheels on external pypi index for cchardet and ciso8601

docs/exts/eventsub.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,32 @@ This is a list of events dispatched by the eventsub ext.
104104
.. note::
105105
You generally won't need to interact with this event. The ext will handle responding to the challenge automatically.
106106

107+
.. function:: event_eventsub_keepalive(event: KeepaliveEvent)
108+
109+
Called when a Twitch sends a keepalive event. You do not need to use this in daily usage.
110+
111+
.. note::
112+
You generally won't need to interact with this event.
113+
114+
.. function:: event_eventsub_reconnect(event: ReconnectEvent)
115+
116+
Called when a Twitch wishes for us to reconnect.
117+
118+
.. note::
119+
You generally won't need to interact with this event. The library will automatically handle reconnecting.
120+
107121
.. function:: event_eventsub_notification_follow(event: ChannelFollowData)
108122

109123
Called when someone creates a follow on a channel you've subscribed to.
110124

125+
.. warning::
126+
Twitch has removed this, please use :func:`event_eventsub_notification_followV2`
127+
128+
129+
.. function:: event_eventsub_notification_followV2(event: ChannelFollowData)
130+
131+
Called when someone creates a follow on a channel you've subscribed to.
132+
111133
.. function:: event_eventsub_notification_subscription(event: ChannelSubscribeData)
112134

113135
Called when someone subscribes to a channel that you've subscribed to.
@@ -217,6 +239,12 @@ API Reference
217239
:members:
218240
:undoc-members:
219241

242+
.. attributetable:: EventSubWSClient
243+
244+
.. autoclass:: EventSubWSClient
245+
:members:
246+
:undoc-members:
247+
220248
.. attributetable:: Subscription
221249

222250
.. autoclass:: Subscription
@@ -229,6 +257,12 @@ API Reference
229257
:members:
230258
:inherited-members:
231259

260+
.. attributetable:: WebsocketHeaders
261+
262+
.. autoclass:: WebsocketHeaders
263+
:members:
264+
:inherited-members:
265+
232266
.. attributetable::: ChannelBanData
233267
234268
.. autoclass:: ChannelBanData

twitchio/ext/eventsub/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@
2323
"""
2424

2525
from .server import EventSubClient
26+
from .websocket import EventSubWSClient, Websocket
2627
from .models import *

twitchio/ext/eventsub/http.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@
66

77
if TYPE_CHECKING:
88
from .server import EventSubClient
9+
from .websocket import EventSubWSClient
910
from .models import EventData, Subscription
1011

1112
__all__ = ("EventSubHTTP",)
1213

1314

1415
class EventSubHTTP:
15-
def __init__(self, client: EventSubClient, token: Optional[str]):
16+
def __init__(self, client: Union[EventSubClient, EventSubWSClient], token: Optional[str]):
1617
self._client = client
1718
self._http = client.client._http
1819
self._token = token
1920

20-
async def create_subscription(self, event_type: Tuple[str, int, Type[EventData]], condition: Dict[str, str]):
21+
async def create_webhook_subscription(
22+
self, event_type: Tuple[str, int, Type[EventData]], condition: Dict[str, str]
23+
):
2124
payload = {
2225
"type": event_type[0],
2326
"version": str(event_type[1]),
@@ -27,6 +30,18 @@ async def create_subscription(self, event_type: Tuple[str, int, Type[EventData]]
2730
route = Route("POST", "eventsub/subscriptions", body=payload, token=self._token)
2831
return await self._http.request(route, paginate=False, force_app_token=True)
2932

33+
async def create_websocket_subscription(
34+
self, event_type: Tuple[str, int, Type[EventData]], condition: Dict[str, str], session_id: str, token: str
35+
) -> dict:
36+
payload = {
37+
"type": event_type[0],
38+
"version": str(event_type[1]),
39+
"condition": condition,
40+
"transport": {"method": "websocket", "session_id": session_id},
41+
}
42+
route = Route("POST", "eventsub/subscriptions", body=payload, token=token)
43+
return await self._http.request(route, paginate=False, full_body=True) # type: ignore
44+
3045
async def delete_subscription(self, subscription: Union[str, Subscription]):
3146
if isinstance(subscription, models.Subscription):
3247
return await self._http.request(

twitchio/ext/eventsub/models.py

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import hashlib
55
import logging
66
from enum import Enum
7-
from typing import Dict, TYPE_CHECKING, Optional, Type, Union, Tuple, List
7+
from typing import Dict, TYPE_CHECKING, Optional, Type, Union, Tuple, List, overload
88
from typing_extensions import Literal
99

1010
from aiohttp import web
@@ -13,6 +13,7 @@
1313

1414
if TYPE_CHECKING:
1515
from .server import EventSubClient
16+
from .websocket import EventSubWSClient
1617

1718
try:
1819
import ujson as json
@@ -36,7 +37,7 @@ def __init__(self, **kwargs):
3637

3738

3839
class Subscription:
39-
__slots__ = "id", "status", "type", "version", "cost", "condition", "transport", "created_at"
40+
__slots__ = "id", "status", "type", "version", "cost", "condition", "transport", "transport_method", "created_at"
4041

4142
def __init__(self, data: dict):
4243
self.id: str = data["id"]
@@ -47,8 +48,14 @@ def __init__(self, data: dict):
4748
self.condition: Dict[str, str] = data["condition"]
4849
self.created_at = _parse_datetime(data["created_at"])
4950
self.transport = EmptyObject()
50-
self.transport.method: str = data["transport"]["method"] # noqa
51-
self.transport.callback: str = data["transport"]["callback"] # noqa
51+
self.transport_method: TransportType = getattr(TransportType, data["transport"]["method"])
52+
self.transport.method: str = data["transport"]["method"] # type: ignore
53+
54+
if self.transport_method is TransportType.webhook:
55+
self.transport.callback: str = data["transport"]["callback"] # type: ignore
56+
else:
57+
self.transport.callback: str = "" # type: ignore # compatibility
58+
self.transport.session_id: str = data["transport"]["session_id"] # type: ignore
5259

5360

5461
class Headers:
@@ -82,33 +89,104 @@ def __init__(self, request: web.Request):
8289
self._raw_timestamp = request.headers["Twitch-Eventsub-Message-Timestamp"]
8390

8491

92+
class WebsocketHeaders:
93+
"""
94+
The headers of the inbound Websocket EventSub message
95+
96+
Attributes
97+
-----------
98+
message_id: :class:`str`
99+
The unique ID of the message
100+
message_type: :class:`str`
101+
The type of the message coming through
102+
message_retry: :class:`int`
103+
Kept for compatibility with :class:`Headers`
104+
signature: :class:`str`
105+
Kept for compatibility with :class:`Headers`
106+
subscription_type: :class:`str`
107+
The type of the subscription on the inbound message
108+
subscription_version: :class:`str`
109+
The version of the subscription.
110+
timestamp: :class:`datetime.datetime`
111+
The timestamp the message was sent at
112+
"""
113+
114+
def __init__(self, frame: dict):
115+
meta = frame["metadata"]
116+
self.message_id: str = meta["message_id"]
117+
self.timestamp = _parse_datetime(meta["message_timestamp"])
118+
self.message_type: Literal["notification", "revocation", "reconnect", "session_keepalive"] = meta[
119+
"message_type"
120+
]
121+
self.message_retry: int = 0 # don't make breaking changes with the Header class
122+
self.signature: str = ""
123+
self.subscription_type: Optional[str]
124+
self.subscription_version: Optional[str]
125+
if frame["payload"]:
126+
self.subscription_type = frame["payload"]["subscription"]["type"]
127+
self.subscription_version = frame["payload"]["subscription"]["version"]
128+
else:
129+
self.subscription_type = None
130+
self.subscription_version = None
131+
132+
85133
class BaseEvent:
86134
"""
87135
The base of all the event classes
88136
89137
Attributes
90138
-----------
91-
subscription: :class:`Subscription`
92-
The subscription attached to the message
139+
subscription: Optional[:class:`Subscription`]
140+
The subscription attached to the message. This is only optional when using the websocket eventsub transport
93141
headers: :class`Headers`
94142
The headers received with the message
95143
"""
96144

97-
__slots__ = "_client", "_raw_data", "subscription", "headers"
145+
__slots__ = ("_client", "_raw_data", "subscription", "headers")
146+
147+
@overload
148+
def __init__(self, client: EventSubClient, _data: str, request: web.Request):
149+
...
98150

99-
def __init__(self, client: EventSubClient, data: str, request: web.Request):
151+
@overload
152+
def __init__(self, client: EventSubWSClient, _data: dict, request: None):
153+
...
154+
155+
def __init__(
156+
self, client: Union[EventSubClient, EventSubWSClient], _data: Union[str, dict], request: Optional[web.Request]
157+
):
100158
self._client = client
101-
self._raw_data = data
102-
_data: dict = _loads(data)
103-
self.subscription = Subscription(_data["subscription"])
104-
self.headers = Headers(request)
105-
self.setup(_data)
159+
self._raw_data = _data
160+
161+
if isinstance(_data, str):
162+
data: dict = _loads(_data)
163+
else:
164+
data = _data
165+
166+
self.headers: Union[Headers, WebsocketHeaders]
167+
self.subscription: Optional[Subscription]
168+
169+
if request:
170+
data: dict = _loads(_data)
171+
self.headers = Headers(request)
172+
self.subscription = Subscription(data["subscription"])
173+
self.setup(data)
174+
else:
175+
self.headers = WebsocketHeaders(data)
176+
if data["payload"]:
177+
self.subscription = Subscription(data["payload"]["subscription"])
178+
else:
179+
self.subscription = None
180+
self.setup(data["payload"])
106181

107182
def setup(self, data: dict):
108183
pass
109184

110185
def verify(self):
111-
hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8")
186+
"""
187+
Only used in webhook transport types. Verifies the message is valid
188+
"""
189+
hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8") # type: ignore
112190
secret = self._client.secret.encode("utf-8")
113191
digest = hmac.new(secret, msg=hmac_message, digestmod=hashlib.sha256).hexdigest()
114192

@@ -127,6 +205,9 @@ class ChallengeEvent(BaseEvent):
127205
"""
128206
A challenge event.
129207
208+
.. note::
209+
These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubClient`
210+
130211
Attributes
131212
-----------
132213
challenge: :class`str`
@@ -139,7 +220,7 @@ def setup(self, data: dict):
139220
self.challenge: str = data["challenge"]
140221

141222
def verify(self):
142-
hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8")
223+
hmac_message = (self.headers.message_id + self.headers._raw_timestamp + self._raw_data).encode("utf-8") # type: ignore
143224
secret = self._client.secret.encode("utf-8")
144225
digest = hmac.new(secret, msg=hmac_message, digestmod=hashlib.sha256).hexdigest()
145226

@@ -150,6 +231,40 @@ def verify(self):
150231
return web.Response(status=200, text=self.challenge)
151232

152233

234+
class ReconnectEvent(BaseEvent):
235+
"""
236+
A reconnect event. Called by twitch when the websocket needs to be disconnected for maintenance or other reasons
237+
238+
.. note::
239+
These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubWSClient`
240+
241+
Attributes
242+
-----------
243+
reconnect_url: :class:`str`
244+
The URL to reconnect to
245+
connected_at: :class:`datetime.datetime`
246+
When the original websocket connected
247+
"""
248+
249+
__slots__ = ("reconnect_url", "connected_at")
250+
251+
def setup(self, data: dict):
252+
self.reconnect_url: str = data["session"]["reconnect_url"]
253+
self.connected_at: datetime.datetime = _parse_datetime(data["session"]["connected_at"])
254+
255+
256+
class KeepAliveEvent(BaseEvent):
257+
"""
258+
A keep-alive event. Called by twitch when no message has been sent for more than ``keepalive_timeout``
259+
260+
.. note::
261+
These are only dispatched when using :class:`~twitchio.ext.eventsub.EventSubWSClient`
262+
263+
"""
264+
265+
pass
266+
267+
153268
class NotificationEvent(BaseEvent):
154269
"""
155270
A notification event
@@ -1536,3 +1651,8 @@ class _SubscriptionTypes(metaclass=_SubTypesMeta):
15361651

15371652

15381653
SubscriptionTypes = _SubscriptionTypes()
1654+
1655+
1656+
class TransportType(Enum):
1657+
webhook = "webhook"
1658+
websocket = "websocket"

0 commit comments

Comments
 (0)