diff --git a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py index 5d8a49d..e96cbdf 100644 --- a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py +++ b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py @@ -5,6 +5,7 @@ from TwitchChannelPointsMiner.classes.WebSocketsPool import WebSocketsPool from TwitchChannelPointsMiner.classes.PubsubTopic import PubsubTopic from TwitchChannelPointsMiner.classes.Streamer import Streamer from TwitchChannelPointsMiner.classes.Twitch import Twitch +from TwitchChannelPointsMiner.classes.TwitchBrowser import TwitchBrowser, Browser from TwitchChannelPointsMiner.classes.Exceptions import StreamerDoesNotExistException logging.basicConfig( @@ -22,6 +23,7 @@ class TwitchChannelPointsMiner: raid: bool = True, ): self.twitch = Twitch(username) + self.twitch_browser = False self.predictions = predictions self.raid = raid self.streamers = [] @@ -48,12 +50,16 @@ class TwitchChannelPointsMiner: self.twitch.check_streamer_online(streamer) # logger.info(streamer) + if self.predictions is True: # We need a browser to make predictions / bet + self.twitch_browser = TwitchBrowser(self.twitch.twitch_login.get_auth_token()) + self.twitch_browser.init(show=True, browser=Browser.FIREFOX) + self.minute_watcher_thread = threading.Thread( target=self.twitch.send_minute_watched_events, args=(self.streamers,) ) self.minute_watcher_thread.start() - self.ws_pool = WebSocketsPool(twitch=self.twitch, streamers=self.streamers) + self.ws_pool = WebSocketsPool(twitch=self.twitch, twitch_browser=self.twitch_browser, streamers=self.streamers) topics = [ PubsubTopic( "community-points-user-v1", user_id=self.twitch.twitch_login.get_user_id() diff --git a/TwitchChannelPointsMiner/classes/Bet.py b/TwitchChannelPointsMiner/classes/Bet.py new file mode 100644 index 0000000..22b9c8b --- /dev/null +++ b/TwitchChannelPointsMiner/classes/Bet.py @@ -0,0 +1,63 @@ +import logging + +from enum import Enum, auto + +logger = logging.getLogger(__name__) + + +class Strategy(Enum): + MOST_VOTED = auto() + HIGH_COTE = auto() + SMART = auto() + + +class Bet: + def __init__(self, outcomes): + self.outcomes = outcomes + self.__clear_outcomes() + self.total_users = 0 + self.total_points = 0 + + def update_outcomes(self, outcomes): + self.outcomes[0]["total_users"] = int(outcomes[0]["total_users"]) + self.outcomes[1]["total_users"] = int(outcomes[1]["total_users"]) + self.outcomes[0]["total_points"] = int(outcomes[0]["total_points"]) + self.outcomes[1]["total_points"] = int(outcomes[1]["total_points"]) + + self.total_users = self.outcomes[0]["total_users"] + self.outcomes[1]["total_users"] + self.total_points = self.outcomes[0]["total_points"] + self.outcomes[1]["total_points"] + + if (self.total_users > 0 and self.outcomes[0]["total_points"] > 0 and self.outcomes[1]["total_points"] > 0): + self.outcomes[0]["percentage_users"] = round(float((100 * self.outcomes[0]["total_users"]) / self.total_users), 2) + self.outcomes[1]["percentage_users"] = round(float((100 * self.outcomes[1]["total_users"]) / self.total_users), 2) + + self.outcomes[0]["cote"] = round(float(self.total_points / self.outcomes[0]["total_points"]), 2) + self.outcomes[1]["cote"] = round(float(self.total_points / self.outcomes[1]["total_points"]), 2) + + self.__clear_outcomes() + + def __repr__(self): + return f"Bet(Outcome0={self.outcomes[0]}, Outcome1={self.outcomes[1]}, total_users={self.total_users}, total_points={self.total_points})" + + def __clear_outcomes(self): + for index in range(0, len(self.outcomes)): + for key in self.outcomes[index]: + if key not in ["total_users", "total_points", "percentage_users", "cote", "title", "color"]: + del self.outcomes[index][key] + + def calculate(self, balance: int, strategy: Strategy = Strategy.SMART, percentage=5, percentage_gap=20, max_points=50000): + output = {"choice": "", "amount": 0} + + if strategy == Strategy.MOST_VOTED: + output['choice'] = "A" if self.outcomes[0]["total_users"] > self.outcomes[1]["total_users"] else "B" + elif strategy == Strategy.HIGH_COTE: + output['choice'] = "A" if self.outcomes[0]["cote"] > self.outcomes[1]["cote"] else "B" + elif strategy == Strategy.SMART: + difference_percentage = (self.outcomes[0]["percentage_users"] - self.outcomes[1]["percentage_users"]) if self.outcomes[0]["percentage_users"] > self.outcomes[1]["percentage_users"] else (self.outcomes[1]["percentage_users"] - self.outcomes[0]["percentage_users"]) + if difference_percentage < percentage_gap: + output['choice'] = "A" if self.outcomes[0]["cote"] > self.outcomes[1]["cote"] else "B" + else: + output['choice'] = "A" if self.outcomes[0]["total_users"] > self.outcomes[1]["total_users"] else "B" # Follow other users + + output['amount'] = min(round(balance * (percentage / 100)), max_points) + return output diff --git a/TwitchChannelPointsMiner/classes/EventPrediction.py b/TwitchChannelPointsMiner/classes/EventPrediction.py new file mode 100644 index 0000000..3c7ca38 --- /dev/null +++ b/TwitchChannelPointsMiner/classes/EventPrediction.py @@ -0,0 +1,25 @@ +from TwitchChannelPointsMiner.classes.Bet import Bet +from TwitchChannelPointsMiner.classes.Streamer import Streamer + + +class EventPrediction: + def __init__(self, streamer: Streamer, event_id, title, created_at, prediction_window_seconds, status, outcomes): + self.streamer = streamer + + self.event_id = event_id + self.title = title + self.created_at = created_at + self.prediction_window_seconds = prediction_window_seconds + self.status = status + + self.box_fillable = False + self.bet = Bet(outcomes) + + def __repr__(self): + return f"EventPrediction(event_id={self.event_id}, title={self.title})" + + def elapsed(self, timestamp): + return round(float((timestamp - self.created_at).total_seconds()), 2) + + def closing_bet_after(self, timestamp): + return round(float(self.prediction_window_seconds - self.elapsed(timestamp)), 2) \ No newline at end of file diff --git a/TwitchChannelPointsMiner/classes/Streamer.py b/TwitchChannelPointsMiner/classes/Streamer.py index 4c0b585..a02784b 100644 --- a/TwitchChannelPointsMiner/classes/Streamer.py +++ b/TwitchChannelPointsMiner/classes/Streamer.py @@ -20,7 +20,7 @@ class Streamer: self.raid = None self.streamer_url = f"https://www.twitch.tv/{self.username}" - self.char_url = f"https://www.twitch.tv/popout/{self.username}/chat?popout=" + self.chat_url = f"https://www.twitch.tv/popout/{self.username}/chat?popout=" def __repr__(self): return f"Streamer(username={self.username}, channel_id={self.channel_id}, channel_points={self.channel_points})" diff --git a/TwitchChannelPointsMiner/classes/Twitch.py b/TwitchChannelPointsMiner/classes/Twitch.py index ea21082..672a8da 100644 --- a/TwitchChannelPointsMiner/classes/Twitch.py +++ b/TwitchChannelPointsMiner/classes/Twitch.py @@ -114,7 +114,7 @@ class Twitch: streamer.set_online() def claim_channel_points_bonus(self, streamer, claim_id): - logger.info(f"🎁 Claiming the bonus for {streamer.username}!") + logger.info(f"🎁 Claiming the bonus for {streamer}!") json_data = { "operationName": "ClaimCommunityPoints", "variables": { diff --git a/TwitchChannelPointsMiner/classes/TwitchBrowser.py b/TwitchChannelPointsMiner/classes/TwitchBrowser.py new file mode 100644 index 0000000..ded68ba --- /dev/null +++ b/TwitchChannelPointsMiner/classes/TwitchBrowser.py @@ -0,0 +1,244 @@ +import time +import logging +import random + +from datetime import datetime +from enum import Enum, auto +from PIL import Image, ImageDraw, ImageFont + +from selenium import webdriver +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.common.by import By +from selenium.webdriver.common.action_chains import ActionChains + +from TwitchChannelPointsMiner.classes.EventPrediction import EventPrediction + +TWITCH_URL = 'https://www.twitch.tv/' + +cookiePolicyQuery = 'button[data-a-target="consent-banner-accept"]' +streamCoinsMenu = '//div[@data-test-selector="community-points-summary"]//button' +streamBetTitleInBet = '[data-test-selector="predictions-list-item__title"]' +streamBetCustomVote = '[data-test-selector="prediction-checkout-active-footer__input-type-toggle"]' +streamBetVoteInput = "(//input[contains(@class,'tw-block tw-border-bottom-left-radius-medium')])" +streamBetVoteButton = "(//div[@id='channel-points-reward-center-body']//button)" + +logger = logging.getLogger(__name__) + + +class Browser(Enum): + CHROME = auto() + FIREFOX = auto() + + +class TwitchBrowser: + def __init__( + self, + auth_token: str, + timeout: float = 5.0 + ): + self.auth_token = auth_token + self.timeout = timeout + self.currently_is_betting = False + self.browser = None + + def init(self, show: bool = True, browser: Browser = Browser.FIREFOX): + if browser == Browser.FIREFOX: + self.__init_firefox(show) + elif browser == Browser.CHROME: + self.__init_chrome(show) + + if self.browser is not None: + self.browser.set_window_size(250, 900) + self.browser.implicitly_wait(2) + + self.__init_twitch() + + def __init_twitch(self): + logger.info("⚙️ Init Twitch page - Cookie - LocalStorage items") + cookie = { + "domain": ".twitch.tv", + "hostOnly": False, + "httpOnly": False, + "name": "auth-token", + "path": "/", + "SameSite": "no_restriction", + "secure": True, + "session": False, + "storeId": "0", + "id": 1, + "value": self.auth_token + } + self.browser.get(TWITCH_URL) + self.browser.add_cookie(cookie) + + self.__click_when_exist(cookiePolicyQuery, By.CSS_SELECTOR) + time.sleep(1.5) + + # Edit value in localStorage for dark theme, point consent etc. + self.browser.execute_script(""" + window.localStorage.setItem("volume", 0); + window.localStorage.setItem("channelPointsOnboardingDismissed", true); + window.localStorage.setItem("twilight.theme", 1); + window.localStorage.setItem("mature", true); + window.localStorage.setItem("rebrand-notice-dismissed", true); + """) + time.sleep(1.5) + self.__blank() + + def __blank(self): + self.browser.get("about:blank") + + # Private method __ - We can instantiate webdriver only with init_browser + def __init_chrome(self, show): + logger.info("⚙️ Init Chrome browser") + options = webdriver.ChromeOptions() + options.add_argument('--mute-audio') + if not show: + options.add_argument('headless') + options.add_argument('--disable-dev-shm-usage') + options.add_argument('--disable-accelerated-2d-canvas') + options.add_argument('--no-first-run') + options.add_argument('--no-zygote') + options.add_argument('--disable-gpu') + options.add_argument('--no-sandbox') + options.add_argument('--disable-setuid-sandbox') + options.add_experimental_option('excludeSwitches', ['enable-logging']) + self.browser = webdriver.Chrome(options=options) + + # Private method __ - We can instantiate webdriver only with init_browser + def __init_firefox(self, show): + logger.info("⚙️ Init Firefox browser") + options = webdriver.FirefoxOptions() + if not show: + options.headless = True + + fp = webdriver.FirefoxProfile() + fp.set_preference('permissions.default.image', 2) + fp.set_preference("permissions.default.stylesheet", 2) + fp.set_preference('dom.ipc.plugins.enabled.libflashplayer.so', 'false') + fp.set_preference('media.volume_scale', '0.0') + fp.set_preference("browser.startup.homepage", "about:blank") + fp.set_preference("startup.homepage_welcome_url", "about:blank") + fp.set_preference("startup.homepage_welcome_url.additional", "about:blank") + + self.browser = webdriver.Firefox(options=options, firefox_profile=fp) + + def screenshot(self, fname, write_timestamp=True): + self.browser.save_screenshot(fname) + + if write_timestamp is True: + image = Image.open(fname) + draw = ImageDraw.Draw(image) + + font = ImageFont.truetype('./Roboto/Roboto-Bold.ttf', size=35) + (x, y) = (15, image.height // 3) + datetime_text = datetime.now().strftime('%d/%m %H:%M:%S.%f') + + shadowcolor = 'rgb(0, 0, 0)' # black color + # Thin border + draw.text((x - 1, y), datetime_text, font=font, fill=shadowcolor) + draw.text((x + 1, y), datetime_text, font=font, fill=shadowcolor) + draw.text((x, y - 1), datetime_text, font=font, fill=shadowcolor) + draw.text((x, y + 1), datetime_text, font=font, fill=shadowcolor) + + # Thicker border + draw.text((x - 1, y - 1), datetime_text, font=font, fill=shadowcolor) + draw.text((x + 1, y - 1), datetime_text, font=font, fill=shadowcolor) + draw.text((x - 1, y + 1), datetime_text, font=font, fill=shadowcolor) + draw.text((x + 1, y + 1), datetime_text, font=font, fill=shadowcolor) + + color = 'rgb(255, 255, 255)' # white color + draw.text((x, y), datetime_text, font=font, fill=color) + image.save(fname, optimize=True, quality=20) + + def __click_when_exist(self, selector, by: By = By.CSS_SELECTOR): + try: + element = WebDriverWait(self.browser, self.timeout).until(expected_conditions.element_to_be_clickable((by, selector))) + ActionChains(self.browser).move_to_element(element).click().perform() + return element + except Exception: + logger.error(f"Exception raised with: {selector}", exc_info=True) + finally: + return None + + def __send_text(self, selector, text, by: By = By.CSS_SELECTOR): + try: + element = WebDriverWait(self.browser, self.timeout).until(expected_conditions.element_to_be_clickable((By.XPATH, selector))) + ActionChains(self.browser, self.timeout).move_to_element(element).click().send_keys(text).perform() + return element + except Exception: + logger.error(f"Exception raised with: {selector}", exc_info=True) + finally: + return None + + def start_bet(self, event: EventPrediction): + if self.currently_is_betting: + logger.info("Sorry, unable to start the bet. The browser it's currently betting another event") + else: + logger.info(f"⚙️ Start betting at {event.streamer.chat_url} for event: {event}") + self.browser.get(event.streamer.chat_url) + time.sleep(random.uniform(4, 6)) + self.__open_coins_menu(event) + self.__click_on_bet(event) + self.__enable_custom_bet_value(event) + return self.currently_is_betting + + def complete_bet(self, event: EventPrediction): + logger.info(f"⚙️ Going to complete bet for event {event}. Current url page: {self.browser.current_url}") + if event.box_fillable and self.currently_is_betting: + decision = event.bet.calculate(event.streamer.channel_points) + if decision["choice"]: + selector_index = "[1]" if decision["choice"] == "A" else "[2]" + + try: + logger.info(f"⚙️ Going to write: {decision['amount']} on input {decision['choice']}") + self.__send_text(streamBetVoteInput + selector_index, decision["amount"], By.XPATH) + if self.do_screenshot: + self.screenshot("./tmp/{}_TEXT_WRITE.png".format(event.event_id)) + + logger.info("Going to place the bet") + self.__click_when_exist(streamBetVoteButton + selector_index, By.XPATH) + if self.do_screenshot: + self.screenshot("./tmp/{}_CLICK_ON_VOTE.png".format(event.event_id)) + + time.sleep(random.uniform(15, 25)) + event.bet_completed = True + + self.browser.get("about:blank") + self.currently_is_betting = False + + except Exception: + logger.error("Exception raised", exc_info=True) + + self.browser.get("about:blank") + self.currently_is_betting = False + + else: + logger.info(f"Sorry, unable to complete the bet. Event box fillable: {event.box_fillable}, the browser is betting: {self.currently_is_betting}") + + def __open_coins_menu(self, event: EventPrediction): + logger.info(f"⚙️ Open coins menu for event: {event}") + self.__click_when_exist(streamCoinsMenu, By.XPATH) + time.sleep(random.uniform(0.05, 0.1)) + if self.do_screenshot: + self.screenshot("./tmp/{}_OPEN_COINS.png".format(event.event_id)) + + def __click_on_bet(self, event): + logger.info(f"⚙️ Click on the bet for event: {event}") + self.__click_when_exist(streamBetTitleInBet, By.CSS_SELECTOR) + time.sleep(random.uniform(0.05, 0.1)) + if self.do_screenshot: + self.screenshot("./tmp/{}_CLICK_ON_BET.png".format(event.event_id)) + + def __enable_custom_bet_value(self, event): + logger.info(f"⚙️ Enable input of custom value for event: {event}") + if self.__click_when_exist(streamBetCustomVote, By.CSS_SELECTOR) is not None: + time.sleep(random.uniform(0.05, 0.1)) + if self.do_screenshot: + self.screenshot("./tmp/{}_INPUT_ENABLE.png".format(event.event_id)) + + event.box_fillable = True + self.currently_is_betting = True + else: + logger.info("Something whent wrong unable to continue with betting - Fillable box not avaible") diff --git a/TwitchChannelPointsMiner/classes/WebSocketsPool.py b/TwitchChannelPointsMiner/classes/WebSocketsPool.py index 682db27..6c22adf 100644 --- a/TwitchChannelPointsMiner/classes/WebSocketsPool.py +++ b/TwitchChannelPointsMiner/classes/WebSocketsPool.py @@ -3,6 +3,9 @@ import threading import time import json +from dateutil import parser + +from TwitchChannelPointsMiner.classes.EventPrediction import EventPrediction from TwitchChannelPointsMiner.classes.Raid import Raid from TwitchChannelPointsMiner.classes.TwitchWebSocket import TwitchWebSocket @@ -19,9 +22,10 @@ def get_streamer_index(streamers, channel_id): # You can't listen for more than 50 topics at once class WebSocketsPool: - def __init__(self, twitch, streamers): + def __init__(self, twitch, twitch_browser, streamers): self.ws = None self.twitch = twitch + self.twitch_browser = twitch_browser self.streamers = streamers def submit(self, topic): @@ -49,7 +53,9 @@ class WebSocketsPool: self.ws.pending_topics = [] self.ws.twitch = self.twitch + self.ws.twitch_browser = self.twitch_browser self.ws.streamers = self.streamers + self.ws.events_predictions = {} self.ws.last_message_time = 0 self.ws.last_message_type = None @@ -136,6 +142,36 @@ class WebSocketsPool: raid = Raid(message["raid"]["id"], message["raid"]["target_login"]) ws.twitch.update_raid(ws.streamers[streamer_index], raid) + elif topic == "predictions-channel-v1": + streamer_index = get_streamer_index(ws.streamers, topic_user) # message_data["event"]["channel_id"] + + event_id = message_data["event"]["id"] + event_status = message_data["event"]["status"] + + current_timestamp = parser.parse(message_data["timestamp"]) + + if event_id not in ws.events_predictions: + if event_status == "ACTIVE": + event = EventPrediction( + ws.streamers[streamer_index], + event_id, + parser.parse(message_data["event"]["created_at"]), + float(message_data["event"]["prediction_window_seconds"]) - 20, + event_status, + message_data["event"]["outcomes"] + ) + if event.closing_bet_after(current_timestamp) > 0: + ws.events_predictions[event_id] = event + if ws.twitch_browser.currently_is_betting is False: + if ws.twitch_browser.start_bet(ws.events_predictions[event_id]): + complete_bet_thread = threading.Timer(event.closing_bet_after(current_timestamp), ws.twitch_browser.complete_bet, (ws.events_predictions[event_id],)) + complete_bet_thread.start() + logger.info(f"⏰ A thread should start and place the bet after: {event.closing_bet_after(current_timestamp)}s for the event: {ws.events_predictions[event_id]}") + + else: + ws.events_predictions[event_id].status = event_status + ws.events_predictions[event_id].bet.update_outcomes(message_data["event"]["outcomes"]) + elif response["type"] == "RESPONSE" and len(response.get("error", "")) > 0: raise RuntimeError(f"Error while trying to listen for a topic: {response}") diff --git a/requirements.txt b/requirements.txt index 2e4e908..ea73063 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ requests websocket-client browser_cookie3 +pillow +selenium +python-dateutil \ No newline at end of file