Skip to content

Commit 6cf71e4

Browse files
authored
V2.9 (#442)
* refactor eventsubws subscription error handling to not error on reconnect * potential fix for bug with headers not getting set after token updates * Revert "refactor eventsubws subscription error handling to not error on reconnect" This reverts commit bdb21b0. * formatting * changelog entry for both prs * add more changelogs * refactor eventsubws subscription error handling to not error on reconnect (#439) * refactor eventsubws subscription error handling to not error on reconnect * Why do we still support 3.7 * formatting * Add new API routes (#441) * Add new API routes * add docs * Add user emote endpoint * work around bad frame disconnect * run black
1 parent 8e93d0e commit 6cf71e4

File tree

6 files changed

+207
-18
lines changed

6 files changed

+207
-18
lines changed

docs/changelog.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
:orphan:
22

3+
2.9.0
4+
=======
5+
- TwitchIO
6+
- Additions
7+
- Added :class:`~twitchio.AdSchedule` and :class:`~twitchio.Emote`
8+
- Added the new ad-related methods for :class:`~twitchio.PartialUser`:
9+
- :func:`~twitchio.PartialUser.fetch_ad_schedule`
10+
- :func:`~twitchio.PartialUser.snooze_ad`
11+
- Added new method :func:`~twitchio.PartialUser.fetch_user_emotes` to :class:`~twitchio.PartialUser`
12+
- Added :func:`~twitchio.PartialUser.fetch_moderated_channels` to :class:`~twitchio.PartialUser`
13+
14+
- Bug fixes
15+
- Fixed ``event_token_expired`` not applying to the current request.
16+
17+
- ext.eventsub
18+
- Bug fixes
19+
- Fixed a crash where a Future could be None, causing unintentional errors.
20+
- Special-cased a restart when a specific known bad frame is received.
21+
22+
323
2.8.2
424
======
525
- ext.commands

docs/reference.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@ CustomRewardRedemption
220220
:members:
221221
:inherited-members:
222222

223+
Emote
224+
------
225+
.. attributetable:: Emote
226+
227+
.. autoclass:: Emote
228+
:members:
229+
223230
Extension
224231
-----------
225232
.. attributetable:: Extension

twitchio/ext/eventsub/websocket.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,11 @@ async def pump(self) -> None:
212212
except TypeError as e:
213213
logger.warning(f"Received bad frame: {e.args[0]}")
214214

215+
if e.args[0] is None: # websocket was closed, reconnect
216+
logger.info("Known bad frame, restarting connection")
217+
await self.connect()
218+
return
219+
215220
except Exception as e:
216221
logger.error("Exception in the pump function!", exc_info=e)
217222
raise

twitchio/http.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -121,22 +121,8 @@ async def request(self, route: Route, *, paginate=True, limit=100, full_body=Fal
121121
raise errors.NoClientID("A Client ID is required to use the Twitch API")
122122
headers = route.headers or {}
123123

124-
if force_app_token and "Authorization" not in headers:
125-
if not self.client_secret:
126-
raise errors.NoToken(
127-
"An app access token is required for this route, please provide a client id and client secret"
128-
)
129-
if self.app_token is None:
130-
await self._generate_login()
131-
headers["Authorization"] = f"Bearer {self.app_token}"
132-
elif not self.token and not self.client_secret and "Authorization" not in headers:
133-
raise errors.NoToken(
134-
"Authorization is required to use the Twitch API. Pass token and/or client_secret to the Client constructor"
135-
)
136-
if "Authorization" not in headers:
137-
if not self.token:
138-
await self._generate_login()
139-
headers["Authorization"] = f"Bearer {self.token}"
124+
await self._apply_auth(headers, force_app_token, False)
125+
140126
headers["Client-ID"] = self.client_id
141127

142128
if not self.session:
@@ -165,7 +151,7 @@ def get_limit():
165151
q = [("after", cursor), *q]
166152
q = [("first", get_limit()), *q]
167153
path = path.with_query(q)
168-
body, is_text = await self._request(route, path, headers)
154+
body, is_text = await self._request(route, path, headers, force_app_token=force_app_token)
169155
if is_text:
170156
return body
171157
if full_body:
@@ -182,7 +168,26 @@ def get_limit():
182168
is_finished = reached_limit() if limit is not None else True if paginate else True
183169
return data
184170

185-
async def _request(self, route, path, headers, utilize_bucket=True):
171+
async def _apply_auth(self, headers: dict, force_app_token: bool, force_apply: bool) -> None:
172+
if force_app_token and "Authorization" not in headers:
173+
if not self.client_secret:
174+
raise errors.NoToken(
175+
"An app access token is required for this route, please provide a client id and client secret"
176+
)
177+
if self.app_token is None:
178+
await self._generate_login()
179+
headers["Authorization"] = f"Bearer {self.app_token}"
180+
elif not self.token and not self.client_secret and "Authorization" not in headers:
181+
raise errors.NoToken(
182+
"Authorization is required to use the Twitch API. Pass token and/or client_secret to the Client constructor"
183+
)
184+
if "Authorization" not in headers or force_apply:
185+
if not self.token:
186+
await self._generate_login()
187+
188+
headers["Authorization"] = f"Bearer {self.token}"
189+
190+
async def _request(self, route, path, headers, utilize_bucket=True, force_app_token: bool = False):
186191
reason = None
187192

188193
for attempt in range(5):
@@ -224,6 +229,7 @@ async def _request(self, route, path, headers, utilize_bucket=True):
224229
if "Invalid OAuth token" in message_json.get("message", ""):
225230
try:
226231
await self._generate_login()
232+
await self._apply_auth(headers, force_app_token, True)
227233
continue
228234
except:
229235
raise errors.Unauthorized(
@@ -699,6 +705,13 @@ async def get_search_channels(self, query: str, token: str = None, live: bool =
699705
Route("GET", "search/channels", query=[("query", query), ("live_only", str(live))], token=token)
700706
)
701707

708+
async def get_user_emotes(self, user_id: str, broadcaster_id: Optional[str], token: str):
709+
q: List = [("user_id", user_id)]
710+
if broadcaster_id:
711+
q.append(("broadcaster_id", broadcaster_id))
712+
713+
return await self.request(Route("GET", "chat/emotes/user", query=q, token=token))
714+
702715
async def get_stream_key(self, token: str, broadcaster_id: str):
703716
return await self.request(
704717
Route("GET", "streams/key", query=[("broadcaster_id", broadcaster_id)], token=token), paginate=False

twitchio/models.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@
3131
from .user import BitLeaderboardUser, PartialUser, User
3232

3333
if TYPE_CHECKING:
34+
from typing_extensions import Literal
3435
from .http import TwitchHTTP
36+
3537
__all__ = (
3638
"AdSchedule",
3739
"BitsLeaderboard",
3840
"Clip",
3941
"CheerEmote",
4042
"CheerEmoteTier",
43+
"Emote",
4144
"GlobalEmote",
4245
"ChannelEmote",
4346
"HypeTrainContribution",
@@ -651,6 +654,115 @@ def __repr__(self):
651654
)
652655

653656

657+
class Emote:
658+
"""
659+
Represents an Emote.
660+
661+
.. note::
662+
663+
It seems twitch is sometimes returning duplicate information from the emotes endpoint.
664+
To deduplicate your emotes, you can call ``set()`` on the list of emotes (or any other hashmap), which will remove the duplicates.
665+
666+
.. code-block:: python
667+
668+
my_list_of_emotes = await user.get_user_emotes(...)
669+
deduplicated_emotes = set(my_list_of_emotes)
670+
671+
Attributes
672+
-----------
673+
id: :class:`str`
674+
The unique ID of the emote.
675+
set_id: Optional[:class:`str`]
676+
The ID of the set this emote belongs to.
677+
Will be ``None`` if the emote doesn't belong to a set.
678+
owner_id: Optional[:class:`str`]
679+
The ID of the channel this emote belongs to.
680+
name: :class:`str`
681+
The name of this emote, as the user sees it.
682+
type: :class:`str`
683+
The reason this emote is available to the user.
684+
Some available values (twitch hasn't documented this properly, there might be more):
685+
686+
- follower
687+
- subscription
688+
- bitstier
689+
- hypetrain
690+
- globals (global emotes)
691+
692+
scales: list[:class:`str`]
693+
The available scaling for this emote. These are typically floats (ex. "1.0", "2.0").
694+
format_static: :class:`bool`
695+
Whether this emote is available as a static (PNG) file.
696+
format_animated: :class:`bool`
697+
Whether this emote is available as an animated (GIF) file.
698+
theme_light: :class:`bool`
699+
Whether this emote is available in light theme background mode.
700+
theme_dark: :class:`bool`
701+
Whether this emote is available in dark theme background mode.
702+
"""
703+
704+
__slots__ = (
705+
"id",
706+
"set_id",
707+
"owner_id",
708+
"name",
709+
"type",
710+
"scales",
711+
"format_static",
712+
"format_animated",
713+
"theme_light",
714+
"theme_dark",
715+
)
716+
717+
def __init__(self, data: dict) -> None:
718+
self.id: str = data["id"]
719+
self.set_id: Optional[str] = data["emote_set_id"] and None
720+
self.owner_id: Optional[str] = data["owner_id"] and None
721+
self.name: str = data["name"]
722+
self.type: str = data["emote_type"]
723+
self.scales: List[str] = data["scale"]
724+
self.theme_dark: bool = "dark" in data["theme_mode"]
725+
self.theme_light: bool = "light" in data["theme_mode"]
726+
self.format_static: bool = "static" in data["format"]
727+
self.format_animated: bool = "animated" in data["format"]
728+
729+
def url_for(self, format: Literal["static", "animated"], theme: Literal["dark", "light"], scale: str) -> str:
730+
"""
731+
Returns a cdn url that can be used to download or serve the emote on a website.
732+
This function validates that the arguments passed are possible values to serve the emote.
733+
734+
Parameters
735+
-----------
736+
format: Literal["static", "animated"]
737+
The format of the emote. You can check what formats are available using :attr:`~.format_static` and :attr:`~.format_animated`.
738+
theme: Literal["dark", "light"]
739+
The theme of the emote. You can check what themes are available using :attr:`~.format_dark` and :attr:`~.format_light`.
740+
scale: :class:`str`
741+
The scale of the emote. This should be formatted in this format: ``"1.0"``.
742+
The scales available for this emote can be checked via :attr:`~.scales`.
743+
744+
Returns
745+
--------
746+
:class:`str`
747+
"""
748+
if scale not in self.scales:
749+
raise ValueError(f"scale for this emote must be one of {', '.join(self.scales)}, not {scale}")
750+
751+
if (theme == "dark" and not self.theme_dark) or (theme == "light" and not self.theme_light):
752+
raise ValueError(f"theme {theme} is not an available value for this emote")
753+
754+
if (format == "static" and not self.format_static) or (format == "animated" and not self.format_animated):
755+
raise ValueError(f"format {format} is not an available value for this emote")
756+
757+
return f"https://static-cdn.jtvnw.net/emoticons/v2/{self.id}/{format}/{theme}/{scale}"
758+
759+
def __repr__(self) -> str:
760+
return f"<Emote id={self.id} name={self.name}>"
761+
762+
def __hash__(self) -> int: # this exists so we can do set(list of emotes) to get rid of duplicates
763+
return hash(self.id)
764+
765+
654766
class Marker:
655767
"""
656768
Represents a stream Marker

twitchio/user.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
AdSchedule,
4141
BitsLeaderboard,
4242
Clip,
43+
Emote,
4344
ExtensionBuilder,
4445
Tag,
4546
FollowEvent,
@@ -639,6 +640,33 @@ async def fetch_channel_emotes(self):
639640
data = await self._http.get_channel_emotes(str(self.id))
640641
return [ChannelEmote(self._http, x) for x in data]
641642

643+
async def fetch_user_emotes(self, token: str, broadcaster: Optional[PartialUser] = None) -> List[Emote]:
644+
"""|coro|
645+
646+
Fetches emotes the user has access to. Optionally, you can filter by a broadcaster.
647+
648+
.. note::
649+
650+
As of writing, this endpoint seems extrememly unoptimized by twitch, and may (read: will) take a lot of API requests to load.
651+
See https://github.com/twitchdev/issues/issues/921 .
652+
653+
Parameters
654+
-----------
655+
token: :class:`str`
656+
An OAuth token belonging to this user with the ``user:read:emotes`` scope.
657+
broadcaster: Optional[:class:`~twitchio.PartialUser`]
658+
A channel to filter the results with.
659+
Filtering will return all emotes available to the user on that channel, including global emotes.
660+
661+
Returns
662+
--------
663+
List[:class:`~twitchio.Emote`]
664+
"""
665+
from .models import Emote
666+
667+
data = await self._http.get_user_emotes(str(self.id), broadcaster and str(broadcaster.id), token)
668+
return [Emote(d) for d in data]
669+
642670
async def follow(self, userid: int, token: str, *, notifications=False):
643671
"""|coro|
644672
@@ -666,6 +694,10 @@ async def unfollow(self, userid: int, token: str):
666694
667695
Unfollows the user
668696
697+
.. warning::
698+
699+
This method is obsolete as Twitch removed the endpoint.
700+
669701
Parameters
670702
-----------
671703
userid: :class:`int`

0 commit comments

Comments
 (0)