├── src ├── .gitignore ├── .jshintrc ├── config │ ├── .gitignore │ └── example.json ├── test │ └── bot │ │ ├── res │ │ ├── paintings.js │ │ └── painters.js │ │ ├── webhook.js │ │ ├── queries.js │ │ ├── wikidata.js │ │ └── search.js ├── package.json ├── app.js ├── bot │ ├── webhook.js │ ├── search.js │ ├── queries.js │ ├── wikidata.js │ ├── gvn.js │ └── handlers.js └── fb │ └── fb-lib.js ├── .gitignore ├── .travis.yml ├── etc ├── deploy ├── message.sh ├── postback.sh ├── deploy-heroku ├── postback.json └── message.json ├── README.md └── LICENSE /src/.gitignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | node_modules 3 | -------------------------------------------------------------------------------- /src/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6 3 | } 4 | -------------------------------------------------------------------------------- /src/config/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !example.json 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | src/npm-debug.log 4 | src/start.sh 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.9.4" 4 | before_install: cd src 5 | -------------------------------------------------------------------------------- /etc/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rsync -avz --exclude "node_modules" ../src/* pris:/var/www/nodejs/erfgoedbot/ -------------------------------------------------------------------------------- /etc/message.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | curl -i -X POST -H 'Content-Type: application/json' -d "`sed "s/:MSG:/$1/" < message.json`" $2 5 | echo '\n' 6 | -------------------------------------------------------------------------------- /etc/postback.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | curl -i -X POST -H 'Content-Type: application/json' -d "`sed "s/:PAYLOAD:/$1/" < postback.json`" $2 5 | echo "\n" 6 | -------------------------------------------------------------------------------- /src/config/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "appSecret": "", 3 | "pageAccessToken": "", 4 | "validationToken": "erfgoed", 5 | "serverURL": "", 6 | "pathPrefix" : "", 7 | "port" : 8080 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Erfgoedbot 2 | 3 | Facebook Messenger bot with Dutch erfgoed data, linked to Wikidata 4 | 5 | 6 | [![Build Status](https://travis-ci.org/renevanderark/erfgoedbot.svg?branch=master)](https://travis-ci.org/KBNLresearch/erfgoedbot) 7 | -------------------------------------------------------------------------------- /etc/deploy-heroku: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | workdir=${PWD##*/} 4 | if [ "$workdir" == "etc" ]; then 5 | cd .. 6 | fi 7 | 8 | git subtree push --prefix src heroku master 9 | 10 | if [ "$workdir" == "etc" ]; then 11 | cd "etc" 12 | fi 13 | -------------------------------------------------------------------------------- /etc/postback.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": [ 3 | { 4 | "id": 1397278930293852, 5 | "messaging": [ 6 | { 7 | "postback": { 8 | "payload": ":PAYLOAD:" 9 | }, 10 | "recipient": { 11 | "id": 1397278930293852 12 | }, 13 | "sender": { 14 | "id": 1397278930293852 15 | }, 16 | "timestamp": 1460620433123 17 | } 18 | ], 19 | "time": 1460620433256 20 | } 21 | ], 22 | "object": "page" 23 | } 24 | -------------------------------------------------------------------------------- /etc/message.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": [ 3 | { 4 | "id": 1397278930293852, 5 | "messaging": [ 6 | { 7 | "message": { 8 | "mid": "mid.1460620432888:f8e3412003d2d1cd93", 9 | "seq": 12604, 10 | "text": ":MSG:" 11 | }, 12 | "recipient": { 13 | "id": 1397278930293852 14 | }, 15 | "sender": { 16 | "id": 1397278930293852 17 | }, 18 | "timestamp": 1460620433123 19 | } 20 | ], 21 | "time": 1460620433256 22 | } 23 | ], 24 | "object": "page" 25 | } 26 | -------------------------------------------------------------------------------- /src/test/bot/res/paintings.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { item: 3 | { type: 'uri', 4 | value: 'http://www.wikidata.org/entity/Q19836161' }, 5 | described: 6 | { type: 'uri', 7 | value: 'http://www.vggallery.com/painting/p_0388v.htm' }, 8 | image: 9 | { type: 'uri', 10 | value: 'http://commons.wikimedia.org/wiki/Special:FilePath/Moestuin%20met%20zonnebloem%20-%20s0004V1962v%20-%20Van%20Gogh%20Museum.jpg' }, 11 | itemLabel: 12 | { 'xml:lang': 'en', 13 | type: 'literal', 14 | value: 'Allotment with Sunflower' }, 15 | itemDescription: 16 | { 'xml:lang': 'en', 17 | type: 'literal', 18 | value: 'painting by Vincent van Gogh, 1887' }, 19 | collectionLabel: { 'xml:lang': 'en', type: 'literal', value: 'Van Gogh Museum' } } 20 | ]; -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "erfgoedbot", 3 | "version": "1.0.0", 4 | "description": "Facebook Messenger bot with Dutch erfgoed data, linked to Wikidata", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js", 8 | "lint": "jshint --exclude node_modules .", 9 | "test": "mocha --recursive test" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/hay/erfgoedbot.git" 14 | }, 15 | "author": "Facebook", 16 | "license": "MIT", 17 | "dependencies": { 18 | "body-parser": "^1.15.0", 19 | "express": "^4.13.4", 20 | "lodash": "^4.16.6", 21 | "request": "^2.72.0", 22 | "request-promise": "^4.1.1" 23 | }, 24 | "engines": { 25 | "node": "^6.9.4" 26 | }, 27 | "devDependencies": { 28 | "bluebird": "^3.5.0", 29 | "expect": "^1.20.2", 30 | "mocha": "^3.2.0", 31 | "sinon": "^1.17.7", 32 | "sinon-as-promised": "^4.0.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hay Kranen < http://www.haykranen.nl > 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | 5 | // Read config 6 | const config = fs.existsSync('config/production.json') 7 | ? JSON.parse(fs.readFileSync('config/production.json', 'utf-8')) 8 | : { 9 | "appSecret": process.env.MESSENGER_APP_SECRET, 10 | "pageAccessToken": process.env.MESSENGER_PAGE_ACCESS_TOKEN, 11 | "validationToken": process.env.MESSENGER_VALIDATION_TOKEN, 12 | "serverURL": process.env.SERVER_URL, 13 | "pathPrefix": "", 14 | "port": process.env.PORT 15 | }; 16 | 17 | const 18 | bodyParser = require('body-parser'), 19 | express = require('express'), 20 | fb = require("./fb/fb-lib")(config), 21 | botHandlers = require("./bot/handlers")(fb), 22 | webHook = require("./bot/webhook")(fb, botHandlers); 23 | 24 | const PATH_PREFIX = config.pathPrefix; 25 | 26 | const app = express(); 27 | app.set('port', config.port); 28 | app.use(bodyParser.json({verify: fb.verifyRequestSignature})); 29 | 30 | app.get(`${PATH_PREFIX}/webhook`, fb.validateWebhook); 31 | app.post(`${PATH_PREFIX}/webhook`, webHook); 32 | app.listen(app.get('port'), () => console.log('Node app is running on port', app.get('port'))); 33 | 34 | module.exports = app; -------------------------------------------------------------------------------- /src/bot/webhook.js: -------------------------------------------------------------------------------- 1 | module.exports = (fb, handlers) => function (req, res) { 2 | const data = req.body; 3 | 4 | // Make sure this is a page subscription 5 | if (data.object == 'page') { 6 | // Iterate over each entry 7 | // There may be multiple if batched 8 | data.entry.forEach(function (pageEntry) { 9 | 10 | // Iterate over each messaging event 11 | pageEntry.messaging.forEach(function (messagingEvent) { 12 | if (messagingEvent.message) { 13 | fb.receivedMessage(messagingEvent, handlers); 14 | } else if (messagingEvent.delivery) { 15 | fb.receivedDeliveryConfirmation(messagingEvent); 16 | } else if (messagingEvent.postback) { 17 | fb.receivedPostback(messagingEvent, handlers); 18 | } else if (messagingEvent.read) { 19 | fb.receivedMessageRead(messagingEvent); 20 | } else { 21 | console.log("Webhook received unimplemented messagingEvent: ", messagingEvent); 22 | } 23 | }); 24 | }); 25 | 26 | // Assume all went well. 27 | // 28 | // You must send back a 200, within 20 seconds, to let us know you've 29 | // successfully received the callback. Otherwise, the request will time out. 30 | res.sendStatus(200); 31 | } 32 | }; -------------------------------------------------------------------------------- /src/test/bot/res/painters.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | "item": { 4 | "type": "uri", 5 | "value": "http://www.wikidata.org/entity/Q5582" 6 | }, 7 | "itemLabel": { 8 | "xml:lang": "nl", 9 | "type": "literal", 10 | "value": "Vincent van Gogh" 11 | }, 12 | "itemDescription": { 13 | "xml:lang": "nl", 14 | "type": "literal", 15 | "value": "Nederlands kunstschilder" 16 | }, 17 | "itemAltLabel": { 18 | "xml:lang": "nl", 19 | "type": "literal", 20 | "value": "Vincent Willem van Gogh" 21 | } 22 | }, 23 | { 24 | "item": { 25 | "type": "uri", 26 | "value": "http://www.wikidata.org/entity/Q317188" 27 | }, 28 | "itemLabel": { 29 | "xml:lang": "nl", 30 | "type": "literal", 31 | "value": "Theo van Gogh" 32 | }, 33 | "itemDescription": { 34 | "xml:lang": "nl", 35 | "type": "literal", 36 | "value": "kunsthandelaar" 37 | }, 38 | "itemAltLabel": { 39 | "xml:lang": "nl", 40 | "type": "literal", 41 | "value": "Theodorus van Gogh, Theodorus \"Theo\" van Gogh" 42 | } 43 | }, 44 | { 45 | "item": { 46 | "type": "uri", 47 | "value": "http://www.wikidata.org/entity/Q12173703" 48 | }, 49 | "itemLabel": { 50 | "xml:lang": "nl", 51 | "type": "literal", 52 | "value": "Jan van Gogh" 53 | } 54 | } 55 | ]; -------------------------------------------------------------------------------- /src/bot/search.js: -------------------------------------------------------------------------------- 1 | const wikidata = require('./wikidata.js'); 2 | const _ = require('lodash'); 3 | 4 | function randomArtist(callback) { 5 | wikidata.randomArtist((err, data) => { 6 | if (err) { 7 | handlePainters(err, null, callback); 8 | } else { 9 | data.data = [_.sample(data.data)]; 10 | handlePainters(err, data, callback); 11 | } 12 | }); 13 | } 14 | 15 | function painterByDate(month, day, callback) { 16 | wikidata.painterByDate(month, day, (err, data) => { 17 | if (!data || data.length === 0) { 18 | callback('Geen resultaten gevonden', null); 19 | } else { 20 | callback(null, { 21 | type : 'buttons', 22 | buttons : data 23 | }); 24 | } 25 | }); 26 | } 27 | 28 | function getMonuments(callback) { 29 | wikidata.getMonuments((err, data) => { 30 | handleImages(err, data,callback); 31 | }); 32 | } 33 | 34 | function handlePainters(err, data, callback) { 35 | if (err) { 36 | callback(err, null); 37 | } else { 38 | callback(null, { 39 | type : 'buttons', 40 | buttons : data 41 | }); 42 | } 43 | } 44 | 45 | function searchPainters(q, callback) { 46 | wikidata.searchPainters(q, (err, data) => { 47 | handlePainters(err, data, callback); 48 | }); 49 | } 50 | 51 | function handleImages(err, data, callback) { 52 | if (err) { 53 | callback(err, null); 54 | } else { 55 | callback(null, { 56 | type : 'images', 57 | images : data 58 | }); 59 | } 60 | } 61 | 62 | function paintingsByArtist(id, callback) { 63 | wikidata.paintingsByArtist(id, (err, data) => { 64 | handleImages(err, data, callback); 65 | }); 66 | } 67 | 68 | module.exports = { paintingsByArtist, searchPainters, painterByDate, getMonuments, randomArtist }; -------------------------------------------------------------------------------- /src/bot/queries.js: -------------------------------------------------------------------------------- 1 | const rp = require('request-promise'); 2 | 3 | function monuments(location) { 4 | return ` 5 | SELECT ?item ?itemLabel ?itemDescription ?image WHERE { 6 | ?item wdt:P1435 wd:Q916333 . 7 | ?item wdt:P131 wd:${location} . 8 | ?item wdt:P18 ?image . 9 | SERVICE wikibase:label { bd:serviceParam wikibase:language "nl". } 10 | } LIMIT 100 11 | `; 12 | } 13 | 14 | function painterByDate(month, day) { 15 | return ` 16 | PREFIX xsd: 17 | 18 | SELECT ?entity (YEAR(?date) AS ?year) ?entityLabel WHERE { 19 | ?entity wdt:P31 wd:Q5. 20 | ?entity wdt:P106 wd:Q1028181. 21 | ?entity wdt:P569 ?date. 22 | SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl". } 23 | FILTER(((DATATYPE(?date)) = xsd:dateTime) && ((MONTH(?date)) = ${month}) && ((DAY(?date)) = ${day})) 24 | } LIMIT 3`; 25 | } 26 | 27 | function paintingsByArtist(id) { 28 | return ` 29 | select distinct ?item ?image ?itemLabel ?itemDescription ?collectionLabel ?described where { 30 | ?item wdt:P170 wd:${id} . 31 | ?item wdt:P18 ?image . 32 | ?item wdt:P195 ?collection . 33 | ?item wdt:P973 ?described . 34 | SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl" } 35 | } LIMIT 100`; 36 | } 37 | 38 | function searchPainters(q) { 39 | return ` 40 | select distinct ?item ?itemLabel ?itemDescription ?itemAltLabel where { 41 | ?item wdt:P31 wd:Q5; wdt:P106 wd:Q1028181; rdfs:label ?label . 42 | FILTER( LANG(?label) = "nl" || LANG(?label) = "en" ) . 43 | FILTER( CONTAINS(LCASE(?label), "${q}") || CONTAINS(LCASE(?altLabel), "${q}") ) . 44 | SERVICE wikibase:label { bd:serviceParam wikibase:language "nl" } . 45 | } order by desc(?item)`; 46 | } 47 | 48 | function randomArtist() { 49 | return ` 50 | SELECT DISTINCT ?item ?itemLabel WHERE { 51 | ?work wdt:P31 wd:Q3305213 . 52 | ?work wdt:P18 ?image . 53 | ?work wdt:P195 ?collection . 54 | ?collection wdt:P17 wd:Q55 . 55 | ?work wdt:P170 ?item . 56 | SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl" } 57 | } LIMIT 1000`; 58 | } 59 | 60 | function query(q, url = null) { 61 | const ENDPOINT = url || ` 62 | https://query.wikidata.org/bigdata/namespace/wdq/sparql 63 | ?format=json&query=${encodeURIComponent(q)} 64 | `; 65 | 66 | return rp.get({ 67 | uri : ENDPOINT, 68 | json : true 69 | }); 70 | } 71 | 72 | module.exports = { 73 | monuments, painterByDate, paintingsByArtist, searchPainters, randomArtist, query 74 | }; -------------------------------------------------------------------------------- /src/bot/wikidata.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const queries = require('./queries.js'); 3 | 4 | function getMonuments(callback) { 5 | const q = queries.monuments("Q803"); 6 | 7 | queries.query(q).then((data) => { 8 | handleImages(data, callback); 9 | }); 10 | } 11 | 12 | function painterByDate(month, day, cb) { 13 | const q = queries.painterByDate(month, day); 14 | 15 | queries.query(q).then((data) => { 16 | data = data.results.bindings.map((item) => { 17 | return { 18 | title : item.entityLabel.value, 19 | payload : item.entity.value.replace('http://www.wikidata.org/entity/', '') 20 | }; 21 | }); 22 | 23 | cb(null, { 24 | text : `Deze schilders zijn geboren op ${day}-${month}. Kies er een.`, 25 | data : data 26 | }); 27 | }); 28 | } 29 | 30 | function handleImages(data, cb, authorId) { 31 | authorId = authorId || null; 32 | 33 | if (!data.results.bindings || data.results.bindings.length === 0) { 34 | cb("Sorry, daar kan ik geen schilderijen van vinden.", null); 35 | return; 36 | } 37 | 38 | const p = _.sample(data.results.bindings); 39 | 40 | const result = { 41 | image: `${p.image.value}?width=800`, 42 | label: p.itemLabel.value, 43 | description: p.itemDescription.value, 44 | id: p.item.value.replace('http://www.wikidata.org/entity/', ''), 45 | author: authorId 46 | }; 47 | 48 | result.collection = p.collectionLabel ? p.collectionLabel.value : null; 49 | result.url = p.described ? p.described.value : null; 50 | 51 | cb(null, result); 52 | } 53 | 54 | function paintingsByArtist(id, cb) { 55 | const q = queries.paintingsByArtist(id); 56 | 57 | queries.query(q).then((data) => { 58 | handleImages(data, cb, id); 59 | }); 60 | } 61 | 62 | function handlePainters(data, cb, limit) { 63 | limit = limit || 3; 64 | 65 | if (!data.results.bindings || data.results.bindings.length === 0) { 66 | cb("Sorry, ik kan geen schilders vinden die zo heten.", null); 67 | } else { 68 | data = data.results.bindings.slice(0, limit).map((item) => { 69 | return { 70 | title : item.itemLabel.value, 71 | payload : item.item.value.replace('http://www.wikidata.org/entity/', '') 72 | }; 73 | }).sort(function(a, b) { 74 | return parseInt(a.payload.slice(1)) < parseInt(b.payload.slice(1)) ? -1 : 1; 75 | }); 76 | 77 | cb(null, { 78 | text : `Welke van deze schilders wil je hebben?`, 79 | data : data 80 | }); 81 | } 82 | } 83 | 84 | function searchPainters(q, cb) { 85 | q = queries.searchPainters(q.toLowerCase()); 86 | 87 | queries.query(q).then((data) => { 88 | handlePainters(data, cb); 89 | }); 90 | } 91 | 92 | function randomArtist(cb) { 93 | q = queries.randomArtist(); 94 | 95 | queries.query(q).then((data) => { 96 | handlePainters(data, cb, 100); 97 | }); 98 | } 99 | 100 | module.exports = { paintingsByArtist, searchPainters, painterByDate, getMonuments, randomArtist }; -------------------------------------------------------------------------------- /src/test/bot/webhook.js: -------------------------------------------------------------------------------- 1 | const expect = require("expect"); 2 | 3 | describe("webhook", () => { 4 | it("should handle messages", (done) => { 5 | const expectedHandlers = {expected: "handler"}; 6 | const eventPayload = {message: "message payload"}; 7 | const req = {body: {object: 'page', entry: [ 8 | {messaging: [eventPayload]} 9 | ]}}; 10 | const underTest = require("../../bot/webhook")({ 11 | receivedMessage: (data, handlers) => { 12 | try { 13 | expect(data).toEqual(eventPayload); 14 | expect(handlers).toEqual(expectedHandlers); 15 | done(); 16 | } catch (e) { 17 | done(e); 18 | } 19 | } 20 | }, expectedHandlers); 21 | underTest(req, {sendStatus: () => {}}); 22 | }); 23 | 24 | it("should handle postbacks", (done) => { 25 | const expectedHandlers = {expected: "handler"}; 26 | const eventPayload = {postback: "postback payload"}; 27 | const req = {body: {object: 'page', entry: [ 28 | {messaging: [eventPayload]} 29 | ]}}; 30 | const underTest = require("../../bot/webhook")({ 31 | receivedPostback: (data, handlers) => { 32 | try { 33 | expect(data).toEqual(eventPayload); 34 | expect(handlers).toEqual(expectedHandlers); 35 | done(); 36 | } catch (e) { 37 | done(e); 38 | } 39 | } 40 | }, expectedHandlers); 41 | underTest(req, {sendStatus: () => {}}); 42 | }); 43 | 44 | it("should handle delivery confirmations", (done) => { 45 | const eventPayload = {delivery: "delivery payload"}; 46 | const req = {body: {object: 'page', entry: [ 47 | {messaging: [eventPayload]} 48 | ]}}; 49 | const underTest = require("../../bot/webhook")({ 50 | receivedDeliveryConfirmation: (data) => { 51 | try { 52 | expect(data).toEqual(eventPayload); 53 | done(); 54 | } catch (e) { 55 | done(e); 56 | } 57 | } 58 | }, {}); 59 | underTest(req, {sendStatus: () => {}}); 60 | }); 61 | 62 | it("should handle read confirmations", (done) => { 63 | const eventPayload = {read: "read payload"}; 64 | const req = {body: {object: 'page', entry: [ 65 | {messaging: [eventPayload]} 66 | ]}}; 67 | const underTest = require("../../bot/webhook")({ 68 | receivedMessageRead: (data) => { 69 | try { 70 | expect(data).toEqual(eventPayload); 71 | done(); 72 | } catch (e) { 73 | done(e); 74 | } 75 | } 76 | }, {}); 77 | underTest(req, {sendStatus: () => {}}); 78 | }); 79 | 80 | it("should send 200 OK", (done) => { 81 | const underTest = require("../../bot/webhook")({}, {}); 82 | const req = {body: {object: 'page', entry: []}}; 83 | 84 | underTest(req, {sendStatus: (statusCode) => { 85 | try { 86 | expect(statusCode).toEqual(200); 87 | done(); 88 | } catch (e) { 89 | done(e); 90 | } 91 | }}); 92 | }); 93 | }); -------------------------------------------------------------------------------- /src/bot/gvn.js: -------------------------------------------------------------------------------- 1 | const rp = require("request-promise"); 2 | const queries = require("./queries"); 3 | const _ = require("lodash"); 4 | 5 | const randomSample = (data, q) => _.shuffle( 6 | data.facets 7 | .filter(({name}) => name.indexOf("EN") < 0) 8 | .map(facet => facet.values 9 | .map(facetValue => ({ 10 | name: facet.name, 11 | value: facetValue.replace(/\([0-9]+\)$/, "").trim(), 12 | count: facetValue.replace(/^.*\(([0-9]+)\)$/, "$1"), 13 | }))) 14 | .reduce((a, b) => a.concat(b)) 15 | .filter(facet => facet.value.toLowerCase().indexOf(q) > -1) 16 | ).slice(0, 3) 17 | .map(facet => ({ 18 | title: facet.value, 19 | payload: `GVN|${facet.name}|${facet.value}|${facet.count}` 20 | })); 21 | 22 | const search = (q, {onSuccess, onError}) => { 23 | 24 | queries 25 | .query(null, `${process.env['GVN_URL']}/results?maxperpage=0&coll=ngvn`) 26 | .then((data) => { 27 | if (data && data.facets) { 28 | const result = randomSample(data, q); 29 | if (result.length > 0) { 30 | setTimeout(() => 31 | onSuccess(null, {type: "buttons", buttons: {text: "Welk onderwerp wil je hebben?", data: result}}), 32 | 1000 33 | ); 34 | } else { 35 | onError() 36 | } 37 | } else { 38 | onError(); 39 | } 40 | }).catch(onError); 41 | }; 42 | 43 | const imageByDidl = (result, callback) => { 44 | 45 | queries 46 | .query(null, `${process.env['GVN_URL']}/resource?coll=ngvn&identifier=` + 47 | `${encodeURIComponent(result.recordIdentifier)}&type=didl`) 48 | .then((data) => { 49 | console.log(JSON.stringify(data, null, 2)); 50 | if (data.resourceList && data.resourceList.images && 51 | data.resourceList.images.length > 0 && data.resourceList.images[0].image.length > 0 && 52 | data.resourceList.images[0].image[data.resourceList.images[0].image.length - 1].src 53 | ) { 54 | callback(data.resourceList.images[0].image[data.resourceList.images[0].image.length - 1].src) 55 | } else { 56 | callback(); 57 | } 58 | }) 59 | .catch(() => callback()); 60 | }; 61 | 62 | const imageByFacet = (facet, callback) => { 63 | const [x, facetName, facetValue, facetCount] = facet.split("|"); 64 | const page = _.random(1, facetCount); 65 | 66 | queries 67 | .query(null, `${process.env['GVN_URL']}/results?maxperpage=1&page=${page}&coll=ngvn` + 68 | `&facets[${encodeURIComponent(facetName)}][]=${encodeURIComponent(facetValue)}`) 69 | .then((data) => { 70 | if(data.diag || !data.records || data.records.length === 0) { 71 | callback("Geen beeld gevonden"); 72 | } else { 73 | const [ result ]= data.records; 74 | const title = typeof result.title === 'string' ? result.title : result.title[0]; 75 | imageByDidl(result, (biggerImageUrl = null) => callback(null, { 76 | type: "images", 77 | images: { 78 | image: biggerImageUrl || result.thumbnail, 79 | label: title, 80 | description: result.creator || "", 81 | subjectName: "dit onderwerp", 82 | author: facet, 83 | collection: result.institutionString, 84 | url: `http://geheugenvannederland.nl/nl/geheugen/view?identifier=${encodeURIComponent(result.recordIdentifier)}` 85 | } 86 | })); 87 | } 88 | }) 89 | .catch(() => callback("Geen beeld gevonden")); 90 | }; 91 | 92 | module.exports = { search, imageByFacet }; -------------------------------------------------------------------------------- /src/bot/handlers.js: -------------------------------------------------------------------------------- 1 | module.exports = (fb) => { 2 | 3 | const 4 | search = require('./search.js'), 5 | gvn = require('./gvn'), 6 | _ = require('lodash'); 7 | 8 | function sendDelayedRandomizedSocialFeedback(recipientId) { 9 | setTimeout(function () { 10 | fb.sendTextMessage(recipientId, `${_.random(8, 50)} mensen zagen deze afbeelding ook, ${_.random(2, 4)} mensen kijken op dit moment`); 11 | }, 4000); 12 | } 13 | 14 | 15 | const handleSearchResponse = (recipientID) => (err, data) => { 16 | fb.sendTypingOff(recipientID); 17 | 18 | if (err) { 19 | fb.sendTextMessage(recipientID, err); 20 | } else { 21 | if (data.type === 'buttons') { 22 | fb.sendButtonMessage(recipientID, data.buttons); 23 | } 24 | 25 | if (data.type === 'images') { 26 | fb.sendTextMessage(recipientID, `Je gaat zo zien: ${data.images.label}, ${data.images.description}`); 27 | fb.sendImageMessage(recipientID, data.images.image); 28 | setTimeout(function () { 29 | fb.sendURL(recipientID, `http://www.wikidata.org/wiki/${data.images.id}`); 30 | }, 3000); 31 | sendDelayedRandomizedSocialFeedback(recipientID); 32 | } 33 | 34 | if (data.type === 'text') { 35 | fb.sendTextMessage(recipientID, data.text); 36 | } 37 | } 38 | }; 39 | 40 | const handlePostbackResponse = (recipientId) => (err, data) => { 41 | if (err) { 42 | fb.sendTextMessage(recipientId, `Er ging iets mis: ${err}`); 43 | } else { 44 | if (data.type === 'images') { 45 | fb.sendTextMessage(recipientId, `Je gaat zo zien: ${data.images.label}, ${data.images.description}`); 46 | fb.sendImageMessage(recipientId, data.images.image); 47 | sendDelayedRandomizedSocialFeedback(recipientId); 48 | if (data.images.collection) { 49 | setTimeout(function () { 50 | fb.sendTextMessage(recipientId, `Dit kun je trouwens zien in de collectie van ${data.images.collection}`) 51 | const moreUrl = data.images.url ? data.images.url : `http://www.wikidata.org/wiki/${data.images.id}?width=800`; 52 | fb.sendURL(recipientId, moreUrl); 53 | fb.sendButtonMessage(recipientId, { 54 | text: `Nog een werk van ${data.images.subjectName || "deze schilder"}?`, 55 | data: [{ 56 | title: "Ja, leuk!", 57 | payload: data.images.author 58 | }] 59 | }) 60 | }, 5000); 61 | } 62 | } 63 | } 64 | }; 65 | 66 | 67 | const onTextMessage = (messageText, senderID) => { 68 | const parsedMsg = messageText.trim().toLowerCase(); 69 | fb.sendTextMessage(senderID, "Ik ben nu aan het zoeken, een momentje..."); 70 | fb.sendTypingOn(senderID); 71 | 72 | if (parsedMsg.indexOf('-') !== -1) { 73 | const dates = parsedMsg.split('-'); 74 | search.painterByDate(dates[1], dates[0], handleSearchResponse(senderID)); 75 | } else if (parsedMsg === 'utrecht') { 76 | search.getMonuments(handleSearchResponse(senderID)); 77 | } else if (parsedMsg === 'surprise') { 78 | search.randomArtist(handleSearchResponse(senderID)); 79 | } else { 80 | const searchPainters = () => search.searchPainters(parsedMsg, handleSearchResponse(senderID)); 81 | if(_.random(true, false)) { 82 | searchPainters(); 83 | } else { 84 | gvn.search(parsedMsg, {onSuccess: handleSearchResponse(senderID), onError: searchPainters}); 85 | } 86 | 87 | } 88 | }; 89 | 90 | const onAttachments = (senderID) => 91 | fb.sendTextMessage(senderID, "Sorry, dit snap ik even niet."); 92 | 93 | 94 | const onPostback = (senderID, payload) => { 95 | 96 | if(payload.charAt(0) === 'Q') { 97 | search.paintingsByArtist(payload, handlePostbackResponse(senderID)); 98 | 99 | fb.sendTextMessage(senderID, "Ik ben nu een schilderij aan het ophalen..."); 100 | } else if(payload.match(/^GVN/)) { 101 | gvn.imageByFacet(payload, handlePostbackResponse(senderID)); 102 | fb.sendTextMessage(senderID, "Ik ben nu een beeld aan het ophalen..."); 103 | } 104 | }; 105 | 106 | return { 107 | onAttachments: onAttachments, 108 | onPostback: onPostback, 109 | onTextMessage: onTextMessage 110 | } 111 | }; -------------------------------------------------------------------------------- /src/test/bot/queries.js: -------------------------------------------------------------------------------- 1 | const expect = require("expect"); 2 | 3 | const { 4 | monuments, painterByDate, paintingsByArtist, searchPainters, randomArtist 5 | } = require("../../bot/queries"); 6 | 7 | const splitAndFilter = (str) => str 8 | .split(/\n/) 9 | .map(r => r.trim()) 10 | .filter(r => r.length !== 0); 11 | 12 | describe("queries", () => { 13 | 14 | describe("monuments", () => { 15 | it("should build a sparql query based on the location name", () => { 16 | const result = splitAndFilter(monuments("utrecht")); 17 | 18 | expect(result.length).toEqual(6); 19 | expect(result[0]).toEqual("SELECT ?item ?itemLabel ?itemDescription ?image WHERE {"); 20 | expect(result[1]).toEqual("?item wdt:P1435 wd:Q916333 ."); 21 | expect(result[2]).toEqual("?item wdt:P131 wd:utrecht ."); 22 | expect(result[3]).toEqual("?item wdt:P18 ?image ."); 23 | expect(result[4]).toEqual(`SERVICE wikibase:label { bd:serviceParam wikibase:language "nl". }`); 24 | expect(result[5]).toEqual("} LIMIT 100"); 25 | }); 26 | }); 27 | 28 | describe("painterByDate", () => { 29 | it("should build a sparql query based on the month and day", () => { 30 | const month = 10; 31 | const day = 5; 32 | const result = splitAndFilter(painterByDate(month, day)); 33 | 34 | expect(result.length).toEqual(8); 35 | expect(result[0]).toEqual(`PREFIX xsd: `); 36 | expect(result[1]).toEqual("SELECT ?entity (YEAR(?date) AS ?year) ?entityLabel WHERE {"); 37 | expect(result[2]).toEqual("?entity wdt:P31 wd:Q5."); 38 | expect(result[3]).toEqual("?entity wdt:P106 wd:Q1028181."); 39 | expect(result[4]).toEqual("?entity wdt:P569 ?date."); 40 | expect(result[5]).toEqual(`SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl". }`); 41 | expect(result[6]).toEqual(`FILTER(((DATATYPE(?date)) = xsd:dateTime) && ((MONTH(?date)) = ${month}) && ((DAY(?date)) = ${day}))`); 42 | expect(result[7]).toEqual("} LIMIT 3"); 43 | }); 44 | }); 45 | 46 | describe("paintingsByArtist", () => { 47 | it("should build a sparql query based on the artist ID", () => { 48 | const id = 123; 49 | const result = splitAndFilter(paintingsByArtist(id)); 50 | 51 | expect(result.length).toEqual(7); 52 | expect(result[0]).toEqual("select distinct ?item ?image ?itemLabel ?itemDescription ?collectionLabel ?described where {"); 53 | expect(result[1]).toEqual(`?item wdt:P170 wd:${id} .`); 54 | expect(result[2]).toEqual("?item wdt:P18 ?image ."); 55 | expect(result[3]).toEqual("?item wdt:P195 ?collection ."); 56 | expect(result[4]).toEqual("?item wdt:P973 ?described ."); 57 | expect(result[5]).toEqual(`SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl" }`); 58 | expect(result[6]).toEqual("} LIMIT 100"); 59 | }); 60 | }); 61 | 62 | describe("searchPainters", () => { 63 | it("should build a sparql query based on the query string", () => { 64 | const query = "gogh"; 65 | const result = splitAndFilter(searchPainters(query)); 66 | 67 | expect(result.length).toEqual(6); 68 | expect(result[0]).toEqual("select distinct ?item ?itemLabel ?itemDescription ?itemAltLabel where {"); 69 | expect(result[1]).toEqual("?item wdt:P31 wd:Q5; wdt:P106 wd:Q1028181; rdfs:label ?label ."); 70 | expect(result[2]).toEqual(`FILTER( LANG(?label) = "nl" || LANG(?label) = "en" ) .`); 71 | expect(result[3]).toEqual(`FILTER( CONTAINS(LCASE(?label), "${query}") || CONTAINS(LCASE(?altLabel), "${query}") ) .`); 72 | expect(result[4]).toEqual(`SERVICE wikibase:label { bd:serviceParam wikibase:language "nl" } .`); 73 | expect(result[5]).toEqual("} order by desc(?item)"); 74 | }); 75 | }); 76 | 77 | describe("randomArtist", () => { 78 | it("should return this exact sparql query", () => { 79 | const result = splitAndFilter(randomArtist()); 80 | 81 | expect(result.length).toEqual(8); 82 | expect(result[0]).toEqual("SELECT DISTINCT ?item ?itemLabel WHERE {"); 83 | expect(result[1]).toEqual("?work wdt:P31 wd:Q3305213 ."); 84 | expect(result[2]).toEqual("?work wdt:P18 ?image ."); 85 | expect(result[3]).toEqual("?work wdt:P195 ?collection ."); 86 | expect(result[4]).toEqual("?collection wdt:P17 wd:Q55 ."); 87 | expect(result[5]).toEqual("?work wdt:P170 ?item ."); 88 | expect(result[6]).toEqual(`SERVICE wikibase:label { bd:serviceParam wikibase:language "en,nl" }`); 89 | expect(result[7]).toEqual("} LIMIT 1000"); 90 | }); 91 | }) 92 | }); -------------------------------------------------------------------------------- /src/test/bot/wikidata.js: -------------------------------------------------------------------------------- 1 | const rp = require("request-promise"); 2 | const Bluebird = require('bluebird'); 3 | require('sinon-as-promised')(Bluebird); 4 | const sinon = require("sinon"); 5 | const expect = require("expect"); 6 | 7 | const queries = require("../../bot/queries"); 8 | 9 | const { 10 | paintingsByArtist, searchPainters, painterByDate, getMonuments, randomArtist 11 | } = require("../../bot/wikidata"); 12 | 13 | 14 | describe("wikidata", () => { 15 | describe("painter searches", () => { 16 | let rpStub, spy; 17 | beforeEach(() => { 18 | rpStub = sinon.stub(rp, 'get'); 19 | spy = sinon.spy(queries, 'query'); 20 | rpStub.resolves({results: {bindings: []}}); 21 | }); 22 | 23 | afterEach(() => { 24 | rpStub.restore(); 25 | queries.query.restore(); 26 | }); 27 | 28 | describe("randomArtist", () => { 29 | it("should invoke query at let it invoke handlePainters", (done) => { 30 | const expectedQuery = "[RANDOM ARTIST QUERY]"; 31 | sinon.stub(queries, 'randomArtist', () => expectedQuery); 32 | 33 | randomArtist((msg) => { 34 | queries.randomArtist.restore(); 35 | try { 36 | expect(spy.calledWith(expectedQuery)).toEqual(true); 37 | expect(msg).toEqual("Sorry, ik kan geen schilders vinden die zo heten."); 38 | done(); 39 | } catch (e) { 40 | done(e); 41 | } 42 | }); 43 | }); 44 | }); 45 | 46 | describe("searchPainters", () => { 47 | it("should invoke query at let it invoke handlePainters", (done) => { 48 | const expectedQuery = "[artist QUERY]"; 49 | sinon.stub(queries, 'searchPainters', (q) => `[${q} QUERY]`); 50 | 51 | searchPainters("ARTIST", (msg) => { 52 | queries.searchPainters.restore(); 53 | try { 54 | expect(spy.calledWith(expectedQuery)).toEqual(true); 55 | expect(msg).toEqual("Sorry, ik kan geen schilders vinden die zo heten."); 56 | done(); 57 | } catch (e) { 58 | done(e); 59 | } 60 | }); 61 | }); 62 | }); 63 | 64 | 65 | describe("painterByDate", () => { 66 | it("should invoke query at let it invoke handlePainters", (done) => { 67 | const expectedQuery = "[11-10 QUERY]"; 68 | sinon.stub(queries, 'painterByDate', (m, d) => `[${d}-${m} QUERY]`); 69 | 70 | painterByDate(10, 11, (_, payload) => { 71 | queries.painterByDate.restore(); 72 | try { 73 | expect(spy.calledWith(expectedQuery)).toEqual(true); 74 | expect(payload).toEqual({ 75 | text: 'Deze schilders zijn geboren op 11-10. Kies er een.', 76 | data: [] 77 | }); 78 | done(); 79 | } catch (e) { 80 | done(e); 81 | } 82 | }); 83 | }); 84 | }); 85 | }); 86 | 87 | describe("handlePainters", () => { 88 | it("should respond with painters when painters were found", (done) => { 89 | const rpStub = sinon.stub(rp, 'get'); 90 | rpStub.resolves({results: {bindings: require("./res/painters")}}); 91 | 92 | sinon.stub(queries, 'randomArtist'); 93 | 94 | randomArtist((msg, payload) => { 95 | queries.randomArtist.restore(); 96 | rpStub.restore(); 97 | try { 98 | expect(msg).toEqual(null); 99 | expect(payload).toEqual({ 100 | data: [ 101 | { payload: 'Q5582', title: 'Vincent van Gogh' }, 102 | { payload: 'Q317188', title: 'Theo van Gogh' }, 103 | { payload: 'Q12173703', title: 'Jan van Gogh' }], 104 | text: 'Welke van deze schilders wil je hebben?' 105 | }); 106 | done(); 107 | } catch (e) { 108 | done(e); 109 | } 110 | }); 111 | }); 112 | }); 113 | 114 | 115 | describe("images searches", () => { 116 | let rpStub, spy; 117 | beforeEach(() => { 118 | rpStub = sinon.stub(rp, 'get'); 119 | spy = sinon.spy(queries, 'query'); 120 | rpStub.resolves({results: {bindings: []}}); 121 | }); 122 | 123 | afterEach(() => { 124 | rpStub.restore(); 125 | queries.query.restore(); 126 | }); 127 | 128 | describe("paintingsByArtist", () => { 129 | it("should invoke query at let it invoke handleImages", (done) => { 130 | const expectedQuery = "[123 QUERY]"; 131 | sinon.stub(queries, 'paintingsByArtist', (id) => `[${id} QUERY]`); 132 | 133 | paintingsByArtist(123, (msg) => { 134 | queries.paintingsByArtist.restore(); 135 | try { 136 | expect(spy.calledWith(expectedQuery)).toEqual(true); 137 | expect(msg).toEqual("Sorry, daar kan ik geen schilderijen van vinden."); 138 | done(); 139 | } catch (e) { 140 | done(e); 141 | } 142 | }); 143 | }); 144 | }); 145 | 146 | describe("getMonuments", () => { 147 | it("should invoke query at let it invoke handleImages", (done) => { 148 | const expectedQuery = "[Q803 QUERY]"; 149 | sinon.stub(queries, 'monuments', (q) => `[${q} QUERY]`); 150 | 151 | getMonuments((msg) => { 152 | queries.monuments.restore(); 153 | try { 154 | expect(spy.calledWith(expectedQuery)).toEqual(true); 155 | expect(msg).toEqual("Sorry, daar kan ik geen schilderijen van vinden."); 156 | done(); 157 | } catch (e) { 158 | done(e); 159 | } 160 | }); 161 | }); 162 | }); 163 | }); 164 | 165 | 166 | describe("handleImages", () => { 167 | it("should respond with 1 image when paintings were found", (done) => { 168 | const rpStub = sinon.stub(rp, 'get'); 169 | rpStub.resolves({results: {bindings: require("./res/paintings")}}); 170 | 171 | sinon.stub(queries, 'paintingsByArtist'); 172 | 173 | paintingsByArtist(0, (msg, payload) => { 174 | queries.paintingsByArtist.restore(); 175 | rpStub.restore(); 176 | try { 177 | expect(msg).toEqual(null); 178 | expect(payload).toEqual({ 179 | author: null, 180 | collection: 'Van Gogh Museum', 181 | description: 'painting by Vincent van Gogh, 1887', 182 | id: 'Q19836161', 183 | image: 'http://commons.wikimedia.org/wiki/Special:FilePath/Moestuin%20met%20zonnebloem%20-%20s0004V1962v%20-%20Van%20Gogh%20Museum.jpg?width=800', 184 | label: 'Allotment with Sunflower', 185 | url: 'http://www.vggallery.com/painting/p_0388v.htm' 186 | }); 187 | done(); 188 | } catch (e) { 189 | done(e); 190 | } 191 | }); 192 | }); 193 | }); 194 | }); -------------------------------------------------------------------------------- /src/test/bot/search.js: -------------------------------------------------------------------------------- 1 | const sinon = require("sinon"); 2 | const expect = require("expect"); 3 | 4 | const wikidata = require("../../bot/wikidata"); 5 | const { paintingsByArtist, searchPainters, painterByDate, getMonuments, randomArtist } = require("../../bot/search"); 6 | 7 | describe("search", () => { 8 | 9 | describe("paintingsByArtist", () => { 10 | it("should invoke wikidata.paintingsByArtist and handle success", (done) => { 11 | const searchResult = {payload: "payload"}; 12 | const finalize = (e) => { 13 | wikidata.paintingsByArtist.restore(); 14 | done(e); 15 | }; 16 | 17 | const assertCallback = (err, data) => { 18 | try { 19 | expect(err).toEqual(null); 20 | expect(data).toEqual({ 21 | images: searchResult, 22 | type: 'images' 23 | }); 24 | finalize(); 25 | } catch(e) { 26 | finalize(e); 27 | } 28 | }; 29 | 30 | sinon.stub(wikidata, 'paintingsByArtist', (id, responseCallback) => { 31 | try { 32 | expect(id).toEqual(123); 33 | responseCallback(null, searchResult, assertCallback); 34 | } catch (e) { 35 | finalize(e); 36 | } 37 | }); 38 | 39 | paintingsByArtist(123, assertCallback); 40 | }); 41 | 42 | it("should invoke wikidata.paintingsByArtist and handle an error", (done) => { 43 | const error = {error: "error"}; 44 | const finalize = (e) => { 45 | wikidata.paintingsByArtist.restore(); 46 | done(e); 47 | }; 48 | 49 | const assertCallback = (err, data) => { 50 | try { 51 | expect(err).toEqual(error); 52 | expect(data).toEqual(null); 53 | finalize(); 54 | } catch(e) { 55 | finalize(e); 56 | } 57 | }; 58 | 59 | sinon.stub(wikidata, 'paintingsByArtist', (id, responseCallback) => { 60 | try { 61 | responseCallback(error, null, assertCallback); 62 | } catch (e) { 63 | finalize(e); 64 | } 65 | }); 66 | 67 | paintingsByArtist(123, assertCallback); 68 | }); 69 | }); 70 | 71 | describe("getMonuments", () => { 72 | it("should invoke wikidata.getMonuments and handle success", (done) => { 73 | const searchResult = {payload: "payload"}; 74 | const finalize = (e) => { 75 | wikidata.getMonuments.restore(); 76 | done(e); 77 | }; 78 | 79 | const assertCallback = (err, data) => { 80 | try { 81 | expect(err).toEqual(null); 82 | expect(data).toEqual({ 83 | images: searchResult, 84 | type: 'images' 85 | }); 86 | finalize(); 87 | } catch(e) { 88 | finalize(e); 89 | } 90 | }; 91 | 92 | sinon.stub(wikidata, 'getMonuments', (responseCallback) => responseCallback(null, searchResult, assertCallback)); 93 | 94 | getMonuments(assertCallback); 95 | }); 96 | 97 | it("should invoke wikidata.getMonuments and handle an error", (done) => { 98 | const error = {error: "error"}; 99 | const finalize = (e) => { 100 | wikidata.getMonuments.restore(); 101 | done(e); 102 | }; 103 | 104 | const assertCallback = (err, data) => { 105 | try { 106 | expect(err).toEqual(error); 107 | expect(data).toEqual(null); 108 | finalize(); 109 | } catch(e) { 110 | finalize(e); 111 | } 112 | }; 113 | 114 | sinon.stub(wikidata, 'getMonuments', (responseCallback) => responseCallback(error, null, assertCallback)); 115 | 116 | getMonuments(assertCallback); 117 | }); 118 | }); 119 | 120 | describe("searchPainters", () => { 121 | it("should invoke wikidata.searchPainters and handle success", (done) => { 122 | const searchResult = {payload: "payload"}; 123 | const finalize = (e) => { 124 | wikidata.searchPainters.restore(); 125 | done(e); 126 | }; 127 | 128 | const assertCallback = (err, data) => { 129 | try { 130 | expect(err).toEqual(null); 131 | expect(data).toEqual({ 132 | buttons: searchResult, 133 | type: 'buttons' 134 | }); 135 | finalize(); 136 | } catch(e) { 137 | finalize(e); 138 | } 139 | }; 140 | 141 | sinon.stub(wikidata, 'searchPainters', (q, responseCallback) => { 142 | try { 143 | expect(q).toEqual("q"); 144 | responseCallback(null, searchResult, assertCallback); 145 | } catch (e) { 146 | finalize(e); 147 | } 148 | }); 149 | 150 | searchPainters("q", assertCallback); 151 | }); 152 | 153 | it("should invoke wikidata.searchPainters and handle an error", (done) => { 154 | const error = {error: "error"}; 155 | const finalize = (e) => { 156 | wikidata.searchPainters.restore(); 157 | done(e); 158 | }; 159 | 160 | const assertCallback = (err, data) => { 161 | try { 162 | expect(err).toEqual(error); 163 | expect(data).toEqual(null); 164 | finalize(); 165 | } catch(e) { 166 | finalize(e); 167 | } 168 | }; 169 | 170 | sinon.stub(wikidata, 'searchPainters', (q, responseCallback) => { 171 | try { 172 | responseCallback(error, null, assertCallback); 173 | } catch (e) { 174 | finalize(e); 175 | } 176 | }); 177 | 178 | searchPainters("q", assertCallback); 179 | }); 180 | }); 181 | 182 | describe("painterByDate", () => { 183 | it("should invoke wikidata.painterByDate and handle success", (done) => { 184 | const searchResult = {payload: "payload"}; 185 | const finalize = (e) => { 186 | wikidata.painterByDate.restore(); 187 | done(e); 188 | }; 189 | 190 | const assertCallback = (err, data) => { 191 | try { 192 | expect(err).toEqual(null); 193 | expect(data).toEqual({ 194 | buttons: searchResult, 195 | type: 'buttons' 196 | }); 197 | finalize(); 198 | } catch(e) { 199 | finalize(e); 200 | } 201 | }; 202 | 203 | sinon.stub(wikidata, 'painterByDate', (d, m, responseCallback) => { 204 | try { 205 | expect(d).toEqual(1); 206 | expect(m).toEqual(2); 207 | responseCallback(null, searchResult, assertCallback); 208 | } catch (e) { 209 | finalize(e); 210 | } 211 | }); 212 | 213 | painterByDate(1, 2, assertCallback); 214 | }); 215 | 216 | it("should invoke wikidata.painterByDate and handle null", (done) => { 217 | const finalize = (e) => { 218 | wikidata.painterByDate.restore(); 219 | done(e); 220 | }; 221 | 222 | const assertCallback = (err, data) => { 223 | try { 224 | expect(data).toEqual(null); 225 | expect(err).toEqual("Geen resultaten gevonden"); 226 | finalize(); 227 | } catch(e) { 228 | finalize(e); 229 | } 230 | }; 231 | 232 | sinon.stub(wikidata, 'painterByDate', (d, m, responseCallback) => { 233 | try { 234 | expect(d).toEqual(1); 235 | expect(m).toEqual(2); 236 | responseCallback({}, null, assertCallback); 237 | } catch (e) { 238 | finalize(e); 239 | } 240 | }); 241 | 242 | painterByDate(1, 2, assertCallback); 243 | }); 244 | }); 245 | 246 | describe("randomArtist", () => { 247 | it("should invoke wikidata.randomArtist and handle success", (done) => { 248 | const searchResult = {data: [{payload: "payload"}, {payload: "payload"}]}; 249 | const finalize = (e) => { 250 | wikidata.randomArtist.restore(); 251 | done(e); 252 | }; 253 | 254 | const assertCallback = (err, data) => { 255 | try { 256 | expect(err).toEqual(null); 257 | expect(data).toEqual({ 258 | buttons: { data: [{payload: "payload"}]}, 259 | type: 'buttons' 260 | }); 261 | finalize(); 262 | } catch(e) { 263 | finalize(e); 264 | } 265 | }; 266 | 267 | sinon.stub(wikidata, 'randomArtist', (responseCallback) => 268 | responseCallback(null, searchResult, assertCallback)); 269 | 270 | randomArtist(assertCallback); 271 | }); 272 | 273 | it("should invoke wikidata.randomArtist and handle an error", (done) => { 274 | const error = {error: "error"}; 275 | const finalize = (e) => { 276 | wikidata.randomArtist.restore(); 277 | done(e); 278 | }; 279 | 280 | const assertCallback = (err, data) => { 281 | try { 282 | expect(err).toEqual(error); 283 | expect(data).toEqual(null); 284 | finalize(); 285 | } catch(e) { 286 | finalize(e); 287 | } 288 | }; 289 | 290 | sinon.stub(wikidata, 'randomArtist', (responseCallback) => 291 | responseCallback(error, null, assertCallback)); 292 | 293 | randomArtist(assertCallback); 294 | }); 295 | }); 296 | 297 | }); -------------------------------------------------------------------------------- /src/fb/fb-lib.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'), 2 | https = require('https'), 3 | request = require('request'); 4 | 5 | /* 6 | * Copyright 2016-present, Facebook, Inc. 7 | * All rights reserved. 8 | * 9 | * This source code is licensed under the license found in the 10 | * LICENSE file in the root directory of this source tree. 11 | * 12 | */ 13 | module.exports = (config) => { 14 | 15 | // Arbitrary value used to validate a webhook 16 | const VALIDATION_TOKEN = config.validationToken; 17 | // App Secret can be retrieved from the App Dashboard 18 | const APP_SECRET = config.appSecret; 19 | // Generate a page access token for your page from the App Dashboard 20 | const PAGE_ACCESS_TOKEN = config.pageAccessToken; 21 | 22 | /* 23 | * Use your own validation token. Check that the token used in the Webhook 24 | * setup is the same token used here. 25 | * 26 | */ 27 | function validateWebhook(req, res) { 28 | if (req.query['hub.mode'] === 'subscribe' && 29 | req.query['hub.verify_token'] === VALIDATION_TOKEN) { 30 | console.log("Validating webhook"); 31 | res.status(200).send(req.query['hub.challenge']); 32 | } else { 33 | console.error("Failed validation. Make sure the validation tokens match."); 34 | res.sendStatus(403); 35 | } 36 | } 37 | 38 | 39 | /* 40 | * Call the Send API. The message data goes in the body. If successful, we'll 41 | * get the message id in a response 42 | * 43 | */ 44 | function callSendAPI(messageData) { 45 | if (process.env.MODE === 'mock') { 46 | console.log(JSON.stringify(messageData, null, 2)); 47 | return; 48 | } 49 | 50 | request({ 51 | uri: 'https://graph.facebook.com/v2.6/me/messages', 52 | qs: {access_token: PAGE_ACCESS_TOKEN}, 53 | method: 'POST', 54 | json: messageData 55 | 56 | }, function (error, response, body) { 57 | if (!error && response.statusCode == 200) { 58 | const recipientId = body.recipient_id; 59 | const messageId = body.message_id; 60 | 61 | if (messageId) { 62 | console.log("Successfully sent message with id %s to recipient %s", messageId, recipientId); 63 | } else { 64 | console.log("Successfully called Send API for recipient %s", recipientId); 65 | } 66 | console.log("Message data was:", JSON.stringify(messageData, null, 2)); 67 | console.log("=====\n\n") 68 | 69 | } else { 70 | console.error("Failed calling Send API", response.statusCode, response.statusMessage, body.error); 71 | console.error("Message data was:", JSON.stringify(messageData, null, 2)); 72 | console.error("=====\n\n") 73 | } 74 | }); 75 | } 76 | 77 | /* 78 | * Turn typing indicator on 79 | * 80 | */ 81 | function sendTypingOn(recipientId) { 82 | console.log("Turning typing indicator on"); 83 | 84 | const messageData = { 85 | recipient: { 86 | id: recipientId 87 | }, 88 | sender_action: "typing_on" 89 | }; 90 | 91 | callSendAPI(messageData); 92 | } 93 | 94 | /* 95 | * Turn typing indicator off 96 | * 97 | */ 98 | function sendTypingOff(recipientId) { 99 | console.log("Turning typing indicator off"); 100 | 101 | const messageData = { 102 | recipient: { 103 | id: recipientId 104 | }, 105 | sender_action: "typing_off" 106 | }; 107 | 108 | callSendAPI(messageData); 109 | } 110 | 111 | /* 112 | * Verify that the callback came from Facebook. Using the App Secret from 113 | * the App Dashboard, we can verify the signature that is sent with each 114 | * callback in the x-hub-signature field, located in the header. 115 | * 116 | * https://developers.facebook.com/docs/graph-api/webhooks#setup 117 | * 118 | */ 119 | function verifyRequestSignature(req, res, buf) { 120 | const signature = req.headers["x-hub-signature"]; 121 | 122 | if (!signature) { 123 | // For testing, let's log an error. In production, you should throw an 124 | // error. 125 | console.error("Couldn't validate the signature."); 126 | } else { 127 | const elements = signature.split('='); 128 | const method = elements[0]; 129 | const signatureHash = elements[1]; 130 | 131 | const expectedHash = crypto.createHmac('sha1', APP_SECRET) 132 | .update(buf) 133 | .digest('hex'); 134 | 135 | if (signatureHash != expectedHash) { 136 | throw new Error("Couldn't validate the request signature."); 137 | } 138 | } 139 | } 140 | 141 | /* 142 | * Message Event 143 | * 144 | * This event is called when a message is sent to your page. The 'message' 145 | * object format can vary depending on the kind of message that was received. 146 | * Read more at https://developers.facebook.com/docs/messenger-platform/webhook-reference/message-received 147 | * 148 | * For this example, we're going to echo any text that we get. If we get some 149 | * special keywords ('button', 'generic', 'receipt'), then we'll send back 150 | * examples of those bubbles to illustrate the special message bubbles we've 151 | * created. If we receive a message with an attachment (image, video, audio), 152 | * then we'll simply confirm that we've received the attachment. 153 | * 154 | */ 155 | function receivedMessage(event, { onTextMessage, onAttachments}) { 156 | const senderID = event.sender.id; 157 | const recipientID = event.recipient.id; 158 | const timeOfMessage = event.timestamp; 159 | const message = event.message; 160 | 161 | console.log("Received message for user %d and page %d at %d with message:", senderID, recipientID, timeOfMessage); 162 | console.log(JSON.stringify(message)); 163 | 164 | const isEcho = message.is_echo; 165 | const messageId = message.mid; 166 | const appId = message.app_id; 167 | const metadata = message.metadata; 168 | const quickReply = message.quick_reply; 169 | 170 | if (isEcho) { 171 | // Just logging message echoes to console 172 | console.log("Received echo for message %s and app %d with metadata %s", messageId, appId, metadata); 173 | return; 174 | } else if (quickReply) { 175 | const quickReplyPayload = quickReply.payload; 176 | console.log("Quick reply for message %s with payload %s", messageId, quickReplyPayload); 177 | fb.sendTextMessage(senderID, "Quick reply tapped"); 178 | return; 179 | } 180 | // You may get a text or attachment but not both 181 | const messageText = message.text; 182 | 183 | // Currently the only type we support is text 184 | if (messageText) { 185 | onTextMessage(messageText, senderID) 186 | } else { 187 | onAttachments(senderID); 188 | } 189 | } 190 | 191 | /* 192 | * Postback Event 193 | * 194 | * This event is called when a postback is tapped on a Structured Message. 195 | * https://developers.facebook.com/docs/messenger-platform/webhook-reference/postback-received 196 | * 197 | */ 198 | function receivedPostback(event, {onPostback}) { 199 | const senderID = event.sender.id; 200 | const recipientID = event.recipient.id; 201 | const timeOfPostback = event.timestamp; 202 | 203 | // The 'payload' param is a developer-defined field which is set in a postback 204 | // button for Structured Messages. 205 | const payload = event.postback.payload; 206 | 207 | console.log("Received postback for user %d and page %d with payload '%s' " + 208 | "at %d", senderID, recipientID, payload, timeOfPostback); 209 | 210 | onPostback(senderID, payload); 211 | } 212 | 213 | /* 214 | * Delivery Confirmation Event 215 | * 216 | * This event is sent to confirm the delivery of a message. Read more about 217 | * these fields at https://developers.facebook.com/docs/messenger-platform/webhook-reference/message-delivered 218 | * 219 | */ 220 | function receivedDeliveryConfirmation(event) { 221 | const delivery = event.delivery; 222 | const messageIDs = delivery.mids; 223 | const watermark = delivery.watermark; 224 | if (messageIDs) { 225 | messageIDs.forEach(function (messageID) { 226 | console.log("Received delivery confirmation for message ID: %s", 227 | messageID); 228 | }); 229 | } 230 | 231 | console.log("All message before %d were delivered.", watermark); 232 | } 233 | 234 | /* 235 | * Message Read Event 236 | * 237 | * This event is called when a previously-sent message has been read. 238 | * https://developers.facebook.com/docs/messenger-platform/webhook-reference/message-read 239 | * 240 | */ 241 | function receivedMessageRead(event) { 242 | // All messages before watermark (a timestamp) or sequence have been seen. 243 | const watermark = event.read.watermark; 244 | const sequenceNumber = event.read.seq; 245 | 246 | console.log("Received message read event for watermark %d and sequence " + 247 | "number %d", watermark, sequenceNumber); 248 | } 249 | 250 | /* 251 | * Send an image using the Send API. 252 | * 253 | */ 254 | function sendImageMessage(recipientId, url) { 255 | url = `${url}`; 256 | 257 | callSendAPI({ 258 | recipient: { 259 | id: recipientId 260 | }, 261 | message: { 262 | attachment: { 263 | type: 'image', 264 | payload: { 265 | url: url 266 | } 267 | } 268 | } 269 | }); 270 | } 271 | 272 | /* 273 | * Send a text message using the Send API. 274 | * 275 | */ 276 | function sendTextMessage(recipientId, messageText) { 277 | const messageData = { 278 | recipient: { 279 | id: recipientId 280 | }, 281 | message: { 282 | text: messageText, 283 | metadata: "DEVELOPER_DEFINED_METADATA" 284 | } 285 | }; 286 | 287 | callSendAPI(messageData); 288 | } 289 | 290 | /* 291 | * Send a button message using the Send API. 292 | * 293 | */ 294 | function sendButtonMessage(recipientId, buttons) { 295 | const data = { 296 | recipient: { 297 | id: recipientId 298 | }, 299 | message: { 300 | attachment: { 301 | type: "template", 302 | payload: { 303 | "template_type": "button", 304 | "text": buttons.text, 305 | buttons: buttons.data.map((b) => { 306 | return { 307 | type: "postback", 308 | title: b.title, 309 | payload: b.payload 310 | } 311 | }) 312 | } 313 | } 314 | } 315 | }; 316 | 317 | callSendAPI(data); 318 | } 319 | 320 | function sendURL(recId, url) { 321 | callSendAPI({ 322 | recipient: { 323 | id: recId 324 | }, 325 | message: { 326 | attachment: { 327 | type: "template", 328 | payload: { 329 | "template_type": "button", 330 | "text": 'Wil je meer weten?', 331 | buttons: [{ 332 | type: "web_url", 333 | url: url, 334 | title: "Lees verder" 335 | }] 336 | } 337 | } 338 | } 339 | }) 340 | } 341 | 342 | 343 | return { 344 | validateWebhook: validateWebhook, 345 | sendTypingOn: sendTypingOn, 346 | sendTypingOff: sendTypingOff, 347 | verifyRequestSignature: verifyRequestSignature, 348 | sendURL: sendURL, 349 | sendButtonMessage: sendButtonMessage, 350 | sendTextMessage: sendTextMessage, 351 | sendImageMessage: sendImageMessage, 352 | receivedDeliveryConfirmation: receivedDeliveryConfirmation, 353 | receivedMessageRead: receivedMessageRead, 354 | receivedMessage: receivedMessage, 355 | receivedPostback: receivedPostback 356 | } 357 | }; --------------------------------------------------------------------------------