]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add some feedback to the puzzle's channel when a puzzle is solved
[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_channel(turb, channel_id, channel_name):
248     """Given a channel ID/name find the id/name of the hunt for this channel
249
250     This works whether the original channel is a primary hunt channel,
251     or if it is one of the channels of a puzzle belonging to the hunt.
252
253     Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
254
255     (hunt, _) = channel_is_hunt(turb, channel_id)
256
257     if hunt:
258         return (hunt['hunt_id'], hunt['name'])
259
260     # So we're not a hunt channel, let's look to see if we are a
261     # puzzle channel with a hunt-id prefix.
262     hunt_id = channel_name.split('-')[0]
263
264     hunts_table = turb.db.Table("hunts")
265
266     response = hunts_table.scan(
267         FilterExpression='hunt_id = :hunt_id',
268         ExpressionAttributeValues={':hunt_id': hunt_id}
269     )
270
271     if 'Items' in response and len(response['Items']):
272         item = response['Items'][0]
273         return (item['hunt_id'], item['name'])
274
275     return (None, None)
276
277 def puzzle(turb, body, args):
278     """Implementation of the /puzzle command
279
280     The args string is currently ignored (this command will bring up
281     a modal dialog for user input instead)."""
282
283     channel_id = body['channel_id'][0]
284     channel_name = body['channel_name'][0]
285     trigger_id = body['trigger_id'][0]
286
287     (hunt_id, hunt_name) = find_hunt_for_channel(turb,
288                                                  channel_id,
289                                                  channel_name)
290
291     if not hunt_id:
292         return bot_reply("Sorry, this channel doesn't appear to "
293                          + "be a hunt or puzzle channel")
294
295     view = {
296         "type": "modal",
297         "private_metadata": json.dumps({
298             "hunt_id": hunt_id,
299         }),
300         "title": {"type": "plain_text", "text": "New Puzzle"},
301         "submit": { "type": "plain_text", "text": "Create" },
302         "blocks": [
303             section_block(text_block("*For {}*".format(hunt_name))),
304             input_block("Puzzle name", "name", "Name of the puzzle"),
305             input_block("Puzzle ID", "puzzle_id",
306                         "Used as part of channel name "
307                         + "(no spaces nor punctuation)"),
308             input_block("Puzzle URL", "url", "External URL of puzzle",
309                         optional=True)
310         ]
311     }
312
313     result = turb.slack_client.views_open(trigger_id=trigger_id,
314                                           view=view)
315
316     if (result['ok']):
317         submission_handlers[result['view']['id']] = puzzle_submission
318
319     return lambda_ok
320
321 commands["/puzzle"] = puzzle
322
323 def puzzle_submission(turb, payload, metadata):
324     """Handler for the user submitting the new puzzle modal
325
326     This is the modal view presented to the user by the puzzle function
327     above."""
328
329     meta = json.loads(metadata)
330     hunt_id = meta['hunt_id']
331
332     state = payload['view']['state']['values']
333     name = state['name']['name']['value']
334     puzzle_id = state['puzzle_id']['puzzle_id']['value']
335     url = state['url']['url']['value']
336
337     # Validate that the puzzle_id contains no invalid characters
338     if not re.match(valid_id_re, puzzle_id):
339         return submission_error("puzzle_id",
340                                 "Puzzle ID can only contain lowercase letters,"
341                                 + " numbers, and underscores")
342
343     # Create a channel for the puzzle
344     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
345
346     try:
347         response = turb.slack_client.conversations_create(
348             name=hunt_dash_channel)
349     except SlackApiError as e:
350         return submission_error("puzzle_id",
351                                 "Error creating Slack channel: {}"
352                                 .format(e.response['error']))
353
354     puzzle_channel_id = response['channel']['id']
355
356     # Insert the newly-created puzzle into the database
357     table = turb.db.Table(hunt_id)
358     table.put_item(
359         Item={
360             "channel_id": puzzle_channel_id,
361             "solution": [],
362             "status": 'unsolved',
363             "name": name,
364             "puzzle_id": puzzle_id,
365             "url": url,
366         }
367     )
368
369     return lambda_ok
370
371 # XXX: This duplicates functionality eith events.py:set_channel_description
372 def set_channel_topic(turb, puzzle):
373     channel_id = puzzle['channel_id']
374     name = puzzle['name']
375     url = puzzle.get('url', None)
376     sheet_url = puzzle.get('sheet_url', None)
377     state = puzzle.get('state', None)
378     status = puzzle['status']
379
380     description = ''
381
382     if status == 'solved':
383         description += "Solved: `{}` ".format('`, `'.join(puzzle['solution']))
384
385     description += name
386
387     links = []
388     if url:
389         links.append("<{}|Puzzle>".format(url))
390     if sheet_url:
391         links.append("<{}|Sheet>".format(sheet_url))
392
393     if len(links):
394         description += "({})".format(', '.join(links))
395
396     if state:
397         description += " {}".format(state)
398
399     turb.slack_client.conversations_setTopic(channel=channel_id,
400                                              topic=description)
401
402 def state(turb, body, args):
403     """Implementation of the /state command
404
405     The args string should be a brief sentence describing where things
406     stand or what's needed."""
407
408     channel_id = body['channel_id'][0]
409     channel_name = body['channel_name'][0]
410
411     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
412
413     if not puzzle:
414         return bot_reply("Sorry, this is not a puzzle channel.")
415
416     # Set the state field in the database
417     puzzle['state'] = args
418     table.put_item(Item=puzzle)
419
420     set_channel_topic(turb, puzzle)
421
422     return lambda_ok
423
424 commands["/state"] = state
425
426 def solved(turb, body, args):
427     """Implementation of the /solved command
428
429     The args string should be a confirmed solution."""
430
431     channel_id = body['channel_id'][0]
432     channel_name = body['channel_name'][0]
433     user_name = body['user_name'][0]
434
435     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
436
437     if not puzzle:
438         return bot_reply("Sorry, this is not a puzzle channel.")
439
440     # Set the status and solution fields in the database
441     puzzle['status'] = 'solved'
442     puzzle['solution'].append(args)
443     table.put_item(Item=puzzle)
444
445     # Report the solution to the puzzle's channel
446     slack_send_message(
447         turb.slack_client, channel_id,
448         "Puzzle mark solved by {}: `{}`".format(user_name, args))
449
450     # And update the puzzle's description
451     set_channel_topic(turb, puzzle)
452
453     return lambda_ok
454
455 commands["/solved"] = solved