Implement a way to send watch events through GQL

This commit is contained in:
DevilXD committed 2026-04-28 20:25:21 +02:00
1 parent cac9b29587
commit 40000cf295
5 files changed
+98 -38

No files matched your search

+50 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import gzip
import json import json
import asyncio import asyncio
import logging import logging
@@ -11,14 +12,14 @@ from typing import Any, SupportsInt, cast, TYPE_CHECKING
import aiohttp import aiohttp
from yarl import URL from yarl import URL
from utils import Game, json_minify from utils import Game, json_minify, isonow
from exceptions import MinerException, RequestException from exceptions import MinerException, RequestException
from constants import CALL, GQL_OPERATIONS, ONLINE_DELAY, URLType from constants import CALL, GQL_QUERIES, ONLINE_DELAY, URLType, GQLQuery
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
from gui import ChannelList from gui import ChannelList
from constants import JsonType, GQLOperation from constants import JsonType, GQLPersistedQuery
logger = logging.getLogger("TwitchDrops") logger = logging.getLogger("TwitchDrops")
@@ -63,6 +64,36 @@ class Stream:
] ]
return {"data": (b64encode(json_minify(payload).encode("utf8"))).decode("utf8")} return {"data": (b64encode(json_minify(payload).encode("utf8"))).decode("utf8")}
@cached_property
def _gql_payload(self) -> GQLQuery:
payload = [
{
"event": "minute-watched",
"properties": {
"broadcast_id": str(self.broadcast_id),
"channel_id": str(self.channel.id),
"channel": self.channel._login,
"client_time": isonow(),
"game": self.game.name if self.game is not None else "",
"game_id": str(self.game.id) if self.game is not None else "",
"hidden": False,
"is_live": True,
"live": True,
"logged_in": True,
"minutes_logged": 1,
"muted": False,
"user_id": self.channel._twitch._auth_state.user_id,
}
}
]
return GQLQuery(
(
"\n mutation SendEvents($input: SendSpadeEventsInput!) "
"{\n sendSpadeEvents(input: $input) {\n statusCode\n}\n}\n"
),
b64encode(gzip.compress(json_minify(payload).encode("utf8"))).decode("utf8")
)
@classmethod @classmethod
def from_get_stream(cls, channel: Channel, channel_data: JsonType) -> Stream: def from_get_stream(cls, channel: Channel, channel_data: JsonType) -> Stream:
stream = channel_data["stream"] stream = channel_data["stream"]
@@ -99,7 +130,7 @@ class Stream:
return self._stream_url return self._stream_url
# get the stream playback access token from GQL # get the stream playback access token from GQL
playback_token_response: JsonType = await self.channel._twitch.gql_request( playback_token_response: JsonType = await self.channel._twitch.gql_request(
GQL_OPERATIONS["PlaybackAccessToken"].with_variables({"login": self.channel._login}) GQL_QUERIES["PlaybackAccessToken"].with_variables({"login": self.channel._login})
) )
token_data: JsonType = playback_token_response["data"]["streamPlaybackAccessToken"] token_data: JsonType = playback_token_response["data"]["streamPlaybackAccessToken"]
token_value = token_data["value"] token_value = token_data["value"]
@@ -203,8 +234,8 @@ class Channel:
return self.id return self.id
@property @property
def stream_gql(self) -> GQLOperation: def stream_gql(self) -> GQLPersistedQuery:
return GQL_OPERATIONS["GetStreamInfo"].with_variables({"channel": self._login}) return GQL_QUERIES["GetStreamInfo"].with_variables({"channel": self._login})
@property @property
def name(self) -> str: def name(self) -> str:
@@ -342,7 +373,7 @@ class Channel:
if not stream.drops_enabled: if not stream.drops_enabled:
try: try:
available_drops_campaigns: JsonType = await self._twitch.gql_request( available_drops_campaigns: JsonType = await self._twitch.gql_request(
GQL_OPERATIONS["AvailableDrops"].with_variables({"channelID": str(self.id)}) GQL_QUERIES["AvailableDrops"].with_variables({"channelID": str(self.id)})
) )
except MinerException: except MinerException:
logger.log(CALL, f"AvailableDrops GQL call failed for channel: {self._login}") logger.log(CALL, f"AvailableDrops GQL call failed for channel: {self._login}")
@@ -408,7 +439,7 @@ class Channel:
self.display() self.display()
# NOTE: This is currently unused. # NOTE: This is currently unused.
async def _send_watch(self) -> bool: async def _send_watch_playlist(self) -> bool:
""" """
This performs a HEAD request on the stream's current playlist, This performs a HEAD request on the stream's current playlist,
to simulate watching the stream. to simulate watching the stream.
@@ -460,7 +491,8 @@ class Channel:
async with self._twitch.request("HEAD", stream_chunk_url) as head_response: async with self._twitch.request("HEAD", stream_chunk_url) as head_response:
return head_response.status == 200 return head_response.status == 200
async def send_watch(self) -> bool: # NOTE: This is currently unused.
async def _send_watch_spade(self) -> bool:
if self._stream is None: if self._stream is None:
return False return False
if self._spade_url is None: if self._spade_url is None:
@@ -472,3 +504,12 @@ class Channel:
return response.status == 204 return response.status == 204
except RequestException: except RequestException:
return False return False
async def send_watch(self) -> bool: # send_watch_gql
if self._stream is None:
return False
try:
watch_response = await self._twitch.gql_request(self._stream._gql_payload)
return watch_response["data"]["sendSpadeEvents"]["statusCode"] == 204
except RequestException:
return False
+33 -18
View File
@@ -110,6 +110,7 @@ SETTINGS_PATH = Path(WORKING_DIR, "settings.json")
# Typing # Typing
JsonType = Dict[str, Any] JsonType = Dict[str, Any]
URLType = NewType("URLType", str) URLType = NewType("URLType", str)
GQLOperation: TypeAlias = "GQLQuery | GQLPersistedQuery"
TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]" TopicProcess: TypeAlias = "abc.Callable[[int, JsonType], Any]"
# Values # Values
MAX_INT = sys.maxsize MAX_INT = sys.maxsize
@@ -264,7 +265,21 @@ class PriorityMode(Enum):
LOW_AVBL_FIRST = 2 LOW_AVBL_FIRST = 2
class GQLOperation(JsonType): class GQLQuery(JsonType):
def __init__(self, query: str, g64data: str):
super().__init__(
query=query,
variables={
"input": {
"data": g64data,
"repository": "twilight",
"encoding": "GZIP_B64",
}
}
)
class GQLPersistedQuery(JsonType):
def __init__(self, name: str, sha256: str, *, variables: JsonType | None = None): def __init__(self, name: str, sha256: str, *, variables: JsonType | None = None):
super().__init__( super().__init__(
operationName=name, operationName=name,
@@ -278,7 +293,7 @@ class GQLOperation(JsonType):
if variables is not None: if variables is not None:
self.__setitem__("variables", variables) self.__setitem__("variables", variables)
def with_variables(self, variables: JsonType) -> GQLOperation: def with_variables(self, variables: JsonType) -> GQLPersistedQuery:
modified = deepcopy(self) modified = deepcopy(self)
if "variables" in self: if "variables" in self:
existing_variables: JsonType = modified["variables"] existing_variables: JsonType = modified["variables"]
@@ -288,9 +303,9 @@ class GQLOperation(JsonType):
return modified return modified
GQL_OPERATIONS: dict[str, GQLOperation] = { GQL_QUERIES: dict[str, GQLPersistedQuery] = {
# returns stream information for a particular channel # returns stream information for a particular channel
"GetStreamInfo": GQLOperation( "GetStreamInfo": GQLPersistedQuery(
"VideoPlayerStreamInfoOverlayChannel", "VideoPlayerStreamInfoOverlayChannel",
"198492e0857f6aedead9665c81c5a06d67b25b58034649687124083ff288597d", "198492e0857f6aedead9665c81c5a06d67b25b58034649687124083ff288597d",
variables={ variables={
@@ -298,7 +313,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# can be used to claim channel points # can be used to claim channel points
"ClaimCommunityPoints": GQLOperation( "ClaimCommunityPoints": GQLPersistedQuery(
"ClaimCommunityPoints", "ClaimCommunityPoints",
"46aaeebe02c99afdf4fc97c7c0cba964124bf6b0af229395f1f6d1feed05b3d0", "46aaeebe02c99afdf4fc97c7c0cba964124bf6b0af229395f1f6d1feed05b3d0",
variables={ variables={
@@ -309,7 +324,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# can be used to claim a drop # can be used to claim a drop
"ClaimDrop": GQLOperation( "ClaimDrop": GQLPersistedQuery(
"DropsPage_ClaimDropRewards", "DropsPage_ClaimDropRewards",
"a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930", "a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930",
variables={ variables={
@@ -319,7 +334,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# returns current state of points (balance, claim available) for a particular channel # returns current state of points (balance, claim available) for a particular channel
"ChannelPointsContext": GQLOperation( "ChannelPointsContext": GQLPersistedQuery(
"ChannelPointsContext", "ChannelPointsContext",
"374314de591e69925fce3ddc2bcf085796f56ebb8cad67a0daa3165c03adc345", "374314de591e69925fce3ddc2bcf085796f56ebb8cad67a0daa3165c03adc345",
variables={ variables={
@@ -327,7 +342,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# returns all in-progress campaigns # returns all in-progress campaigns
"Inventory": GQLOperation( "Inventory": GQLPersistedQuery(
"Inventory", "Inventory",
"d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b", "d86775d0ef16a63a33ad52e80eaff963b2d5b72fada7c991504a57496e1d8e4b",
variables={ variables={
@@ -335,7 +350,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
} }
), ),
# returns current state of drops (current drop progress) # returns current state of drops (current drop progress)
"CurrentDrop": GQLOperation( "CurrentDrop": GQLPersistedQuery(
"DropCurrentSessionContext", "DropCurrentSessionContext",
"4d06b702d25d652afb9ef835d2a550031f1cf762b193523a92166f40ea3d142b", "4d06b702d25d652afb9ef835d2a550031f1cf762b193523a92166f40ea3d142b",
variables={ variables={
@@ -344,7 +359,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# returns all available campaigns # returns all available campaigns
"Campaigns": GQLOperation( "Campaigns": GQLPersistedQuery(
"ViewerDropsDashboard", "ViewerDropsDashboard",
"5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619", "5a4da2ab3d5b47c9f9ce864e727b2cb346af1e3ea8b897fe8f704a97ff017619",
variables={ variables={
@@ -352,7 +367,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
} }
), ),
# returns extended information about a particular campaign # returns extended information about a particular campaign
"CampaignDetails": GQLOperation( "CampaignDetails": GQLPersistedQuery(
"DropCampaignDetails", "DropCampaignDetails",
"039277bf98f3130929262cc7c6efd9c141ca3749cb6dca442fc8ead9a53f77c1", "039277bf98f3130929262cc7c6efd9c141ca3749cb6dca442fc8ead9a53f77c1",
variables={ variables={
@@ -361,7 +376,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# returns drops available for a particular channel # returns drops available for a particular channel
"AvailableDrops": GQLOperation( "AvailableDrops": GQLPersistedQuery(
"DropsHighlightService_AvailableDrops", "DropsHighlightService_AvailableDrops",
"782dad0f032942260171d2d80a654f88bdd0c5a9dddc392e9bc92218a0f42d20", "782dad0f032942260171d2d80a654f88bdd0c5a9dddc392e9bc92218a0f42d20",
variables={ variables={
@@ -369,7 +384,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# retuns stream playback access token # retuns stream playback access token
"PlaybackAccessToken": GQLOperation( "PlaybackAccessToken": GQLPersistedQuery(
"PlaybackAccessToken", "PlaybackAccessToken",
"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9", "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9",
variables={ variables={
@@ -382,7 +397,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
}, },
), ),
# returns live channels for a particular game # returns live channels for a particular game
"GameDirectory": GQLOperation( "GameDirectory": GQLPersistedQuery(
"DirectoryPage_Game", "DirectoryPage_Game",
"76cb069d835b8a02914c08dc42c421d0dafda8af5b113a3f19141824b901402f", "76cb069d835b8a02914c08dc42c421d0dafda8af5b113a3f19141824b901402f",
variables={ variables={
@@ -403,21 +418,21 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
"sortTypeIsRecency": False, "sortTypeIsRecency": False,
}, },
), ),
"SlugRedirect": GQLOperation( # can be used to turn game name -> game slug "SlugRedirect": GQLPersistedQuery( # can be used to turn game name -> game slug
"DirectoryGameRedirect", "DirectoryGameRedirect",
"1f0300090caceec51f33c5e20647aceff9017f740f223c3c532ba6fa59f6b6cc", "1f0300090caceec51f33c5e20647aceff9017f740f223c3c532ba6fa59f6b6cc",
variables={ variables={
"name": ..., # game name "name": ..., # game name
}, },
), ),
"NotificationsView": GQLOperation( # unused, triggers notifications "update-summary" "NotificationsView": GQLPersistedQuery( # unused, triggers notifications "update-summary"
"OnsiteNotifications_View", "OnsiteNotifications_View",
"e8e06193f8df73d04a1260df318585d1bd7a7bb447afa058e52095513f2bfa4f", "e8e06193f8df73d04a1260df318585d1bd7a7bb447afa058e52095513f2bfa4f",
variables={ variables={
"input": {}, "input": {},
}, },
), ),
"NotificationsList": GQLOperation( # unused "NotificationsList": GQLPersistedQuery( # unused
"OnsiteNotifications_ListNotifications", "OnsiteNotifications_ListNotifications",
"11cdb54a2706c2c0b2969769907675680f02a6e77d8afe79a749180ad16bfea6", "11cdb54a2706c2c0b2969769907675680f02a6e77d8afe79a749180ad16bfea6",
variables={ variables={
@@ -428,7 +443,7 @@ GQL_OPERATIONS: dict[str, GQLOperation] = {
"shouldLoadLastBroadcast": False, "shouldLoadLastBroadcast": False,
}, },
), ),
"NotificationsDelete": GQLOperation( "NotificationsDelete": GQLPersistedQuery(
"OnsiteNotifications_DeleteNotification", "OnsiteNotifications_DeleteNotification",
"13d463c831f28ffe17dccf55b3148ed8b3edbbd0ebadd56352f1ff0160616816", "13d463c831f28ffe17dccf55b3148ed8b3edbbd0ebadd56352f1ff0160616816",
variables={ variables={
+2 -2
View File
@@ -13,7 +13,7 @@ from translate import _
from channel import Channel from channel import Channel
from utils import timestamp, Game from utils import timestamp, Game
from exceptions import GQLException from exceptions import GQLException
from constants import GQL_OPERATIONS, MAX_EXTRA_MINUTES, URLType, State from constants import GQL_QUERIES, MAX_EXTRA_MINUTES, URLType, State
if TYPE_CHECKING: if TYPE_CHECKING:
from collections import abc from collections import abc
@@ -193,7 +193,7 @@ class BaseDrop:
return False return False
try: try:
response = await self._twitch.gql_request( response = await self._twitch.gql_request(
GQL_OPERATIONS["ClaimDrop"].with_variables( GQL_QUERIES["ClaimDrop"].with_variables(
{"input": {"dropInstanceID": self.claim_id}} {"input": {"dropInstanceID": self.claim_id}}
) )
) )
+9 -9
View File
@@ -46,7 +46,7 @@ from constants import (
DUMP_PATH, DUMP_PATH,
COOKIES_PATH, COOKIES_PATH,
MAX_CHANNELS, MAX_CHANNELS,
GQL_OPERATIONS, GQL_QUERIES,
WATCH_INTERVAL, WATCH_INTERVAL,
State, State,
ClientType, ClientType,
@@ -906,7 +906,7 @@ class Twitch:
# Solution 1: use GQL to query for the currently mined drop status # Solution 1: use GQL to query for the currently mined drop status
try: try:
context = await self.gql_request( context = await self.gql_request(
GQL_OPERATIONS["CurrentDrop"].with_variables( GQL_QUERIES["CurrentDrop"].with_variables(
{"channelID": str(channel.id)} {"channelID": str(channel.id)}
) )
) )
@@ -1175,7 +1175,7 @@ class Twitch:
if watching_channel is not None: if watching_channel is not None:
for attempt in range(8): for attempt in range(8):
context = await self.gql_request( context = await self.gql_request(
GQL_OPERATIONS["CurrentDrop"].with_variables( GQL_QUERIES["CurrentDrop"].with_variables(
{"channelID": str(watching_channel.id)} {"channelID": str(watching_channel.id)}
) )
) )
@@ -1215,7 +1215,7 @@ class Twitch:
): ):
self.change_state(State.INVENTORY_FETCH) self.change_state(State.INVENTORY_FETCH)
await self.gql_request( await self.gql_request(
GQL_OPERATIONS["NotificationsDelete"].with_variables( GQL_QUERIES["NotificationsDelete"].with_variables(
{"input": {"id": data["id"]}} {"input": {"id": data["id"]}}
) )
) )
@@ -1387,7 +1387,7 @@ class Twitch:
auth_state = await self.get_auth() auth_state = await self.get_auth()
response_list: list[JsonType] = await self.gql_request( response_list: list[JsonType] = await self.gql_request(
[ [
GQL_OPERATIONS["CampaignDetails"].with_variables( GQL_QUERIES["CampaignDetails"].with_variables(
{"channelLogin": str(auth_state.user_id), "dropID": cid} {"channelLogin": str(auth_state.user_id), "dropID": cid}
) )
for cid in campaign_ids for cid in campaign_ids
@@ -1403,7 +1403,7 @@ class Twitch:
status_update = self.gui.status.update status_update = self.gui.status.update
status_update(_("gui", "status", "fetching_inventory")) status_update(_("gui", "status", "fetching_inventory"))
# fetch in-progress campaigns (inventory) # fetch in-progress campaigns (inventory)
response = await self.gql_request(GQL_OPERATIONS["Inventory"]) response = await self.gql_request(GQL_QUERIES["Inventory"])
inventory: JsonType = response["data"]["currentUser"]["inventory"] inventory: JsonType = response["data"]["currentUser"]["inventory"]
ongoing_campaigns: list[JsonType] = inventory["dropCampaignsInProgress"] or [] ongoing_campaigns: list[JsonType] = inventory["dropCampaignsInProgress"] or []
# this contains claimed benefit edge IDs, not drop IDs # this contains claimed benefit edge IDs, not drop IDs
@@ -1412,7 +1412,7 @@ class Twitch:
} }
inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns} inventory_data: dict[str, JsonType] = {c["id"]: c for c in ongoing_campaigns}
# fetch general available campaigns data (campaigns) # fetch general available campaigns data (campaigns)
response = await self.gql_request(GQL_OPERATIONS["Campaigns"]) response = await self.gql_request(GQL_QUERIES["Campaigns"])
available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or [] available_list: list[JsonType] = response["data"]["currentUser"]["dropCampaigns"] or []
applicable_statuses = ("ACTIVE", "UPCOMING") applicable_statuses = ("ACTIVE", "UPCOMING")
available_campaigns: dict[str, JsonType] = { available_campaigns: dict[str, JsonType] = {
@@ -1546,7 +1546,7 @@ class Twitch:
filters.append("DROPS_ENABLED") filters.append("DROPS_ENABLED")
try: try:
response = await self.gql_request( response = await self.gql_request(
GQL_OPERATIONS["GameDirectory"].with_variables({ GQL_QUERIES["GameDirectory"].with_variables({
"limit": limit, "limit": limit,
"slug": game.slug, "slug": game.slug,
"options": { "options": {
@@ -1598,7 +1598,7 @@ class Twitch:
acl_available_drops_map: dict[int, list[JsonType]] = {} acl_available_drops_map: dict[int, list[JsonType]] = {}
if self.settings.available_drops_check: if self.settings.available_drops_check:
available_gql_ops: list[GQLOperation] = [ available_gql_ops: list[GQLOperation] = [
GQL_OPERATIONS["AvailableDrops"].with_variables({"channelID": str(channel_id)}) GQL_QUERIES["AvailableDrops"].with_variables({"channelID": str(channel_id)})
for channel_id, channel_data in acl_streams_map.items() for channel_id, channel_data in acl_streams_map.items()
if channel_data["stream"] is not None # only do this for ONLINE channels if channel_data["stream"] is not None # only do this for ONLINE channels
] ]
+4
View File
@@ -107,6 +107,10 @@ def timestamp(string: str) -> datetime:
return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) return datetime.strptime(string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
def isonow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", 'Z')
CHARS_ASCII = string.ascii_letters + string.digits CHARS_ASCII = string.ascii_letters + string.digits
CHARS_HEX_LOWER = string.digits + "abcdef" CHARS_HEX_LOWER = string.digits + "abcdef"
CHARS_HEX_UPPER = string.digits + "ABCDEF" CHARS_HEX_UPPER = string.digits + "ABCDEF"