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