Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - marionette updates part 1 #1

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e681c86
updated readme with quart app instructions
marlop352 Mar 20, 2021
d92a02f
add server var to viewer.js
marlop352 Mar 20, 2021
8f3fecc
set run port on quartapp.py
marlop352 Mar 20, 2021
753e8c0
migrated all options to a config file and added encryption
marlop352 Mar 21, 2021
c7f74ad
Make sure that the JWT has the info required for the future command g…
marlop352 Mar 24, 2021
004b072
Fix so that responses are only sent to the correct connection
marlop352 Mar 24, 2021
11d369b
remove unused variable
marlop352 Mar 24, 2021
fcf7095
PEP8 style fixes, with 2 spaces indentation
marlop352 Mar 24, 2021
d0781e7
remo.tv auth sequence partially working, robot connects to server cor…
marlop352 Mar 24, 2021
94c7ea3
Force users of the extension to be logged in to twitch
marlop352 Mar 24, 2021
1a036bc
remove crypto code in favor of using just SSL
marlop352 Mar 24, 2021
74389b1
fixed sending commands to robot(need to figure out how to use a webso…
marlop352 Mar 25, 2021
6cbffcd
Moved some configs around
marlop352 Mar 27, 2021
ae0ea61
add warning to readme
marlop352 Mar 27, 2021
045dc94
Update and rename the token generation/"""robot manager""" script
marlop352 Mar 27, 2021
b1368c0
generalize the "channels/list" route
marlop352 Mar 27, 2021
c856d68
now validates the robot token before sending "ROBOT_VALIDATED", also …
marlop352 Mar 27, 2021
1cca92c
disable the strafe buttons
marlop352 Mar 27, 2021
8dd260d
updated TODO
marlop352 Mar 27, 2021
fadb34f
disables code not meant to run on production
marlop352 Mar 27, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ server/virtualenv
.*
*.pyc
server/secret.key
server/config.conf
server/robots.json
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# Marionette
Twitch extension for controlling robots remotely

### Warning: This code only supports one streamer with one robot per server

# Installation

cd server
virtualenv virtualenv
pip3 install -r requirements.txt
```
$ git clone https://github.com/strangeparts/marionette
$ cd marionette/server
$ virtualenv virtualenv
```
On linux do: `$ source virtualenv/bin/activate`
```
(virtualenv)$ pip3 install -r requirements.txt
```

# Run development server

gunicorn -k flask_sockets.worker --threads 5 --workers 5 -b '0.0.0.0:8000' app:app
On linux do: `$ source virtualenv/bin/activate`
```
(virtualenv)$ QUART_DEBUG=1 QUART_APP=quartapp.py quart run --host 0.0.0.0 --port 8000
```
5 changes: 3 additions & 2 deletions frontend/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ let token = '';
let tuid = '';

const twitch = window.Twitch.ext;
const buttons = ['l', 'r', 'f', 'b', 'tl', 'tr', 'CAMDOWN', 'CAMRESET', 'CAMUP'];
const buttons = ['f', 'b', 'tl', 'tr', 'CAMDOWN', 'CAMRESET', 'CAMUP'];
const server = location.protocol + '//localhost:8000'

twitch.onContext(function (context) {
twitch.rig.log(context);
Expand Down Expand Up @@ -30,7 +31,7 @@ function createRequest (type, command) {
twitch.rig.log('createRequest(' + type + ", " + command + ")");
return {
type: type,
url: location.protocol + '//localhost:8000/command?command=' + command,
url: server + '/command?command=' + command,
headers: { 'Authorization': 'Bearer ' + token },
success: logSuccess,
error: logError
Expand Down
5 changes: 4 additions & 1 deletion server/TODO
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
X Make buttons bigger
X Add client authentication
Add basic robot authentication
X Add basic robot authentication
X Emulate remo.tv auth sequence (make robot trust server)
Add support for the TTS
Add the capability to gate a command to only people with certain roles

Deploy server to cloud
Get the extension running on Twitch
9 changes: 9 additions & 0 deletions server/config.sample.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[server]
# secret key used to sign an validate the robot tokens
# if this is not set correctly token-gen.py will generate one
# if this is set correctly token-gen.py will use it
secret_key = put_key_generated_by_token-gen.py_here

[twitch]
#The Twitch extension secret Key
ext_secret = put_Twitch_extension_key_here
171 changes: 146 additions & 25 deletions server/quartapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,55 @@
import os

import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, InvalidTokenError, MissingRequiredClaimError

from quart import websocket, Quart, request, Response
from quart_cors import cors

import sys
from configparser import ConfigParser, NoSectionError, NoOptionError


app = Quart(__name__)
app = cors(app)

connected_websockets = set()

secret = os.getenv("TWITCH_SECRET_KEY", None)
if secret is None:
secret_file_path = os.path.join(os.getcwd(), "secret.key")
secret = base64.b64decode(open(secret_file_path).read().strip())
# robots_con = {}
robots_config = {}

# config start
config = ConfigParser()
try:
with open('config.conf', 'r') as filepointer:
config.read_file(filepointer)
filepointer.close()
except(IOError, FileNotFoundError):
print("Unable to read config.conf, check that it exists and that the program has permission to read it.")
sys.exit()

try:
with open('robots.json', 'r') as filepointer:
robots_config = json.load(filepointer)
filepointer.close()
except(IOError, FileNotFoundError):
print("Unable to read robots.json, check that it exists and that the program has permission to read it.")
sys.exit()

try:
# To read values from config:
# value = config.get('section', 'key')

twitch_ext_secret = os.getenv("TWITCH_SECRET_KEY", None)
if twitch_ext_secret is None:
twitch_ext_secret = base64.b64decode(config.get('twitch', 'ext_secret'))
# twitch_ext_secret = base64.b64decode(open(os.path.join(os.getcwd(), "secret.key")).read().strip())

secret_key = config.get('server', 'secret_key')

except(NoSectionError, NoOptionError):
print("Error in config.conf:", sys.exc_info()[1])
sys.exit()
# config end


def collect_websocket(func):
Expand All @@ -31,65 +68,149 @@ async def wrapper(*args, **kwargs):
connected_websockets.remove(queue)
return wrapper


async def sending(queue):
while True:
data = await queue.get()
await websocket.send(data)

async def receiving(queue):

async def receiving():
while True:
data = await websocket.receive()
await process_message(websocket, data)
await process_message(data)


async def broadcast(message):
for queue in connected_websockets:
await queue.put(message)


@app.websocket('/')
@collect_websocket
async def ws(queue):
producer = asyncio.create_task(sending(queue))
consumer = asyncio.create_task(receiving(queue))
consumer = asyncio.create_task(receiving())
await asyncio.gather(producer, consumer)


@app.route('/')
async def root():
return 'OK'


@app.route('/api/dev/channels/list/<host>')
async def channels_list(host):
chanlist = []
try:
for rid in robots_config[host]:
chanlist.append({
"name": robots_config[host][rid]["info"]["name"],
"id": rid,
"chat": robots_config[host][rid]['info']['chat']
})
except KeyError:
response = Response('')
response.status_code = 404
response.headers['Access-Control-Allow-Origin'] = '*'
return response
return {"channels": chanlist}


@app.route('/command')
async def command():
c = request.args.get('command')

auth = request.headers.get('Authorization', '').replace('Bearer ', '')

r = jwt.decode(auth, secret, algorithms=['HS256'])

j = json.dumps({
'e': 'BUTTON_COMMAND',
'd': {
'button': {
'command': c,
},
'user': {
'username': 'NONEUSER',
},
},
})
await broadcast(j)
try:
t_jwt = jwt.decode(auth, twitch_ext_secret, algorithms=['HS256'],
options={"require": ["channel_id", "opaque_user_id", "role"]})
except(InvalidSignatureError, ExpiredSignatureError, InvalidTokenError, MissingRequiredClaimError):
response = Response('')
response.status_code = 403
response.headers['Access-Control-Allow-Origin'] = '*'
return response

if t_jwt:
if t_jwt.get('opaque_user_id', '')[0] == 'U':
j = json.dumps({
'e': 'BUTTON_COMMAND',
'd': {
'button': {
'command': c,
},
'user': {
'username': 'NONEUSER',
},
},
})
else:
app.logger.debug("JWT: " + str(t_jwt))
response = Response('')
response.status_code = 403
response.headers['Access-Control-Allow-Origin'] = '*'
return response
else:
app.logger.debug("JWT: " + str(t_jwt))
response = Response('')
response.status_code = 403
response.headers['Access-Control-Allow-Origin'] = '*'
return response

await broadcast(j) # after the websockets context issue is solved change this to send message only to target robot
response = Response('OK')
response.headers['Access-Control-Allow-Origin'] = '*'
return response

async def process_message(websocket, message):

async def process_message(message):
app.logger.debug(message)
m = json.loads(message)
if m.get('e', '') == 'AUTHENTICATE_ROBOT':
try:
r_jwt = jwt.decode(m['d']['token'], secret_key, algorithms=['HS256'])
except(InvalidSignatureError, ExpiredSignatureError, InvalidTokenError):
e = json.dumps({
'e': 'INVALID_TOKEN',
'd': "token did not validate, check that it's correct",
})

await websocket.send(e)
# await websocket.close(1000) not available on a quart release yet
return None

j = json.dumps({
'e': 'ROBOT_VALIDATED',
'd': {
'host': '192.168.0.136:8000',
'host': r_jwt["host"],
'stream_key': robots_config[r_jwt["host"]][r_jwt["id"]]["info"]["stream_key"],
},
})

await websocket.send(j)
return None

# future code to copy websockets context for use to send websockets messages
# if m.get('e', '') == 'JOIN_CHANNEL':
# robots_con[m.get('d', '')] = webaocket_context_copy
# app.logger.debug(robots)

if m.get('e', '') == 'ERROR':
j = json.dumps({
'e': 'ERROR',
'd': m.get('d', '')
})

await websocket.send(j)
# await websocket.close(1000) not available on a quart release yet
return None


# @app.cli.command('run')
# def run():
# app.run(host='0.0.0.0', port=8000) # , certfile='cert.pem', keyfile='key.pem'


if __name__ == "__main__":
app.run(host='0.0.0.0')
app.run(host='0.0.0.0', port=8000) # , certfile='cert.pem', keyfile='key.pem'
1 change: 1 addition & 0 deletions server/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ quart
quart-cors
pyjwt
websocket_client
configparser
Loading