-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathapp.js
299 lines (257 loc) · 12.1 KB
/
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
// Load validated Config object from config.js (which uses dotenv to read from the local .env file)
const { EnvVars } = require('./configs/environment_variables')
const { Fields, SystemFields } = require('./configs/fields')
// Load Bolt app, a Slack application framework which wraps Express
const { App } = require('@slack/bolt')
// Load Airtable.js, a wrapper for the Airtable API
const Airtable = require('airtable')
// Load util stdlib for full object logging
const util = require('util')
// Load helper functions
const helpers = require('./helpers')
const modals = require('./views/modals')
const messages = require('./views/messages')
const appHome = require('./views/app_home')
// Call some helper functions in preparation for runtime
const fieldToPrefillForMessageShortcut = helpers.determineFieldNameForMessageShortcutPrefill(Fields)
// Initializes your app with your bot token and app token
const app = new App({
socketMode: true,
token: EnvVars.SLACK_BOT_TOKEN,
appToken: EnvVars.SLACK_APP_TOKEN,
logLevel: EnvVars.LOG_LEVEL || 'debug'
})
// Initialize Airtable client
const airtableClient = new Airtable({ apiKey: EnvVars.AIRTABLE_API_KEY })
const airtableBase = airtableClient.base(EnvVars.AIRTABLE_BASE_ID)
const airtableTable = airtableBase(EnvVars.AIRTABLE_TABLE_ID)
// == SLACK BOLT LISTENERS ==
// Listen for global shortcut
app.shortcut('create_record_from_global_shortcut', async ({ shortcut, ack, client }) => {
// Acknowledge shortcut request
await ack()
// Open modal using WebClient passed in from middleware.
// Uses modal defintion from views/modals.js
const view = modals.createRecordForm(Fields)
await client.views.open({
trigger_id: shortcut.trigger_id,
view
})
})
// Listen for message shortcut
app.shortcut('create_record_from_message_shortcut', async ({ ack, shortcut, client }) => {
await ack()
// Create a copy of the Fields map and prefill it with the value from the message shortcut
const copyOfFieldsWithPrefill = new Map(Fields)
copyOfFieldsWithPrefill.set(fieldToPrefillForMessageShortcut, { value: shortcut.message.text, ...Fields.get(fieldToPrefillForMessageShortcut) })
// Open modal using WebClient passed in from middleware.
// Uses modal defintion from views/modals.js
const view = modals.createRecordForm(copyOfFieldsWithPrefill)
await client.views.open({
trigger_id: shortcut.trigger_id,
view
})
})
// Listen for form/modal submission
app.view('create_record_submission', async ({ ack, body, view, client, logger }) => {
// Extract values from view submission payload and validate them/generate errors
const fieldsWithValues = await helpers.extractInputsFromViewSubmissionPayload({ view }, Fields)
const errors = helpers.validateInputs(fieldsWithValues)
logger.debug({ fieldsWithValues, errors })
// If there are errors, respond to Slack with errors; otherwise, respond with a confirmation
if (Object.keys(errors).length > 0) {
await ack({
response_action: 'errors',
errors
})
} else {
// If there are no validation errors, close the modal and DM the user a confirmation
await ack()
const { blocks: recordCreationRequestReceivedBlocks } = messages.recordCreationRequestReceived(fieldsWithValues, body.user.id)
const initialDmToSubmitter = await client.chat.postMessage({
channel: body.user.id,
blocks: recordCreationRequestReceivedBlocks
})
// Create object to be inserted into Airtable table
// Start with fields that are not editable by Slack users
const newRecordFields = {
[SystemFields.get('submitter_slack_uid').airtableFieldName]: body.user.id,
[SystemFields.get('submitter_slack_name').airtableFieldName]: body.user.name
}
// Add fields from view submission payload
Object.keys(fieldsWithValues).forEach((fieldName) => {
const fieldWithValue = fieldsWithValues[fieldName]
newRecordFields[fieldWithValue.airtableFieldName] = fieldWithValue.value
})
// Depending on success/failure from Airtable API, update DM to submitter
let recordCreationResultBlocks
try {
const newRecord = await airtableTable.create([{ fields: newRecordFields }])
const newRecordId = newRecord[0].getId()
const newRecordPrimaryFieldValue = newRecord[0].get(EnvVars.AIRTABLE_PRIMARY_FIELD_NAME)
recordCreationResultBlocks = messages.recordCreationSuccessful(EnvVars.AIRTABLE_BASE_ID, EnvVars.AIRTABLE_TABLE_ID, newRecordId, newRecordPrimaryFieldValue).blocks
} catch (error) {
recordCreationResultBlocks = messages.simpleMessage(`:bangbang: An error occured while saving your record to Airtable: ${helpers.objectStringifiedAsMardownCodeBlock(error)}`).blocks
}
// Update initial DM to submitter
recordCreationRequestReceivedBlocks.pop() // remove last block
const updatedRecordCreationRequestReceivedBlocks = recordCreationRequestReceivedBlocks.concat(recordCreationResultBlocks)
await client.chat.update({
channel: initialDmToSubmitter.channel,
ts: initialDmToSubmitter.ts,
blocks: updatedRecordCreationRequestReceivedBlocks
})
}
})
// Listen for users opening App Home
app.event('app_home_opened', async ({ event, client }) => {
// Publish App Home view
const view = appHome(EnvVars.AIRTABLE_BASE_ID, EnvVars.AIRTABLE_TABLE_ID)
await client.views.publish({
user_id: event.user,
view
})
})
// Listen for users clicking the new record button from App Home
app.action('create_record', async ({ ack, body, client }) => {
await ack()
const view = modals.createRecordForm(Fields)
await client.views.open({
trigger_id: body.trigger_id,
view
})
})
// Listen for users clicking a button that opens a URL
// Without this, Slack will show a /!\ warning icon next to buttons
app.action('url_button', async ({ ack }) => {
await ack()
})
// Listen for users clicking the 'Delete' button from their DMs
app.action('delete_record', async ({ ack, action, client, body, logger }) => {
await ack()
const recordId = action.value
// Attempt to delete record from Airtable
try {
// Get current values before deleting the record
const recordBeforeDeletion = await airtableTable.find(recordId)
// Attempt to delete the record
await recordBeforeDeletion.destroy()
// If successful, update the parent message to user by removing action buttons
const updatedBlocksForOriginalMessage = body.message.blocks.slice(0, 2) // TODO improve this to be less dependent on the number of blocks
const { blocks: recordDeletionSuccessfulBlocks } = messages.simpleMessage(`:ghost: Record *${recordBeforeDeletion.get(EnvVars.AIRTABLE_PRIMARY_FIELD_NAME)}* (${recordId}) was successfully deleted. You can recover deleted records from your <https://support.airtable.com/hc/en-us/articles/115014104628-Base-trash|base trash> for a limited amount of time.`)
updatedBlocksForOriginalMessage.push(...recordDeletionSuccessfulBlocks)
await client.chat.update({
blocks: updatedBlocksForOriginalMessage,
channel: body.channel.id,
ts: body.message.ts
})
} catch (error) {
// If not successful, thread a message to the user with the error
const { blocks: recordDeletionFailedBlocks } = messages.simpleMessage(`<@${body.user.id}> An error occured while trying to delete this record (it may have been already deleted by someone else): ${helpers.objectStringifiedAsMardownCodeBlock(error)}`)
await client.chat.postMessage({
blocks: recordDeletionFailedBlocks,
channel: body.channel.id,
thread_ts: body.message.ts
})
}
})
// Listen for users clicking the 'Edit' button from their DMs
app.action('edit_record', async ({ ack, action, client, body, logger }) => {
await ack()
// Retrieve latest record values from Airtable
const recordId = action.value
let view
try {
// Try to retrieve the record from Airtable and generate blocks
const recordBeforeEditing = await airtableTable.find(recordId)
const privateMetadataAsString = JSON.stringify({ recordId, channelId: body.channel.id, threadTs: body.message.ts })
// Create a copy of the Fields map and prefill it with the value from the message shortcut
const copyOfFieldsWithPrefill = new Map(Fields)
Fields.forEach((fieldConfig, fieldName) => {
const currentAirtableValue = recordBeforeEditing.get(fieldConfig.airtableFieldName)
// If there is a value for the current field in the latest version of the record, prefill it
if (currentAirtableValue) {
copyOfFieldsWithPrefill.set(fieldName, {
...fieldConfig,
value: currentAirtableValue
})
}
})
view = modals.updateRecordForm(copyOfFieldsWithPrefill, privateMetadataAsString)
} catch (error) {
view = modals.simpleMessage(`:bangbang: Sorry, but an error occured and this record cannot be edited at this time. Most likely, the record has been deleted. Error details: ${helpers.objectStringifiedAsMardownCodeBlock(error)}`)
}
// Open modal
await client.views.open({
trigger_id: body.trigger_id,
view
})
})
// Listen for form/modal submission
app.view('update_record_submission', async ({ ack, body, view, client, logger }) => {
// Extract user-submitted values from view submission object
const privateMetadataAsString = view.private_metadata
const { recordId, channelId, threadTs } = JSON.parse(privateMetadataAsString)
// Extract values from view submission payload and validate them/generate errors
const fieldsWithValues = await helpers.extractInputsFromViewSubmissionPayload({ view }, Fields)
const errors = helpers.validateInputs(fieldsWithValues)
logger.debug({ fieldsWithValues, errors })
// If there are errors, respond to Slack with errors; otherwise, respond with a confirmation
if (Object.keys(errors).length > 0) {
await ack({
response_action: 'errors',
errors
})
} else {
// If there are no validation errors, close the modal and update the thread
await ack()
const { blocks: recordUpdateRequestReceived } = messages.recordUpdateRequestReceived(fieldsWithValues)
await client.chat.postMessage({
channel: channelId,
thread_ts: threadTs,
blocks: recordUpdateRequestReceived
})
// Determine payload for Airtable record update
const fieldsToUpdate = {
[SystemFields.get('updater_slack_uid').airtableFieldName]: body.user.id,
[SystemFields.get('updater_slack_name').airtableFieldName]: body.user.name
}
Object.keys(fieldsWithValues).forEach((fieldName) => {
const fieldWithValue = fieldsWithValues[fieldName]
fieldsToUpdate[fieldWithValue.airtableFieldName] = fieldWithValue.value
})
// Depending on success/failure from Airtable API, send DM to submitter (to existing thread)
let updateToSubmitter
try {
const updatedRecord = await airtableTable.update(recordId, fieldsToUpdate)
updateToSubmitter = messages.simpleMessage(`:white_check_mark: Your <https://airtable.com/${EnvVars.AIRTABLE_BASE_ID}/${EnvVars.AIRTABLE_TABLE_ID}/${recordId}|record> has been updated. The primary field value is now *${updatedRecord.get(EnvVars.AIRTABLE_PRIMARY_FIELD_NAME)}*`).blocks
} catch (error) {
updateToSubmitter = messages.simpleMessage(`:bangbang: An error occured while updating your <https://airtable.com/${EnvVars.AIRTABLE_BASE_ID}/${EnvVars.AIRTABLE_TABLE_ID}/${recordId}|record> details in Airtable: ${helpers.objectStringifiedAsMardownCodeBlock(error)}`).blocks
}
// Thread update to DM thread
await client.chat.postMessage({
channel: channelId,
thread_ts: threadTs,
blocks: updateToSubmitter,
unfurl_links: false
})
}
})
app.error((error) => {
console.error('An error was caught by Bolt app.error:')
console.log(util.inspect(error, false, null, true))
})
// == START SLACK APP SERVER ==
;(async () => {
// Test connection to Airtable before starting Bolt
try {
await airtableTable.select({ maxRecords: 1 }).all()
app.client.logger.info('✅ Connected to Airtable')
} catch (error) {
app.client.logger.error('❌ Bolt NOT started; there was an error connecting to Airtable: ', error)
process.exit(1)
}
// Start Bolt app
await app.start()
app.client.logger.info('✅ Slack Bolt app is running!')
})()