--- /dev/null
+build/
+dist/
--- /dev/null
+#Architecture
+
+background.py executed periodically (every 25 min).
+service.py is a constantly-running service, uses postgresql asynchronous notifications to detect when there is work to do.
+See file headers of the above to learn about their responsibilities.
+background.py and service.py act on a "service"; this is sort of a multi-tenant capability. The default service name is "autojoin"; to use a different service name set the environment variable LIVEAUTOJOINSERVICE
+service name is also used to find the database via "~/.pg\_service.conf"
+
+web.py is the Flask (wsgi) web application which handles /authorize, /invite, /ticket.
+
+flow:
+1. user clicks join link (/authorize) in thread sidebar
+2. app validates `thread` argument
+3. app redirects to reddit OAuth2 /api/v1/authorize, state is <service>:<event>
+4. user clicks 'Allow'
+5. reddit redirects to /invite
+6. app creates ticket linked to authorization code and `NOTIFY live_autojoin`
+7. app redirects to /ticket (templates/status.html - auto-refreshing)
+7. service.py retrieves authorization code to create authorization
+8. service.py fills in ticket username using authorization
+9. service.py invites user using admin authorization
+10. service.py accepts invite using authorization
+11. auto-refreshing /ticket starts redirecting back to the live event
+
+# Roadmap/wishlist
+- allowed events: load more than 1 page of search results
+- allowed events: search linkpost self text and "thread directory" wiki page for links, not just linkpost links
+- when accepting admin invite, update reddit\_liveupdate\_event\_contributor table like when inviting users
+- don't mention users in announcements
+- respect sr bans
+- check whether we will succeed (have permission, have scope, not too many outstanding invites) before adding ticket
+- configurable (wiki page) allow/deny list for events
+- invite sr moderators with extra permissions
+- handle LIVEUPDATE\_TOO\_MANY\_INVITES (or whatever)
+- actually report status/errors on /ticket
+- handle no admin permission in `invite\_contributor`
+- ticket processing rate limit (max 3 tickets in 60 seconds) - and if waiting on ratelimit, say so in status.html
+- read modmail (and PMs?) and create tickets for messages with "inviteme" commands
+- sync /live/mine (or allowed threads by\_id) to reddit\_liveupdate\_event table (background.py)
+- include event title in error page's /message/compose link template
+- after accepting admin invite, send PM to event creator (we don't know who created a thread, but we could find out who posted it in /r/livecounting and check if they have `settings` permission) with instructions for adding join link
+- remove everyone's "close" permission?
+- should be open-sourced, but needs deployment documentation and database schema script and pg\_reddit open-sourced first
+
+- find and fix DoS opportunities (something invalid submitted through web.py crashes service.py): better now.
+- send "/u/username has joined this thread" updates: done.
+- sync event contributor _invites_, not just contributors: done
+- decide how to handle LIVEUPDATE\_ALREADY\_CONTRIBUTOR when `invite\_contributor`ing: done
+- speculatively update invite table after `invite\_contributor`, speculatively update contributor table after `accept\_contributor\_invite`: done
--- /dev/null
+#!/bin/bash
+
+echo "Ok, but why aren't you using a package manager"
+
+#tar cz systemd | ssh root@bingus.internet6.net. tar xzC /etc/systemd/system
+python3 -m build --no-isolation --wheel
+tar cC dist joinbot-0.1.0-py3-none-any.whl | ssh anders@bingus.internet6.net. tar xC /var/tmp \&\& sudo pip3 install -U /var/tmp/joinbot-0.1.0-py3-none-any.whl --no-deps \&\& rm /var/tmp/joinbot-0.1.0-py3-none-any.whl
--- /dev/null
+# announce.py - announce new users
+
+import select
+import urllib.parse
+import urllib.request
+import urllib.error
+import base64
+import json
+
+from .common import connect_db, service_name
+
+POLL_INTERVAL=3600
+
+def main():
+ (cn, cr) = connect_db()
+
+ cr.execute("LISTEN live_autojoin")
+
+ while True:
+ cn.poll()
+ cn.notifies.clear()
+ work = False
+ while True:
+ cr.execute("BEGIN")
+ # - invited less than 1 minute ago
+ # - no announcement of same user in same event less than 30 days ago
+ # - user has update permission (not banned)
+ # - admin has update permission
+ # - admin authorization has submit scope
+ cr.execute("""SELECT access_token, admin_username, ticket.event_flake, username FROM live_autojoin_ticket AS ticket JOIN live_autojoin_service ON service_name=name JOIN live_autojoin_admin_authorization USING (service_name) JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_liveupdate_event_contributor AS userrel ON userrel."type"='contributor' AND ticket.event_flake=userrel.event_flake AND username=userrel.name JOIN reddit_liveupdate_event_contributor AS adminrel ON adminrel."type"='contributor' AND ticket.event_flake = adminrel.event_flake AND admin_username = adminrel.name WHERE service_name = %s AND status = 'ok' AND updated_at + '1m' > CURRENT_TIMESTAMP AND NOT EXISTS (SELECT * FROM live_autojoin_announcement WHERE for_username = username AND event_flake = ticket.event_flake AND at + '30d' > updated_at) AND has_permission('update', userrel.permissions) AND has_permission('update', adminrel.permissions) AND array_position(scope, 'submit') IS NOT NULL LIMIT 1""", (service_name,))
+ try:
+ [(access_token, admin_username, event_flake, username)] = cr.fetchall()
+ except ValueError:
+ break
+ else:
+ work = True
+ escaped_username = username.replace('_', '\\_')
+ usertext = f'*[\\/u\\/{ escaped_username }](/user/{ escaped_username }) has joined this thread*'
+ body = urllib.parse.urlencode({ 'api_type': 'json', 'body': usertext }).encode('utf-8')
+ req = urllib.request.Request(f'https://oauth.reddit.com/api/live/{event_flake}/update', data=body, method='POST')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ try:
+ res = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 403:
+ admin_username = None
+ usertext = None
+ else:
+ raise
+ cr.execute("INSERT INTO live_autojoin_announcement (at, event_flake, for_username, author, body) VALUES (CURRENT_TIMESTAMP, %s, %s, %s, %s)", (event_flake, username, admin_username, usertext))
+ finally:
+ cn.commit()
+
+ cn.poll()
+ if work or len(cn.notifies) > 0:
+ continue
+
+ select.select([cn], [], [], POLL_INTERVAL)
--- /dev/null
+# background.py - jobs that are not time-critical.
+# - refresh access tokens
+# - sync PMs
+# - sync allowed threads (subreddit links)
+# - accept admin invites
+# - sync contributor lists
+# - TODO sync subreddit bans and moderators
+# - TODO sync modmail (to receive joining requests to modmail)
+
+# roadmap
+# - for PM, allowed threads, and modmail sync, create a lightweight version to run frequently to handle new events quickly--distinct from the more expensive "full sync" that is implemented here.
+
+import urllib.request
+import urllib.parse
+
+import json
+import base64
+import re
+
+from .common import connect_db, service_name
+
+def main():
+
+ #
+ # refresh tokens
+ #
+
+ (cn, cr) = connect_db()
+
+ def do_refresh_token(client_id, client_secret, refresh_token):
+ body = urllib.parse.urlencode({ 'grant_type': 'refresh_token', 'refresh_token': refresh_token }).encode('utf-8')
+ req = urllib.request.Request('https://www.reddit.com/api/v1/access_token', data=body, method='POST')
+ auth = base64.b64encode('{}:{}'.format(client_id, client_secret).encode('utf-8')).decode('utf-8')
+ req.add_header('Authorization', 'Basic {}'.format(auth))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ res = urllib.request.urlopen(req)
+ return json.load(res)
+
+ while True:
+ cr.execute("BEGIN")
+ cr.execute("SELECT authorization_id, client_id, client_secret, refresh_token FROM live_autojoin_admin_authorization JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_app USING (client_id) WHERE service_name = %s AND refresh_token IS NOT NULL AND expires < CURRENT_TIMESTAMP + '30min' LIMIT 1 FOR NO KEY UPDATE OF reddit_app_authorization", [service_name])
+ try:
+ [(authorization_id, client_id, client_secret, refresh_token)] = cr.fetchall()
+ except ValueError:
+ cn.rollback()
+ break
+ else:
+ print('refreshing token for authorization {}'.format(authorization_id))
+ new_token = do_refresh_token(client_id, client_secret, refresh_token)
+ cr.execute("UPDATE reddit_app_authorization SET access_token = %s, refresh_token = %s, scope = regexp_split_to_array(%s, ' ')::reddit_app_scope[], expires = CURRENT_TIMESTAMP + make_interval(secs => %s) WHERE id = %s", (new_token['access_token'], new_token['refresh_token'], new_token['scope'], new_token['expires_in'], authorization_id))
+ cn.commit()
+
+ #
+ # load PMs
+ #
+
+ def flatten_privatemessage_listing(json_):
+ assert json_['kind'] == 'Listing'
+ for p in json_['data']['children']:
+ assert p['kind'] == 't4'
+ yield p['data']
+ replies = p['data']['replies']
+ if replies:
+ assert replies['kind'] == 'Listing'
+ for r in replies['data']['children']:
+ assert p['kind'] == 't4'
+ yield p['data']
+
+ def privatemessage_to_tuple(data):
+ id_ = data['id']
+ parent_id = None
+ if data['parent_id'] is not None:
+ parent_id = data['parent_id'].split('_',maxsplit=1)[1]
+ assert int(parent_id, 36) == data['first_message']
+ created = data['created_utc']
+ sr = data['subreddit']
+ author = None if data['author'] == '[deleted]' else data['author']
+ if data['dest'].startswith('#'):
+ # modmail (destination is subreddit)
+ dest = None
+ dest_is_sr = True
+ else:
+ # destination is user
+ dest = data['dest']
+ dest_is_sr = False
+ subject = data['subject']
+ body = data['body']
+ return (id_, parent_id, created, sr, author, dest, dest_is_sr, subject, body)
+
+ cr.execute("BEGIN")
+ cr.execute("SELECT sr, access_token FROM live_autojoin_service JOIN live_autojoin_admin_authorization ON name = service_name JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_app ON reddit_app.client_id=reddit_app_authorization.client_id WHERE service_name = %s", [service_name])
+ [(sr, access_token)] = cr.fetchall()
+ cr.execute('CREATE TEMPORARY TABLE privatemessage_load (LIKE reddit_privatemessage INCLUDING INDEXES)')
+ after = None
+ while True:
+ if after:
+ rsc = 'https://oauth.reddit.com/message/messages?raw_json=1&limit=100&after={}'.format(after)
+ else:
+ rsc = 'https://oauth.reddit.com/message/messages?raw_json=1&limit=100'
+ req = urllib.request.Request(rsc, method='GET')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ res = json.load(urllib.request.urlopen(req))
+ tuples = (privatemessage_to_tuple(d) for d in flatten_privatemessage_listing(res))
+ cr.executemany('INSERT INTO privatemessage_load (id, parent_id, created, sr, author, dest, dest_is_sr, subject, body) VALUES (%s, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s)', tuples)
+ if 'after' in res:
+ after = res['after']
+ else:
+ break
+ cr.execute("INSERT INTO reddit_privatemessage (id, parent_id, created, sr, author, dest, dest_is_sr, subject, body) SELECT id, parent_id, created, sr, author, dest, dest_is_sr, subject, body FROM privatemessage_load ON CONFLICT ON CONSTRAINT reddit_privatemessage_pkey DO NOTHING")
+ cr.execute("DROP TABLE privatemessage_load")
+ cn.commit()
+
+ #
+ # build allowed thread list
+ # TODO look beyond first page
+ # TODO wiki config page for additional threads
+ #
+
+ def flake_from_url(url_):
+ result = re.fullmatch('https?://[a-z]+\.reddit\.com/live/([a-z0-9]+)/?(?:\?.*)?', url_)
+ return result and result.group(1)
+
+ def allowed_threads():
+ req = urllib.request.Request('https://oauth.reddit.com/r/livecounting/search?q=url%3Alive+site%3Areddit.com+self%3Ano&restrict_sr=on&include_over_18=on&sort=new&t=all&limit=100', method='GET')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ res = json.load(urllib.request.urlopen(req))
+ flakes = (flake_from_url(thing['data']['url']) for thing in res['data']['children'] if thing['data']['is_self'] is False)
+ return set((f for f in flakes if f))
+
+ cr.execute("BEGIN")
+ #cr.execute("DELETE FROM live_autojoin_allowed_event WHERE service_name = %s", (service_name,))
+ cr.executemany("INSERT INTO live_autojoin_allowed_event (service_name, event_flake) VALUES (%s, %s) ON CONFLICT (service_name, event_flake) DO NOTHING", ((service_name, f) for f in allowed_threads()))
+ cn.commit()
+
+ # accept admin invites
+
+ cr.execute("BEGIN")
+ cr.execute("SELECT id, body FROM reddit_privatemessage JOIN live_autojoin_service ON dest = admin_username WHERE author = 'reddit' AND NOT dest_is_sr AND parent_id IS NULL AND subject LIKE 'invitation to contribute to %%' AND NOT EXISTS (SELECT * FROM live_autojoin_admin_invite WHERE privatemessage_id = id AND name = %s)", (service_name,))
+ for (id_, body) in cr.fetchall():
+ match = re.search('/live/([a-z0-9]+)/contributors', body)
+ event_flake = match and match.group(1)
+ result = None
+ if event_flake:
+ body = urllib.parse.urlencode({ 'api_type': 'json' }).encode('utf-8')
+ req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/accept_contributor_invite'.format(event_flake), method='POST', data=body)
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ urllib.request.urlopen(req)
+ result = 'ok'
+ cr.execute("INSERT INTO live_autojoin_admin_invite (privatemessage_id, event_flake, result) VALUES (%s, %s, %s)", [id_, event_flake, result])
+ cn.commit()
+
+ # load contributor lists
+
+ cr.execute("BEGIN")
+ cr.execute("SELECT event_flake FROM live_autojoin_allowed_event WHERE service_name = %s", (service_name,))
+ cn.commit()
+ for (event_flake,) in cr.fetchall():
+ req = urllib.request.Request('https://oauth.reddit.com/live/{}/contributors'.format(event_flake), method='GET')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ contributors_lists = json.load(urllib.request.urlopen(req))
+ # endpoint returns two listings, contributors and contributor _invites_, in JSON sequence.
+ # if not a contributor, cannot see contributor invites.
+ # in that case, no JSON sequence--simply the single listing is returned--annoying.
+ if isinstance(contributors_lists, dict):
+ empty_listing = {'kind': 'UserList', 'data': {'children': []}}
+ contributors_lists = [contributors_lists, empty_listing]
+ assert all((l['kind'] == 'UserList' for l in contributors_lists))
+ contributors = ((event_flake, c['name'], '{{{}}}'.format(','.join(c['permissions'])), ty) for (ty, l) in zip(('contributor', 'invite'), contributors_lists) for c in l['data']['children'])
+ cr.execute("BEGIN")
+ cr.execute("DELETE FROM reddit_liveupdate_event_contributor WHERE event_flake = %s", (event_flake,))
+ cr.executemany("INSERT INTO reddit_liveupdate_event_contributor (event_flake, name, permissions, \"type\") VALUES (%s, %s, %s::text[], %s)", contributors)
+ cn.commit()
+ print(event_flake)
+
+ # TODO load subreddit bans (and moderators)
+ # TODO load modmail for joining requests
+
+ cn.close()
--- /dev/null
+import psycopg2
+import os
+
+service_name = os.environ.get('LIVEAUTOJOINSERVICE', 'autojoin')
+
+def connect_db():
+ cn = psycopg2.connect("service={}".format(service_name))
+ cr = cn.cursor()
+ return (cn, cr)
--- /dev/null
+# service.py - invite processing
+
+#- exchange authentication codes
+#- fetch authorization identity to populate ticket username
+#- invite users
+#- accept invites
+
+import select
+import urllib.parse
+import urllib.request
+import urllib.error
+import base64
+import json
+
+from .common import connect_db, service_name
+
+POLL_INTERVAL=3600
+
+def main():
+ (cn, cr) = connect_db()
+
+ cr.execute("LISTEN live_autojoin")
+
+ while True:
+ cn.poll()
+ cn.notifies.clear()
+ work = False
+ while True:
+ cr.execute("BEGIN")
+ cr.execute("SELECT client_id, client_secret, redirect_uri, id, code FROM live_autojoin_service JOIN reddit_app USING (client_id) JOIN live_autojoin_ticket ON name = service_name JOIN reddit_app_authorization_code ON id = authorization_code_id WHERE authorization_id IS NULL AND service_name = %s LIMIT 1 FOR UPDATE OF live_autojoin_ticket, reddit_app_authorization_code", (service_name,))
+ try:
+ [(client_id, client_secret, redirect_uri, authorization_code_id, code)] = cr.fetchall()
+ except ValueError:
+ break
+ else:
+ work = True
+ body = urllib.parse.urlencode({ 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri }).encode('utf-8')
+ req = urllib.request.Request('https://www.reddit.com/api/v1/access_token', data=body, method='POST')
+ auth = base64.b64encode('{}:{}'.format(client_id, client_secret).encode('utf-8')).decode('utf-8')
+ req.add_header('Authorization', 'Basic {}'.format(auth))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ try:
+ res = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 404:
+ res = {'error': 'invalid_grant'} # supposing 404 really means this
+ else:
+ raise
+ if 'error' in res:
+ if res['error'] == 'invalid_grant':
+ cr.execute("DELETE FROM reddit_app_authorization_code WHERE id = %s", (authorization_code_id,))
+ else:
+ raise ValueError("unrecognized error '{}'".format(res['error']))
+ else:
+ assert res['token_type'] == 'bearer'
+ cr.execute("WITH q1 AS (INSERT INTO reddit_app_authorization (client_id, access_token, scope, expires) VALUES (%s, %s, regexp_split_to_array(%s, ' ')::reddit_app_scope[], CURRENT_TIMESTAMP + make_interval(secs => %s)) RETURNING id) UPDATE reddit_app_authorization_code SET authorization_id = q1.id FROM q1 WHERE reddit_app_authorization_code.id = %s", (client_id, res['access_token'], res['scope'], res['expires_in'], authorization_code_id))
+ finally:
+ cn.commit()
+
+ while True:
+ cr.execute("BEGIN")
+ cr.execute("SELECT reddit_app_authorization.id, access_token, ticket FROM live_autojoin_ticket JOIN reddit_app_authorization_code ON reddit_app_authorization_code.id=authorization_code_id JOIN reddit_app_authorization ON reddit_app_authorization.id = authorization_id WHERE service_name = %s AND username IS NULL AND array_position(scope, 'identity') IS NOT NULL LIMIT 1 FOR UPDATE OF live_autojoin_ticket", (service_name,))
+ try:
+ [(authorization_id, access_token, ticket)] = cr.fetchall()
+ except ValueError:
+ break
+ else:
+ work = True
+ req = urllib.request.Request('https://oauth.reddit.com/api/v1/me', method='GET')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ try:
+ res = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 401:
+ cr.execute("DELETE FROM reddit_app_authorization WHERE id = %s", (authorization_id,))
+ else:
+ raise
+ else:
+ cr.execute("UPDATE live_autojoin_ticket SET username = %s WHERE service_name = %s AND ticket = %s", (res['name'], service_name, ticket))
+ finally:
+ cn.commit()
+
+ while True:
+ cr.execute("BEGIN")
+ cr.execute("SELECT access_token, ticket, event_flake, username FROM live_autojoin_ticket LEFT OUTER JOIN LATERAL (SELECT service_name, access_token FROM live_autojoin_admin_authorization JOIN reddit_app_authorization ON id=authorization_id WHERE service_name = live_autojoin_ticket.service_name LIMIT 1) q1 USING (service_name) WHERE service_name = %s AND username IS NOT NULL AND status IS NULL LIMIT 1 FOR NO KEY UPDATE OF live_autojoin_ticket", (service_name,))
+ try:
+ [(access_token, ticket, event, username)] = cr.fetchall()
+ except ValueError:
+ break
+ else:
+ work = True
+ if access_token is None:
+ result = 'noadmin'
+ else:
+ body = urllib.parse.urlencode({ 'api_type': 'json', 'name': username, 'permissions': '+update', 'type': 'liveupdate_contributor_invite' }).encode('utf-8')
+ req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/invite_contributor'.format(event), data=body, method='POST')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ try:
+ res = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 403:
+ result = 'noadmin'
+ else:
+ raise
+ else:
+ errors = [er[0] for er in res['json']['errors']]
+ if not errors:
+ result = 'invited'
+ elif errors == ['LIVEUPDATE_ALREADY_CONTRIBUTOR']:
+ result = 'already_contributor_or_invited'
+ else:
+ raise RuntimeError(str(errors))
+ if result == 'invited':
+ cr.execute("DELETE FROM reddit_liveupdate_event_contributor WHERE event_flake = %s AND name = %s", (event, username))
+ cr.execute("INSERT INTO reddit_liveupdate_event_contributor (event_flake, name, permissions, type) VALUES (%s, %s, liveupdate_permission_set '+update', 'invite') ON CONFLICT DO NOTHING", (event, username))
+ cr.execute("UPDATE live_autojoin_ticket SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE service_name = %s AND ticket = %s", (result, service_name, ticket))
+ finally:
+ cn.commit()
+
+ while True:
+ cr.execute("BEGIN")
+ cr.execute("SELECT reddit_app_authorization.id, access_token, ticket, event_flake, username, status FROM live_autojoin_ticket JOIN reddit_app_authorization_code ON reddit_app_authorization_code.id=authorization_code_id JOIN reddit_app_authorization ON reddit_app_authorization.id = authorization_id WHERE service_name = %s AND status IN ('invited', 'already_contributor_or_invited') AND array_position(scope, 'livemanage') IS NOT NULL LIMIT 1 FOR NO KEY UPDATE OF live_autojoin_ticket", (service_name,))
+ try:
+ [(authorization_id, access_token, ticket, event_flake, username, status)] = cr.fetchall()
+ except ValueError:
+ break
+ else:
+ work = True
+ body = urllib.parse.urlencode({ 'api_type': 'json' }).encode('utf-8')
+ req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/accept_contributor_invite'.format(event_flake), data=body, method='POST')
+ req.add_header('Authorization', 'Bearer {}'.format(access_token))
+ req.add_header('User-Agent', 'autojoin/0.1.0')
+ try:
+ res = json.load(urllib.request.urlopen(req))
+ except urllib.error.HTTPError as e:
+ if e.code == 401:
+ cr.execute("DELETE FROM reddit_app_authorization WHERE id = %s", (authorization_id,))
+ else:
+ raise
+ else:
+ errors = [er[0] for er in res['json']['errors']]
+ if not errors:
+ result = 'ok'
+ elif errors == ['LIVEUPDATE_NO_INVITE_FOUND']:
+ result = 'already_contributor' if status == 'already_contributor_or_invited' else None
+ else:
+ raise RuntimeError(str(errors))
+ if result == 'ok':
+ cr.execute("UPDATE reddit_liveupdate_event_contributor SET type = 'contributor' WHERE event_flake = %s AND name = %s", (event, username))
+ cr.execute("NOTIFY live_autojoin") # for announcements
+ cr.execute("UPDATE live_autojoin_ticket SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE service_name = %s AND ticket = %s", (result, service_name, ticket))
+ finally:
+ cn.commit()
+
+ cn.poll()
+ if work or len(cn.notifies) > 0:
+ continue
+
+ select.select([cn], [], [], POLL_INTERVAL)
--- /dev/null
+from flask import Flask, request, redirect, render_template, url_for
+
+import secrets
+import re
+import urllib.parse
+
+from ..common import connect_db
+
+DEFAULT_SERVICE = 'autojoin'
+
+app = Flask(__name__)
+
+def make_oauth_url(service_name, client_id, event, redirect_uri):
+ state = '{}:{}'.format(service_name, event)
+ scope = ' '.join(['identity','livemanage'])
+ qs = urllib.parse.urlencode({
+ 'client_id': client_id,
+ 'response_type': 'code',
+ 'state': state,
+ 'redirect_uri': redirect_uri,
+ 'scope': scope,
+ 'duration': 'temporary'
+ })
+ return 'https://www.reddit.com/api/v1/authorize?{}'.format(qs)
+
+def make_privatemessage_url(service_name, event):
+ if event and re.match('[a-z0-9]{10,}$', event):
+ body = '''I would like to join this thread: https://www.reddit.com/live/{1}
+
+(If you send this message with the following line intact, you will be invited automatically if possible)
+
+/autojoin service {0} event {1}'''.format(service_name, event)
+ elif event:
+ body = '''I would like to join this thread: {}'''.format(event)
+ else:
+ body = '''I would like to join this thread: (none)'''
+ assert re.match('[a-z0-9]+$', service_name)
+ qs = urllib.parse.urlencode({
+ 'to': '/r/livecounting',
+ 'subject': 'Please invite me',
+ 'message': body
+ })
+ return 'https://www.reddit.com/message/compose?{}'.format(qs)
+
+@app.route('/authorize')
+def authorize():
+ service_name = request.args.get('service', DEFAULT_SERVICE)
+ event = request.args.get('thread')
+ (cn, cr) = connect_db()
+ cr.execute("SELECT name, client_id, event_flake, redirect_uri FROM live_autojoin_allowed_event JOIN live_autojoin_service ON name = service_name JOIN reddit_app USING (client_id) WHERE service_name = %s AND event_flake = %s", (service_name, event))
+ try:
+ [(service_name, client_id, event, redirect_uri)] = cr.fetchall()
+ except ValueError:
+ privatemessage_url = make_privatemessage_url(service_name, event)
+ return render_template("error.html", privatemessage_url=privatemessage_url)
+ else:
+ return redirect(make_oauth_url(service_name, client_id, event, redirect_uri), code=303)
+ finally:
+ cn.close()
+
+@app.route('/invite')
+def invite():
+ authorization_code = request.args.get('code')
+ state = request.args.get('state')
+ (service_name, event_flake) = state.split(':')
+ ticket = secrets.token_urlsafe()
+ (cn, cr) = connect_db()
+ cr.execute("BEGIN")
+ cr.execute("WITH q1 AS (INSERT INTO reddit_app_authorization_code (state, code) VALUES (%s, %s) RETURNING id) INSERT INTO live_autojoin_ticket (service_name, ticket, issued_at, event_flake, authorization_code_id, status) SELECT %s, %s, CURRENT_TIMESTAMP, %s, id, NULL FROM q1", (state, authorization_code, service_name, ticket, event_flake))
+ cr.execute("NOTIFY live_autojoin")
+ cn.commit()
+ cn.close()
+ return redirect(url_for('ticket', service=service_name, ticket=ticket), code=303)
+
+@app.route('/ticket')
+def ticket():
+ service_name = request.args.get('service')
+ ticket = request.args.get('ticket')
+ (cn, cr) = connect_db()
+ cr.execute("SELECT event_flake, status FROM live_autojoin_ticket WHERE service_name = %s AND ticket = %s", (service_name, ticket))
+ try:
+ [(event, status)] = cr.fetchall()
+ except ValueError:
+ event = None
+ status = 'error'
+ cn.close()
+ if status in ('ok', 'already_contributor'):
+ return redirect('https://www.reddit.com/live/{}'.format(event), code=308)
+ elif status == 'error':
+ privatemessage_url = make_privatemessage_url(service_name, event)
+ return render_template("error.html", privatemessage_url=privatemessage_url)
+ else:
+ privatemessage_url = make_privatemessage_url(service_name, event)
+ return render_template("status.html", privatemessage_url=privatemessage_url)
+
+# TODO
+# - unallowed thread
+# - allowed but disabled thread
+# - authorization denied
+# - indication of progress/errors
+
+#- exchange authentication codes
+#- fetch authorization identity to populate ticket username
+#- invite users
+#- accept invites
--- /dev/null
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Autojoin: Error</title>
+ </head>
+ <body>
+ <h1>Error: {% block short %}unknown{% endblock %}</h1>
+ {% block long %}<p>Please <a href="{{ privatemessage_url }}">message the moderators</a> for assistance.<p>{% endblock %}
+ </body>
+</html>
--- /dev/null
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Autojoin: Pending</title>
+ <meta http-equiv="refresh" content="5">
+ </head>
+ <body>
+ <h1>Inviting you to this thread</h1>
+ <p>This should only take a few seconds. If many users are trying to join right now, you might have to wait longer.</p>
+ <p>You will be redirected back to the live thread once you have been added.</p>
+ <p>If this message persists, please <a href="{{ privatemessage_url }}">message the moderators</a> for assistance.</p>
+ </body>
+</html>
--- /dev/null
+[build-system]
+requires = ["setuptools>=42", "wheel"]
+build-backend = "setuptools.build_meta"
--- /dev/null
+[metadata]
+name = joinbot
+version = 0.1.0
+
+[options]
+packages =
+ joinbot
+ joinbot.web
+install_requires =
+ psycopg2 ~= 2.8
+ flask ~= 2.2
+include_package_data = True
+
+[options.package_data]
+joinbot.web =
+ templates/*.html
+
+[options.entry_points]
+console_scripts =
+ joinbot-service = joinbot.service:main
+ joinbot-background = joinbot.background:main
+ joinbot-announce = joinbot.announce:main
--- /dev/null
+import setuptools
+
+setuptools.setup()
+++ /dev/null
-#Architecture
-
-background.py executed periodically (every 25 min).
-service.py is a constantly-running service, uses postgresql asynchronous notifications to detect when there is work to do.
-See file headers of the above to learn about their responsibilities.
-background.py and service.py act on a "service"; this is sort of a multi-tenant capability. The default service name is "autojoin"; to use a different service name set the environment variable LIVEAUTOJOINSERVICE
-service name is also used to find the database via "~/.pg\_service.conf"
-
-web.py is the Flask (wsgi) web application which handles /authorize, /invite, /ticket.
-
-flow:
-1. user clicks join link (/authorize) in thread sidebar
-2. app validates `thread` argument
-3. app redirects to reddit OAuth2 /api/v1/authorize, state is <service>:<event>
-4. user clicks 'Allow'
-5. reddit redirects to /invite
-6. app creates ticket linked to authorization code and `NOTIFY live_autojoin`
-7. app redirects to /ticket (templates/status.html - auto-refreshing)
-7. service.py retrieves authorization code to create authorization
-8. service.py fills in ticket username using authorization
-9. service.py invites user using admin authorization
-10. service.py accepts invite using authorization
-11. auto-refreshing /ticket starts redirecting back to the live event
-
-# Roadmap/wishlist
-- allowed events: load more than 1 page of search results
-- allowed events: search linkpost self text and "thread directory" wiki page for links, not just linkpost links
-- when accepting admin invite, update reddit\_liveupdate\_event\_contributor table like when inviting users
-- don't mention users in announcements
-- respect sr bans
-- check whether we will succeed (have permission, have scope, not too many outstanding invites) before adding ticket
-- configurable (wiki page) allow/deny list for events
-- invite sr moderators with extra permissions
-- handle LIVEUPDATE\_TOO\_MANY\_INVITES (or whatever)
-- actually report status/errors on /ticket
-- handle no admin permission in `invite\_contributor`
-- ticket processing rate limit (max 3 tickets in 60 seconds) - and if waiting on ratelimit, say so in status.html
-- read modmail (and PMs?) and create tickets for messages with "inviteme" commands
-- sync /live/mine (or allowed threads by\_id) to reddit\_liveupdate\_event table (background.py)
-- include event title in error page's /message/compose link template
-- after accepting admin invite, send PM to event creator (we don't know who created a thread, but we could find out who posted it in /r/livecounting and check if they have `settings` permission) with instructions for adding join link
-- remove everyone's "close" permission?
-- should be open-sourced, but needs deployment documentation and database schema script and pg\_reddit open-sourced first
-
-- find and fix DoS opportunities (something invalid submitted through web.py crashes service.py): better now.
-- send "/u/username has joined this thread" updates: done.
-- sync event contributor _invites_, not just contributors: done
-- decide how to handle LIVEUPDATE\_ALREADY\_CONTRIBUTOR when `invite\_contributor`ing: done
-- speculatively update invite table after `invite\_contributor`, speculatively update contributor table after `accept\_contributor\_invite`: done
+++ /dev/null
-# announce.py - announce new users
-
-import select
-import urllib.parse
-import urllib.request
-import urllib.error
-import base64
-import json
-
-from common import connect_db, service_name
-
-POLL_INTERVAL=3600
-
-(cn, cr) = connect_db()
-
-cr.execute("LISTEN live_autojoin")
-
-while True:
- cn.poll()
- cn.notifies.clear()
- work = False
- while True:
- cr.execute("BEGIN")
- # - invited less than 1 minute ago
- # - no announcement of same user in same event less than 30 days ago
- # - user has update permission (not banned)
- # - admin has update permission
- # - admin authorization has submit scope
- cr.execute("""SELECT access_token, admin_username, ticket.event_flake, username FROM live_autojoin_ticket AS ticket JOIN live_autojoin_service ON service_name=name JOIN live_autojoin_admin_authorization USING (service_name) JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_liveupdate_event_contributor AS userrel ON userrel."type"='contributor' AND ticket.event_flake=userrel.event_flake AND username=userrel.name JOIN reddit_liveupdate_event_contributor AS adminrel ON adminrel."type"='contributor' AND ticket.event_flake = adminrel.event_flake AND admin_username = adminrel.name WHERE service_name = %s AND status = 'ok' AND updated_at + '1m' > CURRENT_TIMESTAMP AND NOT EXISTS (SELECT * FROM live_autojoin_announcement WHERE for_username = username AND event_flake = ticket.event_flake AND at + '30d' > updated_at) AND has_permission('update', userrel.permissions) AND has_permission('update', adminrel.permissions) AND array_position(scope, 'submit') IS NOT NULL LIMIT 1""", (service_name,))
- try:
- [(access_token, admin_username, event_flake, username)] = cr.fetchall()
- except ValueError:
- break
- else:
- work = True
- escaped_username = username.replace('_', '\\_')
- usertext = f'*[\\/u\\/{ escaped_username }](/user/{ escaped_username }) has joined this thread*'
- body = urllib.parse.urlencode({ 'api_type': 'json', 'body': usertext }).encode('utf-8')
- req = urllib.request.Request(f'https://oauth.reddit.com/api/live/{event_flake}/update', data=body, method='POST')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- try:
- res = json.load(urllib.request.urlopen(req))
- except urllib.error.HTTPError as e:
- if e.code == 403:
- admin_username = None
- usertext = None
- else:
- raise
- cr.execute("INSERT INTO live_autojoin_announcement (at, event_flake, for_username, author, body) VALUES (CURRENT_TIMESTAMP, %s, %s, %s, %s)", (event_flake, username, admin_username, usertext))
- finally:
- cn.commit()
-
- cn.poll()
- if work or len(cn.notifies) > 0:
- continue
-
- select.select([cn], [], [], POLL_INTERVAL)
+++ /dev/null
-# background.py - jobs that are not time-critical.
-# - refresh access tokens
-# - sync PMs
-# - sync allowed threads (subreddit links)
-# - accept admin invites
-# - sync contributor lists
-# - TODO sync subreddit bans and moderators
-# - TODO sync modmail (to receive joining requests to modmail)
-
-# roadmap
-# - for PM, allowed threads, and modmail sync, create a lightweight version to run frequently to handle new events quickly--distinct from the more expensive "full sync" that is implemented here.
-
-import urllib.request
-import urllib.parse
-
-import json
-import base64
-import re
-
-from common import connect_db, service_name
-
-#
-# refresh tokens
-#
-
-(cn, cr) = connect_db()
-
-def do_refresh_token(client_id, client_secret, refresh_token):
- body = urllib.parse.urlencode({ 'grant_type': 'refresh_token', 'refresh_token': refresh_token }).encode('utf-8')
- req = urllib.request.Request('https://www.reddit.com/api/v1/access_token', data=body, method='POST')
- auth = base64.b64encode('{}:{}'.format(client_id, client_secret).encode('utf-8')).decode('utf-8')
- req.add_header('Authorization', 'Basic {}'.format(auth))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- res = urllib.request.urlopen(req)
- return json.load(res)
-
-while True:
- cr.execute("BEGIN")
- cr.execute("SELECT authorization_id, client_id, client_secret, refresh_token FROM live_autojoin_admin_authorization JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_app USING (client_id) WHERE service_name = %s AND refresh_token IS NOT NULL AND expires < CURRENT_TIMESTAMP + '30min' LIMIT 1 FOR NO KEY UPDATE OF reddit_app_authorization", [service_name])
- try:
- [(authorization_id, client_id, client_secret, refresh_token)] = cr.fetchall()
- except ValueError:
- cn.rollback()
- break
- else:
- print('refreshing token for authorization {}'.format(authorization_id))
- new_token = do_refresh_token(client_id, client_secret, refresh_token)
- cr.execute("UPDATE reddit_app_authorization SET access_token = %s, refresh_token = %s, scope = regexp_split_to_array(%s, ' ')::reddit_app_scope[], expires = CURRENT_TIMESTAMP + make_interval(secs => %s) WHERE id = %s", (new_token['access_token'], new_token['refresh_token'], new_token['scope'], new_token['expires_in'], authorization_id))
- cn.commit()
-
-#
-# load PMs
-#
-
-def flatten_privatemessage_listing(json_):
- assert json_['kind'] == 'Listing'
- for p in json_['data']['children']:
- assert p['kind'] == 't4'
- yield p['data']
- replies = p['data']['replies']
- if replies:
- assert replies['kind'] == 'Listing'
- for r in replies['data']['children']:
- assert p['kind'] == 't4'
- yield p['data']
-
-def privatemessage_to_tuple(data):
- id_ = data['id']
- parent_id = None
- if data['parent_id'] is not None:
- parent_id = data['parent_id'].split('_',maxsplit=1)[1]
- assert int(parent_id, 36) == data['first_message']
- created = data['created_utc']
- sr = data['subreddit']
- author = None if data['author'] == '[deleted]' else data['author']
- if data['dest'].startswith('#'):
- # modmail (destination is subreddit)
- dest = None
- dest_is_sr = True
- else:
- # destination is user
- dest = data['dest']
- dest_is_sr = False
- subject = data['subject']
- body = data['body']
- return (id_, parent_id, created, sr, author, dest, dest_is_sr, subject, body)
-
-cr.execute("BEGIN")
-cr.execute("SELECT sr, access_token FROM live_autojoin_service JOIN live_autojoin_admin_authorization ON name = service_name JOIN reddit_app_authorization ON authorization_id=id JOIN reddit_app ON reddit_app.client_id=reddit_app_authorization.client_id WHERE service_name = %s", [service_name])
-[(sr, access_token)] = cr.fetchall()
-cr.execute('CREATE TEMPORARY TABLE privatemessage_load (LIKE reddit_privatemessage INCLUDING INDEXES)')
-after = None
-while True:
- if after:
- rsc = 'https://oauth.reddit.com/message/messages?raw_json=1&limit=100&after={}'.format(after)
- else:
- rsc = 'https://oauth.reddit.com/message/messages?raw_json=1&limit=100'
- req = urllib.request.Request(rsc, method='GET')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- res = json.load(urllib.request.urlopen(req))
- tuples = (privatemessage_to_tuple(d) for d in flatten_privatemessage_listing(res))
- cr.executemany('INSERT INTO privatemessage_load (id, parent_id, created, sr, author, dest, dest_is_sr, subject, body) VALUES (%s, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s)', tuples)
- if 'after' in res:
- after = res['after']
- else:
- break
-cr.execute("INSERT INTO reddit_privatemessage (id, parent_id, created, sr, author, dest, dest_is_sr, subject, body) SELECT id, parent_id, created, sr, author, dest, dest_is_sr, subject, body FROM privatemessage_load ON CONFLICT ON CONSTRAINT reddit_privatemessage_pkey DO NOTHING")
-cr.execute("DROP TABLE privatemessage_load")
-cn.commit()
-
-#
-# build allowed thread list
-# TODO look beyond first page
-# TODO wiki config page for additional threads
-#
-
-def flake_from_url(url_):
- result = re.fullmatch('https?://[a-z]+\.reddit\.com/live/([a-z0-9]+)/?(?:\?.*)?', url_)
- return result and result.group(1)
-
-def allowed_threads():
- req = urllib.request.Request('https://oauth.reddit.com/r/livecounting/search?q=url%3Alive+site%3Areddit.com+self%3Ano&restrict_sr=on&include_over_18=on&sort=new&t=all&limit=100', method='GET')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- res = json.load(urllib.request.urlopen(req))
- flakes = (flake_from_url(thing['data']['url']) for thing in res['data']['children'] if thing['data']['is_self'] is False)
- return set((f for f in flakes if f))
-
-cr.execute("BEGIN")
-#cr.execute("DELETE FROM live_autojoin_allowed_event WHERE service_name = %s", (service_name,))
-cr.executemany("INSERT INTO live_autojoin_allowed_event (service_name, event_flake) VALUES (%s, %s) ON CONFLICT (service_name, event_flake) DO NOTHING", ((service_name, f) for f in allowed_threads()))
-cn.commit()
-
-# accept admin invites
-
-cr.execute("BEGIN")
-cr.execute("SELECT id, body FROM reddit_privatemessage JOIN live_autojoin_service ON dest = admin_username WHERE author = 'reddit' AND NOT dest_is_sr AND parent_id IS NULL AND subject LIKE 'invitation to contribute to %%' AND NOT EXISTS (SELECT * FROM live_autojoin_admin_invite WHERE privatemessage_id = id AND name = %s)", (service_name,))
-for (id_, body) in cr.fetchall():
- match = re.search('/live/([a-z0-9]+)/contributors', body)
- event_flake = match and match.group(1)
- result = None
- if event_flake:
- body = urllib.parse.urlencode({ 'api_type': 'json' }).encode('utf-8')
- req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/accept_contributor_invite'.format(event_flake), method='POST', data=body)
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- urllib.request.urlopen(req)
- result = 'ok'
- cr.execute("INSERT INTO live_autojoin_admin_invite (privatemessage_id, event_flake, result) VALUES (%s, %s, %s)", [id_, event_flake, result])
-cn.commit()
-
-# load contributor lists
-
-cr.execute("BEGIN")
-cr.execute("SELECT event_flake FROM live_autojoin_allowed_event WHERE service_name = %s", (service_name,))
-cn.commit()
-for (event_flake,) in cr.fetchall():
- req = urllib.request.Request('https://oauth.reddit.com/live/{}/contributors'.format(event_flake), method='GET')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- contributors_lists = json.load(urllib.request.urlopen(req))
- # endpoint returns two listings, contributors and contributor _invites_, in JSON sequence.
- # if not a contributor, cannot see contributor invites.
- # in that case, no JSON sequence--simply the single listing is returned--annoying.
- if isinstance(contributors_lists, dict):
- empty_listing = {'kind': 'UserList', 'data': {'children': []}}
- contributors_lists = [contributors_lists, empty_listing]
- assert all((l['kind'] == 'UserList' for l in contributors_lists))
- contributors = ((event_flake, c['name'], '{{{}}}'.format(','.join(c['permissions'])), ty) for (ty, l) in zip(('contributor', 'invite'), contributors_lists) for c in l['data']['children'])
- cr.execute("BEGIN")
- cr.execute("DELETE FROM reddit_liveupdate_event_contributor WHERE event_flake = %s", (event_flake,))
- cr.executemany("INSERT INTO reddit_liveupdate_event_contributor (event_flake, name, permissions, \"type\") VALUES (%s, %s, %s::text[], %s)", contributors)
- cn.commit()
- print(event_flake)
-
-# TODO load subreddit bans (and moderators)
-# TODO load modmail for joining requests
-
-cn.close()
+++ /dev/null
-import psycopg2
-import os
-
-service_name = os.environ.get('LIVEAUTOJOINSERVICE', 'autojoin')
-
-def connect_db():
- cn = psycopg2.connect("service={}".format(service_name))
- cr = cn.cursor()
- return (cn, cr)
+++ /dev/null
-#!/bin/bash
-
-echo "Ok, but why aren't you using a package manager"
-
-tar cz {cert,privkey}.pem {background,service,announce,common,web}.py templates systemd | ssh root@hatnd.acorn.pw tar xzC /opt/live-autojoin
+++ /dev/null
-# service.py - invite processing
-
-#- exchange authentication codes
-#- fetch authorization identity to populate ticket username
-#- invite users
-#- accept invites
-
-import select
-import urllib.parse
-import urllib.request
-import urllib.error
-import base64
-import json
-
-from common import connect_db, service_name
-
-POLL_INTERVAL=3600
-
-(cn, cr) = connect_db()
-
-cr.execute("LISTEN live_autojoin")
-
-while True:
- cn.poll()
- cn.notifies.clear()
- work = False
- while True:
- cr.execute("BEGIN")
- cr.execute("SELECT client_id, client_secret, redirect_uri, id, code FROM live_autojoin_service JOIN reddit_app USING (client_id) JOIN live_autojoin_ticket ON name = service_name JOIN reddit_app_authorization_code ON id = authorization_code_id WHERE authorization_id IS NULL AND service_name = %s LIMIT 1 FOR UPDATE OF live_autojoin_ticket, reddit_app_authorization_code", (service_name,))
- try:
- [(client_id, client_secret, redirect_uri, authorization_code_id, code)] = cr.fetchall()
- except ValueError:
- break
- else:
- work = True
- body = urllib.parse.urlencode({ 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri }).encode('utf-8')
- req = urllib.request.Request('https://www.reddit.com/api/v1/access_token', data=body, method='POST')
- auth = base64.b64encode('{}:{}'.format(client_id, client_secret).encode('utf-8')).decode('utf-8')
- req.add_header('Authorization', 'Basic {}'.format(auth))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- res = json.load(urllib.request.urlopen(req))
- if 'error' in res:
- if res['error'] == 'invalid_grant':
- cr.execute("DELETE FROM reddit_app_authorization_code WHERE id = %s", (authorization_code_id,))
- else:
- raise ValueError("unrecognized error '{}'".format(res['error']))
- else:
- assert res['token_type'] == 'bearer'
- cr.execute("WITH q1 AS (INSERT INTO reddit_app_authorization (client_id, access_token, scope, expires) VALUES (%s, %s, regexp_split_to_array(%s, ' ')::reddit_app_scope[], CURRENT_TIMESTAMP + make_interval(secs => %s)) RETURNING id) UPDATE reddit_app_authorization_code SET authorization_id = q1.id FROM q1 WHERE reddit_app_authorization_code.id = %s", (client_id, res['access_token'], res['scope'], res['expires_in'], authorization_code_id))
- finally:
- cn.commit()
-
- while True:
- cr.execute("BEGIN")
- cr.execute("SELECT reddit_app_authorization.id, access_token, ticket FROM live_autojoin_ticket JOIN reddit_app_authorization_code ON reddit_app_authorization_code.id=authorization_code_id JOIN reddit_app_authorization ON reddit_app_authorization.id = authorization_id WHERE service_name = %s AND username IS NULL AND array_position(scope, 'identity') IS NOT NULL LIMIT 1 FOR UPDATE OF live_autojoin_ticket", (service_name,))
- try:
- [(authorization_id, access_token, ticket)] = cr.fetchall()
- except ValueError:
- break
- else:
- work = True
- req = urllib.request.Request('https://oauth.reddit.com/api/v1/me', method='GET')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- try:
- res = json.load(urllib.request.urlopen(req))
- except urllib.error.HTTPError as e:
- if e.code == 401:
- cr.execute("DELETE FROM reddit_app_authorization WHERE id = %s", (authorization_id,))
- else:
- raise
- else:
- cr.execute("UPDATE live_autojoin_ticket SET username = %s WHERE service_name = %s AND ticket = %s", (res['name'], service_name, ticket))
- finally:
- cn.commit()
-
- while True:
- cr.execute("BEGIN")
- cr.execute("SELECT access_token, ticket, event_flake, username FROM live_autojoin_ticket LEFT OUTER JOIN LATERAL (SELECT service_name, access_token FROM live_autojoin_admin_authorization JOIN reddit_app_authorization ON id=authorization_id WHERE service_name = live_autojoin_ticket.service_name LIMIT 1) q1 USING (service_name) WHERE service_name = %s AND username IS NOT NULL AND status IS NULL LIMIT 1 FOR NO KEY UPDATE OF live_autojoin_ticket", (service_name,))
- try:
- [(access_token, ticket, event, username)] = cr.fetchall()
- except ValueError:
- break
- else:
- work = True
- if access_token is None:
- result = 'noadmin'
- else:
- body = urllib.parse.urlencode({ 'api_type': 'json', 'name': username, 'permissions': '+update', 'type': 'liveupdate_contributor_invite' }).encode('utf-8')
- req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/invite_contributor'.format(event), data=body, method='POST')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- try:
- res = json.load(urllib.request.urlopen(req))
- except urllib.error.HTTPError as e:
- if e.code == 403:
- result = 'noadmin'
- else:
- raise
- else:
- errors = [er[0] for er in res['json']['errors']]
- if not errors:
- result = 'invited'
- elif errors == ['LIVEUPDATE_ALREADY_CONTRIBUTOR']:
- result = 'already_contributor_or_invited'
- else:
- raise RuntimeError(str(errors))
- if result == 'invited':
- cr.execute("DELETE FROM reddit_liveupdate_event_contributor WHERE event_flake = %s AND name = %s", (event, username))
- cr.execute("INSERT INTO reddit_liveupdate_event_contributor (event_flake, name, permissions, type) VALUES (%s, %s, liveupdate_permission_set '+update', 'invite') ON CONFLICT DO NOTHING", (event, username))
- cr.execute("UPDATE live_autojoin_ticket SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE service_name = %s AND ticket = %s", (result, service_name, ticket))
- finally:
- cn.commit()
-
- while True:
- cr.execute("BEGIN")
- cr.execute("SELECT reddit_app_authorization.id, access_token, ticket, event_flake, username, status FROM live_autojoin_ticket JOIN reddit_app_authorization_code ON reddit_app_authorization_code.id=authorization_code_id JOIN reddit_app_authorization ON reddit_app_authorization.id = authorization_id WHERE service_name = %s AND status IN ('invited', 'already_contributor_or_invited') AND array_position(scope, 'livemanage') IS NOT NULL LIMIT 1 FOR NO KEY UPDATE OF live_autojoin_ticket", (service_name,))
- try:
- [(authorization_id, access_token, ticket, event_flake, username, status)] = cr.fetchall()
- except ValueError:
- break
- else:
- work = True
- body = urllib.parse.urlencode({ 'api_type': 'json' }).encode('utf-8')
- req = urllib.request.Request('https://oauth.reddit.com/api/live/{}/accept_contributor_invite'.format(event_flake), data=body, method='POST')
- req.add_header('Authorization', 'Bearer {}'.format(access_token))
- req.add_header('User-Agent', 'autojoin/0.1.0')
- try:
- res = json.load(urllib.request.urlopen(req))
- except urllib.error.HTTPError as e:
- if e.code == 401:
- cr.execute("DELETE FROM reddit_app_authorization WHERE id = %s", (authorization_id,))
- else:
- raise
- else:
- errors = [er[0] for er in res['json']['errors']]
- if not errors:
- result = 'ok'
- elif errors == ['LIVEUPDATE_NO_INVITE_FOUND']:
- result = 'already_contributor' if status == 'already_contributor_or_invited' else None
- else:
- raise RuntimeError(str(errors))
- if result == 'ok':
- cr.execute("UPDATE reddit_liveupdate_event_contributor SET type = 'contributor' WHERE event_flake = %s AND name = %s", (event, username))
- cr.execute("NOTIFY live_autojoin") # for announcements
- cr.execute("UPDATE live_autojoin_ticket SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE service_name = %s AND ticket = %s", (result, service_name, ticket))
- finally:
- cn.commit()
-
- cn.poll()
- if work or len(cn.notifies) > 0:
- continue
-
- select.select([cn], [], [], POLL_INTERVAL)
+++ /dev/null
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=simple
-ExecStart=python3 announce.py
-Restart=always
+++ /dev/null
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=oneshot
-ExecStart=python3 background.py
+++ /dev/null
-[Timer]
-OnActiveSec=0
-OnUnitActiveSec=25min
+++ /dev/null
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=simple
-ExecStart=python3 service.py
-Restart=always
+++ /dev/null
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=SCRIPT_NAME=/autojoin LIVEAUTOJOINSERVICE=autojoin
-Type=simple # `notify` if gunicorn >= 20
-ExecStart=/usr/bin/gunicorn3 web:app
+++ /dev/null
-[Socket]
-ListenStream=/tmp/live-autojoin.socket
-SocketUser=www-data
-SocketGroup=www-data
+++ /dev/null
-[Unit]
-Requires=live-autojoin-cron.timer live-autojoin-service.service live-autojoin-web.socket live-autojoin-announce.service
-
-[Install]
-WantedBy=multi-user.target
+++ /dev/null
-<!DOCTYPE html>
-<html>
- <head>
- <title>Autojoin: Error</title>
- </head>
- <body>
- <h1>Error: {% block short %}unknown{% endblock %}</h1>
- {% block long %}<p>Please <a href="{{ privatemessage_url }}">message the moderators</a> for assistance.<p>{% endblock %}
- </body>
-</html>
+++ /dev/null
-<!DOCTYPE html>
-<html>
- <head>
- <title>Autojoin: Pending</title>
- <meta http-equiv="refresh" content="5">
- </head>
- <body>
- <h1>Inviting you to this thread</h1>
- <p>This should only take a few seconds. If many users are trying to join right now, you might have to wait longer.</p>
- <p>You will be redirected back to the live thread once you have been added.</p>
- <p>If this message persists, please <a href="{{ privatemessage_url }}">message the moderators</a> for assistance.</p>
- </body>
-</html>
+++ /dev/null
-from flask import Flask, request, redirect, render_template, url_for
-
-import secrets
-import re
-import urllib.parse
-
-from common import connect_db
-
-DEFAULT_SERVICE = 'autojoin'
-
-app = Flask(__name__)
-
-def make_oauth_url(service_name, client_id, event, redirect_uri):
- state = '{}:{}'.format(service_name, event)
- scope = ' '.join(['identity','livemanage'])
- qs = urllib.parse.urlencode({
- 'client_id': client_id,
- 'response_type': 'code',
- 'state': state,
- 'redirect_uri': redirect_uri,
- 'scope': scope,
- 'duration': 'temporary'
- })
- return 'https://www.reddit.com/api/v1/authorize?{}'.format(qs)
-
-def make_privatemessage_url(service_name, event):
- if event and re.match('[a-z0-9]{10,}$', event):
- body = '''I would like to join this thread: https://www.reddit.com/live/{1}
-
-(If you send this message with the following line intact, you will be invited automatically if possible)
-
-/autojoin service {0} event {1}'''.format(service_name, event)
- elif event:
- body = '''I would like to join this thread: {}'''.format(event)
- else:
- body = '''I would like to join this thread: (none)'''
- assert re.match('[a-z0-9]+$', service_name)
- qs = urllib.parse.urlencode({
- 'to': '/r/livecounting',
- 'subject': 'Please invite me',
- 'message': body
- })
- return 'https://www.reddit.com/message/compose?{}'.format(qs)
-
-@app.route('/authorize')
-def authorize():
- service_name = request.args.get('service', DEFAULT_SERVICE)
- event = request.args.get('thread')
- (cn, cr) = connect_db()
- cr.execute("SELECT name, client_id, event_flake, redirect_uri FROM live_autojoin_allowed_event JOIN live_autojoin_service ON name = service_name JOIN reddit_app USING (client_id) WHERE service_name = %s AND event_flake = %s", (service_name, event))
- try:
- [(service_name, client_id, event, redirect_uri)] = cr.fetchall()
- except ValueError:
- privatemessage_url = make_privatemessage_url(service_name, event)
- return render_template("error.html", privatemessage_url=privatemessage_url)
- else:
- return redirect(make_oauth_url(service_name, client_id, event, redirect_uri), code=303)
- finally:
- cn.close()
-
-@app.route('/invite')
-def invite():
- authorization_code = request.args.get('code')
- state = request.args.get('state')
- (service_name, event_flake) = state.split(':')
- ticket = secrets.token_urlsafe()
- (cn, cr) = connect_db()
- cr.execute("BEGIN")
- cr.execute("WITH q1 AS (INSERT INTO reddit_app_authorization_code (state, code) VALUES (%s, %s) RETURNING id) INSERT INTO live_autojoin_ticket (service_name, ticket, issued_at, event_flake, authorization_code_id, status) SELECT %s, %s, CURRENT_TIMESTAMP, %s, id, NULL FROM q1", (state, authorization_code, service_name, ticket, event_flake))
- cr.execute("NOTIFY live_autojoin")
- cn.commit()
- cn.close()
- return redirect(url_for('ticket', service=service_name, ticket=ticket), code=303)
-
-@app.route('/ticket')
-def ticket():
- service_name = request.args.get('service')
- ticket = request.args.get('ticket')
- (cn, cr) = connect_db()
- cr.execute("SELECT event_flake, status FROM live_autojoin_ticket WHERE service_name = %s AND ticket = %s", (service_name, ticket))
- try:
- [(event, status)] = cr.fetchall()
- except ValueError:
- event = None
- status = 'error'
- cn.close()
- if status in ('ok', 'already_contributor'):
- return redirect('https://www.reddit.com/live/{}'.format(event), code=308)
- elif status == 'error':
- privatemessage_url = make_privatemessage_url(service_name, event)
- return render_template("error.html", privatemessage_url=privatemessage_url)
- else:
- privatemessage_url = make_privatemessage_url(service_name, event)
- return render_template("status.html", privatemessage_url=privatemessage_url)
-
-# TODO
-# - unallowed thread
-# - allowed but disabled thread
-# - authorization denied
-# - indication of progress/errors
-
-#- exchange authentication codes
-#- fetch authorization identity to populate ticket username
-#- invite users
-#- accept invites