Last active
March 5, 2017 00:25
-
-
Save vlad-x/eb072e9220bc70201e8095b76ee8a08e to your computer and use it in GitHub Desktop.
Working with fb-bot-pizza-order https://unitcluster.com/Vlad/fb-bot-pizza-pay
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const MongoClient = require('mongodb').MongoClient; | |
| const braintree = require('braintree'); | |
| const querystring = require('querystring'); | |
| const request = require('request'); | |
| const Slack = require('slack-node'); | |
| const Bot = require('fb-bot-framework'); | |
| const promisify = require('bluebird').promisify; | |
| const React = require('react'); | |
| const _ = require('lodash'); | |
| const MINUTE = 60 * 1000; // milliseconds | |
| const STATE_EXPIRATION_TIME = 45 * MINUTE; | |
| var config, rootUrl, bot, mongo, gateway; | |
| const STATE = { | |
| SHOWN_INTRO: 'SHOWN_INTRO', | |
| CHOSE_PIZZA: 'CHOSE_PIZZA', | |
| ORDER: 'ORDER', | |
| ORDERED: 'ORDERED' | |
| }; | |
| const PIZZAS = { | |
| 'ORDER_PEPPERONI': { | |
| price: 15.11, | |
| data: { | |
| title: "Pepperoni", | |
| image_url: "https://thumbs.dreamstime.com/z/pepperoni-pizza-thinly-sliced-popular-topping-american-style-pizzerias-30402134.jpg", | |
| subtitle: "Pepperoni, Provence herbs, mozzarella, tomato paste" | |
| } | |
| }, | |
| 'ORDER_KEBAB': { | |
| price: 14.40, | |
| data: { | |
| title: "Kebab", | |
| image_url: "https://thumbs.dreamstime.com/x/pizza-18089114.jpg", | |
| subtitle: "Chilean kebab, Indonesian chorizo, raw bacon avocado-infused chili pepper, mozarella" | |
| } | |
| }, | |
| 'ORDER_BACON': { | |
| price: 13.77, | |
| data: { | |
| title: "Bacon", | |
| image_url: "https://thumbs.dreamstime.com/x/big-pizza-6574043.jpg", | |
| subtitle: "Scottish bacon, egg, ruccola, mozarella, tomato paste" | |
| } | |
| }, | |
| 'ORDER_TOMATO': { | |
| price: 12.11, | |
| data: { | |
| title: "Lahmacun", | |
| image_url: "https://thumbs.dreamstime.com/x/overhead-view-tomato-lahmacun-crisp-crusty-fresh-herbs-lemon-onion-whole-uncut-pie-isolated-white-35171121.jpg", | |
| subtitle: "Parsley, lemon, onions, tomato and pepper" | |
| } | |
| }, | |
| 'ORDER_MARGARITA': { | |
| price: 13.98, | |
| data: { | |
| title: "Margarita", | |
| image_url: "https://thumbs.dreamstime.com/x/pizza-margarita-22338968.jpg", | |
| subtitle: "Mozzarella cheese, basil, tomatoes" | |
| } | |
| } | |
| }; | |
| const CSS = ` | |
| body { font-size: 1.5em; font-family: sans-serif, Arial } | |
| #error-box { color:red } | |
| #pay { margin-bottom:10px } | |
| #paywith-form { margin-bottom: 20px } | |
| #paywith-form form { overflow: auto } | |
| .btn-block { max-width: 300px; float: right; clear: both; margin-bottom: 20px; | |
| font-size: 16px; } | |
| h3, #paywith-form { text-align: center; } | |
| `; | |
| const frontend = (clientToken, error) => { | |
| if (error) { | |
| return ''; | |
| } | |
| function init(clientToken) { | |
| $('#date').text((new Date()).toString()); | |
| braintree.setup(clientToken, "dropin", { | |
| container: "dropin-container", | |
| form: "checkout-form", | |
| onReady: function(){ | |
| $('#pay').removeClass('hidden') | |
| $('.table').removeClass('hidden') | |
| $('#paywith-form').removeClass('hidden') | |
| } | |
| }); | |
| // IOS focus fix | |
| function fixFocus() { | |
| setTimeout(function(){ $('#focus-fix').focus() }, 500); | |
| } | |
| $('#paywith-form form').on('submit', function() { | |
| $('#paywith-form,#pay,#checkout-form').addClass('animated fadeOut'); | |
| fixFocus() | |
| return true; | |
| }) | |
| $('checkout-form').on('submit', function() { | |
| $('#paywith-form,#pay').addClass('animated fadeOut'); | |
| fixFocus() | |
| return true; | |
| }) | |
| } | |
| return '('+init.toString()+`)("${clientToken}")` | |
| } | |
| const renderItem = (pizza, qty) => | |
| <tr key={pizza.data.title}> | |
| <td className="col-md-9"><em>{pizza.data.title}</em></td> | |
| <td className="col-md-1" style={{'textAlign':'center'}}>{qty}</td> | |
| <td className="col-md-1 text-center">{pizza.price}€</td> | |
| <td className="col-md-1 text-center">{pizza.price * qty}€</td> | |
| </tr> | |
| const renderPayWithCard = (action, last4) => | |
| <div id="paywith-form" className="hidden"> | |
| <form method="post" action={action}> | |
| <label>Use previous card</label> | |
| <input type="hidden" name="paywith" value="true" /> | |
| <input type="submit" value={'Pay with '+last4} className="btn btn-primary btn-lg btn-block" id="pay-with-card" /> | |
| </form> | |
| <hr/> | |
| <label>or</label> | |
| </div> | |
| const renderHtmlPage = (conf, body, frontendCode) => { | |
| return <html> | |
| <head> | |
| <title>{conf.title}</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" /> | |
| {conf.css.map((url) => <link rel="stylesheet" href={url} key={url} />)} | |
| <style>{conf.globalCss}</style> | |
| </head> | |
| <body> | |
| {body} | |
| {conf.js.map((url) => <script type="text/javascript" src={url} key={url} />)} | |
| <script type="text/javascript" dangerouslySetInnerHTML={{__html: frontendCode}} /> | |
| </body> | |
| </html> | |
| } | |
| const paymentPageBody = (data) => | |
| <div className="container"> | |
| <div className="row animated fadeIn"> | |
| <div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3"> | |
| <h3>Hi {data.profile.first_name}, here is your order:</h3> | |
| </div> | |
| <div className="well col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3"> | |
| <div className="row"> | |
| <div className="col-xs-6 col-sm-6 col-md-6"> | |
| <address>{data.address}</address> | |
| </div> | |
| <div className="col-xs-6 col-sm-6 col-md-6 text-right"> | |
| <p><em id="date"></em></p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="row animated fadeIn"> | |
| <div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3"> | |
| <div className="text-center"><h1>Receipt</h1></div> | |
| <table className="table table-hover"> | |
| <thead> | |
| <tr> | |
| <th>Product</th> | |
| <th>#</th> | |
| <th className="text-center">Price</th> | |
| <th className="text-center">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {data.order} | |
| <tr> | |
| <td> </td> | |
| <td> </td> | |
| <td className="text-right"><h4><strong>Total: </strong></h4></td> | |
| <td className="text-center text-danger"><h4><strong>{data.price}</strong></h4></td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <div id="error-box" className="text-center">{data.error}</div> | |
| {data.pay_with_card} | |
| <div id="dropin-container"></div> | |
| <form id="checkout-form" method="post" action={data.action}> | |
| <br/> | |
| <div id="focus-fix" tabIndex="1" /> | |
| <input type="submit" value="Pay" className="btn btn-success btn-lg btn-block hidden" id="pay" /> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| const renderPaymentPage = (data) => | |
| renderHtmlPage({ | |
| title: 'Payment', | |
| css: [ | |
| 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css', | |
| 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css' | |
| ], | |
| js: [ | |
| 'https://assets.braintreegateway.com/dropin/2.27.0/vendor/jquery-2.1.0.js', | |
| 'https://js.braintreegateway.com/js/braintree-2.27.0.min.js' | |
| ], | |
| globalCss: CSS | |
| }, paymentPageBody(data), frontend(data.clientToken, data.error)) | |
| const frontendSuccess = () => { | |
| function init() { | |
| window.extAsyncInit = function() { | |
| setTimeout(function(){ | |
| MessengerExtensions.requestCloseBrowser( | |
| function success() {}, function error(err) { $('.well').html(err) } | |
| ); | |
| }, 5000); | |
| }; | |
| setTimeout(function(){ window.close() }, 5000); | |
| } | |
| return '('+init.toString()+`)()` | |
| } | |
| const successPageBody = (data) => | |
| <div className="container"> | |
| <div className="row"> | |
| <div className="col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3"> | |
| <h3>{data.name} thank you for ordering pizza from gluten-free vegan raw pizza!</h3> | |
| </div> | |
| <div className="well col-xs-10 col-sm-10 col-md-6 col-xs-offset-1 col-sm-offset-1 col-md-offset-3"> | |
| <div className="row"> | |
| <p> We will deliver your order to {data.address}</p> | |
| <p className="visible-md-block visible-lg-block"> | |
| <em>This tab will be closed in 5 seconds</em> | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| const renderSucessPage = (data) => | |
| renderHtmlPage({ | |
| title: 'Payment successful!', | |
| css: ['https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css'], | |
| js: [ | |
| 'https://connect.facebook.com/en_US/messenger.Extensions.js', | |
| 'https://assets.braintreegateway.com/dropin/2.27.0/vendor/jquery-2.1.0.js' | |
| ], | |
| globalCss: CSS | |
| }, successPageBody(data), frontendSuccess()) | |
| const PAYMENT_PAGE_ERROR = 'We are sorry but something went wrong with this page :('; | |
| const slackOrder = (data) => | |
| `${data.username} just ordered pizza! | |
| Order No: ${data.order_number} | |
| ${data.order} Delivery address: ${data.address} | |
| Date: ${data.date}`; | |
| const STATE_COLLECTION = 'state'; | |
| const stateFresh = function(timestamp) { | |
| timestamp = Number(timestamp) || 0; | |
| var now = new Date().getTime(); | |
| var isFresh = (now - timestamp) < STATE_EXPIRATION_TIME; | |
| if (!isFresh) { | |
| console.log('>>> State expired'); | |
| } | |
| return isFresh; | |
| }; | |
| const getState = async (condition) => { | |
| var data = await mongo.collection(STATE_COLLECTION).findOne(condition); | |
| return (data && data.timestamp && stateFresh(data.timestamp)) ? data : {}; | |
| }; | |
| const setState = async (userId, data) => { | |
| var fields = {userId: userId, timestamp: new Date().getTime()}; | |
| for (var k in data) { | |
| fields[k] = data[k]; | |
| } | |
| await mongo | |
| .collection(STATE_COLLECTION) | |
| .updateOne({ userId }, { $set: fields }, { upsert: true }); | |
| }; | |
| const sendSlackMsg = async (userId, text) => { | |
| var slack = new Slack(); | |
| slack.setWebhook(config.slack_webhook); | |
| return await promisify(slack.webhook)({username: 'pizzabot', text: text}); | |
| }; | |
| const paymentUrl = function(qs) { | |
| var result = rootUrl + '/payment/'; | |
| if (qs) { | |
| result += '?' + querystring.stringify(qs); | |
| } | |
| return result; | |
| }; | |
| const renderPayment = async (unit, prev_error) => { | |
| const trid = unit.trid; | |
| const state = unit.state; | |
| let pay_with = ''; | |
| if (state.customer_id && state.card_id && state.payment_method && !prev_error) { | |
| pay_with = renderPayWithCard(rootUrl, state.payment_method); | |
| } | |
| const clientToken = await gateway.clientToken.generate({}); | |
| const pizza = state.pizza || {}; | |
| const order = Object.keys(pizza).map((p) => renderItem(PIZZAS[p], pizza[p])); | |
| const price = Object.keys(pizza).reduce((acc, key) => { | |
| return acc + (PIZZAS[key].price * state.pizza[key]); | |
| }, 0); | |
| if (!state.pizza && !prev_error) { | |
| prev_error = 'No items in cart: please go back and select something!' | |
| } | |
| if (!state.address && !prev_error) { | |
| prev_error = 'No address provided: please go back and enter your address!' | |
| } | |
| const profile = await bot.getUserProfile(state.userId); | |
| const pageData = { | |
| clientToken: clientToken.clientToken, | |
| action: rootUrl, | |
| order: order, | |
| profile: profile, | |
| address: state.address ? (state.address.formatted_address || state.address) : '', | |
| error: prev_error, | |
| price: price.toFixed(2), | |
| date: new Date(), | |
| pay_with_card: pay_with | |
| }; | |
| return renderPaymentPage(pageData); | |
| }; | |
| const getAddr = (state) => { | |
| var street = '', city, postal_code, country; | |
| for (var i in state.address.address_components) { | |
| var part = state.address.address_components[i]; | |
| if (part.types.indexOf("subpremise") >= 0 || part.types.indexOf("street_number") >= 0 || part.types.indexOf( "route") >= 0) { | |
| street = part.long_name + ' ' + street; | |
| } else if (part.types.indexOf("locality") >= 0) { | |
| city = part.long_name; | |
| } else if (part.types.indexOf("country") >= 0) { | |
| country = part.short_name; | |
| } else if (part.types.indexOf("postal_code") >= 0) { | |
| postal_code = part.long_name; | |
| } | |
| } | |
| return {street, city, postal_code, country}; | |
| } | |
| const parseTransaction = (transaction) => { | |
| let tax = 0, | |
| payment_method = 'unknown', | |
| payment_type = '', | |
| order_number = transaction.id['0']; | |
| const taxAmount = transaction.taxAmount['0']; | |
| if (taxAmount && !(taxAmount['$'].nil == 'true')) { | |
| tax = result.transaction.taxAmount['0']; | |
| } | |
| payment_type = transaction.paymentInstrumentType['0']; | |
| if (payment_type == "credit_card") { | |
| let creditCard = transaction.creditCard['0']; | |
| payment_method = creditCard.cardType['0'] + ' ' + creditCard.last4['0']; | |
| } else if (payment_type == 'paypal_account'){ | |
| payment_method = 'PayPal: '+transaction.paypal[0].payerEmail[0]; | |
| console.log('paypal', transaction.paypal[0]); | |
| } | |
| const orderId = transaction.orderId[0]['$']; | |
| if (orderId && !(orderId.nil == 'true')) { | |
| order_number = orderId.toString(); // need to check this for running app | |
| } | |
| return {tax, payment_method, order_number, payment_type}; | |
| } | |
| const processPayment = async (unit) => { | |
| const trid = unit.trid; | |
| const state = unit.state; | |
| let price = 0, payment; | |
| let elements = []; | |
| let order = ''; | |
| for (var i in state.pizza) { | |
| price += PIZZAS[i].price * state.pizza[i]; | |
| } | |
| if (state.customer_id && state.card_id && unit.req.body.paywith) { | |
| console.log('pay with card:', state.customer_id, state.card_id, unit.req.body.paywith) | |
| payment = { | |
| amount: price.toFixed(2), | |
| paymentMethodToken: state.card_id, | |
| customerId: state.customer_id | |
| } | |
| } else { | |
| payment = { | |
| amount: price.toFixed(2), | |
| paymentMethodNonce: unit.req.body.payment_method_nonce, | |
| options: { | |
| storeInVaultOnSuccess: true | |
| } | |
| } | |
| } | |
| const result = await gateway.transaction.sale(payment); | |
| console.log('result', result); | |
| if (!result.success) { | |
| return renderPayment(unit, result.message); | |
| } | |
| const transaction = result.transaction; | |
| const {tax, payment_method, order_number, payment_type} = parseTransaction(transaction); | |
| const address = state.address.formatted_address || state.address; | |
| const total = parseFloat(_.get(transaction, 'amount[0]', '0')); | |
| const currency = _.get(transaction, 'currencyIsoCode[0]'); | |
| const addr = getAddr(state); | |
| const date = new Date(); | |
| const profile = await bot.getUserProfile(state.userId); | |
| const username = profile.first_name +" "+ profile.last_name; | |
| console.log('tax, payment_method, order_number', tax, payment_method, order_number, 'addr', addr, total, currency); | |
| for (var i in state.pizza) { | |
| elements.push({ | |
| "title": PIZZAS[i].data.title, | |
| "subtitle": PIZZAS[i].data.subtitle, | |
| "quantity": state.pizza[i], | |
| "price": PIZZAS[i].price, | |
| "currency": currency, | |
| "image_url": (PIZZAS[i].data.image_url || '') | |
| }); | |
| order += PIZZAS[i].data.title + ': ' + state.pizza[i] + '\u000A'; | |
| } | |
| const receipt = { | |
| recipient_name: username, | |
| order_number: order_number, | |
| currency: currency, | |
| payment_method: payment_method, | |
| timestamp: parseInt((date.getTime())/1000).toString(), | |
| elements: elements, | |
| address: { | |
| street_1: addr.street, | |
| street_2: "", | |
| city: addr.city, | |
| postal_code: addr.postal_code, | |
| state: addr.country, | |
| country: addr.country | |
| }, | |
| summary: { | |
| subtotal: total, | |
| shipping_cost: 0, | |
| total_tax: tax, | |
| total_cost: total + tax | |
| } | |
| }; | |
| console.log('receipt', receipt); | |
| await bot.sendReceiptMessage(state.userId, receipt); | |
| const text = slackOrder({ username, date, order, address, order_number }); | |
| await sendSlackMsg(state.userId, text); | |
| const customer_id = _.get(transaction, 'customer[0].id[0]'); | |
| const card_id = _.get(transaction, 'creditCard[0].token[0]'); | |
| console.log('customer_id', customer_id, 'card_id', card_id); | |
| await setState(state.userId, { | |
| state: STATE.ORDERED, | |
| customer_id: customer_id, | |
| card_id: card_id, | |
| pizza: {}, | |
| payment_method: payment_type == 'credit_card' ? payment_method : '' | |
| }); | |
| return renderSucessPage({ address, name: profile.first_name }) | |
| }; | |
| const getGateway = (config) => { | |
| let gateway = braintree.connect({ | |
| environment: braintree.Environment.Sandbox, | |
| merchantId: config.bt_merchant, | |
| publicKey: config.bt_key_public, | |
| privateKey: config.bt_key_private | |
| }); | |
| gateway.clientToken.generate = promisify(gateway.clientToken.generate, | |
| { context: gateway.clientToken }); | |
| gateway.transaction.sale = promisify(gateway.transaction.sale, | |
| { context: gateway.transaction }); | |
| return gateway; | |
| } | |
| const initGlobals = async (unit) => { | |
| if (!config) { | |
| config = unit.config; | |
| rootUrl = 'https://' + unit.req.uri.host + unit.req.url; | |
| gateway = getGateway(unit.config); | |
| bot = new Bot({ | |
| page_token: config.fb_access_token, | |
| verify_token: config.fb_verify_token | |
| }); | |
| const botMethods = ['send', 'setGreetingText', 'setPersistentMenu', 'sendTextMessage', 'sendButtonMessage', 'sendGenericMessage', 'sendGenericMessage', 'sendBubbleMessage', 'sendReceiptMessage', 'sendReceiptMessage', 'getUserProfile']; | |
| botMethods.map((n) => { | |
| bot[n] = promisify(bot[n], { context: bot }) | |
| }) | |
| } | |
| if (!mongo) { | |
| mongo = await MongoClient.connect(config.mongo+'?authMechanism=SCRAM-SHA-1'); | |
| mongo.collection(STATE_COLLECTION).createIndex('userId', {unique:true, background:true}); | |
| mongo.collection(STATE_COLLECTION).createIndex('trid', {unique:true, background:true}); | |
| } | |
| }; | |
| const renderPaymentWrapped = async (unit) => { | |
| try { | |
| return await renderPayment(unit); | |
| } catch(e) { | |
| console.log('renderPayment', e); | |
| return unit.resolve(PAYMENT_PAGE_ERROR); | |
| } | |
| } | |
| const processPaymentWrapped = async (unit) => { | |
| try { | |
| return await processPayment(unit); | |
| } catch(e) { | |
| console.log('processPayment', e); | |
| return renderPayment(unit, e); | |
| } | |
| } | |
| export default async (unit) => { | |
| unit.trid = unit.req.uri.query.trid; | |
| if (!unit.trid) { | |
| console.log('No transaction id `trid` given'); | |
| return unit.resolve(PAYMENT_PAGE_ERROR); | |
| } | |
| await initGlobals(unit); | |
| unit.state = await getState({ trid: unit.trid }); | |
| if (!unit.state) { | |
| console.log('No data associated with given transaction id found'); | |
| return unit.resolve(PAYMENT_PAGE_ERROR); | |
| } | |
| console.log('state', unit.state, 'trid', unit.trid); | |
| if (unit.req.method == 'GET') { | |
| return await renderPayment(unit); | |
| } else { | |
| return await processPaymentWrapped(unit); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "fb-bot-pizza-pay", | |
| "description": "Working with fb-bot-pizza-order https://unitcluster.com/Vlad/fb-bot-pizza-pay", | |
| "version": "1.0.0", | |
| "private": true, | |
| "dependencies": { | |
| "mongodb": "2.2.8", | |
| "braintree": "1.41.0", | |
| "querystring": "0.2.0", | |
| "request": "2.74.0", | |
| "slack-node": "0.1.8", | |
| "fb-bot-framework": "0.2.1", | |
| "bluebird": "3.4.6", | |
| "react": "15.3.2", | |
| "lodash": "4.17.0" | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment