From d78e587baaae0ecfa4d23e9cb921a540621c574f Mon Sep 17 00:00:00 2001 From: Matthew Padbury Date: Tue, 18 Jul 2023 12:42:35 +0100 Subject: [PATCH] Added functionality to automatically contribute to community goals. --- README.md | 20 +- .../TwitchChannelPointsMiner.py | 43 ++-- TwitchChannelPointsMiner/classes/Twitch.py | 201 ++++++++++++------ .../classes/WebSocketsPool.py | 20 ++ .../classes/entities/CommunityGoal.py | 64 ++++++ .../classes/entities/Streamer.py | 26 ++- TwitchChannelPointsMiner/constants.py | 20 +- example.py | 11 +- 8 files changed, 300 insertions(+), 105 deletions(-) create mode 100644 TwitchChannelPointsMiner/classes/entities/CommunityGoal.py diff --git a/README.md b/README.md index 7223cfb..0d959a6 100644 --- a/README.md +++ b/README.md @@ -265,8 +265,9 @@ twitch_miner = TwitchChannelPointsMiner( make_predictions=True, # If you want to Bet / Make prediction follow_raid=True, # Follow raid to obtain more points claim_drops=True, # We can't filter rewards base on stream. Set to False for skip viewing counter increase and you will never obtain a drop reward from this script. Issue #21 - claim_moments=True, # If set to True, https://help.twitch.tv/s/article/moments will be claimed when available + claim_moments=True, # If set to True, https://help.twitch.tv/s/article/moments will be claimed when available watch_streak=True, # If a streamer go online change the priority of streamers array and catch the watch screak. Issue #11 + community_goals=False, # If True, contributes the max channel points per stream to the streamers' community challenge goals chat=ChatPresence.ONLINE, # Join irc chat to increase watch-time [ALWAYS, NEVER, ONLINE, OFFLINE] bet=BetSettings( strategy=Strategy.SMART, # Choose you strategy! @@ -298,11 +299,11 @@ twitch_miner = TwitchChannelPointsMiner( twitch_miner.mine( [ - Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), - Streamer("streamer-username02", settings=StreamerSettings(make_predictions=False , follow_raid=True , claim_drops=False , bet=BetSettings(strategy=Strategy.PERCENTAGE , percentage=5 , stealth_mode=False, percentage_gap=20 , max_points=1234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_POINTS, where=Condition.GTE, value=250 ) ) )), - Streamer("streamer-username03", settings=StreamerSettings(make_predictions=True , follow_raid=False , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=False, percentage_gap=30 , max_points=50000 , filter_condition=FilterCondition(by=OutcomeKeys.ODDS, where=Condition.LT, value=300 ) ) )), - Streamer("streamer-username04", settings=StreamerSettings(make_predictions=False , follow_raid=True , watch_streak=True )), - Streamer("streamer-username05", settings=StreamerSettings(make_predictions=True , follow_raid=True , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.HIGH_ODDS , percentage=7 , stealth_mode=True, percentage_gap=20 , max_points=90 , filter_condition=FilterCondition(by=OutcomeKeys.PERCENTAGE_USERS, where=Condition.GTE, value=300 ) ) )), + Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , community_goals=False , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), + Streamer("streamer-username02", settings=StreamerSettings(make_predictions=False , follow_raid=True , claim_drops=False , bet=BetSettings(strategy=Strategy.PERCENTAGE , percentage=5 , stealth_mode=False, percentage_gap=20 , max_points=1234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_POINTS, where=Condition.GTE, value=250 ) ) )), + Streamer("streamer-username03", settings=StreamerSettings(make_predictions=True , follow_raid=False , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=False, percentage_gap=30 , max_points=50000 , filter_condition=FilterCondition(by=OutcomeKeys.ODDS, where=Condition.LT, value=300 ) ) )), + Streamer("streamer-username04", settings=StreamerSettings(make_predictions=False , follow_raid=True , watch_streak=True , )), + Streamer("streamer-username05", settings=StreamerSettings(make_predictions=True , follow_raid=True , claim_drops=True , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.HIGH_ODDS , percentage=7 , stealth_mode=True, percentage_gap=20 , max_points=90 , filter_condition=FilterCondition(by=OutcomeKeys.PERCENTAGE_USERS, where=Condition.GTE, value=300 ) ) )), Streamer("streamer-username06"), Streamer("streamer-username07"), Streamer("streamer-username08"), @@ -520,11 +521,11 @@ Discord( ``` #### Generic Webhook -You can use generic webhook +You can use generic webhook | Key | Type | Default | Description | |----------------------- |------------------|-----------|------------------------------------------------------------------- | -| `endpoint` | string | | webhook url | +| `endpoint` | string | | webhook url | | `method` | string | | `POST` or `GET` | | `events` | list | | Only these events will be sent to the endpoint. Array of Event. or str | @@ -566,6 +567,7 @@ Webhook( | `claim_drops` | bool | True | If this value is True, the script will increase the watch-time for the current game. With this, you can claim the drops from Twitch Inventory [#21](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/21) | | `claim_moments` | bool | True | If set to True, [moments](https://help.twitch.tv/s/article/moments) will be claimed when available | | `watch_streak` | bool | True | Choose if you want to change a priority for these streamers and try to catch the Watch Streak event [#11](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/11) | +| `community_goals` | bool | False | If True, contributes the max channel points per stream to the streamers' community challenge goals | | `bet` | BetSettings | | Rules to follow for the bet | | `chat` | ChatPresence | ONLINE | Join IRC-Chat to appear online in chat and attempt to get StreamElements channel points and increase view-time [#47](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues/47) | @@ -698,7 +700,7 @@ You can also follow this [video tutorial](https://www.youtube.com/watch?v=0VkM7N ## Termux **1. Upgrade packages** -``` +``` pkg upgrade ``` diff --git a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py index 9097b5c..7a9285b 100644 --- a/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py +++ b/TwitchChannelPointsMiner/TwitchChannelPointsMiner.py @@ -88,8 +88,7 @@ class TwitchChannelPointsMiner: ): # Fixes TypeError: 'NoneType' object is not subscriptable if not username or username == "your-twitch-username": - logger.error( - "Please edit your runner file (usually run.py) and try again.") + logger.error("Please edit your runner file (usually run.py) and try again.") logger.error("No username, exiting...") sys.exit(0) @@ -164,16 +163,14 @@ class TwitchChannelPointsMiner: logger.info( f"Twitch Channel Points Miner v2-{current_version} (fork by rdavydov)" ) - logger.info( - "https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2") + logger.info("https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2") if github_version == "0.0.0": logger.error( "Unable to detect if you have the latest version of this script" ) elif current_version != github_version: - logger.info( - f"You are running version {current_version} of this script") + logger.info(f"You are running version {current_version} of this script") logger.info(f"The latest version on GitHub is {github_version}") for sign in [signal.SIGINT, signal.SIGSEGV, signal.SIGTERM]: @@ -190,16 +187,19 @@ class TwitchChannelPointsMiner: if Settings.enable_analytics is True: from TwitchChannelPointsMiner.classes.AnalyticsServer import AnalyticsServer - days_ago = days_ago if days_ago <= 365*15 else 365*15 + days_ago = days_ago if days_ago <= 365 * 15 else 365 * 15 http_server = AnalyticsServer( - host=host, port=port, refresh=refresh, days_ago=days_ago, username=self.username + host=host, + port=port, + refresh=refresh, + days_ago=days_ago, + username=self.username, ) http_server.daemon = True http_server.name = "Analytics Thread" http_server.start() else: - logger.error( - "Can't start analytics(), please set enable_analytics=True") + logger.error("Can't start analytics(), please set enable_analytics=True") def mine( self, @@ -245,8 +245,7 @@ class TwitchChannelPointsMiner: streamers_dict[username] = streamer if followers is True: - followers_array = self.twitch.get_followers( - order=followers_order) + followers_array = self.twitch.get_followers(order=followers_order) logger.info( f"Load {len(followers_array)} followers from your profile!", extra={"emoji": ":clipboard:"}, @@ -269,8 +268,7 @@ class TwitchChannelPointsMiner: if isinstance(streamers_dict[username], Streamer) is True else Streamer(username) ) - streamer.channel_id = self.twitch.get_channel_id( - username) + streamer.channel_id = self.twitch.get_channel_id(username) streamer.settings = set_default_settings( streamer.settings, Settings.streamer_settings ) @@ -312,8 +310,7 @@ class TwitchChannelPointsMiner: # If we have at least one streamer with settings = claim_drops True # Spawn a thread for sync inventory and dashboard if ( - at_least_one_value_in_settings_is( - self.streamers, "claim_drops", True) + at_least_one_value_in_settings_is(self.streamers, "claim_drops", True) is True ): self.sync_campaigns_thread = threading.Thread( @@ -372,14 +369,17 @@ class TwitchChannelPointsMiner: if streamer.settings.make_predictions is True: self.ws_pool.submit( - PubsubTopic("predictions-channel-v1", - streamer=streamer) + PubsubTopic("predictions-channel-v1", streamer=streamer) ) if streamer.settings.claim_moments is True: self.ws_pool.submit( - PubsubTopic("community-moments-channel-v1", - streamer=streamer) + PubsubTopic("community-moments-channel-v1", streamer=streamer) + ) + + if streamer.settings.community_goals is True: + self.ws_pool.submit( + PubsubTopic("community-points-channel-v1", streamer=streamer) ) refresh_context = time.time() @@ -396,8 +396,7 @@ class TwitchChannelPointsMiner: logger.info( f"#{index} - The last PING was sent more than 10 minutes ago. Reconnecting to the WebSocket..." ) - WebSocketsPool.handle_reconnection( - self.ws_pool.ws[index]) + WebSocketsPool.handle_reconnection(self.ws_pool.ws[index]) if ((time.time() - refresh_context) // 60) >= 30: refresh_context = time.time() diff --git a/TwitchChannelPointsMiner/classes/Twitch.py b/TwitchChannelPointsMiner/classes/Twitch.py index ea12537..3d98b98 100644 --- a/TwitchChannelPointsMiner/classes/Twitch.py +++ b/TwitchChannelPointsMiner/classes/Twitch.py @@ -11,16 +11,15 @@ import random import re import string import time +from pathlib import Path +from secrets import choice, token_hex + # import json import requests import validators -from pathlib import Path -from secrets import choice, token_hex -# from urllib.parse import quote -# from base64 import urlsafe_b64decode -# from datetime import datetime from TwitchChannelPointsMiner.classes.entities.Campaign import Campaign +from TwitchChannelPointsMiner.classes.entities.CommunityGoal import CommunityGoal from TwitchChannelPointsMiner.classes.entities.Drop import Drop from TwitchChannelPointsMiner.classes.Exceptions import ( StreamerDoesNotExistException, @@ -45,6 +44,11 @@ from TwitchChannelPointsMiner.utils import ( internet_connection_available, ) +# from urllib.parse import quote +# from base64 import urlsafe_b64decode +# from datetime import datetime + + logger = logging.getLogger(__name__) @@ -109,7 +113,7 @@ class Twitch(object): "player": "site", "user_id": self.twitch_login.get_user_id(), "live": True, - "channel": streamer.username + "channel": streamer.username, } if ( @@ -133,10 +137,10 @@ class Twitch(object): # fixes AttributeError: 'NoneType' object has no attribute 'group' # headers = {"User-Agent": self.user_agent} from TwitchChannelPointsMiner.constants import USER_AGENTS + headers = {"User-Agent": USER_AGENTS["Linux"]["FIREFOX"]} - main_page_request = requests.get( - streamer.streamer_url, headers=headers) + main_page_request = requests.get(streamer.streamer_url, headers=headers) response = main_page_request.text # logger.info(response) regex_settings = "(https://static.twitchcdn.net/config/settings.*?js|https://assets.twitch.tv/config/settings.*?.js)" @@ -145,11 +149,9 @@ class Twitch(object): settings_request = requests.get(settings_url, headers=headers) response = settings_request.text regex_spade = '"spade_url":"(.*?)"' - streamer.stream.spade_url = re.search( - regex_spade, response).group(1) + streamer.stream.spade_url = re.search(regex_spade, response).group(1) except requests.exceptions.RequestException as e: - logger.error( - f"Something went wrong during extraction of 'spade_url': {e}") + logger.error(f"Something went wrong during extraction of 'spade_url': {e}") def get_broadcast_id(self, streamer): json_data = copy.deepcopy(GQLOperations.WithIsStreamLiveQuery) @@ -163,8 +165,7 @@ class Twitch(object): raise StreamerIsOfflineException def get_stream_info(self, streamer): - json_data = copy.deepcopy( - GQLOperations.VideoPlayerStreamInfoOverlayChannel) + json_data = copy.deepcopy(GQLOperations.VideoPlayerStreamInfoOverlayChannel) json_data["variables"] = {"channel": streamer.username} response = self.post_gql_request(json_data) if response != {}: @@ -236,8 +237,7 @@ class Twitch(object): logger.info( f"Joining raid from {streamer} to {raid.target_login}!", - extra={"emoji": ":performing_arts:", - "event": Events.JOIN_RAID}, + extra={"emoji": ":performing_arts:", "event": Events.JOIN_RAID}, ) def viewer_is_mod(self, streamer): @@ -394,13 +394,11 @@ class Twitch(object): streamers_watching += streamers_index[:2] elif ( - prior in [Priority.POINTS_ASCENDING, - Priority.POINTS_DESCENDING] + prior in [Priority.POINTS_ASCENDING, Priority.POINTS_DESCENDING] and len(streamers_watching) < 2 ): items = [ - {"points": streamers[index].channel_points, - "index": index} + {"points": streamers[index].channel_points, "index": index} for index in streamers_index ] items = sorted( @@ -410,8 +408,7 @@ class Twitch(object): True if prior == Priority.POINTS_DESCENDING else False ), ) - streamers_watching += [item["index"] - for item in items][:2] + streamers_watching += [item["index"] for item in items][:2] elif prior == Priority.STREAK and len(streamers_watching) < 2: """ @@ -427,8 +424,7 @@ class Twitch(object): and ( streamers[index].offline_at == 0 or ( - (time.time() - - streamers[index].offline_at) + (time.time() - streamers[index].offline_at) // 60 ) > 30 @@ -455,8 +451,7 @@ class Twitch(object): ] streamers_with_multiplier = sorted( streamers_with_multiplier, - key=lambda x: streamers[x].total_points_multiplier( - ), + key=lambda x: streamers[x].total_points_multiplier(), reverse=True, ) streamers_watching += streamers_with_multiplier[:2] @@ -475,25 +470,27 @@ class Twitch(object): #################################### # Start of fix for 2024/5 API Change # Create the JSON data for the GraphQL request - json_data = copy.deepcopy( - GQLOperations.PlaybackAccessToken) + json_data = copy.deepcopy(GQLOperations.PlaybackAccessToken) json_data["variables"] = { "login": streamers[index].username, "isLive": True, "isVod": False, "vodID": "", # "playerType": "site" - "playerType": "picture-by-picture" + "playerType": "picture-by-picture", } # Get signature and value using the post_gql_request method - responsePlaybackAccessToken = self.post_gql_request( - json_data) + responsePlaybackAccessToken = self.post_gql_request(json_data) logger.debug( f"Sent PlaybackAccessToken request for {streamers[index]}" ) - signature = responsePlaybackAccessToken["data"]['streamPlaybackAccessToken']["signature"] - value = responsePlaybackAccessToken["data"]['streamPlaybackAccessToken']["value"] + signature = responsePlaybackAccessToken["data"][ + "streamPlaybackAccessToken" + ]["signature"] + value = responsePlaybackAccessToken["data"][ + "streamPlaybackAccessToken" + ]["value"] if not signature or not value: continue @@ -503,8 +500,11 @@ class Twitch(object): RequestBroadcastQualitiesURL = f"https://usher.ttvnw.net/api/channel/hls/{streamers[index].username}.m3u8?sig={signature}&token={value}" # Get list of video qualities - responseBroadcastQualities = requests.get(RequestBroadcastQualitiesURL, headers={ - "User-Agent": self.user_agent}, timeout=20) # timeout=60 + responseBroadcastQualities = requests.get( + RequestBroadcastQualitiesURL, + headers={"User-Agent": self.user_agent}, + timeout=20, + ) # timeout=60 logger.debug( f"Send RequestBroadcastQualitiesURL request for {streamers[index]} - Status code: {responseBroadcastQualities.status_code}" ) @@ -513,14 +513,16 @@ class Twitch(object): BroadcastQualities = responseBroadcastQualities.text # Just takes the last line, which should be the URL for the lowest quality - BroadcastLowestQualityURL = BroadcastQualities.split( - "\n")[-1] + BroadcastLowestQualityURL = BroadcastQualities.split("\n")[-1] if not validators.url(BroadcastLowestQualityURL): continue # Get list of video URLs - responseStreamURLList = requests.get(BroadcastLowestQualityURL, headers={ - "User-Agent": self.user_agent}, timeout=20) # timeout=60 + responseStreamURLList = requests.get( + BroadcastLowestQualityURL, + headers={"User-Agent": self.user_agent}, + timeout=20, + ) # timeout=60 logger.debug( f"Send BroadcastLowestQualityURL request for {streamers[index]} - Status code: {responseStreamURLList.status_code}" ) @@ -534,8 +536,11 @@ class Twitch(object): continue # Perform a HEAD request to simulate watching the stream - responseStreamLowestQualityURL = requests.head(StreamLowestQualityURL, headers={ - "User-Agent": self.user_agent}, timeout=20) # timeout=60 + responseStreamLowestQualityURL = requests.head( + StreamLowestQualityURL, + headers={"User-Agent": self.user_agent}, + timeout=20, + ) # timeout=60 logger.debug( f"Send StreamLowestQualityURL request for {streamers[index]} - Status code: {responseStreamLowestQualityURL.status_code}" ) @@ -605,12 +610,10 @@ class Twitch(object): ) except requests.exceptions.ConnectionError as e: - logger.error( - f"Error while trying to send minute watched: {e}") + logger.error(f"Error while trying to send minute watched: {e}") self.__check_connection_handler(chunk_size) except requests.exceptions.Timeout as e: - logger.error( - f"Error while trying to send minute watched: {e}") + logger.error(f"Error while trying to send minute watched: {e}") self.__chuncked_sleep( next_iteration - time.time(), chunk_size=chunk_size @@ -620,8 +623,7 @@ class Twitch(object): # self.__chuncked_sleep(60, chunk_size=chunk_size) self.__chuncked_sleep(20, chunk_size=chunk_size) except Exception: - logger.error( - "Exception raised in send minute watched", exc_info=True) + logger.error("Exception raised in send minute watched", exc_info=True) # === CHANNEL POINTS / PREDICTION === # # Load the amount of current points for a channel, check if a bonus is available @@ -638,9 +640,17 @@ class Twitch(object): streamer.channel_points = community_points["balance"] streamer.activeMultipliers = community_points["activeMultipliers"] + if streamer.settings.community_goals is True: + streamer.community_goals = { + goal["id"]: CommunityGoal.from_gql(goal) + for goal in channel["communityPointsSettings"]["goals"] + } + if community_points["availableClaim"] is not None: - self.claim_bonus( - streamer, community_points["availableClaim"]["id"]) + self.claim_bonus(streamer, community_points["availableClaim"]["id"]) + + if streamer.settings.community_goals is True: + self.contribute_to_community_goals(streamer) def make_predictions(self, event): decision = event.bet.calculate(event.streamer.channel_points) @@ -740,20 +750,16 @@ class Twitch(object): if Settings.logger.less is False: logger.info( f"Claiming the moment for {streamer}!", - extra={"emoji": ":video_camera:", - "event": Events.MOMENT_CLAIM}, + extra={"emoji": ":video_camera:", "event": Events.MOMENT_CLAIM}, ) json_data = copy.deepcopy(GQLOperations.CommunityMomentCallout_Claim) - json_data["variables"] = { - "input": {"momentID": moment_id} - } + json_data["variables"] = {"input": {"momentID": moment_id}} self.post_gql_request(json_data) # === CAMPAIGNS / DROPS / INVENTORY === # def __get_campaign_ids_from_streamer(self, streamer): - json_data = copy.deepcopy( - GQLOperations.DropsHighlightService_AvailableDrops) + json_data = copy.deepcopy(GQLOperations.DropsHighlightService_AvailableDrops) json_data["variables"] = {"channelID": streamer.channel_id} response = self.post_gql_request(json_data) try: @@ -782,8 +788,9 @@ class Twitch(object): campaigns = response["data"]["currentUser"]["dropCampaigns"] or [] if status is not None: - campaigns = list( - filter(lambda x: x["status"] == status.upper(), campaigns)) or [] + campaigns = ( + list(filter(lambda x: x["status"] == status.upper(), campaigns)) or [] + ) return campaigns @@ -793,8 +800,7 @@ class Twitch(object): for chunk in chunks: json_data = [] for campaign in chunk: - json_data.append(copy.deepcopy( - GQLOperations.DropCampaignDetails)) + json_data.append(copy.deepcopy(GQLOperations.DropCampaignDetails)) json_data[-1]["variables"] = { "dropID": campaign["id"], "channelLogin": f"{self.twitch_login.get_user_id()}", @@ -836,8 +842,7 @@ class Twitch(object): ) json_data = copy.deepcopy(GQLOperations.DropsPage_ClaimDropRewards) - json_data["variables"] = { - "input": {"dropInstanceID": drop.drop_instance_id}} + json_data["variables"] = {"input": {"dropInstanceID": drop.drop_instance_id}} response = self.post_gql_request(json_data) try: # response["data"]["claimDropRewards"] can be null and respose["data"]["errors"] != [] @@ -878,7 +883,6 @@ class Twitch(object): if ( campaigns_update == 0 # or ((time.time() - campaigns_update) / 60) > 60 - # TEMPORARY AUTO DROP CLAIMING FIX # 30 minutes instead of 60 minutes or ((time.time() - campaigns_update) / 30) > 30 @@ -932,3 +936,74 @@ class Twitch(object): self.__check_connection_handler(chunk_size) self.__chuncked_sleep(60, chunk_size=chunk_size) + + def contribute_to_community_goals(self, streamer): + # Don't bother doing the request if no goal is currently started or in stock + if any( + goal.status == "STARTED" and goal.is_in_stock + for goal in streamer.community_goals.values() + ): + json_data = copy.deepcopy(GQLOperations.UserPointsContribution) + json_data["variables"] = {"channelLogin": streamer.username} + response = self.post_gql_request(json_data) + user_goal_contributions = response["data"]["user"]["channel"]["self"][ + "communityPoints" + ]["goalContributions"] + + logger.debug( + f"Found {len(user_goal_contributions)} community goals for the current stream" + ) + + for goal_contribution in user_goal_contributions: + goal_id = goal_contribution["goal"]["id"] + goal = streamer.community_goals[goal_id] + if goal is None: + # TODO should this trigger a new load context request + logger.error( + f"Unable to find context data for community goal {goal_id}" + ) + else: + user_stream_contribution = goal_contribution[ + "userPointsContributedThisStream" + ] + user_left_to_contribute = ( + goal.per_stream_user_maximum_contribution + - user_stream_contribution + ) + amount = min( + goal.amount_left(), + user_left_to_contribute, + streamer.channel_points, + ) + if amount > 0: + self.contribute_to_community_goal( + streamer, goal_id, goal.title, amount + ) + else: + logger.debug( + f"Not contributing to community goal {goal.title}, user channel points {streamer.channel_points}, user stream contribution {user_stream_contribution}, all users total contribution {goal.points_contributed}" + ) + + def contribute_to_community_goal(self, streamer, goal_id, title, amount): + json_data = copy.deepcopy(GQLOperations.ContributeCommunityPointsCommunityGoal) + json_data["variables"] = { + "input": { + "amount": amount, + "channelID": streamer.channel_id, + "goalID": goal_id, + "transactionID": token_hex(16), + } + } + + response = self.post_gql_request(json_data) + + error = response["data"]["contributeCommunityPointsCommunityGoal"]["error"] + if error: + logger.error( + f"Unable to contribute channel points to community goal '{title}', reason '{error}'" + ) + else: + logger.info( + f"Contributed {amount} channel points to community goal '{title}'" + ) + streamer.channel_points -= amount diff --git a/TwitchChannelPointsMiner/classes/WebSocketsPool.py b/TwitchChannelPointsMiner/classes/WebSocketsPool.py index 7af4c48..372597e 100644 --- a/TwitchChannelPointsMiner/classes/WebSocketsPool.py +++ b/TwitchChannelPointsMiner/classes/WebSocketsPool.py @@ -8,6 +8,7 @@ from threading import Thread, Timer from dateutil import parser +from TwitchChannelPointsMiner.classes.entities.CommunityGoal import CommunityGoal from TwitchChannelPointsMiner.classes.entities.EventPrediction import EventPrediction from TwitchChannelPointsMiner.classes.entities.Message import Message from TwitchChannelPointsMiner.classes.entities.Raid import Raid @@ -399,6 +400,25 @@ class WebSocketsPool: "PREDICTION_MADE", f"Decision: {event_prediction.bet.decision['choice']} - {event_prediction.title}", ) + elif message.topic == "community-points-channel-v1": + if message.type == "community-goal-created": + # TODO Untested, hard to find this happening live + ws.streamers[streamer_index].add_community_goal( + CommunityGoal.from_pubsub(message.data["community_goal"]) + ) + elif message.type == "community-goal-updated": + ws.streamers[streamer_index].update_community_goal( + CommunityGoal.from_pubsub(message.data["community_goal"]) + ) + elif message.type == "community-goal-deleted": + # TODO Untested, not sure what the message format for this is, + # https://github.com/sammwyy/twitch-ps/blob/master/main.js#L417 + # suggests that it should be just the entire, now deleted, goal model + ws.streamers[streamer_index].delete_community_goal(message.data["community_goal"]["id"]) + + if message.type in ["community-goal-updated", "community-goal-created"]: + ws.twitch.contribute_to_community_goals(ws.streamers[streamer_index]) + except Exception: logger.error( f"Exception raised for topic: {message.topic} and message: {message}", diff --git a/TwitchChannelPointsMiner/classes/entities/CommunityGoal.py b/TwitchChannelPointsMiner/classes/entities/CommunityGoal.py new file mode 100644 index 0000000..909cbba --- /dev/null +++ b/TwitchChannelPointsMiner/classes/entities/CommunityGoal.py @@ -0,0 +1,64 @@ +class CommunityGoal(object): + __slots__ = [ + "goal_id", + "title", + "is_in_stock", + "points_contributed", + "amount_needed", + "per_stream_user_maximum_contribution", + "status" + ] + + def __init__( + self, + goal_id, + title, + is_in_stock, + points_contributed, + amount_needed, + per_stream_user_maximum_contribution, + status + ): + self.goal_id = goal_id + self.title = title + self.is_in_stock = is_in_stock + self.points_contributed = points_contributed + self.amount_needed = amount_needed + self.per_stream_user_maximum_contribution = per_stream_user_maximum_contribution + self.status = status + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.goal_id == other.goal_id + else: + return False + + def __repr__(self) -> str: + return f"CommunityGoal(goal_id: {self.goal_id}, title: {self.title}, is_in_stock: {self.is_in_stock}, points_contributed: {self.points_contributed}, amount_needed: {self.amount_needed}, per_stream_user_maximum_contribution: {self.per_stream_user_maximum_contribution}, status: {self.status})" + + def amount_left(self): + return self.amount_needed - self.points_contributed + + @classmethod + def from_gql(cls, gql_goal): + return cls( + gql_goal["id"], + gql_goal["title"], + gql_goal["isInStock"], + gql_goal["pointsContributed"], + gql_goal["amountNeeded"], + gql_goal["perStreamUserMaximumContribution"], + gql_goal["status"] + ) + + @classmethod + def from_pubsub(cls, pubsub_goal): + return cls( + pubsub_goal["id"], + pubsub_goal["title"], + pubsub_goal["is_in_stock"], + pubsub_goal["points_contributed"], + pubsub_goal["goal_amount"], + pubsub_goal["per_stream_maximum_user_contribution"], + pubsub_goal["status"] + ) diff --git a/TwitchChannelPointsMiner/classes/entities/Streamer.py b/TwitchChannelPointsMiner/classes/entities/Streamer.py index 39e269a..2dbc01f 100644 --- a/TwitchChannelPointsMiner/classes/entities/Streamer.py +++ b/TwitchChannelPointsMiner/classes/entities/Streamer.py @@ -22,6 +22,7 @@ class StreamerSettings(object): "claim_drops", "claim_moments", "watch_streak", + "community_goals", "bet", "chat", ] @@ -33,6 +34,7 @@ class StreamerSettings(object): claim_drops: bool = None, claim_moments: bool = None, watch_streak: bool = None, + community_goals: bool = None, bet: BetSettings = None, chat: ChatPresence = None, ): @@ -41,6 +43,7 @@ class StreamerSettings(object): self.claim_drops = claim_drops self.claim_moments = claim_moments self.watch_streak = watch_streak + self.community_goals = community_goals self.bet = bet self.chat = chat @@ -54,13 +57,15 @@ class StreamerSettings(object): ]: if getattr(self, name) is None: setattr(self, name, True) + if self.community_goals is None: + self.community_goals = False if self.bet is None: self.bet = BetSettings() if self.chat is None: self.chat = ChatPresence.ONLINE def __repr__(self): - return f"BetSettings(make_predictions={self.make_predictions}, follow_raid={self.follow_raid}, claim_drops={self.claim_drops}, claim_moments={self.claim_moments}, watch_streak={self.watch_streak}, bet={self.bet}, chat={self.chat})" + return f"BetSettings(make_predictions={self.make_predictions}, follow_raid={self.follow_raid}, claim_drops={self.claim_drops}, claim_moments={self.claim_moments}, watch_streak={self.watch_streak}, community_goals={self.community_goals}, bet={self.bet}, chat={self.chat})" class Streamer(object): @@ -73,6 +78,7 @@ class Streamer(object): "online_at", "offline_at", "channel_points", + "community_goals", "minute_watched_requests", "viewer_is_mod", "activeMultipliers", @@ -93,6 +99,7 @@ class Streamer(object): self.online_at = 0 self.offline_at = 0 self.channel_points = 0 + self.community_goals = {} self.minute_watched_requests = None self.viewer_is_mod = False self.activeMultipliers = None @@ -211,7 +218,11 @@ class Streamer(object): primary_color = ( "#45c1ff" # blue #45c1ff yellow #ffe045 green #36b535 red #ff4545 if event_type == "WATCH_STREAK" - else ("#ffe045" if event_type == "PREDICTION_MADE" else ("#36b535" if event_type == "WIN" else "#ff4545")) + else ( + "#ffe045" + if event_type == "PREDICTION_MADE" + else ("#36b535" if event_type == "WIN" else "#ff4545") + ) ) data = { "borderColor": primary_color, @@ -236,13 +247,12 @@ class Streamer(object): data.update({"z": event_type.replace("_", " ").title()}) fname = os.path.join(Settings.analytics_path, f"{self.username}.json") - temp_fname = fname + '.temp' # Temporary file name + temp_fname = fname + ".temp" # Temporary file name with self.mutex: # Create and write to the temporary file with open(temp_fname, "w") as temp_file: - json_data = json.load( - open(fname, "r")) if os.path.isfile(fname) else {} + json_data = json.load(open(fname, "r")) if os.path.isfile(fname) else {} if key not in json_data: json_data[key] = [] json_data[key].append(data) @@ -282,3 +292,9 @@ class Streamer(object): self.leave_chat() elif self.settings.chat == ChatPresence.OFFLINE: self.__join_chat() + + def update_community_goal(self, community_goal): + self.community_goals[community_goal.goal_id] = community_goal + + def delete_community_goal(self, goal_id): + self.community_goals.pop(goal_id) diff --git a/TwitchChannelPointsMiner/constants.py b/TwitchChannelPointsMiner/constants.py index c73ad4f..642f482 100644 --- a/TwitchChannelPointsMiner/constants.py +++ b/TwitchChannelPointsMiner/constants.py @@ -5,7 +5,7 @@ URL = "https://www.twitch.tv" # Browser, Apps IRC = "irc.chat.twitch.tv" IRC_PORT = 6667 WEBSOCKET = "wss://pubsub-edge.twitch.tv/v1" -CLIENT_ID = "ue6666qo983tsx6so1t0vnawi233wa" # TV +CLIENT_ID = "ue6666qo983tsx6so1t0vnawi233wa" # TV # CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # Browser # CLIENT_ID = "r8s4dac0uhzifbpu9sjdiwzctle17ff" # Mobile Browser # CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" # Android App @@ -209,3 +209,21 @@ class GQLOperations: } }, } + UserPointsContribution = { + "operationName": "UserPointsContribution", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "23ff2c2d60708379131178742327ead913b93b1bd6f665517a6d9085b73f661f" + } + } + } + ContributeCommunityPointsCommunityGoal = { + "operationName": "ContributeCommunityPointsCommunityGoal", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "5774f0ea5d89587d73021a2e03c3c44777d903840c608754a1be519f51e37bb6" + } + } + } diff --git a/example.py b/example.py index 8d48036..3344fad 100644 --- a/example.py +++ b/example.py @@ -80,6 +80,7 @@ twitch_miner = TwitchChannelPointsMiner( claim_drops=True, # We can't filter rewards base on stream. Set to False for skip viewing counter increase and you will never obtain a drop reward from this script. Issue #21 claim_moments=True, # If set to True, https://help.twitch.tv/s/article/moments will be claimed when available watch_streak=True, # If a streamer go online change the priority of streamers array and catch the watch screak. Issue #11 + community_goals=False, # If True, contributes the max channel points per stream to the streamers' community challenge goals chat=ChatPresence.ONLINE, # Join irc chat to increase watch-time [ALWAYS, NEVER, ONLINE, OFFLINE] bet=BetSettings( strategy=Strategy.SMART, # Choose you strategy! @@ -111,11 +112,11 @@ twitch_miner = TwitchChannelPointsMiner( twitch_miner.mine( [ - Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), - Streamer("streamer-username02", settings=StreamerSettings(make_predictions=False , follow_raid=True , claim_drops=False , bet=BetSettings(strategy=Strategy.PERCENTAGE , percentage=5 , stealth_mode=False, percentage_gap=20 , max_points=1234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_POINTS, where=Condition.GTE, value=250 ) ) )), - Streamer("streamer-username03", settings=StreamerSettings(make_predictions=True , follow_raid=False , watch_streak=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=False, percentage_gap=30 , max_points=50000 , filter_condition=FilterCondition(by=OutcomeKeys.ODDS, where=Condition.LT, value=300 ) ) )), - Streamer("streamer-username04", settings=StreamerSettings(make_predictions=False , follow_raid=True , watch_streak=True )), - Streamer("streamer-username05", settings=StreamerSettings(make_predictions=True , follow_raid=True , claim_drops=True , watch_streak=True , bet=BetSettings(strategy=Strategy.HIGH_ODDS , percentage=7 , stealth_mode=True, percentage_gap=20 , max_points=90 , filter_condition=FilterCondition(by=OutcomeKeys.PERCENTAGE_USERS, where=Condition.GTE, value=300 ) ) )), + Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , community_goals=False , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), + Streamer("streamer-username02", settings=StreamerSettings(make_predictions=False , follow_raid=True , claim_drops=False , bet=BetSettings(strategy=Strategy.PERCENTAGE , percentage=5 , stealth_mode=False, percentage_gap=20 , max_points=1234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_POINTS, where=Condition.GTE, value=250 ) ) )), + Streamer("streamer-username03", settings=StreamerSettings(make_predictions=True , follow_raid=False , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=False, percentage_gap=30 , max_points=50000 , filter_condition=FilterCondition(by=OutcomeKeys.ODDS, where=Condition.LT, value=300 ) ) )), + Streamer("streamer-username04", settings=StreamerSettings(make_predictions=False , follow_raid=True , watch_streak=True , )), + Streamer("streamer-username05", settings=StreamerSettings(make_predictions=True , follow_raid=True , claim_drops=True , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.HIGH_ODDS , percentage=7 , stealth_mode=True, percentage_gap=20 , max_points=90 , filter_condition=FilterCondition(by=OutcomeKeys.PERCENTAGE_USERS, where=Condition.GTE, value=300 ) ) )), Streamer("streamer-username06"), Streamer("streamer-username07"), Streamer("streamer-username08"),