├── 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 |
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 |
--------------------------------------------------------------------------------