]> git.cworth.org Git - turbot/blob - turbot_lambda/turbot_lambda.py
Add notes on how to update the Google sheets credentials
[turbot] / turbot_lambda / turbot_lambda.py
1 from urllib.parse import parse_qs
2 from slack import WebClient
3 import base64
4 import boto3
5 import requests
6 import json
7 import pickle
8 import os
9 from types import SimpleNamespace
10 from google.auth.transport.requests import Request
11 from googleapiclient.discovery import build
12
13 import turbot.interaction
14 import turbot.events
15
16 ssm = boto3.client('ssm')
17
18 # Note: Late import here to have the environment variable above available
19 from turbot.slack import slack_is_valid_request # noqa
20
21 if 'SLACK_BOT_TOKEN' in os.environ:
22     slack_bot_token = os.environ['SLACK_BOT_TOKEN']
23 else:
24     response = ssm.get_parameter(Name='SLACK_BOT_TOKEN', WithDecryption=True)
25     slack_bot_token = response['Parameter']['Value']
26     os.environ['SLACK_BOT_TOKEN'] = slack_bot_token
27 slack_client = WebClient(slack_bot_token)
28
29 if 'GSHEETS_PICKLE_BASE64' in os.environ:
30     gsheets_pick_base64 = os.environ['GSHEETS_PICKLE_BASE64']
31 else:
32     response = ssm.get_parameter(Name='GSHEETS_PICKLE_BASE64',
33                                  WithDecryption=True)
34     gsheets_pickle_base64 = response['Parameter']['Value']
35     os.environ['GSHEETS_PICKLE_BASE64'] = gsheets_pickle_base64
36 gsheets_pickle = base64.b64decode(gsheets_pickle_base64)
37 gsheets_creds = pickle.loads(gsheets_pickle)
38
39 if gsheets_creds:
40     if gsheets_creds.valid:
41         print("Loaded valid GSheets credentials from SSM")
42     else:
43         gsheets_creds.refresh(Request())
44         gsheets_pickle = pickle.dumps(gsheets_creds)
45         gsheets_pickle_base64_bytes = base64.b64encode(gsheets_pickle)
46         gsheets_pickle_base64 = gsheets_pickle_base64_bytes.decode('us-ascii')
47         print("Storing refreshed GSheets credentials into SSM")
48         os.environ['GSHEETS_PICKLE_BASE64'] = gsheets_pickle_base64
49         ssm.put_parameter(Name='GSHEETS_PICKLE_BASE64',
50                           Type='SecureString',
51                           Value=gsheets_pickle_base64,
52                           Overwrite=True)
53 service = build('sheets',
54                 'v4',
55                 credentials=gsheets_creds,
56                 cache_discovery=False)
57 sheets = service.spreadsheets()
58 service = build('drive',
59                 'v3',
60                 credentials=gsheets_creds,
61                 cache_discovery=False)
62 files = service.files()
63 permissions = service.permissions()
64
65 db = boto3.resource('dynamodb')
66
67 turb = SimpleNamespace()
68 turb.slack_client = slack_client
69 turb.db = db
70 turb.table = db.Table("turbot")
71 turb.sheets = sheets
72 turb.files = files
73 turb.permissions = permissions
74
75 def error(message):
76     """Generate an error response for a Slack request
77
78     This will print the error message (so that it appears in CloudWatch
79     logs) and will then return a dictionary suitable for returning
80     as an error response."""
81
82     print("Error: {}.".format(message))
83
84     return {
85         'statusCode': 400,
86         'body': ''
87     }
88
89 def turbot_lambda(event, context):
90     """Top-level entry point for our lambda function.
91
92     This can handle either a REST API request from Slack, or an HTTP
93     request for teh Turbot web view
94     """
95
96     # First, determine if we've been invoked by Slack, (by presence of
97     # the X-Slack-Signature header)
98     headers = requests.structures.CaseInsensitiveDict(event['headers'])
99
100     if 'X-Slack-Signature' in headers:
101         return turbot_slack_handler(event, context)
102
103     # Otherwise, emit the Turbot web view
104     return turbot_web_view(event, context)
105
106 def turbot_web_view(event, context):
107     """Turbot web view
108
109     """
110
111     return {
112         'statusCode': '200',
113         'body': 'Hello, Lambda world.',
114         'headers': {
115             'Content-Type': 'application/text',
116         },
117     }
118
119 def turbot_slack_handler(event, context):
120     """Primary entry point for all Slack-initiated API requests to Turbot
121
122     This function first verifies that the request actually came from
123     Slack, (by means of the SLACK_SIGNING_SECRET SSM parameter), and
124     refuses to do anything if not.
125
126     Then this defers to either turbot_event_handler or
127     turbot_slash_command to do any real work.
128
129     """
130
131     headers = requests.structures.CaseInsensitiveDict(event['headers'])
132
133     signature = headers['X-Slack-Signature']
134     timestamp = headers['X-Slack-Request-Timestamp']
135
136     if not slack_is_valid_request(signature, timestamp, event['body']):
137         return error("Invalid Slack signature")
138
139     # It's a bit cheesy, but we'll just use the content-type header to
140     # determine if we're being called from a Slack event or from a
141     # slash command or other interactivity. (The more typical way to
142     # do this would be to have different URLs for each Slack entry
143     # point, but it's simpler to have our Slack app implemented as a
144     # single AWS Lambda, (which can only have a single entry point).
145     content_type = headers['content-type']
146
147     if (content_type == "application/json"):
148         return turbot_event_handler(turb, event, context)
149     if (content_type == "application/x-www-form-urlencoded"):
150         return turbot_interactive_or_slash_command(turb, event, context)
151     return error("Unknown content-type: {}".format(content_type))
152
153 def turbot_event_handler(turb, event, context):
154     """Handler for all subscribed Slack events"""
155
156     body = json.loads(event['body'])
157
158     type = body['type']
159
160     if type == 'url_verification':
161         return url_verification_handler(turb, body)
162     if type == 'event_callback':
163         return event_callback_handler(turb, body)
164     return error("Unknown event type: {}".format(type))
165
166 def url_verification_handler(turb, body):
167
168     # First, we have to properly respond to url_verification
169     # challenges or else Slack won't let us configure our URL as an
170     # event handler.
171     challenge = body['challenge']
172
173     return {
174         'statusCode': 200,
175         'body': challenge
176     }
177
178 def event_callback_handler(turb, body):
179     event = body['event']
180     type = event['type']
181
182     if type in turbot.events.events:
183         return turbot.events.events[type](turb, event)
184     return error("Unknown event type: {}".format(type))
185
186 def turbot_interactive_or_slash_command(turb, event, context):
187     """Handler for Slack interactive things (buttons, shortcuts, etc.)
188     as well as slash commands.
189
190     This function simply makes a quick determination of what we're looking
191     at and then defers to either turbot_interactive or turbot_slash_command."""
192
193     # Both interactives and slash commands have a urlencoded body
194     body = parse_qs(event['body'])
195
196     # The difference is that an interactive thingy has a 'payload'
197     # while a slash command has a 'command'
198     if 'payload' in body:
199         return turbot_interactive(turb, json.loads(body['payload'][0]))
200     if 'command' in body:
201         return turbot_slash_command(turb, body)
202     return error("Unrecognized event (neither interactive nor slash command)")
203
204 def turbot_interactive(turb, payload):
205     """Handler for Slack interactive requests
206
207     These are the things that come from a user interacting with a button
208     a shortcut or some other interactive element that our app has made
209     available to the user."""
210
211     type = payload['type']
212
213     if type == 'block_actions':
214         return turbot_block_action(turb, payload)
215     if type == 'view_submission':
216         return turbot.interaction.view_submission(turb, payload)
217     if type == 'shortcut':
218         return turbot_shortcut(turb, payload)
219     return error("Unrecognized interactive type: {}".format(type))
220
221 def turbot_block_action(turb, payload):
222     """Handler for Slack interactive block actions
223
224     Specifically, those that have a payload type of 'block_actions'"""
225
226     actions = payload['actions']
227
228     if len(actions) != 1:
229         return error("No support for multiple actions ({}) in a single request"
230                      .format(len(actions)))
231
232     action = actions[0]
233
234     atype = action['type']
235     if 'value' in action:
236         avalue = action['value']
237     else:
238         avalue = '*'
239
240     if (
241             atype in turbot.interaction.actions
242             and avalue in turbot.interaction.actions[atype]
243     ):
244         return turbot.interaction.actions[atype][avalue](turb, payload)
245     return error("Unknown action of type/value: {}/{}".format(atype, avalue))
246
247 def turbot_shortcut(turb, payload):
248     """Handler for Slack shortcuts
249
250     These are invoked as either global or message shortcuts by a user."""
251
252     print("In turbot_shortcut, payload is: {}".format(str(payload)))
253
254     return error("Shortcut interactions not yet implemented")
255
256 def turbot_slash_command(turb, body):
257     """Implementation for Slack slash commands.
258
259     This parses the request and arguments and farms out to
260     supporting functions to implement all supported slash commands.
261     """
262
263     command = body['command'][0]
264     if 'text' in body:
265         args = body['text'][0]
266     else:
267         args = ''
268
269     if command in turbot.interaction.commands:
270         return turbot.interaction.commands[command](turb, body, args)
271
272     return error("Command {} not implemented".format(command))