joinbot build/packaging
authorAndrea Cornell <anders@acorn.pw>
Wed, 2 Nov 2022 01:05:49 +0000 (21:05 -0400)
committerAndrea Cornell <anders@acorn.pw>
Wed, 2 Nov 2022 01:22:49 +0000 (21:22 -0400)
29 files changed:
joinbot/.gitignore [new file with mode: 0644]
joinbot/README.md [new file with mode: 0644]
joinbot/deploy [new file with mode: 0755]
joinbot/joinbot/announce.py [new file with mode: 0644]
joinbot/joinbot/background.py [new file with mode: 0644]
joinbot/joinbot/common.py [new file with mode: 0644]
joinbot/joinbot/service.py [new file with mode: 0644]
joinbot/joinbot/web/__init__.py [new file with mode: 0644]
joinbot/joinbot/web/templates/error.html [new file with mode: 0644]
joinbot/joinbot/web/templates/status.html [new file with mode: 0644]
joinbot/pyproject.toml [new file with mode: 0644]
joinbot/setup.cfg [new file with mode: 0644]
joinbot/setup.py [new file with mode: 0644]
live-autojoin/README.md [deleted file]
live-autojoin/announce.py [deleted file]
live-autojoin/background.py [deleted file]
live-autojoin/common.py [deleted file]
live-autojoin/deploy [deleted file]
live-autojoin/service.py [deleted file]
live-autojoin/systemd/live-autojoin-announce.service [deleted file]
live-autojoin/systemd/live-autojoin-cron.service [deleted file]
live-autojoin/systemd/live-autojoin-cron.timer [deleted file]
live-autojoin/systemd/live-autojoin-service.service [deleted file]
live-autojoin/systemd/live-autojoin-web.service [deleted file]
live-autojoin/systemd/live-autojoin-web.socket [deleted file]
live-autojoin/systemd/live-autojoin.target [deleted file]
live-autojoin/templates/error.html [deleted file]
live-autojoin/templates/status.html [deleted file]
live-autojoin/web.py [deleted file]

diff --git a/joinbot/.gitignore b/joinbot/.gitignore
new file mode 100644 (file)
index 0000000..edd9d60
--- /dev/null
@@ -0,0 +1,2 @@
+build/
+dist/
diff --git a/joinbot/README.md b/joinbot/README.md
new file mode 100644 (file)
index 0000000..3af2e1d
--- /dev/null
@@ -0,0 +1,49 @@
+#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
diff --git a/joinbot/deploy b/joinbot/deploy
new file mode 100755 (executable)
index 0000000..02fd157
--- /dev/null
@@ -0,0 +1,7 @@
+#!/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
diff --git a/joinbot/joinbot/announce.py b/joinbot/joinbot/announce.py
new file mode 100644 (file)
index 0000000..0953b1c
--- /dev/null
@@ -0,0 +1,59 @@
+# 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)
diff --git a/joinbot/joinbot/background.py b/joinbot/joinbot/background.py
new file mode 100644 (file)
index 0000000..abfe534
--- /dev/null
@@ -0,0 +1,182 @@
+# 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()
diff --git a/joinbot/joinbot/common.py b/joinbot/joinbot/common.py
new file mode 100644 (file)
index 0000000..0e032d3
--- /dev/null
@@ -0,0 +1,9 @@
+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)
diff --git a/joinbot/joinbot/service.py b/joinbot/joinbot/service.py
new file mode 100644 (file)
index 0000000..8246ead
--- /dev/null
@@ -0,0 +1,161 @@
+# 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)
diff --git a/joinbot/joinbot/web/__init__.py b/joinbot/joinbot/web/__init__.py
new file mode 100644 (file)
index 0000000..6d0f74f
--- /dev/null
@@ -0,0 +1,105 @@
+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
diff --git a/joinbot/joinbot/web/templates/error.html b/joinbot/joinbot/web/templates/error.html
new file mode 100644 (file)
index 0000000..d393326
--- /dev/null
@@ -0,0 +1,10 @@
+<!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>
diff --git a/joinbot/joinbot/web/templates/status.html b/joinbot/joinbot/web/templates/status.html
new file mode 100644 (file)
index 0000000..dd87bea
--- /dev/null
@@ -0,0 +1,13 @@
+<!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>
diff --git a/joinbot/pyproject.toml b/joinbot/pyproject.toml
new file mode 100644 (file)
index 0000000..8fe2f47
--- /dev/null
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=42", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/joinbot/setup.cfg b/joinbot/setup.cfg
new file mode 100644 (file)
index 0000000..f81d928
--- /dev/null
@@ -0,0 +1,22 @@
+[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
diff --git a/joinbot/setup.py b/joinbot/setup.py
new file mode 100644 (file)
index 0000000..b908cbe
--- /dev/null
@@ -0,0 +1,3 @@
+import setuptools
+
+setuptools.setup()
diff --git a/live-autojoin/README.md b/live-autojoin/README.md
deleted file mode 100644 (file)
index 3af2e1d..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-#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
diff --git a/live-autojoin/announce.py b/live-autojoin/announce.py
deleted file mode 100644 (file)
index 047ba77..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-# 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)
diff --git a/live-autojoin/background.py b/live-autojoin/background.py
deleted file mode 100644 (file)
index f5a9a87..0000000
+++ /dev/null
@@ -1,180 +0,0 @@
-# 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()
diff --git a/live-autojoin/common.py b/live-autojoin/common.py
deleted file mode 100644 (file)
index 0e032d3..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-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)
diff --git a/live-autojoin/deploy b/live-autojoin/deploy
deleted file mode 100755 (executable)
index eceeb7a..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/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
diff --git a/live-autojoin/service.py b/live-autojoin/service.py
deleted file mode 100644 (file)
index a56c7b1..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-# 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)
diff --git a/live-autojoin/systemd/live-autojoin-announce.service b/live-autojoin/systemd/live-autojoin-announce.service
deleted file mode 100644 (file)
index e00569c..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=simple
-ExecStart=python3 announce.py
-Restart=always
diff --git a/live-autojoin/systemd/live-autojoin-cron.service b/live-autojoin/systemd/live-autojoin-cron.service
deleted file mode 100644 (file)
index 929dbe3..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=oneshot
-ExecStart=python3 background.py
diff --git a/live-autojoin/systemd/live-autojoin-cron.timer b/live-autojoin/systemd/live-autojoin-cron.timer
deleted file mode 100644 (file)
index 5843509..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-[Timer]
-OnActiveSec=0
-OnUnitActiveSec=25min
diff --git a/live-autojoin/systemd/live-autojoin-service.service b/live-autojoin/systemd/live-autojoin-service.service
deleted file mode 100644 (file)
index d69c607..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-[Service]
-WorkingDirectory=/opt/live-autojoin
-User=counting
-Group=counting
-Environment=LIVEAUTOJOINSERVICE=autojoin
-Type=simple
-ExecStart=python3 service.py
-Restart=always
diff --git a/live-autojoin/systemd/live-autojoin-web.service b/live-autojoin/systemd/live-autojoin-web.service
deleted file mode 100644 (file)
index 7b1ebba..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-[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
diff --git a/live-autojoin/systemd/live-autojoin-web.socket b/live-autojoin/systemd/live-autojoin-web.socket
deleted file mode 100644 (file)
index be9f1a0..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-[Socket]
-ListenStream=/tmp/live-autojoin.socket
-SocketUser=www-data
-SocketGroup=www-data
diff --git a/live-autojoin/systemd/live-autojoin.target b/live-autojoin/systemd/live-autojoin.target
deleted file mode 100644 (file)
index a028573..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-[Unit]
-Requires=live-autojoin-cron.timer live-autojoin-service.service live-autojoin-web.socket live-autojoin-announce.service
-
-[Install]
-WantedBy=multi-user.target
diff --git a/live-autojoin/templates/error.html b/live-autojoin/templates/error.html
deleted file mode 100644 (file)
index d393326..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-<!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>
diff --git a/live-autojoin/templates/status.html b/live-autojoin/templates/status.html
deleted file mode 100644 (file)
index dd87bea..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-<!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>
diff --git a/live-autojoin/web.py b/live-autojoin/web.py
deleted file mode 100644 (file)
index 819cf97..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-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