-
Notifications
You must be signed in to change notification settings - Fork 0
/
placebo.py
161 lines (139 loc) · 7.36 KB
/
placebo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
import logging
import os
import queue
import threading
from typing import Callable, Optional
import requests
import google_client
import slack_client
import util
logging.basicConfig(format='{asctime} {name} {levelname}: {message}', style='{')
logging.getLogger('googleapiclient').setLevel(logging.ERROR) # It's real noisy.
log = logging.getLogger('placebo')
log.setLevel(logging.DEBUG if os.getenv('PLACEBO_DEBUG_LOGS') == '1' else logging.INFO)
class Placebo:
def __init__(self) -> None:
self.create_metas = os.getenv('PLACEBO_CREATE_METAS', '1') == '1'
self.metas_have_names = (self.create_metas and
os.environ.get('PLACEBO_METAS_HAVE_NAMES') == '1')
self.google = google_client.Google()
self.slack = slack_client.Slack()
log.addHandler(slack_client.SlackLogHandler(self.slack, level=logging.ERROR))
self.queue: queue.Queue[Callable[[], None]] = queue.Queue()
# If set, it's the round in which the most recent puzzle was unlocked. It's used as the
# default round for the unlock dialog, to make repeated unlocks easier.
self.last_round: Optional[str] = None
threading.Thread(target=self._worker_thread, daemon=True).start()
auth_url = self.google.start_oauth_if_necessary()
if auth_url:
self.slack.dm_admin(f'While logged in as the bot user, please visit {auth_url}')
# The public methods don't do any work -- they just enqueue a call to the corresponding private
# method, which the worker thread picks up. That accomplishes two things:
# - Ensures we always return a 200 for the incoming HTTP request promptly, without waiting for
# our API backends.
# - Ensures we're never handling more than one request at a time.
def new_round(self, round_name: str, round_url: str, round_color: Optional[util.Color],
meta_name: Optional[str] = None) -> None:
self.queue.put(lambda: self._new_round(round_name, round_url, round_color, meta_name))
def new_puzzle(self, round_name: str, puzzle_name: str, puzzle_url: str,
response_url: Optional[str] = None) -> None:
self.queue.put(lambda: self._new_puzzle(round_name, puzzle_name, puzzle_url, response_url,
meta=False, round_color=None))
def solved_puzzle(
self, puzzle_name: str, answer: str, response_url: Optional[str] = None) -> None:
self.queue.put(lambda: self._solved_puzzle(puzzle_name, answer, response_url))
def view_closed(self, view_id: str) -> None:
self.queue.put(lambda: self._view_closed(view_id))
def _worker_thread(self) -> None:
while True:
func = self.queue.get()
try:
func()
except BaseException:
# TODO: Reply to the original command if we can.
log.exception('Error in worker thread.')
def _new_round(self, round_name: str, round_url: str, round_color: Optional[util.Color],
meta_name: Optional[str]) -> None:
if self.create_metas:
if not meta_name:
meta_name = f'{round_name} Meta'
self._new_puzzle(round_name, meta_name, round_url, response_url=None, meta=True,
round_color=round_color)
else:
self.last_round = round_name
round_color = self.google.add_empty_row(round_name, round_color)
self.slack.announce_round(round_name, round_url, round_color)
def _new_puzzle(self, round_name: str, puzzle_name: str, puzzle_url: str,
response_url: Optional[str], meta: bool,
round_color: Optional[util.Color]) -> None:
_ephemeral_ack(f'Adding *{puzzle_name}*...', response_url)
if meta and self.metas_have_names:
full_puzzle_name = f'{puzzle_name} ({round_name} Meta)'
else:
full_puzzle_name = puzzle_name
if self.google.exists(full_puzzle_name):
raise KeyError(f'Puzzle "{full_puzzle_name}" is already in the tracker.')
# Creating the spreadsheet is super slow, so do it in parallel.
doc_url_future = util.future(self.google.create_puzzle_spreadsheet, [full_puzzle_name])
# Meanwhile, set up everything else...
self.last_round = round_name
if meta and self.metas_have_names:
alias = puzzle_name
channel_name, channel_id = self.slack.create_channel(puzzle_url, prefix='meta',
alias=alias)
elif meta:
channel_name, channel_id = self.slack.create_channel(puzzle_url, prefix='meta')
else:
channel_name, channel_id = self.slack.create_channel(puzzle_url)
priority = 'L' if meta else 'M'
round_color = self.google.add_row(round_name, full_puzzle_name, priority, puzzle_url,
channel_name, round_color)
if meta:
self.slack.announce_round(round_name, puzzle_url, round_color)
else:
self.slack.announce_unlock(round_name, full_puzzle_name, puzzle_url, channel_name,
channel_id, round_color)
# ... then wait for the doc URL, and go back and fill it in. But don't hold up the worker
# thread in the meantime.
def await_and_finish():
doc_url = doc_url_future.wait()
self.queue.put(
lambda: self._finish_new_puzzle(full_puzzle_name, puzzle_url, channel_id, doc_url))
threading.Thread(target=await_and_finish).start()
def _finish_new_puzzle(
self, full_puzzle_name: str, puzzle_url: str, channel_id: str, doc_url: str) -> None:
try:
self.google.set_doc_url(full_puzzle_name, doc_url)
except KeyError:
log.exception('Tracker row went missing before we got to it -- puzzle name changed?')
except google_client.UrlConflictError as e:
log.exception('Doc URL was set before we got to it')
doc_url = e.found_url
self.slack.set_topic(channel_id, puzzle_url, doc_url)
def _solved_puzzle(self, puzzle_name: str, answer: str, response_url: Optional[str]) -> None:
# It'll already be in caps if it was typed as a command arg, but it might not if it came
# from the modal.
answer = answer.upper()
_ephemeral_ack(f'Marking *{puzzle_name}* correct...', response_url)
lookup = self.google.lookup(puzzle_name)
if lookup is None:
raise KeyError(f'Puzzle "{puzzle_name}" not found.')
row_index, doc_url, channel_name = lookup
if doc_url:
self.google.mark_doc_solved(doc_url)
self.google.mark_row_solved(row_index, answer)
if channel_name:
self.slack.solved(channel_name, answer)
self.slack.announce_solved(puzzle_name, answer)
def _view_closed(self, view_id: str) -> None:
self.slack.delete_in_progress_message(view_id)
def _ephemeral_ack(message, response_url) -> None:
if not response_url:
return
log.info('Logging ephemeral acknowledgment...')
response = requests.post(response_url, json={
'text': message,
'response_type': 'ephemeral'
})
if response.status_code != 200:
log.error(f"Couldn't log ephemeral acknowledgment: {response.status_code} {response.text}")