Files
TwitchDropsMiner/twitch.py
2021-12-03 21:27:17 +01:00

369 lines
15 KiB
Python

from __future__ import annotations
import os
import asyncio
import logging
from yarl import URL
from getpass import getpass
from functools import partial
from typing import Any, Optional, Union, List, Dict, cast
try:
import aiohttp
except ImportError:
raise ImportError("You have to run 'python -m pip install aiohttp' first")
from channel import Channel
from websocket import Websocket
from inventory import DropsCampaign
from exceptions import LoginException, CaptchaRequired
from constants import (
CLIENT_ID,
USER_AGENT,
COOKIES_PATH,
AUTH_URL,
GQL_URL,
GQL_OPERATIONS,
GQLOperation,
WebsocketTopic,
get_topic,
)
logger = logging.getLogger("TwitchDrops")
class Twitch:
def __init__(
self,
username: Optional[str] = None,
password: Optional[str] = None,
*,
loop: Optional[asyncio.AbstractEventLoop] = None,
):
self.username: Optional[str] = username
self.password: Optional[str] = password
# Cookies, session and auth
cookie_jar = aiohttp.CookieJar()
if os.path.isfile(COOKIES_PATH):
cookie_jar.load(COOKIES_PATH)
self._session = aiohttp.ClientSession(
cookie_jar=cookie_jar, headers={"User-Agent": USER_AGENT}, loop=loop
)
self._access_token: Optional[str] = None
self._user_id: Optional[int] = None
self._is_logged_in = asyncio.Event()
# Websocket
self.websocket = Websocket(self)
# Storing, watching and changing channels
self.channels: Dict[int, Channel] = {}
self._watching_channel: Optional[Channel] = None
self._watching_task: Optional[asyncio.Task[Any]] = None
self._channel_change = asyncio.Event()
# Inventory
self.inventory: List[DropsCampaign] = []
def wait_until_login(self):
return self._is_logged_in.wait()
async def close(self):
print("Exiting...")
self._session.cookie_jar.save(COOKIES_PATH) # type: ignore
self.stop_watching()
await self._session.close()
await self.websocket.close()
await asyncio.sleep(1) # allows aiohttp to safely close the session
@property
def currently_watching(self) -> Optional[Channel]:
return self._watching_channel
async def run(self, channels: List[str] = []):
"""
Main method that runs the whole client.
Here, we manage several things, specifically:
• Fetching the drops inventory to make sure that everything we can claim, is claimed
• Selecting a stream to watch, and watching it
• Changing the stream that's being watched if necessary
"""
# Start our websocket connection - shouldn't require task tracking
asyncio.create_task(self.websocket.connect())
# Claim the drops we can
self.inventory = await self.get_inventory()
games = set()
for campaign in self.inventory:
if campaign.status == "UPCOMING":
# we have no use in processing upcoming campaigns here
continue
for drop in campaign.timed_drops.values():
if drop.can_earn:
games.add(campaign.game)
if drop.can_claim:
await drop.claim()
# Fetch information about all channels we're supposed to handle
for channel_name in channels:
channel: Channel = await Channel(self, channel_name) # type: ignore
self.channels[channel.id] = channel
# Sub to these channel updates
topics: List[WebsocketTopic] = []
for channel_id in self.channels:
topics.append(
get_topic(
"VideoPlayback", channel_id, partial(self.process_stream_state, channel_id)
)
)
await self.websocket.add_topics(topics)
# Repeat: Change into a channel we can watch, then reset the flag
self._channel_change.set()
refresh_channels = False # we're entering having fresh channel data already
while True:
# wait for the change channel signal
await self._channel_change.wait()
for channel in self.channels.values():
if (
channel.stream is not None # steam online
and channel.stream.drops_enabled # drops are enabled
and channel.stream.game in games # streams a game we can earn drops in
):
self.watch(channel)
refresh_channels = True
self._channel_change.clear()
break
else:
# there's no available channel to watch
if refresh_channels:
# refresh the status of all channels,
# to make sure that our websocket didn't miss anything til this point
print("No suitable channel to watch, refreshing...")
for channel in self.channels.values():
await channel.get_stream()
await asyncio.sleep(0.5)
refresh_channels = False
continue
print("No suitable channel to watch, retrying in 120 seconds")
await asyncio.sleep(120)
def watch(self, channel: Channel):
if self._watching_task is not None:
self._watching_task.cancel()
async def watcher(channel: Channel):
op = GQL_OPERATIONS["ChannelPointsContext"].with_variables(
{"channelLogin": channel.name}
)
i = 0
while True:
await channel._send_watch()
if i == 0:
# ensure every 30 minutes that we don't have unclaimed points bonus
response = await self.gql_request(op)
channel_data: Dict[str, Any] = response["data"]["community"]["channel"]
claim_available: Dict[str, Any] = (
channel_data["self"]["communityPoints"]["availableClaim"]
)
if claim_available:
await self.claim_points(channel_data["id"], claim_available["id"])
logger.info("Claimed bonus points")
i = (i + 1) % 30
await asyncio.sleep(59)
print(f"Watching: {channel.name}")
self._watching_channel = channel
self._watching_task = asyncio.create_task(watcher(channel))
def stop_watching(self):
if self._watching_task is not None:
logger.warning("Watching stopped.")
self._watching_task.cancel()
self._watching_task = None
self._watching_channel = None
async def process_stream_state(self, channel_id: int, message: Dict[str, Any]):
msg_type = message["type"]
channel = self.channels.get(channel_id)
if channel is None:
logger.error(f"Stream state change for a non-existing channel: {channel_id}")
return
if msg_type == "stream-down":
logger.info(f"{channel.name} goes OFFLINE")
channel.set_offline()
if self._watching_channel is not None and self._watching_channel.id == channel_id:
# change the channel if we're currently watching it
self._channel_change.set()
elif msg_type == "stream-up":
logger.info(f"{channel.name} goes ONLINE")
# stream_up is sent before the stream actually goes online, so just wait a bit
# and check if it's actually online by then
async def online_delay(channel: Channel):
await asyncio.sleep(10)
await channel.check_online()
asyncio.create_task(online_delay(channel))
elif msg_type == "viewcount":
if channel.stream is None:
# check if we've got a view count for a stream that just started
await channel.check_online()
if channel.stream is not None:
viewers = message["viewers"]
channel.stream.viewer_count = viewers
logger.info(f"{channel.name} viewers: {viewers}")
else:
logger.error(f"Channel viewcount update for an offline stream: {channel.name}")
async def _validate_password(self, password: str) -> bool:
"""
Use Twitch's password validator to validate the password length, characters required, etc.
Helps avoid running into the CAPTCHA if you mistype your password by mistake.
"""
payload = {"password": password}
async with self._session.post(
f"{AUTH_URL}/api/v1/password_strength", json=payload
) as response:
strength_response = await response.json()
return strength_response["isValid"]
async def get_password(self) -> str:
"""
A simple loop that'll keep asking for password, until it's considered valid.
"""
while True:
password = getpass()
if await self._validate_password(password):
return password
async def _login(self) -> str:
logger.debug("Login flow started")
if self.username is None:
self.username = input("Username: ")
if self.password is None:
print(
"\nReminder: Passwords can be pasted in by pressing right click a single time\n"
"inside the window. Due to security reasons, no feedback is displayed.\n"
"Make sure not to paste it in twice.\n"
)
self.password = await self.get_password()
payload: Dict[str, Any] = {
"username": self.username,
"password": self.password,
"client_id": CLIENT_ID,
"undelete_user": False,
"remember_me": True,
}
for attempt in range(10):
async with self._session.post(f"{AUTH_URL}/login", json=payload) as response:
login_response = await response.json()
# Feed this back in to avoid running into CAPTCHA if possible
if "captcha_proof" in login_response:
payload["captcha"] = {"proof": login_response["captcha_proof"]}
# Error handling
if "error_code" in login_response:
error_code = login_response["error_code"]
logger.debug(f"Login error code: {error_code}")
if error_code == 1000:
# we've failed bois
logger.debug("Login failed due to CAPTCHA")
raise CaptchaRequired()
elif error_code == 3001:
# wrong password you dummy
logger.debug("Login failed due to incorrect login or pass")
print(f"Incorrect username or password.\nUsername: {self.username}")
self.password = await self.get_password()
elif error_code in (
3011, # Authy token needed
3012, # Invalid authy token
3022, # Email code needed
3023, # Invalid email code
):
# 2FA handling
email = error_code in (3022, 3023)
logger.debug("2FA token required")
token = input("2FA token: ")
if email:
# email code
payload["twitchguard_code"] = token
else:
# authy token
payload["authy_token"] = token
continue
else:
raise LoginException(login_response["error"])
# Success handling
if "access_token" in login_response:
# we're in bois
self._access_token = login_response["access_token"]
logger.debug(f"Access token: {self._access_token}")
break
if self._access_token is None:
# this means we've ran out of retries
raise LoginException("Ran out of login retries")
return self._access_token
async def check_login(self) -> None:
if self._access_token is not None and self._user_id is not None:
# we're all good
return
# looks like we're missing something
print("Logging in")
jar = self._session.cookie_jar
while True:
cookie = jar.filter_cookies("https://twitch.tv") # type: ignore
if not cookie:
# no cookie - login
await self._login()
# store our auth token inside the cookie
cookie["auth-token"] = cast(str, self._access_token)
elif self._access_token is None:
# have cookie - get our access token
self._access_token = cookie["auth-token"].value
logger.debug("Session restored from cookie")
# validate our access token, by obtaining user_id
async with self._session.get(
"https://id.twitch.tv/oauth2/validate",
headers={"Authorization": f"OAuth {self._access_token}"}
) as response:
status = response.status
if status == 401:
# the access token we have is invalid - clear the cookie and reauth
jar.clear_domain("twitch.tv")
continue
elif status == 200:
validate_response = await response.json()
break
self._user_id = cookie["persistent"] = validate_response["user_id"]
self._is_logged_in.set()
print(f"Login successful, User ID: {self._user_id}")
# update our cookie
jar.update_cookies(cookie, URL("https://twitch.tv"))
async def gql_request(self, op: GQLOperation) -> Dict[str, Any]:
await self.check_login()
headers = {
"Authorization": f"OAuth {self._access_token}",
"Client-Id": CLIENT_ID,
}
logger.debug(f"GQL Request: {op}")
async with self._session.post(GQL_URL, json=op, headers=headers) as response:
response_json = await response.json()
logger.debug(f"GQL Response: {response_json}")
return response_json
async def get_inventory(self) -> List[DropsCampaign]:
response = await self.gql_request(GQL_OPERATIONS["Inventory"])
inventory = response["data"]["currentUser"]["inventory"]
return [DropsCampaign(self, data) for data in inventory["dropCampaignsInProgress"]]
async def claim_points(self, channel_id: Union[str, int], claim_id: str):
variables = {"input": {"channelID": str(channel_id), "claimID": claim_id}}
await self.gql_request(
GQL_OPERATIONS["ClaimCommunityPoints"].with_variables(variables)
)