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