node-wit
Version:
Wit.ai Node.js SDK
601 lines (496 loc) • 16.5 kB
JavaScript
/**
* Copyright (c) Meta Platforms, Inc. and its affiliates. All Rights Reserved.
*/
;
const {DEFAULT_API_VERSION, DEFAULT_WIT_URL} = require('./config');
const log = require('./log');
const fetch = require('isomorphic-fetch');
const EventEmitter = require('events');
const HttpsProxyAgent = require('https-proxy-agent');
const {Readable} = require('stream');
const Url = require('url');
const LIVE_UNDERSTANDING_API_VERSION = 20220608;
const MULTI_RESPONSES_API_VERSION = 20230215;
class Wit extends EventEmitter {
constructor(opts) {
super();
this.config = Object.freeze(validate(opts));
}
runComposer(sessionId, contextMap, message) {
return this.event(sessionId, contextMap, message).then(
this.makeComposerHandler(sessionId),
);
}
runComposerAudio(sessionId, contentType, body, contextMap) {
return this.converse(sessionId, contentType, body, contextMap).then(
this.makeComposerHandler(sessionId),
);
}
converse(sessionId, contentType, body, contextMap) {
if (typeof sessionId !== 'string') {
throw new Error('Please provide a session ID (string).');
}
if (typeof contentType !== 'string') {
throw new Error('Please provide a content-type (string).');
}
if (!body instanceof Readable) {
throw new Error('Please provide an audio stream (Readable).');
}
const {actions, apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
session_id: sessionId,
v: apiVersion,
};
if (typeof contextMap === 'object') {
params.context_map = JSON.stringify(contextMap);
}
const method = 'POST';
const fullURL = witURL + '/converse?' + encodeURIParams(params);
logger.debug(method, fullURL);
const multi_responses_enabled = apiVersion >= MULTI_RESPONSES_API_VERSION;
const req = fetch(fullURL, {
body,
method,
proxy,
headers: {
...headers,
'Content-Type': contentType,
'Transfer-Encoding': 'chunked',
},
});
const _partialResponses = req
.then(
resp =>
new Promise((resolve, reject) => {
logger.debug('status', resp.status);
const bodyStream = resp.body;
bodyStream.on('readable', () => {
let chunk;
let contents = '';
while (null !== (chunk = bodyStream.read())) {
contents += chunk.toString();
}
for (const rsp of parseResponse(contents)) {
const {action, context_map, error, is_final, response, text} =
rsp;
// Live transcription
if (!(error || is_final)) {
logger.debug('[converse] partialTranscription:', text);
this.emit('partialTranscription', text);
}
// Multi-responses
if (
multi_responses_enabled &&
!(error || is_final) &&
(action || response)
) {
if (response) {
logger.debug('[converse] partialResponse:', response);
this.emit('response', response);
}
if (action) {
logger.debug('[converse] got partial action:', action);
runAction(logger, actions, action, context_map);
}
}
}
});
}),
)
.catch(e =>
logger.error('[converse] could not parse partial response', e),
);
return req
.then(response => Promise.all([response.text(), response.status]))
.then(([contents, status]) => {
const finalResponse = parseResponse(contents).pop();
const {text} = finalResponse;
logger.debug('[converse] fullTranscription:', text);
this.emit('fullTranscription', text);
return [finalResponse, status];
})
.catch(e => e)
.then(makeWitResponseHandler(logger, 'converse'));
}
event(sessionId, contextMap, message) {
if (typeof sessionId !== 'string') {
throw new Error('Please provide a session ID (string).');
}
const {actions, apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
session_id: sessionId,
v: apiVersion,
};
if (typeof contextMap === 'object') {
params.context_map = JSON.stringify(contextMap);
}
const body = {};
if (typeof message === 'string') {
body.type = 'message';
body.message = message;
}
const method = 'POST';
const fullURL = witURL + '/event?' + encodeURIParams(params);
logger.debug(method, fullURL);
const req = fetch(fullURL, {
body: JSON.stringify(body),
method,
headers,
proxy,
});
// Multi-responses
if (apiVersion >= MULTI_RESPONSES_API_VERSION) {
const _partialResponses = req
.then(
resp =>
new Promise((resolve, reject) => {
logger.debug('status', resp.status);
const bodyStream = resp.body;
bodyStream.on('readable', () => {
let chunk;
let contents = '';
while (null !== (chunk = bodyStream.read())) {
contents += chunk.toString();
}
for (const rsp of parseResponse(contents)) {
const {action, context_map, error, is_final, response} = rsp;
if (!(error || is_final) && (action || response)) {
if (response) {
logger.debug('[event] partialResponse:', response);
this.emit('response', response);
}
if (action) {
logger.debug('[event] got partial action:', action);
runAction(logger, actions, action, context_map);
}
}
}
});
}),
)
.catch(e =>
logger.error('[event] could not parse partial response', e),
);
}
return req
.then(response => Promise.all([response.text(), response.status]))
.then(([contents, status]) => ([parseResponse(contents).pop(), status]))
.catch(e => e)
.then(makeWitResponseHandler(logger, 'event'));
}
message(q, context, n) {
if (typeof q !== 'string') {
throw new Error('Please provide a text input (string).');
}
const {apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
q,
v: apiVersion,
};
if (typeof context === 'object') {
params.context = JSON.stringify(context);
}
if (typeof n === 'number') {
params.n = JSON.stringify(n);
}
const method = 'GET';
const fullURL = witURL + '/message?' + encodeURIParams(params);
logger.debug(method, fullURL);
return fetch(fullURL, {
method,
headers,
proxy,
})
.then(response => Promise.all([response.json(), response.status]))
.then(makeWitResponseHandler(logger, 'message'));
}
speech(contentType, body, context, n) {
if (typeof contentType !== 'string') {
throw new Error('Please provide a content-type (string).');
}
if (!body instanceof Readable) {
throw new Error('Please provide an audio stream (Readable).');
}
const {apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
v: apiVersion,
};
if (typeof context === 'object') {
params.context = JSON.stringify(context);
}
if (typeof n === 'number') {
params.n = JSON.stringify(n);
}
const method = 'POST';
const fullURL = witURL + '/speech?' + encodeURIParams(params);
logger.debug(method, fullURL);
const live_understanding_enabled =
apiVersion >= LIVE_UNDERSTANDING_API_VERSION;
const req = fetch(fullURL, {
body,
method,
proxy,
headers: {
...headers,
'Content-Type': contentType,
'Transfer-Encoding': 'chunked',
},
});
const _partialResponses = req
.then(
response =>
new Promise((resolve, reject) => {
logger.debug('status', response.status);
const bodyStream = response.body;
bodyStream.on('readable', () => {
let chunk;
let contents = '';
while (null !== (chunk = bodyStream.read())) {
contents += chunk.toString();
}
for (const rsp of parseResponse(contents)) {
const {error, intents, is_final, text} = rsp;
// Live transcription
if (!(error || intents)) {
logger.debug('[speech] partialTranscription:', text);
this.emit('partialTranscription', text);
}
// Live understanding
if (live_understanding_enabled && intents && !is_final) {
logger.debug('[speech] partialUnderstanding:', rsp);
this.emit('partialUnderstanding', rsp);
}
}
});
}),
)
.catch(e => logger.error('[speech] could not parse partial response', e));
return req
.then(response => Promise.all([response.text(), response.status]))
.then(([contents, status]) => {
const finalResponse = parseResponse(contents).pop();
const {text} = finalResponse;
logger.debug('[speech] fullTranscription:', text);
this.emit('fullTranscription', text);
return [finalResponse, status];
})
.catch(e => e)
.then(makeWitResponseHandler(logger, 'speech'));
}
dictation(contentType, body) {
if (typeof contentType !== 'string') {
throw new Error('Please provide a content-type (string).');
}
if (!body instanceof Readable) {
throw new Error('Please provide an audio stream (Readable).');
}
const {apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
v: apiVersion,
};
const method = 'POST';
const fullURL = witURL + '/dictation?' + encodeURIParams(params);
logger.debug(method, fullURL);
const req = fetch(fullURL, {
body,
method,
proxy,
headers: {
...headers,
'Content-Type': contentType,
'Transfer-Encoding': 'chunked',
},
});
const _partialResponses = req
.then(
response =>
new Promise((resolve, reject) => {
logger.debug('status', response.status);
const bodyStream = response.body;
bodyStream.on('readable', () => {
let chunk;
let contents = '';
while (null !== (chunk = bodyStream.read())) {
contents += chunk.toString();
}
for (const rsp of parseResponse(contents)) {
const {error, is_final, text} = rsp;
// Live transcription
if (!error) {
if (!is_final) {
logger.debug('[dictation] partial transcription:', text);
this.emit('partialTranscription', text);
} else {
logger.debug(
'[dictation] full sentence transcription:',
text,
);
this.emit('fullTranscription', text);
}
}
}
});
}),
)
.catch(e =>
logger.error('[dictation] could not parse partial response', e),
);
return req
.then(response => Promise.all([response.text(), response.status]))
.then(([contents, status]) => {
const finalResponse = parseResponse(contents).pop();
const {text} = finalResponse;
logger.debug('[dictation] last full sentence transcription:', text);
this.emit('fullTranscription', text);
return [finalResponse, status];
})
.catch(e => e)
.then(makeWitResponseHandler(logger, 'dictation'));
}
synthesize(
q,
voice,
style = 'default',
speed = 100,
pitch = 100,
gain = 100,
) {
if (typeof q !== 'string') {
throw new Error('Please provide a text input (string).');
}
if (typeof voice !== 'string') {
throw new Error('Please provide a voice input (string).');
}
const {apiVersion, headers, logger, proxy, witURL} = this.config;
const params = {
v: apiVersion,
};
const body = {
q: q,
voice: voice,
style: style,
speed: speed,
pitch: pitch,
gain: gain,
};
const method = 'POST';
const fullURL = witURL + '/synthesize?' + encodeURIParams(params);
logger.debug(method, fullURL);
return fetch(fullURL, {
body: JSON.stringify(body),
method,
proxy,
headers: {
...headers,
'Content-Type': 'application/json',
},
})
.then(response => Promise.all([response, response.status]))
.then(makeWitResponseHandler(logger, 'synthesize'));
}
makeComposerHandler(sessionId) {
const {actions, logger} = this.config;
return ({context_map, action, expects_input, response}) => {
if (typeof context_map !== 'object') {
throw new Error(
'Unexpected context_map in API response: ' +
JSON.stringify(context_map),
);
}
if (response) {
logger.debug('[composer] response:', response);
this.emit('response', response);
}
if (action) {
logger.debug('[composer] got action', action);
return runAction(logger, actions, action, context_map).then(
({context_map, stop}) => {
if (expects_input && !stop) {
return this.runComposer(sessionId, context_map);
}
return {context_map};
},
);
}
return {context_map, expects_input};
};
}
}
const runAction = (logger, actions, name, ...rest) => {
logger.debug('Running action', name);
return Promise.resolve(actions[name](...rest));
};
const makeWitResponseHandler = (logger, endpoint) => rsp => {
const error = e => {
logger.error('[' + endpoint + '] Error: ' + e);
throw e;
};
if (rsp instanceof Error) {
return error(rsp);
}
const [json, status] = rsp;
if (json instanceof Error) {
return error(json);
}
const err = json.error || (status !== 200 && json.body + ' (' + status + ')');
if (err) {
return error(err);
}
logger.debug('[' + endpoint + '] Response: ' + JSON.stringify(json));
return json;
};
const getProxyAgent = witURL => {
const url = Url.parse(witURL);
const proxy =
url.protocol === 'http:'
? process.env.http_proxy || process.env.HTTP_PROXY
: process.env.https_proxy || process.env.HTTPS_PROXY;
const noProxy = process.env.no_proxy || process.env.NO_PROXY;
const shouldIgnore = noProxy && noProxy.indexOf(url.hostname) > -1;
if (proxy && !shouldIgnore) {
return new HttpsProxyAgent(proxy);
}
if (!proxy) {
return null;
}
};
const encodeURIParams = params =>
Object.entries(params)
.map(([key, value]) => key + '=' + encodeURIComponent(value))
.join('&');
const parseResponse = response => {
const chunks = response
.split('\r\n')
.map(x => x.trim())
.filter(x => x.length > 0);
let prev = '';
let jsons = [];
for (const chunk of chunks) {
try {
prev += chunk;
jsons.push(JSON.parse(prev));
prev = '';
} catch (_e) {}
}
return jsons;
};
const validate = opts => {
if (!opts.accessToken) {
throw new Error(
'Could not find access token, learn more at https://wit.ai/docs',
);
}
opts.witURL = opts.witURL || DEFAULT_WIT_URL;
opts.apiVersion = opts.apiVersion || DEFAULT_API_VERSION;
opts.headers = opts.headers || {
Authorization: 'Bearer ' + opts.accessToken,
'Content-Type': 'application/json',
};
opts.logger = opts.logger || new log.Logger(log.INFO);
opts.proxy = getProxyAgent(opts.witURL);
if (opts.actions && typeof opts.actions !== 'object') {
throw new Error('Please provide actions mapping (string -> function).');
}
return opts;
};
module.exports = Wit;