├── docs ├── CNAME └── index.html ├── .gitignore ├── requirements.txt ├── src ├── main.py ├── Worker.py ├── MainWindow.ui └── MainWindow.py ├── README.md ├── foresee.spec └── .github └── workflows └── Build.yml /docs/CNAME: -------------------------------------------------------------------------------- 1 | leigholiver.com 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__ 2 | 3 | env/* 4 | build/* 5 | dist/* 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5 ~= 5.15 2 | requests ~= 2.25 3 | PyInstaller ~= 4.3 4 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PyQt5.QtWidgets import QApplication 4 | 5 | from Worker import Worker 6 | from MainWindow import MainWindow 7 | 8 | if __name__ == "__main__": 9 | 10 | # Initialize the QApplication 11 | app = QApplication(sys.argv) 12 | 13 | # Start polling the SC2 API 14 | w = Worker() 15 | w.start() 16 | 17 | # Start the application 18 | mw = MainWindow(w) 19 | sys.exit(app.exec_()) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foresee 2 | Foresee automates [Twitch Channel Points Predictions](https://help.twitch.tv/s/article/channel-points-predictions?language=en_US) for StarCraft 2 games. 3 | 4 | ### [Download](https://github.com/leigholiver/foresee/releases/latest/) 5 | > Note: Channel Points Predictions are only available to Twitch Partners and Affiliates 6 | 7 | ## Usage 8 | * Download and run the [latest release](https://github.com/leigholiver/foresee/releases/latest/) 9 | * Get a [Twitch OAuth token](https://id.twitch.tv/oauth2/authorize?client_id=83ucn1dvrgjjd7lc0ak8q9snzzb9oc&redirect_uri=https://leigholiver.com/foresee&response_type=token&scope=channel:manage:predictions) 10 | * Paste the token into the Twitch OAuth Token field 11 | * When you join a 1v1 StarCraft 2 game, Foresee will start a Twitch Channel Points Prediction using the names of the players in-game. 12 | * When the game ends, Foresee will update the outcome of the prediction based on the game winner. 13 | -------------------------------------------------------------------------------- /foresee.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | import os 3 | 4 | spec_root = os.path.abspath(SPECPATH) + "/src" 5 | 6 | a = Analysis(['src/main.py'], 7 | pathex=[spec_root], 8 | binaries=[], 9 | datas=[ 10 | ('src/MainWindow.ui', '.'), 11 | ], 12 | hiddenimports=[], 13 | hookspath=[], 14 | runtime_hooks=[], 15 | excludes=[], 16 | win_no_prefer_redirects=False, 17 | win_private_assemblies=False, 18 | cipher=None, 19 | noarchive=False) 20 | pyz = PYZ(a.pure, a.zipped_data, 21 | cipher=None) 22 | exe = EXE(pyz, 23 | a.scripts, 24 | a.binaries, 25 | a.zipfiles, 26 | a.datas, 27 | [], 28 | name='foresee', 29 | debug=False, 30 | bootloader_ignore_signals=False, 31 | strip=False, 32 | upx=True, 33 | runtime_tmpdir=None, 34 | console=False) 35 | -------------------------------------------------------------------------------- /src/Worker.py: -------------------------------------------------------------------------------- 1 | import time 2 | import requests 3 | 4 | from PyQt5.QtCore import QThread 5 | from PyQt5.QtCore import QTimer 6 | from PyQt5.QtCore import pyqtSignal 7 | from PyQt5.QtCore import pyqtSlot 8 | 9 | class Worker(QThread): 10 | """Background thread which polls the SC2 Client API and emits signals 11 | containing the game data when joining or leaving a game 12 | """ 13 | 14 | enter_game = pyqtSignal(dict) 15 | exit_game = pyqtSignal(dict) 16 | in_game = False 17 | 18 | def run(self): 19 | 20 | while True: 21 | try: 22 | # Query the API 23 | r = requests.get("http://localhost:6119/game") 24 | gameResponse = r.json() 25 | 26 | # Determine if we're in game 27 | in_game = ( 28 | gameResponse['players'][0]['result'] == "Undecided" and 29 | gameResponse['isReplay'] == "false" 30 | ) 31 | 32 | # We've entered a game 33 | if in_game and not self.in_game: 34 | self.enter_game.emit(gameResponse) 35 | 36 | # We've left a game 37 | elif not in_game and self.in_game: 38 | self.exit_game.emit(gameResponse) 39 | 40 | self.in_game = in_game 41 | 42 | except Exception as e: 43 | print("%s: %s" % (e.__class__.__name__, e)) 44 | 45 | time.sleep(1) 46 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Foresee 7 | 19 | 20 | 21 |
22 |

Foresee

23 |

24 | Foresee automates Twitch Channel Points Predictions for StarCraft 2 games, find out more on GitHub. 25 |

26 |

27 | Your Twitch OAuth Token is ... 28 |

29 |

30 | To revoke access, disconnect "Foresee" from your Twitch settings. 31 |

32 |

33 | Technical: This application uses the implicit grant flow for the Twitch API to retrieve your token. This means that your token is only ever visible to your browser. 34 |

35 |
36 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /.github/workflows/Build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - README.md 9 | - .gitignore 10 | - docs/** 11 | 12 | jobs: 13 | Build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Setup Python 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: 3.9 26 | 27 | - name: Install dependencies 28 | run: | 29 | pip install -r requirements.txt 30 | 31 | - name: Build binary 32 | run: | 33 | pyinstaller foresee.spec 34 | 35 | - name: Create archive (.zip - Windows) 36 | uses: papeloto/action-zip@v1 37 | with: 38 | files: dist/ 39 | dest: foresee-${{ runner.os }}.zip 40 | if: runner.os == 'Windows' 41 | 42 | - name: Upload archive (.zip - Windows) 43 | uses: actions/upload-artifact@v2 44 | with: 45 | path: foresee-${{ runner.os }}.zip 46 | name: foresee-${{ runner.os }}.zip 47 | retention-days: 1 48 | if: runner.os == 'Windows' 49 | 50 | - name: Create archive (.tar.gz - Linux/macOS) 51 | run: | 52 | tar -czvf foresee-${{ runner.os }}.tar.gz -C dist/ foresee 53 | if: runner.os != 'Windows' 54 | 55 | - name: Upload archive (.tar.gz - Linux/macOS) 56 | uses: actions/upload-artifact@v2 57 | with: 58 | path: foresee-${{ runner.os }}.tar.gz 59 | name: foresee-${{ runner.os }}.tar.gz 60 | retention-days: 1 61 | if: runner.os != 'Windows' 62 | 63 | 64 | Create-Release: 65 | needs: 66 | - Build 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v2 70 | 71 | - name: Download all build archives 72 | uses: actions/download-artifact@v2 73 | 74 | - name: Create draft release 75 | uses: "marvinpinto/action-automatic-releases@latest" 76 | with: 77 | repo_token: ${{ secrets.GITHUB_TOKEN }} 78 | automatic_release_tag: "latest" 79 | draft: true 80 | files: | 81 | **/foresee-*.zip 82 | **/foresee-*.tar.gz 83 | -------------------------------------------------------------------------------- /src/MainWindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 470 10 | 134 11 | 12 | 13 | 14 | Forsee 15 | 16 | 17 | 18 | 19 | 18 20 | 21 | 22 | 18 23 | 24 | 25 | 18 26 | 27 | 28 | 18 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Twitch OAuth Token 38 | 39 | 40 | 41 | 42 | 43 | 44 | QLineEdit::Password 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | Name the prediction 56 | 57 | 58 | 59 | 60 | 61 | 62 | Who will win? 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Submission Period (in seconds) 74 | 75 | 76 | 77 | 78 | 79 | 80 | 300 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /src/MainWindow.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import requests 5 | 6 | from pathlib import Path 7 | from PyQt5 import QtWidgets 8 | from PyQt5 import uic 9 | 10 | 11 | BASE_URL = "https://api.twitch.tv/helix" 12 | CLIENT_ID = "83ucn1dvrgjjd7lc0ak8q9snzzb9oc" # Client IDs are public :) 13 | CONFIG_PATH = "%s/foresee.json" % str(Path.home()) 14 | CONFIG_FIELDS = [ 15 | 'oauth', 16 | 'prediction_name', 17 | 'submission_period', 18 | ] 19 | 20 | 21 | class MainWindow(QtWidgets.QMainWindow): 22 | 23 | # Dict with the ID and outcome IDs of the active prediction 24 | prediction = None 25 | 26 | 27 | def __init__(self, worker): 28 | super(MainWindow, self).__init__() 29 | 30 | # Find and load the UI file. PyInstaller binaries run from a 31 | # tempfile identified in _MEIPASS, so we may need to look there... 32 | base_path = getattr(sys, '_MEIPASS', os.getcwd()) 33 | uic.loadUi(base_path + '/MainWindow.ui', self) 34 | 35 | # Load the settings from the config file 36 | self.load_config() 37 | 38 | # Connect the enter game/exit game signals from the worker 39 | worker.enter_game.connect(self.enter_game) 40 | worker.exit_game.connect(self.exit_game) 41 | 42 | # Set up the UI text fields to save the config file when values change 43 | for key in CONFIG_FIELDS: 44 | try: 45 | getattr(self, key).textChanged.connect(self.save_config) 46 | 47 | except Exception as e: 48 | print("Error updating UI - %s: %s" % (e.__class__.__name__, e)) 49 | 50 | self.show() 51 | 52 | 53 | def enter_game(self, game): 54 | """Enter Game event 55 | Create a Twitch prediction using the players names as options 56 | """ 57 | 58 | # We only care about 1v1 games for now 59 | if len(game['players']) != 2: 60 | return 61 | 62 | config = self.get_config() 63 | 64 | # Get the broadcaster id from the oauth token 65 | # The broadcaster ID must match the oauth user 66 | try: 67 | user_response = self.send_request("GET", "/users") 68 | user_id = user_response['data'][0]['id'] 69 | 70 | except Exception as e: 71 | print("Error getting user info, check OAuth key - %s: %s" % (e.__class__.__name__, e)) 72 | return 73 | 74 | # Create the prediction 75 | prediction_response = self.send_request( 76 | "POST", 77 | "/predictions", 78 | { 79 | 'broadcaster_id': user_id, 80 | 'title': config['prediction_name'], 81 | 'outcomes': [ 82 | { 83 | 'title': game['players'][0]['name'] 84 | }, 85 | { 86 | 'title': game['players'][1]['name'] 87 | } 88 | ], 89 | 'prediction_window': int(config['submission_period']) 90 | } 91 | ) 92 | 93 | # Store the prediction/outcome_ids to update later 94 | try: 95 | self.prediction = { 96 | 'id': prediction_response['data'][0]['id'], 97 | 'user_id': user_id, 98 | 'outcome_ids': [ 99 | prediction_response['data'][0]['outcomes'][0]['id'], 100 | prediction_response['data'][0]['outcomes'][1]['id'], 101 | ] 102 | } 103 | 104 | except Exception as e: 105 | print("Error setting active prediction - %s: %s" % (e.__class__.__name__, e)) 106 | 107 | 108 | def exit_game(self, game): 109 | """Exit Game event 110 | Resolve the Twitch prediction with the winning outcome id 111 | """ 112 | 113 | if not self.prediction: 114 | return 115 | 116 | # Set the outcome id for the winner 117 | winning_outcome_id = None 118 | 119 | if game['players'][0]['result'] == "Victory": 120 | winning_outcome_id = self.prediction['outcome_ids'][0] 121 | 122 | elif game['players'][1]['result'] == "Victory": 123 | winning_outcome_id = self.prediction['outcome_ids'][1] 124 | 125 | 126 | # Resolve the prediction 127 | if winning_outcome_id: 128 | self.send_request( 129 | "PATCH", 130 | "/predictions", 131 | { 132 | 'broadcaster_id': self.prediction['user_id'], 133 | 'id': self.prediction['id'], 134 | 'status': "RESOLVED", 135 | 'winning_outcome_id': winning_outcome_id, 136 | } 137 | ) 138 | 139 | else: 140 | # Neither player won, maybe a tie? Cancel the prediction 141 | self.send_request( 142 | "PATCH", 143 | "/predictions", 144 | { 145 | 'broadcaster_id': self.prediction['user_id'], 146 | 'id': self.prediction['id'], 147 | 'status': "CANCELED", 148 | } 149 | ) 150 | 151 | # Clear the active prediction 152 | self.prediction = None 153 | 154 | 155 | def send_request(self, method, url, data = None, headers = {}): 156 | """Send a request to the Twitch API. 157 | 158 | method (str): The HTTP verb to use. Can be any verb supported by `requests` 159 | url (str): The path to request on the Twitch API, eg "/user" 160 | data (dict): The request body 161 | headers (dict): Any headers to add to the request 162 | 163 | Returns: 164 | (dict): The parsed response 165 | """ 166 | 167 | # Try to get the requests function for the method 168 | action = getattr(requests, method.lower(), None) 169 | if action: 170 | config = self.get_config() 171 | 172 | _headers = { 173 | 'Authorization': "Bearer %s" % config['oauth'], 174 | 'Client-Id': CLIENT_ID, 175 | 'Content-Type': "application/json", 176 | } 177 | 178 | # Add the caller headers 179 | _headers.update(headers) 180 | 181 | # Make the request 182 | response = action( 183 | url=BASE_URL + url, 184 | headers=_headers, 185 | data=json.dumps(data) 186 | ) 187 | 188 | # Return the parsed response 189 | return json.loads(response.content) 190 | 191 | 192 | def get_config(self): 193 | """Returns a dictionary containing the current config values""" 194 | 195 | return { 196 | key: getattr(self, key).text() 197 | for key in CONFIG_FIELDS 198 | } 199 | 200 | 201 | def load_config(self): 202 | """Loads the config from file, and updates the UI fields""" 203 | 204 | # Try to load the config from file 205 | try: 206 | with open(CONFIG_PATH) as json_file: 207 | config = json.load(json_file) 208 | 209 | # Update the UI fields with the config values 210 | for key in CONFIG_FIELDS: 211 | try: 212 | getattr(self, key).setText( 213 | config.get(key, "") 214 | ) 215 | 216 | except Exception as e: 217 | print("Error setting UI field - %s: %s" % (e.__class__.__name__, e)) 218 | 219 | except Exception as e: 220 | print("Error loading config file - %s: %s" % (e.__class__.__name__, e)) 221 | return 222 | 223 | 224 | def save_config(self): 225 | """Saves the current values from the UI into the config file""" 226 | 227 | config = self.get_config() 228 | 229 | try: 230 | with open(CONFIG_PATH, 'w+') as outfile: 231 | json.dump(config, outfile, indent=4, sort_keys=True) 232 | 233 | except Exception as e: 234 | print("Error saving config file - %s: %s" % (e.__class__.__name__, e)) 235 | --------------------------------------------------------------------------------