wingbot
Version:
Enterprise Messaging Bot Conversation Engine
251 lines (196 loc) • 6.94 kB
JavaScript
/**
* @author David Menger
*/
;
const jwt = require('jsonwebtoken');
const { default: fetch, Headers } = require('node-fetch');
const FormData = require('form-data');
const crypto = require('crypto');
const { Agent } = require('https');
const { promisify } = require('util');
const ReturnSender = require('./ReturnSender');
/** @typedef {import('./ReturnSender').ReturnSenderOptions} ReturnSenderOptions */
/** @typedef {import('./ReturnSender').ChatLogStorage} ChatLogStorage */
/** @typedef {import('./ReturnSender').UploadResult} UploadResult */
/**
* @typedef {object} TlsOptions
* @prop {string|Promise<string>} key
* @prop {string|Promise<string>} cert
*/
/**
* @typedef {object} BotAppSenderOptions
* @prop {string} apiUrl
* @prop {string} pageId
* @prop {string} appId
* @prop {TlsOptions} [tls]
* @prop {string} [mid]
* @prop {Function} [fetch]
* @prop {string|Promise<string>} secret
*
* @typedef {BotAppSenderOptions & ReturnSenderOptions} SenderOptions
*/
/**
* @typedef {object} DownloadedFile
* @prop {string} fileName
* @prop {string} mimeType
* @prop {string} extension
* @prop {Buffer} buffer
*/
const sign = promisify(jwt.sign);
const CONTENT_DISPO_ATTACH = /attachment;\s*filename="([^"]+)\.([a-z0-9]{2,6})"/i;
const FILENAME_MATCHER = /^http.+\/([a-zA-Z0-9-]+)_[a-zA-Z0-9]+\.(([a-zA-Z0-9]+)?)$/i;
class BotAppSender extends ReturnSender {
/**
*
* @param {SenderOptions} options
* @param {string} senderId
* @param {object} incommingMessage
* @param {ChatLogStorage} logger - console like logger
*/
constructor (options, senderId, incommingMessage, logger = null) {
super(options, senderId, incommingMessage, logger);
this.waits = true;
this._apiUrl = options.apiUrl;
this._pageId = options.pageId;
this._appId = options.appId;
this._mid = options.mid;
this._secret = Promise.resolve(options.secret);
this._tls = options.tls;
this._agent = null;
/** @type {import('node-fetch').default} */
// @ts-ignore
this._fetch = options.fetch || fetch;
}
static async signBody (body, secret, appId) {
const goodSecret = await Promise.resolve(secret);
const sha1 = body === null
? null
: crypto.createHash('sha1')
.update(body)
.digest('hex');
return sign({
appId,
sha1,
iss: 'apiapp',
t: 'at'
// @ts-ignore
}, goodSecret);
}
async _getAgent () {
if (!this._tls) {
return null;
}
if (!this._agent) {
const [key, cert] = await Promise.all([
Promise.resolve(this._tls.key), Promise.resolve(this._tls.cert)
]);
this._agent = new Agent({ cert, key });
}
return this._agent;
}
/**
*
* @param {string} url
* @returns {Promise<DownloadedFile>}
*/
async download (url) {
const [token, agent] = await Promise.all([
BotAppSender.signBody(null, this._secret, this._appId),
this._getAgent()
]);
const headers = new Headers();
headers.set('Authorization', token);
const res = await this._fetch(url, {
headers, agent, method: 'GET'
});
// 'Content-Disposition': `attachment; filename="${file.origFileName}"`
const disposition = res.headers.get('content-disposition');
let name;
let extension;
if (disposition && disposition.match(CONTENT_DISPO_ATTACH)) {
[, name, extension] = disposition.match(CONTENT_DISPO_ATTACH);
} else if (url.match(FILENAME_MATCHER)) {
[, name, extension] = url.match(FILENAME_MATCHER);
} else {
throw new Error(`Can't resolve file extension on "${disposition || url}"`);
}
const mimeType = res.headers.get('content-type');
const buffer = await res.buffer();
const fileName = `${name}.${extension.toLowerCase()}`;
return {
mimeType,
fileName,
extension,
buffer
};
}
/**
*
* @param {Buffer} data
* @param {string} contentType
* @param {string} fileName
* @param {string} [senderId]
* @returns {Promise<UploadResult>}
*/
async upload (data, contentType, fileName, senderId = this._senderId) {
const formData = new FormData();
const nonce = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36).padEnd(11, '0');
formData.append('nonce', nonce);
formData.append('senderId', senderId || '');
formData.append('f0', data, { filename: fileName, contentType });
const [token, agent] = await Promise.all([
BotAppSender.signBody(nonce, this._secret, this._appId),
this._getAgent()
]);
const headers = new Headers();
headers.set('Authorization', token);
const res = await this._fetch(`${this._apiUrl}/${this._pageId}`, {
headers, body: formData, agent, method: 'POST'
});
const responseText = await res.text();
let response;
try {
response = JSON.parse(responseText);
} catch (e) {
throw new Error(`[${res.status}] Invalid JSON response: ${responseText}`);
}
return response;
}
async _send (payload) {
// attach sender
if (typeof payload.sender === 'undefined') {
Object.assign(payload, { sender: { id: this._pageId } });
}
// attach response_to_mid
if (typeof payload.response_to_mid === 'undefined' && this._mid) {
Object.assign(payload, { response_to_mid: this._mid });
}
const body = JSON.stringify(payload);
const [token, agent] = await Promise.all([
BotAppSender.signBody(body, this._secret, this._appId),
this._getAgent()
]);
const headers = new Headers();
headers.set('Authorization', token);
headers.set('Content-Type', 'application/json');
const res = await this._fetch(this._apiUrl, {
headers, body, agent, method: 'POST'
});
const responseText = await res.text();
let response;
try {
response = JSON.parse(responseText);
} catch (e) {
throw new Error(`[${res.status}] Invalid JSON response: ${responseText}`);
}
const { request, errors = null } = response;
if (errors) {
const [{ error, description = '', code = 500 }] = errors;
throw new Error(`[${code}] ${error} ${description}`);
}
return {
message_id: request.mid
};
}
}
module.exports = BotAppSender;