]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add display of state string to turbot view of each puzzle
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
3 import turbot.rot
4 import turbot.sheets
5 import turbot.slack
6 import json
7 import re
8 import requests
9 from botocore.exceptions import ClientError
10
11 actions = {}
12 commands = {}
13 submission_handlers = {}
14
15 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
16 valid_id_re = r'^[_a-z0-9]+$'
17
18 lambda_ok = {'statusCode': 200}
19
20 def bot_reply(message):
21     """Construct a return value suitable for a bot reply
22
23     This is suitable as a way to give an error back to the user who
24     initiated a slash command, for example."""
25
26     return {
27         'statusCode': 200,
28         'body': message
29     }
30
31 def submission_error(field, error):
32     """Construct an error suitable for returning for an invalid submission.
33
34     Returning this value will prevent a submission and alert the user that
35     the given field is invalid because of the given error."""
36
37     print("Rejecting invalid modal submission: {}".format(error))
38
39     return {
40         'statusCode': 200,
41         'headers': {
42             "Content-Type": "application/json"
43         },
44         'body': json.dumps({
45             "response_action": "errors",
46             "errors": {
47                 field: error
48             }
49         })
50     }
51
52 def new_hunt(turb, payload):
53     """Handler for the action of user pressing the new_hunt button"""
54
55     view = {
56         "type": "modal",
57         "private_metadata": json.dumps({}),
58         "title": { "type": "plain_text", "text": "New Hunt" },
59         "submit": { "type": "plain_text", "text": "Create" },
60         "blocks": [
61             input_block("Hunt name", "name", "Name of the hunt"),
62             input_block("Hunt ID", "hunt_id",
63                         "Used as puzzle channel prefix "
64                         + "(no spaces nor punctuation)"),
65             input_block("Hunt URL", "url", "External URL of hunt",
66                         optional=True)
67         ],
68     }
69
70     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
71                                           view=view)
72     if (result['ok']):
73         submission_handlers[result['view']['id']] = new_hunt_submission
74
75     return lambda_ok
76
77 actions['button'] = {"new_hunt": new_hunt}
78
79 def new_hunt_submission(turb, payload, metadata):
80     """Handler for the user submitting the new hunt modal
81
82     This is the modal view presented to the user by the new_hunt
83     function above."""
84
85     state = payload['view']['state']['values']
86     user_id = payload['user']['id']
87     name = state['name']['name']['value']
88     hunt_id = state['hunt_id']['hunt_id']['value']
89     url = state['url']['url']['value']
90
91     # Validate that the hunt_id contains no invalid characters
92     if not re.match(valid_id_re, hunt_id):
93         return submission_error("hunt_id",
94                                 "Hunt ID can only contain lowercase letters, "
95                                 + "numbers, and underscores")
96
97     # Check to see if the hunts table exists
98     hunts_table = turb.db.Table("hunts")
99
100     try:
101         exists = hunts_table.table_status in ("CREATING", "UPDATING",
102                                               "ACTIVE")
103     except ClientError:
104         exists = False
105
106     # Create the hunts table if necessary.
107     if not exists:
108         hunts_table = turb.db.create_table(
109             TableName='hunts',
110             KeySchema=[
111                 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
112             ],
113             AttributeDefinitions=[
114                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
115             ],
116             ProvisionedThroughput={
117                 'ReadCapacityUnits': 5,
118                 'WriteCapacityUnits': 5
119             }
120         )
121         return submission_error("hunt_id",
122                                 "Still bootstrapping hunts table. Try again.")
123
124     # Create a channel for the hunt
125     try:
126         response = turb.slack_client.conversations_create(name=hunt_id)
127     except SlackApiError as e:
128         return submission_error("hunt_id",
129                                 "Error creating Slack channel: {}"
130                                 .format(e.response['error']))
131
132     channel_id = response['channel']['id']
133
134     # Insert the newly-created hunt into the database
135     # (leaving it as non-active for now until the channel-created handler
136     #  finishes fixing it up with a sheet and a companion table)
137     hunts_table.put_item(
138         Item={
139             'channel_id': channel_id,
140             "active": False,
141             "name": name,
142             "hunt_id": hunt_id,
143             "url": url
144         }
145     )
146
147     # Invite the initiating user to the channel
148     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
149
150     return lambda_ok
151
152 def view_submission(turb, payload):
153     """Handler for Slack interactive view submission
154
155     Specifically, those that have a payload type of 'view_submission'"""
156
157     view_id = payload['view']['id']
158     metadata = payload['view']['private_metadata']
159
160     if view_id in submission_handlers:
161         return submission_handlers[view_id](turb, payload, metadata)
162
163     print("Error: Unknown view ID: {}".format(view_id))
164     return {
165         'statusCode': 400
166     }
167
168 def rot(turb, body, args):
169     """Implementation of the /rot command
170
171     The args string should be as follows:
172
173         [count|*] String to be rotated
174
175     That is, the first word of the string is an optional number (or
176     the character '*'). If this is a number it indicates an amount to
177     rotate each character in the string. If the count is '*' or is not
178     present, then the string will be rotated through all possible 25
179     values.
180
181     The result of the rotation is returned (with Slack formatting) in
182     the body of the response so that Slack will provide it as a reply
183     to the user who submitted the slash command."""
184
185     channel_name = body['channel_name'][0]
186     response_url = body['response_url'][0]
187     channel_id = body['channel_id'][0]
188
189     result = turbot.rot.rot(args)
190
191     if (channel_name == "directmessage"):
192         requests.post(response_url,
193                       json = {"text": result},
194                       headers = {"Content-type": "application/json"})
195     else:
196         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
197
198     return lambda_ok
199
200 commands["/rot"] = rot
201
202 def get_table_item(turb, table_name, key, value):
203     """Get an item from the database 'table_name' with 'key' as 'value'
204
205     Returns a tuple of (item, table) if found and (None, None) otherwise."""
206
207     table = turb.db.Table(table_name)
208
209     response = table.get_item(Key={key: value})
210
211     if 'Item' in response:
212         return (response['Item'], table)
213     else:
214         return None
215
216 def channel_is_puzzle(turb, channel_id, channel_name):
217     """Given a channel ID/name return the database item for the puzzle
218
219     If this channel is a puzzle, this function returns a tuple:
220
221         (puzzle, table)
222
223     Where puzzle is dict filled with database entries, and table is a
224     database table that can be used to update the puzzle in the
225     database.
226
227     Otherwise, this function returns (None, None)."""
228
229     hunt_id = channel_name.split('-')[0]
230
231     # Not a puzzle channel if there is no hyphen in the name
232     if hunt_id == channel_name:
233         return (None, None)
234
235     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
236
237 def channel_is_hunt(turb, channel_id):
238
239     """Given a channel ID/name return the database item for the hunt
240
241     Returns a dict (filled with database entries) if there is a hunt
242     for this channel, otherwise returns None."""
243
244     return get_table_item(turb, "hunts", 'channel_id', channel_id)
245
246 def find_hunt_for_channel(turb, channel_id, channel_name):
247     """Given a channel ID/name find the id/name of the hunt for this channel
248
249     This works whether the original channel is a primary hunt channel,
250     or if it is one of the channels of a puzzle belonging to the hunt.
251
252     Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
253
254     (hunt, hunts_table) = channel_is_hunt(turb, channel_id)
255
256     if hunt:
257         return (hunt['hunt_id'], hunt['name'])
258
259     # So we're not a hunt channel, let's look to see if we are a
260     # puzzle channel with a hunt-id prefix.
261     hunt_id = channel_name.split('-')[0]
262
263     response = hunts_table.scan(
264         FilterExpression='hunt_id = :hunt_id',
265         ExpressionAttributeValues={':hunt_id': hunt_id}
266     )
267
268     if 'Items' in response:
269         item = response['Items'][0]
270         return (item['hunt_id'], item['name'])
271
272     return (None, None)
273
274 def puzzle(turb, body, args):
275     """Implementation of the /puzzle command
276
277     The args string is currently ignored (this command will bring up
278     a modal dialog for user input instead)."""
279
280     channel_id = body['channel_id'][0]
281     channel_name = body['channel_name'][0]
282     trigger_id = body['trigger_id'][0]
283
284     (hunt_id, hunt_name) = find_hunt_for_channel(turb,
285                                                  channel_id,
286                                                  channel_name)
287
288     if not hunt_id:
289         return bot_reply("Sorry, this channel doesn't appear to "
290                          + "be a hunt or puzzle channel")
291
292     view = {
293         "type": "modal",
294         "private_metadata": json.dumps({
295             "hunt_id": hunt_id,
296         }),
297         "title": {"type": "plain_text", "text": "New Puzzle"},
298         "submit": { "type": "plain_text", "text": "Create" },
299         "blocks": [
300             section_block(text_block("*For {}*".format(hunt_name))),
301             input_block("Puzzle name", "name", "Name of the puzzle"),
302             input_block("Puzzle ID", "puzzle_id",
303                         "Used as part of channel name "
304                         + "(no spaces nor punctuation)"),
305             input_block("Puzzle URL", "url", "External URL of puzzle",
306                         optional=True)
307         ]
308     }
309
310     result = turb.slack_client.views_open(trigger_id=trigger_id,
311                                           view=view)
312
313     if (result['ok']):
314         submission_handlers[result['view']['id']] = puzzle_submission
315
316     return lambda_ok
317
318 commands["/puzzle"] = puzzle
319
320 def puzzle_submission(turb, payload, metadata):
321     """Handler for the user submitting the new puzzle modal
322
323     This is the modal view presented to the user by the puzzle function
324     above."""
325
326     meta = json.loads(metadata)
327     hunt_id = meta['hunt_id']
328
329     state = payload['view']['state']['values']
330     name = state['name']['name']['value']
331     puzzle_id = state['puzzle_id']['puzzle_id']['value']
332     url = state['url']['url']['value']
333
334     # Validate that the puzzle_id contains no invalid characters
335     if not re.match(valid_id_re, puzzle_id):
336         return submission_error("puzzle_id",
337                                 "Puzzle ID can only contain lowercase letters,"
338                                 + " numbers, and underscores")
339
340     # Create a channel for the puzzle
341     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
342
343     try:
344         response = turb.slack_client.conversations_create(
345             name=hunt_dash_channel)
346     except SlackApiError as e:
347         return submission_error("puzzle_id",
348                                 "Error creating Slack channel: {}"
349                                 .format(e.response['error']))
350
351     puzzle_channel_id = response['channel']['id']
352
353     # Insert the newly-created puzzle into the database
354     table = turb.db.Table(hunt_id)
355     table.put_item(
356         Item={
357             "channel_id": puzzle_channel_id,
358             "solution": [],
359             "status": 'unsolved',
360             "name": name,
361             "puzzle_id": puzzle_id,
362             "url": url,
363         }
364     )
365
366     return lambda_ok
367
368 # XXX: This duplicates functionality eith events.py:set_channel_description
369 def set_channel_topic(turb, puzzle):
370     channel_id = puzzle['channel_id']
371     description = puzzle['name']
372     url = puzzle.get('url', None)
373     sheet_url = puzzle.get('sheet_url', None)
374     state = puzzle.get('state', None)
375
376     links = []
377     if url:
378         links.append("<{}|Puzzle>".format(url))
379     if sheet_url:
380         links.append("<{}|Sheet>".format(sheet_url))
381
382     if len(links):
383         description += "({})".format(', '.join(links))
384
385     if state:
386         description += " {}".format(state)
387
388     turb.slack_client.conversations_setTopic(channel=channel_id,
389                                              topic=description)
390
391 def state(turb, body, args):
392     """Implementation of the /state command
393
394     The args string should be a brief sentence describing where things
395     stand or what's needed."""
396
397     channel_id = body['channel_id'][0]
398     channel_name = body['channel_name'][0]
399
400     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
401
402     if not puzzle:
403         return bot_reply("Sorry, this is not a puzzle channel.")
404
405     # Set the state field in the database
406     puzzle['state'] = args
407     table.put_item(Item=puzzle)
408
409     set_channel_topic(turb, puzzle)
410
411     return lambda_ok
412
413 commands["/state"] = state