This repository was archived by the owner on Nov 22, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
500 lines (441 loc) · 15.2 KB
/
Copy pathindex.js
File metadata and controls
500 lines (441 loc) · 15.2 KB
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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
/*
* SSPWr chatbot
*
* Warning: This code does not guarantee sequential messages at all. If multiple messages
* are required to be sent, they are all sent concurrently.
*
* Right now this is a good thing, as nothing in the chatbot needs to be sequential
* - there's only single message replies to users' messages.
*
* However, if sequential messages become a requirement, the structure would need
* to be changed a little bit (await on each successive sent message instead of
* one big one for all of them in the handler)
*/
const https = require('https');
const crypto = require('crypto');
const config = {
/*
* Page IDs on which we respond to every text message with a prompt to
* pick a question from menu. Helpful for testing.
*/
RESPOND_TO_EVERYTHING_ON: process.env.RESPOND_TO_EVERYTHING_ON.split(','),
/*
* Token used when verifying the webhook to make sure we have the correct url
*/
VERIFY_TOKEN: process.env.VERIFY_TOKEN,
/*
* Secret used for signature verification to be sure we're actually getting called
* from Facebook and not someone typing the url in curl.
*/
APP_SECRET: process.env.APP_SECRET,
/*
* Maps environment variables PAGE_ACCESS_TOKEN_123=aaa, PAGE_ACCESS_TOKEN_234=bbb
* into the object:
* {
* '123': 'aaa',
* '234': 'bbb'
* }
*/
PAGE_ACCESS_TOKEN:
Object.entries(process.env)
.filter(([key, value]) => key.startsWith('PAGE_ACCESS_TOKEN_'))
.map(([key, value]) => [
key.replace(/^PAGE_ACCESS_TOKEN_/, ''), value
])
.reduce((obj, [key, value]) => (
{ ...obj, [key]: value }
), {})
};
/*
* Config file format: object { payload_name: response_text, ... }
*
* Example:
* {
* "limit_ects": "Limit ects zależy od wydziału",
* "asystentka": "Godziny pracy asystentki są na stronie ..."
* }
*/
//const responses = require('./responses.json');
const responseConfig = require('./response_config.json');
const submenusByPayload =
responseConfig.menu
.reduce((obj, response) => ({
...obj,
[response.payload]: response
}), {});
/*
* Copied from https://stackoverflow.com/a/31652607/3105260
*
* 'mąka' -> 'm\\u0105ka'
*/
function to_ascii_safe(text) {
return text.replace(/[\u007F-\uFFFF]/g, function(chr) {
return "\\u" + ("0000" + chr.charCodeAt(0).toString(16)).substr(-4);
});
}
/*
* Node.js does not have a simple built-in to use API for sending HTTPS POST requests
* Code adapted from https://stackoverflow.com/a/67094088/3105260
*/
async function post(url, data) {
console.log("Sending a post request with data: ", JSON.stringify(data, null, 2))
/*
* No idea why escaping unicode sequences (to_ascii_safe) here is neccessary,
* facebook isn't able to parse the JSON correctly otherwise, even if "; charset=utf-8"
* is added to the Content-Type header
*
* Requests without escaping work when sent from curl though, a mistery.
*/
const dataString = to_ascii_safe(JSON.stringify(data));
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': dataString.length,
},
timeout: 10000, // in ms
};
return new Promise((resolve, reject) => {
const req = https.request(url, options, (res) => {
const body = [];
res.on('data', (chunk) => {
body.push(chunk);
});
res.on('end', () => {
const resString = Buffer.concat(body).toString();
if (res.statusCode < 200 || res.statusCode > 299) {
reject(new Error(`POST code=${res.statusCode} body: ${resString}`));
} else {
resolve(resString);
}
});
});
req.on('error', (err) => {
reject(err);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request time out'));
});
req.write(dataString);
req.end();
});
}
/*
* Sends a `messaging` request to the Facebook API. Every message type (simple text,
* quick response, buttons, ...) shares this format.
*/
function send_message(page_id, to, message) {
if(!(page_id in config.PAGE_ACCESS_TOKEN)) {
throw new Error(`[!!] Can't send message: no PAGE_ACCESS_TOKEN for page with id=${page_id}`);
}
const send_message_api =
`https://graph.facebook.com/v10.0/me/messages?access_token=${config.PAGE_ACCESS_TOKEN[page_id]}`;
return post(send_message_api, {
// Note: the "RESPONSE" type requires the person we're sending a message to
// to have texted us in the last 24 hours
messaging_type: "RESPONSE",
recipient: {
id: to
},
message: message
});
}
function set_ice_breakers(page_id) {
if(!(page_id in config.PAGE_ACCESS_TOKEN)) {
throw new Error(`[!!] Can't set ice breakers: no PAGE_ACCESS_TOKEN for page with id=${page_id}`);
}
const messenger_profile_url =
`https://graph.facebook.com/v10.0/me/messenger_profile?access_token=${config.PAGE_ACCESS_TOKEN[page_id]}`;
const ice_breakers = responseConfig.menu
.filter(menuItem => menuItem.inIceBreakers)
.map(menuItem => ({
payload: menuItem.payload,
question: menuItem.buttonText
}));
return post(messenger_profile_url, {
ice_breakers: ice_breakers
})
}
/*
* Sends a message with additional `quick_replies` buttons attached to it.
*
* page_id: Sender id (string)
*
* to: Receiver id (string)
*
* text: Text for the message (string)
* - required by facebook to be nonempty and contain some non-whitespace characters.
*
* menu: A set of quick_reply buttons (array of objects)
* - example:
* [ { buttonText: "button 1", payload: "1" },
* { buttonText: "button 2", payload: "2" },
* ... ]
*/
function quick_replies_menu(page_id, to, text, menu) {
const quick_replies = menu.map(({buttonText, payload}) => ({
content_type: 'text',
title: buttonText,
payload: payload
}));
return send_message(page_id, to, {
text: text,
quick_replies: quick_replies
});
}
function toplevel_quick_replies_menu(page_id, to, text) {
const menu = responseConfig.menu
.filter(menuItem => menuItem.inQuickMenu)
.map(({ buttonText, payload }) => ({ buttonText, payload }));
return quick_replies_menu(page_id, to, text, menu);
}
/*
* Gets called with every single message received to every page except the test page
* (requets['entry'][i]['messaging'][j]), a message being in the format of:
*
* {
* sender: { id: '3810638709058584' },
* recipient: { id: '108276354796392' },
* timestamp: 1622334055157,
* message: { // (optional)
* mid: 'm_OJ3NJTV9IoKa2BQ-y9zQ...',
* text: 'message text content',
* nlp: { ... }
* },
* postback: { // (optional)
* payload: 'asystentka',
* }
* }
*
* Returns promises for requests made to facebook
*/
function messaging_entry(page_id, messaging) {
const person_id = messaging.sender.id;
const message = messaging.message || {};
const text = message.text || '';
/*
* Payload set by quick reply buttons
*/
const quick_reply_payload = (message.quick_reply || {}).payload;
/*
* Payload set by the `ice_breaker`s displayed at the beginning of the conversation
*/
const ice_breaker_payload = (messaging.postback || {}).payload;
/*
* Ignore messages sent by us, preventing a possible loop with the chatbot
* talking to itself
*/
if(person_id == page_id)
return;
var payload = ice_breaker_payload || quick_reply_payload;
if(payload in responseConfig.responses) {
/*
* A response to a payload (received from either interacting with the
* ice_breakers or quick_replies) with a simple answer and a loop back
* to the toplevel menu
*/
const text = responseConfig.responses[payload];
return [
toplevel_quick_replies_menu(page_id, person_id, text)
];
}
if(payload in submenusByPayload) {
const submenu = submenusByPayload[payload];
if(submenu.choices) {
/*
* A menu entry with an additional submenu (`submenu.choices`), and
* an optional response text message.
*/
const text = submenu.response != null
? responseConfig.responses[submenu.response]
: responseConfig.defaultSubmenuResponse;
if(submenu.noQuickMenuAfter)
return [ send_message(page_id, person_id, {text: text}) ];
else
return [ quick_replies_menu(page_id, person_id, text, submenu.choices) ];
}
if(submenu.response != null) {
/*
* A menu entry without an additional submenu, but with a response.
* Loops back to the toplevel menu.
*/
const text = responseConfig.responses[submenu.response];
if(submenu.noQuickMenuAfter)
return [ send_message(page_id, person_id, {text: text}) ];
else
return [ toplevel_quick_replies_menu(page_id, person_id, text) ];
}
}
/*
* On dev and staging fanpages, reply to every normal text message with the
* toplevel menu for easier testing.
*/
if(config.RESPOND_TO_EVERYTHING_ON.includes(page_id)) {
if(text) {
return [ toplevel_quick_replies_menu(page_id, person_id, 'Wybierz kategorię pytania') ];
}
}
return [];
}
/*
* Gets called with data (request['entry'][i]) in the format of
*
* {
* id: '108276354796392',
* time: 1622334438323,
* messaging: [
* { ... (messaging_entry) },
* { ... (messaging_entry) },
* ...
* ]
* }
*
* Returns promises for requests made to facebook to end
*/
function page_entry(page_entry) {
let promises = [];
// The id of a page the bot acts as (the same as in https://m.me/<page_id>)
const id = page_entry.id;
if(Array.isArray(page_entry.messaging)) {
for(let entry of page_entry.messaging) {
promises.push(...messaging_entry(id, entry));
}
}
return promises;
}
/*
* Gets called with data (the whole request) in the format of
*
* {
* object: 'page',
* entry: [
* { ... (page_entry) },
* { ... (page_entry) },
* ...
* ]
* }
*
* Returns promises for requests made to facebook to end
*/
function post_request(body) {
console.log('Received POST data:', JSON.stringify(body, null, 2));
/*
* Promise for message sending - makes sure to not return from the Lambda before
* actually responding to the message
*/
let promises = [];
if(body.object == 'page' && Array.isArray(body.entry)) {
for(let entry of body.entry) {
promises.push(...page_entry(entry));
}
}
return promises;
}
/*
* Handle the Facebook webhook verification procedure
*
* We get a GET with
* /path?hub.mode=subsribe&hub.verify_token=...&hub.challenge=...
*
* and have to respond with the value of the gotten "hub.challenge"
*
* The value of "hub.verify_token" is going to be the same as a string pasted
* into the Facebook GUI
*/
function get_request(queryStringParameters) {
// Parse the query params
let mode = queryStringParameters["hub.mode"];
let token = queryStringParameters["hub.verify_token"];
let challenge = queryStringParameters["hub.challenge"];
// Checks if a token and mode is in the query string of the request
if (mode && token) {
// Checks the mode and token sent is correct
if (mode === "subscribe" && token === config.VERIFY_TOKEN) {
// Responds with the challenge token from the request
console.log("[!!] webhook verified");
return {
statusCode: 200,
body: challenge
};
} else {
// Responds with '403 Forbidden' if verify tokens do not match
console.log("[!!] webhook not verified - tokens do not match");
return {
statusCode: 403,
body: ""
};
}
}
}
/*
* Make sure the request actually comes from Facebook by comparing the signature
*/
function valid_signature(signature, body) {
let hmac = crypto.createHmac('sha1', config.APP_SECRET);
hmac.update(body, 'utf-8');
return signature == `sha1=${hmac.digest("hex")}`;
}
exports.handler = async (event, context) => {
try {
console.log('Received event:', JSON.stringify(event, null, 2));
if(event.version && event.version != '2.0') {
console.log(`[!!] WARNING: event.version ('${event.version}') different `
+ `from expected '2.0' - if something breaks it's propably because of this`);
}
const httpMethod = event.httpMethod || event.requestContext.http.method;
if(httpMethod == 'GET') {
/*
* The "verify" message goes through a GET request
*/
return get_request(event.queryStringParameters);
} else if(httpMethod == 'POST') {
/*
* Everything else (e.g. messages, typing notifications, reactions, ...)
* gets received through a POST.
*/
/*
* Triggered by Netlify when a successfull deploy is finished
*/
if(event.queryStringParameters.webhookSuccessfulDeploy == '1') {
/*
* A way to set desired `ice_breakes` - just invoke the Lambda with
* input set to {"set_ice_breakers":true}
*/
for(let page_id of Object.keys(config.PAGE_ACCESS_TOKEN)) {
console.log(`Setting ice breakers for page with id ${page_id}`);
await set_ice_breakers(page_id);
}
return {
statusCode: 200,
body: '"set ice_breakers"',
};
}
/*
* Make sure the request actually comes from Facebook by comparing the signature
*/
if(!valid_signature(event.headers['x-hub-signature'], event.body)) {
throw new Error('Invalid signature');
}
/*
* We wait for all message responses to finish sending out
*/
await Promise.all(post_request(JSON.parse(event.body)));
return {
statusCode: 200,
body: ""
};
} else {
/*
* No other request method should ever be reveived from Facebook
*/
throw new Error(`Unsupported method "${event.httpMethod}"`);
}
} catch(e) {
console.error("[!!] Got an error but returning 200 anyway to not make facebook mad", e);
return {
statusCode: 200,
body: e.toString()
};
}
};