Skip to content

Commit

Permalink
V2.9 (#442)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
IAmTomahawkx authored Mar 17, 2024
1 parent 8e93d0e commit 6cf71e4
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 18 deletions.
20 changes: 20 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
:orphan:

2.9.0
=======
- TwitchIO
- Additions
- Added :class:`~twitchio.AdSchedule` and :class:`~twitchio.Emote`
- Added the new ad-related methods for :class:`~twitchio.PartialUser`:
- :func:`~twitchio.PartialUser.fetch_ad_schedule`
- :func:`~twitchio.PartialUser.snooze_ad`
- Added new method :func:`~twitchio.PartialUser.fetch_user_emotes` to :class:`~twitchio.PartialUser`
- Added :func:`~twitchio.PartialUser.fetch_moderated_channels` to :class:`~twitchio.PartialUser`

- Bug fixes
- Fixed ``event_token_expired`` not applying to the current request.

- ext.eventsub
- Bug fixes
- Fixed a crash where a Future could be None, causing unintentional errors.
- Special-cased a restart when a specific known bad frame is received.


2.8.2
======
- ext.commands
Expand Down
7 changes: 7 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ CustomRewardRedemption
:members:
:inherited-members:

Emote
------
.. attributetable:: Emote

.. autoclass:: Emote
:members:

Extension
-----------
.. attributetable:: Extension
Expand Down
5 changes: 5 additions & 0 deletions twitchio/ext/eventsub/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ async def pump(self) -> None:
except TypeError as e:
logger.warning(f"Received bad frame: {e.args[0]}")

if e.args[0] is None: # websocket was closed, reconnect
logger.info("Known bad frame, restarting connection")
await self.connect()
return

except Exception as e:
logger.error("Exception in the pump function!", exc_info=e)
raise
Expand Down
49 changes: 31 additions & 18 deletions twitchio/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,22 +121,8 @@ async def request(self, route: Route, *, paginate=True, limit=100, full_body=Fal
raise errors.NoClientID("A Client ID is required to use the Twitch API")
headers = route.headers or {}

if force_app_token and "Authorization" not in headers:
if not self.client_secret:
raise errors.NoToken(
"An app access token is required for this route, please provide a client id and client secret"
)
if self.app_token is None:
await self._generate_login()
headers["Authorization"] = f"Bearer {self.app_token}"
elif not self.token and not self.client_secret and "Authorization" not in headers:
raise errors.NoToken(
"Authorization is required to use the Twitch API. Pass token and/or client_secret to the Client constructor"
)
if "Authorization" not in headers:
if not self.token:
await self._generate_login()
headers["Authorization"] = f"Bearer {self.token}"
await self._apply_auth(headers, force_app_token, False)

headers["Client-ID"] = self.client_id

if not self.session:
Expand Down Expand Up @@ -165,7 +151,7 @@ def get_limit():
q = [("after", cursor), *q]
q = [("first", get_limit()), *q]
path = path.with_query(q)
body, is_text = await self._request(route, path, headers)
body, is_text = await self._request(route, path, headers, force_app_token=force_app_token)
if is_text:
return body
if full_body:
Expand All @@ -182,7 +168,26 @@ def get_limit():
is_finished = reached_limit() if limit is not None else True if paginate else True
return data

async def _request(self, route, path, headers, utilize_bucket=True):
async def _apply_auth(self, headers: dict, force_app_token: bool, force_apply: bool) -> None:
if force_app_token and "Authorization" not in headers:
if not self.client_secret:
raise errors.NoToken(
"An app access token is required for this route, please provide a client id and client secret"
)
if self.app_token is None:
await self._generate_login()
headers["Authorization"] = f"Bearer {self.app_token}"
elif not self.token and not self.client_secret and "Authorization" not in headers:
raise errors.NoToken(
"Authorization is required to use the Twitch API. Pass token and/or client_secret to the Client constructor"
)
if "Authorization" not in headers or force_apply:
if not self.token:
await self._generate_login()

headers["Authorization"] = f"Bearer {self.token}"

async def _request(self, route, path, headers, utilize_bucket=True, force_app_token: bool = False):
reason = None

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

async def get_user_emotes(self, user_id: str, broadcaster_id: Optional[str], token: str):
q: List = [("user_id", user_id)]
if broadcaster_id:
q.append(("broadcaster_id", broadcaster_id))

return await self.request(Route("GET", "chat/emotes/user", query=q, token=token))

async def get_stream_key(self, token: str, broadcaster_id: str):
return await self.request(
Route("GET", "streams/key", query=[("broadcaster_id", broadcaster_id)], token=token), paginate=False
Expand Down
112 changes: 112 additions & 0 deletions twitchio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@
from .user import BitLeaderboardUser, PartialUser, User

if TYPE_CHECKING:
from typing_extensions import Literal
from .http import TwitchHTTP

__all__ = (
"AdSchedule",
"BitsLeaderboard",
"Clip",
"CheerEmote",
"CheerEmoteTier",
"Emote",
"GlobalEmote",
"ChannelEmote",
"HypeTrainContribution",
Expand Down Expand Up @@ -651,6 +654,115 @@ def __repr__(self):
)


class Emote:
"""
Represents an Emote.
.. note::
It seems twitch is sometimes returning duplicate information from the emotes endpoint.
To deduplicate your emotes, you can call ``set()`` on the list of emotes (or any other hashmap), which will remove the duplicates.
.. code-block:: python
my_list_of_emotes = await user.get_user_emotes(...)
deduplicated_emotes = set(my_list_of_emotes)
Attributes
-----------
id: :class:`str`
The unique ID of the emote.
set_id: Optional[:class:`str`]
The ID of the set this emote belongs to.
Will be ``None`` if the emote doesn't belong to a set.
owner_id: Optional[:class:`str`]
The ID of the channel this emote belongs to.
name: :class:`str`
The name of this emote, as the user sees it.
type: :class:`str`
The reason this emote is available to the user.
Some available values (twitch hasn't documented this properly, there might be more):
- follower
- subscription
- bitstier
- hypetrain
- globals (global emotes)
scales: list[:class:`str`]
The available scaling for this emote. These are typically floats (ex. "1.0", "2.0").
format_static: :class:`bool`
Whether this emote is available as a static (PNG) file.
format_animated: :class:`bool`
Whether this emote is available as an animated (GIF) file.
theme_light: :class:`bool`
Whether this emote is available in light theme background mode.
theme_dark: :class:`bool`
Whether this emote is available in dark theme background mode.
"""

__slots__ = (
"id",
"set_id",
"owner_id",
"name",
"type",
"scales",
"format_static",
"format_animated",
"theme_light",
"theme_dark",
)

def __init__(self, data: dict) -> None:
self.id: str = data["id"]
self.set_id: Optional[str] = data["emote_set_id"] and None
self.owner_id: Optional[str] = data["owner_id"] and None
self.name: str = data["name"]
self.type: str = data["emote_type"]
self.scales: List[str] = data["scale"]
self.theme_dark: bool = "dark" in data["theme_mode"]
self.theme_light: bool = "light" in data["theme_mode"]
self.format_static: bool = "static" in data["format"]
self.format_animated: bool = "animated" in data["format"]

def url_for(self, format: Literal["static", "animated"], theme: Literal["dark", "light"], scale: str) -> str:
"""
Returns a cdn url that can be used to download or serve the emote on a website.
This function validates that the arguments passed are possible values to serve the emote.
Parameters
-----------
format: Literal["static", "animated"]
The format of the emote. You can check what formats are available using :attr:`~.format_static` and :attr:`~.format_animated`.
theme: Literal["dark", "light"]
The theme of the emote. You can check what themes are available using :attr:`~.format_dark` and :attr:`~.format_light`.
scale: :class:`str`
The scale of the emote. This should be formatted in this format: ``"1.0"``.
The scales available for this emote can be checked via :attr:`~.scales`.
Returns
--------
:class:`str`
"""
if scale not in self.scales:
raise ValueError(f"scale for this emote must be one of {', '.join(self.scales)}, not {scale}")

if (theme == "dark" and not self.theme_dark) or (theme == "light" and not self.theme_light):
raise ValueError(f"theme {theme} is not an available value for this emote")

if (format == "static" and not self.format_static) or (format == "animated" and not self.format_animated):
raise ValueError(f"format {format} is not an available value for this emote")

return f"https://static-cdn.jtvnw.net/emoticons/v2/{self.id}/{format}/{theme}/{scale}"

def __repr__(self) -> str:
return f"<Emote id={self.id} name={self.name}>"

def __hash__(self) -> int: # this exists so we can do set(list of emotes) to get rid of duplicates
return hash(self.id)


class Marker:
"""
Represents a stream Marker
Expand Down
32 changes: 32 additions & 0 deletions twitchio/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
AdSchedule,
BitsLeaderboard,
Clip,
Emote,
ExtensionBuilder,
Tag,
FollowEvent,
Expand Down Expand Up @@ -639,6 +640,33 @@ async def fetch_channel_emotes(self):
data = await self._http.get_channel_emotes(str(self.id))
return [ChannelEmote(self._http, x) for x in data]

async def fetch_user_emotes(self, token: str, broadcaster: Optional[PartialUser] = None) -> List[Emote]:
"""|coro|
Fetches emotes the user has access to. Optionally, you can filter by a broadcaster.
.. note::
As of writing, this endpoint seems extrememly unoptimized by twitch, and may (read: will) take a lot of API requests to load.
See https://github.com/twitchdev/issues/issues/921 .
Parameters
-----------
token: :class:`str`
An OAuth token belonging to this user with the ``user:read:emotes`` scope.
broadcaster: Optional[:class:`~twitchio.PartialUser`]
A channel to filter the results with.
Filtering will return all emotes available to the user on that channel, including global emotes.
Returns
--------
List[:class:`~twitchio.Emote`]
"""
from .models import Emote

data = await self._http.get_user_emotes(str(self.id), broadcaster and str(broadcaster.id), token)
return [Emote(d) for d in data]

async def follow(self, userid: int, token: str, *, notifications=False):
"""|coro|
Expand Down Expand Up @@ -666,6 +694,10 @@ async def unfollow(self, userid: int, token: str):
Unfollows the user
.. warning::
This method is obsolete as Twitch removed the endpoint.
Parameters
-----------
userid: :class:`int`
Expand Down

0 comments on commit 6cf71e4

Please sign in to comment.