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