]> git.cworth.org Git - turbot/blob - turbot_lambda/turbot_lambda.py
Fix several problems pointed out by flake8
[turbot] / turbot_lambda / turbot_lambda.py
1 from urllib.parse import parse_qs
2 from turbot.rot import rot
3 import turbot.views
4 import turbot.actions
5 from slack import WebClient
6 import boto3
7 import requests
8 import hashlib
9 import hmac
10 import json
11
12 ssm = boto3.client('ssm')
13
14 response = ssm.get_parameter(Name='SLACK_SIGNING_SECRET', WithDecryption=True)
15 slack_signing_secret = bytes(response['Parameter']['Value'], 'utf-8')
16
17 response = ssm.get_parameter(Name='SLACK_BOT_TOKEN', WithDecryption=True)
18 slack_bot_token = response['Parameter']['Value']
19 slack_client = WebClient(slack_bot_token)
20
21 def error(message):
22     """Generate an error response for a Slack request
23
24     This will print the error message (so that it appears in CloudWatch
25     logs) and will then return a dictionary suitable for returning
26     as an error response."""
27
28     print("Error: {}.".format(message))
29
30     return {
31         'statusCode': 400,
32         'body': ''
33     }
34
35 def slack_is_valid_request(slack_signature, timestamp, body):
36     """Returns True if the timestamp and body correspond to signature.
37
38     This implements the Slack signature verification using the slack
39     signing secret (obtained via an SSM parameter in code above)."""
40
41     content = "v0:{}:{}".format(timestamp, body).encode('utf-8')
42
43     signature = 'v0=' + hmac.new(slack_signing_secret,
44                                  content,
45                                  hashlib.sha256).hexdigest()
46
47     if hmac.compare_digest(signature, slack_signature):
48         return True
49     else:
50         print("Bad signature: {} != {}".format(signature, slack_signature))
51         return False
52
53 def turbot_lambda(event, context):
54     """Top-level entry point for our lambda function.
55
56     This function first verifies that the request actually came from
57     Slack, (by means of the SLACK_SIGNING_SECRET SSM parameter), and
58     refuses to do anything if not.
59
60     Then this defers to either turbot_event_handler or
61     turbot_slash_command to do any real work.
62     """
63
64     headers = requests.structures.CaseInsensitiveDict(event['headers'])
65
66     signature = headers['X-Slack-Signature']
67     timestamp = headers['X-Slack-Request-Timestamp']
68
69     if not slack_is_valid_request(signature, timestamp, event['body']):
70         return error("Invalid Slack signature")
71
72     # It's a bit cheesy, but we'll just use the content-type header to
73     # determine if we're being called from a Slack event or from a
74     # slash command or other interactivity. (The more typical way to
75     # do this would be to have different URLs for each Slack entry
76     # point, but it's simpler to have our Slack app implemented as a
77     # single AWS Lambda, (which can only have a single entry point).
78     content_type = headers['content-type']
79
80     if (content_type == "application/json"):
81         return turbot_event_handler(event, context)
82     if (content_type == "application/x-www-form-urlencoded"):
83         return turbot_interactive_or_slash_command(event, context)
84     return error("Unknown content-type: {}".format(content_type))
85
86 def turbot_event_handler(event, context):
87     """Handler for all subscribed Slack events"""
88
89     body = json.loads(event['body'])
90
91     type = body['type']
92
93     if type == 'url_verification':
94         return url_verification_handler(body)
95     if type == 'event_callback':
96         return event_callback_handler(body)
97     return error("Unknown event type: {}".format(type))
98
99 def url_verification_handler(body):
100
101     # First, we have to properly respond to url_verification
102     # challenges or else Slack won't let us configure our URL as an
103     # event handler.
104     challenge = body['challenge']
105
106     return {
107         'statusCode': 200,
108         'body': challenge
109     }
110
111 def event_callback_handler(body):
112     type = body['event']['type']
113
114     if type == 'app_home_opened':
115         return app_home_opened_handler(body)
116     return error("Unknown event type: {}".format(type))
117
118 def app_home_opened_handler(body):
119     user_id = body['event']['user']
120     view = turbot.views.home(user_id, body)
121     slack_client.views_publish(user_id=user_id, view=view)
122     return "OK"
123
124 def turbot_interactive_or_slash_command(event, context):
125     """Handler for Slack interactive things (buttons, shortcuts, etc.)
126     as well as slash commands.
127
128     This function simply makes a quiuck determination of what we're looking
129     at and then defers to either turbot_interactive or turbot_slash_command."""
130
131     # Both interactives and slash commands have a urlencoded body
132     body = parse_qs(event['body'])
133
134     # The difference is that an interactive thingy has a 'payload'
135     # while a slash command has a 'command'
136     if 'payload' in body:
137         return turbot_interactive(json.loads(body['payload'][0]))
138     if 'command' in body:
139         return turbot_slash_command(body)
140     return error("Unrecognized event (neither interactive nor slash command)")
141
142 def turbot_interactive(payload):
143     """Handler for Slack interactive requests
144
145     These are the things that come from a user interacting with a button
146     a shortcut or some other interactive element that our app has made
147     available to the user."""
148
149     type = payload['type']
150
151     if type == 'block_actions':
152         return turbot_block_action(payload)
153     return error("Unrecognized interactive type: {}".format(type))
154
155 def turbot_block_action(payload):
156     """Handler for Slack interactive block actions
157
158     Specifically, those that have a payload type of 'block_actions'"""
159
160     actions = payload['actions']
161
162     if len(actions) != 1:
163         return error("No support for multiple actions ({}) in a single request"
164                      .format(len(actions)))
165
166     action = actions[0]
167
168     atype = action['type']
169     avalue = action['value']
170
171     if atype == 'button' and avalue == 'new_hunt':
172         return turbot.actions.new_hunt(payload)
173     return error("Unknown action of type/value: {}/{}".format(atype, avalue))
174
175 def turbot_slash_command(body):
176     """Implementation for Slack slash commands.
177
178     This parses the request and arguments and farms out to
179     supporting functions to implement all supported slash commands.
180     """
181
182     command = body['command'][0]
183     args = body['text'][0]
184
185     if (command == "/rotlambda" or command == "/rot"):
186         return rot_slash_command(body, args)
187
188     return error("Command {} not implemented".format(command))
189
190 def rot_slash_command(body, args):
191     """Implementation of the /rot command
192
193     The args string should be as follows:
194
195         [count|*] String to be rotated
196
197     That is, the first word of the string is an optional number (or
198     the character '*'). If this is a number it indicates an amount to
199     rotate each character in the string. If the count is '*' or is not
200     present, then the string will be rotated through all possible 25
201     values.
202
203     The result of the rotation is returned (with Slack formatting) in
204     the body of the response so that Slack will provide it as a reply
205     to the user who submitted the slash command."""
206
207     channel_name = body['channel_name'][0]
208     response_url = body['response_url'][0]
209     channel_id = body['channel_id'][0]
210
211     result = rot(args)
212
213     if (channel_name == "directmessage"):
214         requests.post(response_url,
215                       json = {"text": result},
216                       headers = {"Content-type": "application/json"})
217     else:
218         slack_client.chat_postMessage(channel=channel_id, text=result)
219
220     return {
221         'statusCode': 200,
222         'body': ""
223     }