parent
67a1186cfb
commit
7c891f18cf
@ -0,0 +1,96 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
|
||||
# virtualenv
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
@ -0,0 +1,14 @@
|
||||
FROM python:alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN pip install --no-cache-dir --requirement requirements.txt
|
||||
|
||||
RUN addgroup -g 9999 lilia
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
USER nobody:lilia
|
||||
|
||||
ENTRYPOINT ["python", "app.py"]
|
@ -0,0 +1,81 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import flask
|
||||
import flask_apscheduler
|
||||
import flask_restful
|
||||
import flask_restful.reqparse
|
||||
|
||||
from sender import Sender
|
||||
|
||||
|
||||
subscriptions = {}
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.config.update(
|
||||
ERROR_404_HELP=False,
|
||||
SCHEDULER_TIMEZONE='UTC',
|
||||
SCHEDULER_JOBS=[
|
||||
dict(id='subscribe',
|
||||
func='twitch:Twitch.subscribe',
|
||||
args=(app, subscriptions, 86400),
|
||||
max_instances=1,
|
||||
trigger='interval',
|
||||
seconds=3600,
|
||||
next_run_time=datetime.datetime.utcnow() + datetime.timedelta(seconds=10))])
|
||||
|
||||
scheduler = flask_apscheduler.APScheduler()
|
||||
scheduler.init_app(app)
|
||||
|
||||
api = flask_restful.Api(app)
|
||||
|
||||
sender = Sender(app)
|
||||
|
||||
|
||||
verification_parser = flask_restful.reqparse.RequestParser()
|
||||
verification_parser.add_argument('hub.challenge', type=str, required=True)
|
||||
verification_parser.add_argument('hub.lease_seconds', type=int, required=True)
|
||||
verification_parser.add_argument('hub.mode', type=str, required=True)
|
||||
verification_parser.add_argument('hub.topic', type=str, required=True)
|
||||
|
||||
|
||||
class WebhooksResource(flask_restful.Resource):
|
||||
def get(self):
|
||||
args = verification_parser.parse_args()
|
||||
verified = False
|
||||
for sub in subscriptions.values():
|
||||
h = '{0[hub.mode]}|{0[hub.topic]}|{0[hub.lease_seconds]}'.format(args)
|
||||
if hashlib.sha256(h.encode()).hexdigest() == sub['request_hash']:
|
||||
verified = True
|
||||
break
|
||||
if not verified:
|
||||
flask_restful.abort(400, message='Verification failed')
|
||||
r = app.make_response(args['hub.challenge'])
|
||||
r.mimetype = 'text/plain'
|
||||
r.status_code = 200
|
||||
return r
|
||||
|
||||
def post(self):
|
||||
sha, signature = flask.request.headers.get('X-Hub-Signature').split('=')
|
||||
data = flask.request.data
|
||||
for topic, sub in subscriptions.items():
|
||||
mac = hmac.new(sub['secret'].encode(), data, getattr(hashlib, sha))
|
||||
if not hmac.compare_digest(mac.hexdigest(), signature):
|
||||
continue
|
||||
message = flask.request.get_json(force=True)
|
||||
message['topic'] = topic
|
||||
sender.send(json.dumps(message).encode())
|
||||
return None, 204
|
||||
|
||||
|
||||
api.add_resource(WebhooksResource, '/webhooks')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
scheduler.start()
|
||||
app.run(host='0.0.0.0', threaded=True, debug=False)
|
@ -0,0 +1,5 @@
|
||||
Flask
|
||||
Flask-APScheduler
|
||||
Flask-RESTful
|
||||
python-dateutil
|
||||
requests-futures
|
@ -0,0 +1,20 @@
|
||||
import os
|
||||
import socket
|
||||
|
||||
|
||||
class Sender(object):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
addr, port = os.getenv('CONSUMER_ADDRESS').split(':')
|
||||
self.address = (addr, int(port))
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
|
||||
def __del__(self):
|
||||
self.socket.close()
|
||||
|
||||
def send(self, message):
|
||||
try:
|
||||
self.socket.sendto(message, self.address)
|
||||
except Exception as e:
|
||||
with self.app.app_context():
|
||||
self.app.logger.error('Failed to send message: %s', str(e))
|
@ -0,0 +1,77 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from requests_futures.sessions import FuturesSession
|
||||
|
||||
|
||||
TOPIC_URLS = {
|
||||
'streams': 'https://api.twitch.tv/helix/streams?user_id={0}',
|
||||
'followers': 'https://api.twitch.tv/helix/users/follows?first=1&to_id={0}',
|
||||
}
|
||||
|
||||
|
||||
class Twitch(object):
|
||||
@classmethod
|
||||
def subscribe(cls, app, subscriptions, lease_time):
|
||||
def read_leases(lease_file):
|
||||
result = dict(
|
||||
streams=dict(secret=None, valid_until=None, request_hash=None),
|
||||
followers=dict(secret=None, valid_until=None, request_hash=None))
|
||||
try:
|
||||
with open(lease_file) as f:
|
||||
leases = json.load(f)
|
||||
except IOError:
|
||||
return result
|
||||
else:
|
||||
result.update(leases)
|
||||
return result
|
||||
def write_leases(lease_file, leases):
|
||||
with open(lease_file, 'w') as f:
|
||||
json.dump(leases, f, indent=4, sort_keys=True)
|
||||
def generate_secret(length):
|
||||
pool = string.ascii_letters + string.digits
|
||||
return ''.join(random.SystemRandom().choice(pool) for _ in range(length))
|
||||
def sub(topic, secret):
|
||||
client_id = os.getenv('TWITCH_CLIENT_ID')
|
||||
channel_id = os.getenv('TWITCH_CHANNEL_ID')
|
||||
callback_url = os.getenv('CALLBACK_URL')
|
||||
session = FuturesSession()
|
||||
url = 'https://api.twitch.tv/helix/webhooks/hub'
|
||||
headers = {'Client-ID': client_id}
|
||||
data = {
|
||||
'hub.mode': 'subscribe',
|
||||
'hub.topic': TOPIC_URLS[topic].format(channel_id),
|
||||
'hub.callback': callback_url,
|
||||
'hub.lease_seconds': lease_time,
|
||||
'hub.secret': secret,
|
||||
}
|
||||
h = '{0[hub.mode]}|{0[hub.topic]}|{0[hub.lease_seconds]}'.format(data)
|
||||
request_hash = hashlib.sha256(h.encode()).hexdigest()
|
||||
return session.post(url, headers=headers, data=data), request_hash
|
||||
with app.app_context():
|
||||
app.logger.info('Refreshing Twitch subscriptions')
|
||||
lease_file = os.getenv('LEASE_FILE')
|
||||
leases = read_leases(lease_file)
|
||||
for topic, lease in leases.items():
|
||||
if not lease['secret'] or not lease['valid_until']:
|
||||
remaining = 0
|
||||
if lease['valid_until']:
|
||||
valid_until = dateutil.parser.parse(lease['valid_until'])
|
||||
remaining = (valid_until - datetime.datetime.utcnow()).total_seconds()
|
||||
if remaining < 3600:
|
||||
app.logger.info('Subscription "%s" expired, re-requesting', topic)
|
||||
secret = generate_secret(32)
|
||||
valid_until = datetime.datetime.utcnow() + datetime.timedelta(seconds=lease_time)
|
||||
request, request_hash = sub(topic, secret)
|
||||
if request.result().ok:
|
||||
lease['secret'] = secret
|
||||
lease['valid_until'] = valid_until.isoformat()
|
||||
lease['request_hash'] = request_hash
|
||||
write_leases(lease_file, leases)
|
||||
subscriptions.update(leases)
|
Loading…
Reference in new issue