├── client
├── example.config.json
├── favicon.ico
├── src
│ ├── index.css
│ ├── index.js
│ ├── App.css
│ └── App.js
├── .gitignore
├── index.html
└── package.json
├── server
├── example.cfg
├── requirements.txt
├── .gitignore
└── index.py
├── .gitignore
└── README.md
/client/example.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "clientId": "230948530298402398"
3 | }
--------------------------------------------------------------------------------
/client/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/discord/sample-game-integration/HEAD/client/favicon.ico
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/server/example.cfg:
--------------------------------------------------------------------------------
1 | [DiscordOptions]
2 | bot_token=MjE89sadf89asdf89ajk1ODcy.Cp29fA.nRX9JLJ2JFsdjfs8oIYea6iAc
3 | client_secret=YApeR98asd98asFjmBuApj-lABSDJhf8s9d7WW
4 | client_id=217002986084842878
--------------------------------------------------------------------------------
/server/requirements.txt:
--------------------------------------------------------------------------------
1 | click==6.6
2 | Flask==0.11.1
3 | Flask-Cors==3.0.0
4 | itsdangerous==0.24
5 | Jinja2==2.8
6 | MarkupSafe==0.23
7 | requests==2.11.1
8 | six==1.10.0
9 | Werkzeug==0.11.10
10 | wheel==0.24.0
11 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import './index.css';
5 |
6 | ReactDOM.render(
7 | ,
8 | document.getElementById('root')
9 | );
10 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | config.json
4 |
5 | # dependencies
6 | node_modules
7 |
8 | # production
9 | build
10 |
11 | # misc
12 | .DS_Store
13 | npm-debug.log
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Example user template template
3 | ### Example user template
4 |
5 | # IntelliJ project files
6 | .idea
7 | *.iml
8 | out
9 | gen
10 | .idea/misc.xml
11 | .idea/modules.xml
12 | .idea/sample_game.iml
13 | .idea/workspace.xml
14 | .DS_Store
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Sample Discord RPC Client
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.0.1",
4 | "private": true,
5 | "dependencies": {
6 | "classnames": "^2.2.5",
7 | "create-react-class": "^15.5.2",
8 | "nonce": "^1.0.4",
9 | "react": "^15.3.1",
10 | "react-dom": "^15.3.1",
11 | "react-scripts": "0.2.2",
12 | "react-select": "^1.0.0-rc.2",
13 | "superagent": "^2.2.0"
14 | },
15 | "scripts": {
16 | "start": "react-scripts start",
17 | "build": "react-scripts build",
18 | "eject": "react-scripts eject"
19 | },
20 | "eslintConfig": {
21 | "extends": "./node_modules/react-scripts/config/eslint.js"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | flex: 1;
3 | display: flex;
4 | flex-direction: column;
5 | justify-content: center;
6 | align-items: center;
7 | }
8 |
9 | .status {
10 | margin: 10px;
11 | }
12 |
13 | .sections {
14 | display: flex;
15 | flex-direction: row;
16 | }
17 |
18 | .Select {
19 | margin-bottom: 10px;
20 | }
21 |
22 | .section {
23 | margin: 10px;
24 | }
25 |
26 | .guild {
27 | display: flex;
28 | flex-direction: row;
29 | align-items: center;
30 | }
31 |
32 | .guild-icon {
33 | width: 25px;
34 | height: 25px;
35 | border-radius: 13px;
36 | margin-right: 3px;
37 | }
38 |
39 | .file-share {
40 | margin-bottom: 10px;
41 | font-size: 16px;
42 | width: 250px;
43 | }
44 |
45 | .button {
46 | background-color: #7289DA;
47 | color: #f0f0f0;
48 | border-radius: 10px;
49 | display: flex;
50 | justify-content: center;
51 | align-items: center;
52 | align-content: center;
53 | width: 200px;
54 | height: 45px;
55 | margin-bottom: 10px;
56 | user-select: none;
57 |
58 | transition: opacity 0.2s ease-in-out;
59 | }
60 |
61 | .button:hover {
62 | background-color: #697ec4;
63 | cursor: pointer;
64 | }
65 |
66 | .button.disabled {
67 | background-color: gray;
68 | opacity: 0.2;
69 | cursor: not-allowed;
70 | }
71 |
72 | .chatarea {
73 |
74 | }
75 |
76 | .chatarea .lines {
77 | font-size: 14px;
78 | }
79 |
80 | .chatarea .line {
81 | margin: 6px;
82 | }
83 |
84 | .chatarea .input {
85 | color: red;
86 | }
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | discord.cfg
2 | discord-sample-db.p
3 | client/
4 |
5 | # Created by .ignore support plugin (hsz.mobi)
6 | ### Python template
7 | # Byte-compiled / optimized / DLL files
8 | __pycache__/
9 | *.py[cod]
10 | *$py.class
11 |
12 | # C extensions
13 | *.so
14 |
15 | # Distribution / packaging
16 | .Python
17 | env/
18 | build/
19 | develop-eggs/
20 | dist/
21 | downloads/
22 | eggs/
23 | .eggs/
24 | lib/
25 | lib64/
26 | parts/
27 | sdist/
28 | var/
29 | *.egg-info/
30 | .installed.cfg
31 | *.egg
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .coverage
47 | .coverage.*
48 | .cache
49 | nosetests.xml
50 | coverage.xml
51 | *,cover
52 | .hypothesis/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 |
62 | # Flask stuff:
63 | instance/
64 | .webassets-cache
65 |
66 | # Scrapy stuff:
67 | .scrapy
68 |
69 | # Sphinx documentation
70 | docs/_build/
71 |
72 | # PyBuilder
73 | target/
74 |
75 | # IPython Notebook
76 | .ipynb_checkpoints
77 |
78 | # pyenv
79 | .python-version
80 |
81 | # celery beat schedule file
82 | celerybeat-schedule
83 |
84 | # dotenv
85 | .env
86 |
87 | # virtualenv
88 | venv/
89 | ENV/
90 |
91 | # Spyder project settings
92 | .spyderproject
93 |
94 | # Rope project settings
95 | .ropeproject
96 |
97 |
--------------------------------------------------------------------------------
/server/index.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, request, abort
2 | import json
3 | import requests
4 | from flask_cors import CORS
5 | from time import time
6 | import cPickle as pickle
7 | import ConfigParser
8 |
9 | app = Flask(__name__)
10 | CORS(app) # don't do this in production
11 |
12 | BASE_URL = 'https://discordapp.com/api'
13 |
14 | config = ConfigParser.RawConfigParser()
15 | config.read('discord.cfg')
16 |
17 | APP_NAME = 'Sample-Game-Client'
18 | REDIRECT_URI = 'http://localhost:3006'
19 | BOT_TOKEN = config.get('DiscordOptions', 'bot_token')
20 | CLIENT_SECRET = config.get('DiscordOptions', 'client_secret')
21 | CLIENT_ID = config.get('DiscordOptions', 'client_id')
22 | HEADERS = {
23 | 'Authorization': 'Bot {0}'.format(BOT_TOKEN),
24 | 'User-Agent': 'DiscordBot ({0}, 0.1)'.format(APP_NAME)
25 | }
26 |
27 |
28 | ############################################################
29 | # Who needs a map?
30 | ############################################################
31 | class User:
32 | def __init__(self):
33 | self.id = None
34 | self.discord_id = None
35 | self.refresh_token = None
36 | self.access_token = None
37 | self.expires_at = None
38 | self.scope = None
39 |
40 | def serialize(self):
41 | return {
42 | 'id': self.id,
43 | 'access_token': self.access_token,
44 | 'discord_id': self.discord_id
45 | }
46 |
47 | def request_headers(self):
48 | return {
49 | 'Authorization': 'Bearer {0}'.format(self.access_token),
50 | 'User-Agent': 'DiscordBot ({0}, 0.1)'.format(APP_NAME)
51 | }
52 |
53 |
54 | class Game:
55 | def __init__(self):
56 | self.id = 0
57 | self.channel_id = None
58 |
59 | data = {
60 | 'counter': 0,
61 | 'games': [], # Game
62 | 'users': {} # User
63 | }
64 |
65 | try:
66 | data = pickle.load(open('discord-sample-db.p', 'rb'))
67 | except:
68 | pass
69 |
70 |
71 | def save():
72 | pickle.dump(data, open('discord-sample-db.p', 'wb'))
73 |
74 |
75 | def create_id():
76 | new_id = data['counter'] + 1
77 | data['counter'] = new_id
78 | return str(new_id)
79 |
80 |
81 | ############################################################
82 | # Discord Role Permission Bits
83 | ############################################################
84 | CREATE_INSTANT_INVITE = 0x00000001
85 | KICK_MEMBERS = 0x00000002
86 | BAN_MEMBERS = 0x00000004
87 | ADMINISTRATOR = 0x00000008
88 | MANAGE_CHANNELS = 0x00000010
89 | MANAGE_GUILD = 0x00000020
90 | READ_MESSAGES = 0x00000400
91 | SEND_MESSAGES = 0x00000800
92 | SEND_TTS_MESSAGES = 0x00001000
93 | MANAGE_MESSAGES = 0x00002000
94 | EMBED_LINKS = 0x00004000
95 | ATTACH_FILES = 0x00008000
96 | READ_MESSAGE_HISTORY = 0x00010000
97 | MENTION_EVERYONE = 0x00020000
98 | CONNECT = 0x00100000
99 | SPEAK = 0x00200000
100 | MUTE_MEMBERS = 0x00400000
101 | DEAFEN_MEMBERS = 0x00800000
102 | MOVE_MEMBERS = 0x01000000
103 | USE_VAD = 0x02000000
104 | CHANGE_NICKNAME = 0x04000000
105 | MANAGE_NICKNAMES = 0x08000000
106 | MANAGE_ROLES = 0x10000000
107 |
108 |
109 | ############################################################
110 | # Helper Functions
111 | ############################################################
112 | def refresh_access_token(user):
113 | refresh_token = {
114 | 'grant_type': 'refresh_token',
115 | 'refresh_token': user.refresh_token,
116 | 'scope': user.scope,
117 | 'client_id': CLIENT_ID,
118 | 'client_secret': CLIENT_SECRET
119 | }
120 | r = requests.post(BASE_URL + '/oauth2/token', headers=HEADERS, data=refresh_token)
121 | r.raise_for_status()
122 | user.access_token = r.json()['access_token']
123 | user.refresh_token = r.json()['refresh_token']
124 | user.expires_at = int(r.json()['expires_in']) + int(time())
125 | data['users'][user.id] = user
126 | save()
127 | return user
128 |
129 |
130 | ############################################################
131 | # Game Match Management Routes
132 | ############################################################
133 | @app.route('/login', methods=['POST'])
134 | def login():
135 | user_id = request.get_json()['id']
136 | if user_id not in data['users']:
137 | abort(400, 'User not found')
138 |
139 | user = data['users'][user_id]
140 |
141 | if user.expires_at < time():
142 | user = refresh_access_token(user)
143 |
144 | return json.dumps(user.serialize())
145 |
146 |
147 | @app.route('/find_match', methods=['POST'])
148 | def find_match():
149 | if len(data['games']) <= 0:
150 | abort(400, 'Invalid game_id')
151 | game = data['games'][0]
152 | return json.dumps({
153 | 'game_id': game.id
154 | })
155 |
156 |
157 | @app.route('/create_match', methods=['POST'])
158 | def create_match():
159 | game = Game()
160 | game.id = create_id()
161 | game.channel_id = None
162 | data['games'].append(game)
163 | save()
164 |
165 | return json.dumps({
166 | 'game_id': game.id
167 | })
168 |
169 |
170 | @app.route('/join_match/', methods=['POST'])
171 | def join_match(game_id):
172 | user = data['users'][request.get_json()['id']]
173 |
174 | game = next((g for g in data['games'] if g.id == game_id), None)
175 | if game is None:
176 | abort(400, 'Invalid game_id')
177 |
178 | if user.expires_at < time():
179 | user = refresh_access_token(user)
180 |
181 | channel_id = game.channel_id
182 |
183 | if channel_id is None:
184 | create_match_data = {
185 | 'access_tokens': [user.access_token],
186 | 'nicks': {user.discord_id: user.id}
187 | }
188 | r = requests.post(BASE_URL + '/users/@me/channels', headers=HEADERS, json=create_match_data)
189 | r.raise_for_status()
190 | channel_id = r.json()['id']
191 | game.channel_id = channel_id
192 | save()
193 | else:
194 | add_to_server = {
195 | 'access_token': user.access_token,
196 | 'nick': user.id
197 | }
198 | r = requests.put(BASE_URL + '/channels/{0}/recipients/{1}'.format(channel_id, user.discord_id),
199 | headers=HEADERS, json=add_to_server)
200 | r.raise_for_status()
201 |
202 | return json.dumps({'channel_id': channel_id})
203 |
204 |
205 | @app.route('/end_match', methods=['POST'])
206 | def end_match():
207 | delete_all_games()
208 | return ''
209 |
210 |
211 | @app.route('/delete_all_servers')
212 | def delete_all_games():
213 | games = data['games']
214 | data['games'] = []
215 | save()
216 |
217 | for game in games:
218 | requests.delete(BASE_URL + '/channels/{0}'.format(game.channel_id), headers=HEADERS)
219 |
220 | return ''
221 |
222 |
223 | ############################################################
224 | # OAuth Routes
225 | ############################################################
226 | @app.route('/discord_auth')
227 | def discord_authenticate():
228 | create_token_data = {
229 | 'client_id': CLIENT_ID,
230 | 'client_secret': CLIENT_SECRET
231 | }
232 | r = requests.post(BASE_URL + '/oauth2/token/rpc', headers=HEADERS, data=create_token_data)
233 | r.raise_for_status()
234 | rpc_token = r.json()['rpc_token']
235 |
236 | return json.dumps({
237 | 'rpc_token': rpc_token
238 | })
239 |
240 |
241 | @app.route('/discord_exchange_code', methods=['POST'])
242 | def discord_exchange_token():
243 | code = request.get_json()['code']
244 | exchange_code = {
245 | 'grant_type': 'authorization_code',
246 | 'code': code,
247 | 'redirect_uri': REDIRECT_URI,
248 | 'client_id': CLIENT_ID,
249 | 'client_secret': CLIENT_SECRET
250 | }
251 | r = requests.post(BASE_URL + '/oauth2/token', headers=HEADERS, data=exchange_code)
252 | r.raise_for_status()
253 |
254 | user = User()
255 | user.id = request.get_json()['id']
256 | user.access_token = r.json()['access_token']
257 | user.refresh_token = r.json()['refresh_token']
258 | user.expires_at = int(r.json()['expires_in']) + time()
259 | user.scope = r.json()['scope']
260 |
261 | r = requests.get(BASE_URL + '/users/@me', headers=user.request_headers())
262 | r.raise_for_status()
263 | user.discord_id = r.json()['id']
264 |
265 | data['users'][user.id] = user
266 | save()
267 |
268 | return json.dumps(user.serialize())
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Overview
2 | Here you'll find an almost production grade sample of using Discord's API
3 | and local RPC socket to add Voice and Text chat to a match based multiplayer
4 | game.
5 |
6 | There is also sample code that shows how to build a share feature
7 | that embeds an image (such as a screenshot from your game client) in a chat
8 | message, but this readme does not walk through it just yet :-)
9 |
10 | These steps will be the same if you're using the GameBridge SDK, except
11 | you will connect to the RPC local to your game.
12 |
13 | As far as effort goes, it should take about an afternoon to get a first
14 | cut going if you're roughly familiar with the underlying technologies.
15 | FWIW it took me about a day to build the first cut of this sample app.
16 |
17 | This sample is basically two files that show the different parts of the workflow:
18 | - `server/index.py` for the server side
19 | - `client/src/App.js` for the client side
20 |
21 | Good luck & have fun!
22 |
23 | ### Additional Best Practices
24 | This sample demonstrates a good workflow for using the Discord API & RPC
25 | but does not have the entirety of what represents production quality
26 | code. You should make sure to handle at minimum these scenarios if
27 | you're adapting this into your project:
28 |
29 | 1. All HTTP requests from your game client to your game server could
30 | fail and should retry a few times.
31 | 2. All HTTP requests to the Discord API backend from your game server
32 | or game client could fail and you should retry.
33 | 3. All HTTP requests via the Discord RPC.Proxy (discordapp.io) could
34 | fail and you should retry a few times.
35 |
36 | # Sample Walkthrough
37 | To implement match/instance based voice & text chat the basic workflow is as follows:
38 |
39 | - Connect to the local Discord RPC socket. Be sure to scan the available port range if you're working
40 | against a client. This is done in the `connect()` function in `client/src/App.js`. If you are using the SDK,
41 | the port will be provided in a callback.
42 | - Once connected:
43 | - Check if you have already authorized Discord for this user. To do this, you should return a valid
44 | Discord `access_token` when the user logs in to your game servers. The example `/login` endpoint in
45 | `server/index.py` shows what your login payload might return. Depending on the results...
46 | - **if you don't have the user's access token:** you need to get an RPC Token from Discord API to trade
47 | for a user's `code`. Retrive the RPC token as shown in the `/discord_auth` route in `server/index.py`.
48 | Then trade that `rpc_token` for a user's code by calling `AUTHORIZE` over the RPC socket as shown in `App.js`.
49 | Make sure to include the correct OAuth scopes that you intend to use. With the returned code,
50 | send it to your server and exchange it for the user's OAuth access and refresh tokens as shown in
51 | `/discord_exchange_code`. You should only do this flow the _first_ time a
52 | user appears on your system.
53 | - **if you ALREADY have the user's access token saved:** you need to check if it has expired
54 | by comparing the current time with the `Expiry` you previously retrieved. If it has expired
55 | you need to refresh your access token as described here https://discordapp.com/developers/docs/topics/oauth2#implementing-oauth2.
56 | You can see an example of how this works in `server/index.py` by looking at the `refresh_access_token`
57 | helper function and where it is used.
58 | - Once you have the user's `access_token` in your game client, call `AUTHENTICATE` over the RPC
59 | socket as shown in `client/src/App.js`. If you get a success response then you're ready to go.
60 | - At this point your game will be connected to the local user's Discord account via the RPC
61 | system and ready to do work. If you are dev'ing against the Discord Client you'll see a clear
62 | indicator via a blue strip at the top of the window.
63 | - When a user joins a match, on your server, you should create a Discord Channel
64 | and put the user in it. Remember: be sure to do this lazily when a game user
65 | connects to a match and has Discord. Don't create a group along with your match.
66 | This will cause lots of empty Discord channels to be sitting around! Check out
67 | `/join_match` in `server/index.py` to see an example of lazy creating a channels and
68 | placing the user into it.
69 | - Send the new Discord channel id back to the client. This group will be the container
70 | for your match's voice & text chat functionality and used in many of the RPC calls.
71 | - On the client, now you can assume the channel is in the user's Direct Messages list
72 | and ready to go. Begin by making RPC calls to implement whatever features you want. In this
73 | sample we are joining the voice channel and connecting to text chat. You can see how this is
74 | done in the `joinMatch()` function in `client/src/App.js`.
75 | - One thing to note is when you subscribe to an event over the RPC you'll get
76 | messages sent to you as things happen in real time. Check out the `handleDiscordRPCResponse()` function
77 | in `client/src/App.js` to see an example of how to handle some of these events.
78 |
79 | ### Sending Messages using the RPC Proxy
80 | - Sending messages from your game requires using the Discord RPC Proxy. Note that you can invoke
81 | almost any endpoint shown at http://discordapp.com/developers as if you were the user using
82 | this RPC.Proxy. For an example of how to send text messages as the user check out the `onKeyUp()`
83 | function in `client/src/App.js`.
84 |
85 |
86 | # Trying out this Sample
87 |
88 | ### Creating a Discord Application
89 |
90 | First up you'll need to create an application on Discord's platform. To
91 | do that head to https://discordapp.com/developers/applications/me and click
92 | the giant plus button.
93 |
94 | To configure your application to manage channels properly do this stuff:
95 |
96 | - Set a fun name like _Legend of the Apple Tree: Summary of Clouds_.
97 | - You can set an app icon later. This will show up as the group icon on
98 | a user's Discord client. Eventually it will auto-populate a server
99 | icon if you make one instead.
100 | - For development purposes add the REDIRECT URI `http://localhost:3006`
101 | - For development purposes add the RPC ORIGIN `http://localhost:3000`
102 | - Click Save and you'll be whisked to your app's detail page.
103 | - Click `Create a Bot User` and accept the confirmation.
104 | - Uncheck `Public Bot` in the new `APP BOT USER` section.
105 | - Later on you'll need the Client ID, Secret from the APP DETAILS section.
106 | You'll also need the Token from the APP BOT USER section. Don't need
107 | to grab them now... just pointing it out :-)
108 | - You'll want to come back here to add other people to your tester list
109 | to have them try your game during development.
110 |
111 | Be sure to click _Save Changes_ again at the bottom of the page!
112 |
113 | ### Installing the Sample Client
114 | First you need to clone `client/example.config.json` and rename it to
115 | `client/config.json` then fill out your application's client ID found
116 | at https://discordapp.com/developers/applications/me. Only the `Client ID`
117 | should be set here. The other details will be set on the example server.
118 |
119 | Installing the client requires only that you have node and npm installed.
120 | Then, to install project dependencies, from this project's root folder:
121 | ```
122 | cd client
123 | npm install
124 | ```
125 |
126 | ### Installing the Sample Server
127 | This is a little more involved but not crazy town.
128 |
129 | First you need to clone `server/example.cfg` and rename it to `server/discord.cfg`
130 | then fill out your application's configuration fields found at
131 | https://discordapp.com/developers/applications/me. The `Client ID` and
132 | `Secret` are in the `APP DETAILS` section up top. The `Token` is in
133 | the `APP BOT USER` section.
134 |
135 | Next you'll need to setup and install Flask, the web framework used by
136 | this sample. Detailed instructions are available here
137 | http://flask.pocoo.org/docs/0.11/installation/.
138 |
139 | Be sure to activate your virtual environment then install the python
140 | packages in addition to flask. If you're feeling lazy it's basically
141 | this on MacOS from the project's root folder:
142 | ```
143 | sudo pip install virtualenv
144 | cd server
145 | virtualenv client
146 | . client/bin/activate
147 | pip install -r requirements.txt
148 | ```
149 |
150 | If you're on Windows, you'll have to follow the instructions on Flask's
151 | site. I haven't tried it :-)
152 |
153 | ### Running the Sample Client
154 | Open a Terminal / Shell window to keep running. From the project's
155 | root folder:
156 | ```
157 | cd client
158 | npm start
159 | ```
160 |
161 | ### Running the Sample Server
162 | Open a Terminal / Shell window to keep running. From the project's
163 | root folder:
164 | ```
165 | cd server
166 | . client/bin/activate
167 | FLASK_DEBUG=1 FLASK_APP=index.py flask run
168 | ```
169 |
170 | _Note that if you called your virtualenv something different, you will need to use that instead. E.g., . venv/client/activate_
171 |
--------------------------------------------------------------------------------
/client/src/App.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import request from 'superagent';
3 | import classnames from 'classnames';
4 | import Select from 'react-select';
5 | const nonce = require('nonce')();
6 |
7 | import './App.css';
8 | import 'react-select/dist/react-select.css';
9 |
10 | const config = require('json!./../config.json');
11 |
12 | let username = 'Jason';
13 | let startOffset = 0;
14 |
15 | // For example you can use for testing two users:
16 | // http://localhost:3000/illumina/0
17 | // http://localhost:3000/moonshed/1
18 | if (window.location.pathname !== '/') {
19 | const params = window.location.pathname.split('/');
20 | if (params.length > 1) {
21 | username = params[1];
22 | }
23 |
24 | if (params.length > 2) {
25 | startOffset = parseInt(params[2], 10);
26 | }
27 | }
28 |
29 | const VERSION = '1';
30 | const CLIENT_ID = config.clientId;
31 | const PORT = 6463 + startOffset;
32 | const NUM_PORTS_TO_SEARCH = 10 - startOffset;
33 | const ENCODING = 'json';
34 | const MY_USER_NAME = username;
35 | const ENDPOINT = 'http://localhost:5000';
36 | const DEFAULT_REQUEST_TIMEOUT = 60; // seconds
37 | const ERROR_ALREADY_IN_VOICE_CHANNEL = 5003;
38 | const ERROR_TOKEN_DOESNT_MATCH_CURRENT_USER = 4009;
39 | const CHANNEL_TYPE_TEXT = 0;
40 |
41 | class App extends Component {
42 | constructor(props) {
43 | super(props);
44 | this.handlers = {};
45 | this.state = {
46 | message: 'Hello',
47 | loggedIn: false,
48 | connected: false,
49 | channelId: null,
50 | lines: [],
51 | accessToken: null,
52 | voiceUsers: {},
53 | ioPort: 0,
54 | shareGuilds: null,
55 | shareChannels: null,
56 | shareChannelId: null
57 | };
58 | }
59 |
60 | // ----------------------------------------------------------------------------------------
61 | // RPC Socket helper functions
62 | // ----------------------------------------------------------------------------------------
63 | send(payload) {
64 | this.socket.send(JSON.stringify(payload));
65 | }
66 |
67 | call(command, args, handler=null) {
68 | let n = nonce();
69 | if (handler) {
70 | this.handlers[n] = handler;
71 | }
72 |
73 | this.send({
74 | 'cmd': command,
75 | 'nonce': n,
76 | 'args': args
77 | });
78 | }
79 |
80 | subscribe(event, args) {
81 | this.send({
82 | 'cmd': 'SUBSCRIBE',
83 | 'evt': event,
84 | 'nonce': nonce(),
85 | 'args': args
86 | });
87 | }
88 |
89 | isError(response, code=undefined) {
90 | return response.evt === 'ERROR' && (code ? response.data.code === code : true);
91 | }
92 |
93 | discordRequest(route, body, file) {
94 | if (file) {
95 | request
96 | .post(`http://127.0.0.1:${this.state.ioPort}/${route}`)
97 | .set('Authorization', `Bearer ${this.state.accessToken}`)
98 | .field('payload_json', JSON.stringify(body))
99 | .attach('file', file, file.name)
100 | .end((err, res) => {
101 | console.log('sent', err, res);
102 | });
103 | }
104 | else {
105 | request
106 | .post(`http://127.0.0.1:${this.state.ioPort}/${route}`)
107 | .set('Authorization', `Bearer ${this.state.accessToken}`)
108 | .send(body)
109 | .end((err, res) => {
110 | console.log('sent', err, res);
111 | });
112 | }
113 | }
114 |
115 | // ----------------------------------------------------------------------------------------
116 | // Response handlers
117 | // ----------------------------------------------------------------------------------------
118 | handleError(err) {
119 | // note: You should implement real error handling that, when appropriate, retries with a backoff :-)
120 |
121 | console.log(err);
122 | this.setState({message: err.toString()});
123 | }
124 |
125 | handleGotAccessToken(user) {
126 | const accessToken = user['access_token'];
127 | const discordId = user['discord_id'];
128 | this.setState({accessToken, discordId});
129 | this.call('AUTHENTICATE', {access_token: accessToken}, (response) => {
130 | if (response.data.code === ERROR_TOKEN_DOESNT_MATCH_CURRENT_USER) {
131 | console.error(response.data);
132 | this.disconnect();
133 | return;
134 | }
135 |
136 | this.setState({
137 | message: response.data.user.username,
138 | loggedIn: true
139 | });
140 |
141 | this.loadGuildsForSharing();
142 | });
143 | }
144 |
145 | loadGuildsForSharing() {
146 | this.call('GET_GUILDS', {}, (response) => {
147 | const shareGuilds = response.data.guilds.map((guild) => {
148 | return {value: guild.id, label: guild.name, icon: guild.icon_url}
149 | });
150 |
151 | this.setState({shareGuilds});
152 | if (shareGuilds.length === 1) {
153 | this.handleSelectedShareGuild(shareGuilds[0]);
154 | }
155 | });
156 | }
157 |
158 | handleDiscordRPCResponse(e) {
159 | const data = JSON.parse(e.data);
160 | this.setState({'message': data.cmd});
161 |
162 | if (data.nonce) {
163 | let handler = this.handlers[data.nonce];
164 | if (handler) {
165 | delete this.handlers[data.nonce];
166 | handler(data);
167 | return;
168 | }
169 | }
170 |
171 | if (data.cmd !== 'DISPATCH') {
172 | return;
173 | }
174 |
175 | const event = data.evt;
176 | if (event === 'READY') {
177 | request
178 | .post(`${ENDPOINT}/login`)
179 | .send({id: MY_USER_NAME})
180 | .then(
181 | ({text}) => {
182 | this.handleGotAccessToken(JSON.parse(text));
183 | },
184 | () => {
185 | request
186 | .get(`${ENDPOINT}/discord_auth`)
187 | .then((res) => {
188 | this.call('AUTHORIZE', {
189 | 'client_id': CLIENT_ID,
190 | 'scopes': ['rpc.api', 'rpc', 'identify', 'gdm.join'],
191 | rpc_token: JSON.parse(res.text).rpc_token
192 | },
193 | (response) => {
194 | request
195 | .post(`${ENDPOINT}/discord_exchange_code`)
196 | .send({code: response.data.code, id: MY_USER_NAME})
197 | .then(({text}) => {
198 | this.handleGotAccessToken(JSON.parse(text))
199 | },
200 | this.handleError.bind(this)
201 | );
202 | });
203 | },
204 | this.handleError.bind(this)
205 | );
206 | }
207 | );
208 | }
209 | else if(event === 'MESSAGE_CREATE') {
210 | let lines = this.state.lines.slice();
211 | // type > 0 means it's a bot or system message.
212 | if (data.data.message.type > 0) {
213 | return;
214 | }
215 | lines.push(data.data.message);
216 | this.setState({lines});
217 | }
218 | else if(event === 'MESSAGE_UPDATE') {
219 | const index = this.state.lines.findIndex((message) => message.id === data.data.message.id);
220 | if (index === -1) {
221 | return;
222 | }
223 | let lines = this.state.lines.slice();
224 | lines[index] = data.data.message;
225 | this.setState({lines});
226 | }
227 | else if(event === 'MESSAGE_DELETE') {
228 | let lines = this.state.lines.filter((message) => message.id !== data.data.message.id);
229 | this.setState({lines});
230 | }
231 | else if(event === 'VOICE_STATE_CREATE') {
232 | this.setState({voiceUsers: this.addUserVoiceState(data.data.user)});
233 | }
234 | else if(event === 'VOICE_STATE_DELETE') {
235 | let user = data.data.user;
236 | let voiceUsers = this.state.voiceUsers;
237 | delete voiceUsers[user.id];
238 | this.setState({voiceUsers});
239 | }
240 | else if(event === 'SPEAKING_START') {
241 | let userId = data.data['user_id'];
242 | let voiceUsers = this.state.voiceUsers;
243 | if (!voiceUsers[userId]) {
244 | return;
245 | }
246 | voiceUsers[userId].speaking = true;
247 | this.setState({voiceUsers});
248 | }
249 | else if(event === 'SPEAKING_STOP') {
250 | let userId = data.data['user_id'];
251 | let voiceUsers = this.state.voiceUsers;
252 | if (!voiceUsers[userId]) {
253 | return;
254 | }
255 | voiceUsers[userId].speaking = false;
256 | this.setState({voiceUsers});
257 | }
258 | }
259 |
260 | addUserVoiceState(user) {
261 | if (user.bot) {
262 | return this.state.voiceUsers;
263 | }
264 | let voiceUsers = {...this.state.voiceUsers};
265 | voiceUsers[user.id] = {
266 | username: user.username,
267 | speaking: false
268 | };
269 | return voiceUsers;
270 | }
271 |
272 | // ----------------------------------------------------------------------------------------
273 | // UI Actions
274 | // ----------------------------------------------------------------------------------------
275 | connect(portOffset=0) {
276 | if (this.socket) {
277 | this.disconnect();
278 | }
279 |
280 | const portAttempt = PORT + portOffset;
281 | this.socket = new WebSocket(`ws://127.0.0.1:${portAttempt}/?v=${VERSION}&client_id=${CLIENT_ID}&encoding=${ENCODING}`);
282 |
283 | this.socket.onmessage = this.handleDiscordRPCResponse.bind(this);
284 |
285 | this.socket.onerror = (e) => {
286 | this.setState({'message': `Error ${e}`});
287 | };
288 |
289 | this.socket.onopen = (e) => {
290 | this.setState({'message': `Opened ${e}`, connected: true, lines: [], ioPort: portAttempt});
291 | };
292 |
293 | this.socket.onclose = (e) => {
294 | const wasConnected = this.state.connected;
295 | this.setState({'message': `Closed ${e.code} ${e.reason}`, loggedIn: false, connected: false, channelId: null});
296 |
297 | if (wasConnected === false) {
298 | if (portOffset < NUM_PORTS_TO_SEARCH) {
299 | this.connect(null, portOffset + 1);
300 | }
301 | else {
302 | this.setState({'message': 'Discord is not running or was unable to bind to a local port'});
303 | }
304 | }
305 | };
306 | }
307 |
308 | createMatch() {
309 | request
310 | .post(`${ENDPOINT}/create_match`)
311 | .then(({text}) => {
312 | this.setState({gameId: JSON.parse(text).game_id});
313 | },
314 | this.handleError.bind(this)
315 | );
316 | }
317 |
318 | findMatch() {
319 | request
320 | .post(`${ENDPOINT}/find_match`)
321 | .then(({text}) => {
322 | this.setState({gameId: JSON.parse(text).game_id});
323 | },
324 | this.handleError.bind(this)
325 | );
326 | }
327 |
328 | observeVoiceChannel(voiceChannelId) {
329 | this.call('GET_CHANNEL', {'channel_id': voiceChannelId}, (response) => {
330 | if (this.isError(response)) {
331 | console.error(response.message);
332 | return;
333 | }
334 |
335 | let voiceUsers = {...this.state.voiceUsers};
336 | response.data['voice_states'].forEach((voiceState) => {
337 | voiceUsers = this.addUserVoiceState(voiceState.user);
338 | });
339 | this.setState({voiceUsers});
340 | this.subscribe('VOICE_STATE_CREATE', {'channel_id': voiceChannelId});
341 | this.subscribe('VOICE_STATE_DELETE', {'channel_id': voiceChannelId});
342 | this.subscribe('SPEAKING_START', {'channel_id': voiceChannelId});
343 | this.subscribe('SPEAKING_STOP', {'channel_id': voiceChannelId});
344 | });
345 | }
346 |
347 | joinMatch() {
348 | request
349 | .post(`${ENDPOINT}/join_match/${this.state.gameId}`)
350 | .send({id: MY_USER_NAME})
351 | .then(({text}) => {
352 | const channelId = JSON.parse(text).channel_id;
353 | this.setState({channelId});
354 | this.call('SELECT_VOICE_CHANNEL', {'channel_id': channelId, timeout: DEFAULT_REQUEST_TIMEOUT}, (response) => {
355 | // this focuses the guild's default text channel on the client
356 | this.call('SELECT_TEXT_CHANNEL', {'channel_id': channelId});
357 |
358 | this.subscribe('MESSAGE_CREATE', {'channel_id': channelId});
359 | this.subscribe('MESSAGE_UPDATE', {'channel_id': channelId});
360 | this.subscribe('MESSAGE_DELETE', {'channel_id': channelId});
361 |
362 | if (this.isError(response, ERROR_ALREADY_IN_VOICE_CHANNEL)) {
363 | const leave = window.confirm('Leave your current voice channel to join match chat?');
364 | if (leave) {
365 | this.call('SELECT_VOICE_CHANNEL', {'channel_id': channelId, force: true}, () => {
366 | this.observeVoiceChannel(channelId);
367 | });
368 | }
369 | }
370 | else {
371 | this.observeVoiceChannel(channelId);
372 | }
373 | });
374 | },
375 | this.handleError.bind(this)
376 | );
377 | }
378 |
379 | endMatch() {
380 | this.setState({gameId: null, channelId: null});
381 | request.post(`${ENDPOINT}/end_match`).end();
382 | }
383 |
384 | shareResults() {
385 | // There are two ways to attach an image or thumbnail to an embed:
386 | // 1. You can use a URL that you are hosting somewhere as in the default case here.
387 | // 2. You can upload a png, jpeg, or gif up to 8MB large as shown here when a file is chosen in the picker.
388 | const shareFile = this.refs['SHARE_FILE'];
389 | const hasEmbedAttachment = shareFile.files.length > 0;
390 | const file = hasEmbedAttachment ? shareFile.files[0]: null;
391 | let imageUrl = 'https://lolstatic-a.akamaihd.net/game-info/1.1.9/images/content/gi-modes-sr-the-battle-for-the-rift.jpg';
392 | if (hasEmbedAttachment) {
393 | imageUrl = `attachment://${file.name}`
394 | }
395 |
396 | const embed = {
397 | title: `Defeat on Summoner's Rift`,
398 | description: 'Match results for a Diamond tier ranked game.',
399 | url: 'http://matchhistory.na.leagueoflegends.com/en/#match-details/NA1/2338193457/50068799?tab=overview',
400 | color: 0xFF0000,
401 | fields: [
402 | {
403 | name: 'Champion',
404 | value: 'Lucian',
405 | inline: true
406 | },
407 | {
408 | name: 'K/D/A',
409 | value: '24/18/12',
410 | inline: true
411 | }
412 | ],
413 | image: {
414 | url: imageUrl
415 | },
416 | footer: {
417 | text: `League of Legends`,
418 | icon_url: 'https://encrypted-tbn1.gstatic.com/images?q=tbn:ANd9GcS4Em5ICgyo-AdBKqA74vPAvoDihdnustbwuA23THD9pR8oI5Q0Z1swvw'
419 | }
420 | };
421 |
422 | this.discordRequest(`channels/${this.state.shareChannelId}/messages`, {embed}, file);
423 | }
424 |
425 | disconnect() {
426 | if (this.socket) {
427 | this.setState({
428 | lines: [], voiceUsers: {}, gameId: null, channelId: null,
429 | shareGuilds: null, shareChannels: null, shareGuildId: null, shareChannelId: null
430 | });
431 | this.socket.close();
432 | this.socket = null;
433 | }
434 | }
435 |
436 | // ----------------------------------------------------------------------------------------
437 | // UI Event Handlers
438 | // ----------------------------------------------------------------------------------------
439 | onHandleConnect() {
440 | this.connect();
441 | }
442 |
443 | onKeyUp(e) {
444 | if (e.keyCode === 13 /* enter */) {
445 | let inputBox = this.refs['INPUT_BOX'];
446 | this.discordRequest(`channels/${this.state.channelId}/messages`, {content: inputBox.value});
447 | inputBox.value = '';
448 | }
449 | }
450 |
451 | handleSelectedShareGuild(val) {
452 | const shareGuildId = val.value;
453 | this.setState({shareGuildId, shareChannels: null, shareChannelId: null});
454 |
455 | this.call('GET_CHANNELS', {guild_id: shareGuildId}, (response) => {
456 | const shareChannels = response.data.channels
457 | .filter((channel) => channel.type === CHANNEL_TYPE_TEXT)
458 | .map((channel) => {
459 | return {value: channel.id, label: channel.name}
460 | }
461 | );
462 |
463 | this.setState({shareChannels});
464 |
465 | if (shareChannels.length === 1) {
466 | this.handleSelectedShareChannel(shareChannels[0]);
467 | }
468 | });
469 | }
470 |
471 | handleSelectedShareChannel(val) {
472 | this.setState({shareChannelId: val.value});
473 | }
474 |
475 | // ----------------------------------------------------------------------------------------
476 | // Make It Pretty
477 | // ----------------------------------------------------------------------------------------
478 | renderOption(option) {
479 | if (option.icon) {
480 | return 
{option.label}
;
481 | }
482 |
483 | return {option.label}
;
484 | }
485 |
486 | render() {
487 | const {channelId, gameId, loggedIn, connected} = this.state;
488 |
489 | const lines = this.state.lines.map((message) => {
490 | return {message.author.username}: {message.content}
491 | });
492 |
493 | const voiceUsers = Object.keys(this.state.voiceUsers).map((id) => {
494 | const user = this.state.voiceUsers[id];
495 | return {user.username}: {user.speaking ? 'Speaking' : 'Not Speaking'}
496 | });
497 |
498 | return (
499 |
500 |
{this.state.message}
501 |
Connect to Discord
502 |
Disconnect
503 |
504 |
505 |
Create Match
506 |
Find Match
507 |
Join Match
508 |
End Match
509 |
510 |
511 |
517 |
524 |
529 |
Share Results
530 |
531 |
532 |
533 |
534 | {voiceUsers}
535 |
536 |
537 |
538 |
539 | {lines}
540 |
541 |
542 |
543 |
544 |
545 |
546 | );
547 | }
548 | }
549 |
550 | export default App;
551 |
--------------------------------------------------------------------------------