1
1
mirror of https://github.com/leon-ai/leon.git synced 2024-12-18 22:31:32 +03:00

Merge branch 'over-http' into develop

This commit is contained in:
louistiti 2022-02-03 21:47:00 +08:00
commit 6b9d53e0cc
No known key found for this signature in database
GPG Key ID: 0A1C3B043E70C77D
39 changed files with 1941 additions and 3168 deletions

View File

@ -24,6 +24,11 @@ LEON_TTS=false
# Text-to-speech provider
LEON_TTS_PROVIDER=flite
# Enable/disable packages to be available over HTTP
LEON_OVER_HTTP=true
# HTTP API key (use "npm run generate:http-api-key" to regenerate one)
LEON_HTTP_API_KEY=
# Enable/disable collaborative logger
LEON_LOGGER=true

View File

@ -96,7 +96,7 @@ export default class Client {
})
this.socket.on('download', (data) => {
window.location = `${this.serverUrl}/v1/downloads?package=${data.package}&module=${data.module}`
window.location = `${this.serverUrl}/api/v1/downloads?package=${data.package}&module=${data.module}`
})
if (this.history !== null) {

View File

@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
loader.start()
request.get(`${serverUrl}/v1/info`)
request.get(`${serverUrl}/api/v1/info`)
.end((err, res) => {
if (err || !res.ok) {
console.error(err.response.error.message)

146
core/pkgs-endpoints.json Normal file
View File

@ -0,0 +1,146 @@
{
"endpoints": [
{
"method": "POST",
"route": "/api/p/calendar/todolist/create_list",
"params": [
"list"
],
"entitiesType": "trim"
},
{
"method": "GET",
"route": "/api/p/calendar/todolist/view_lists",
"params": []
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/view_list",
"params": [
"list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/rename_list",
"params": [
"old_list",
"new_list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/delete_list",
"params": [
"list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/add_todos",
"params": [
"todos",
"list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/complete_todos",
"params": [
"todos",
"list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/calendar/todolist/uncheck_todos",
"params": [
"todos",
"list"
],
"entitiesType": "trim"
},
{
"method": "POST",
"route": "/api/p/checker/isitdown/run",
"params": [
"url"
],
"entitiesType": "builtIn"
},
{
"method": "GET",
"route": "/api/p/checker/haveibeenpwned/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/whoami/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/joke/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/greeting/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/welcome/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/meaningoflife/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/randomnumber/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/bye/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/leon/partnerassistant/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/network/speedtest/run",
"params": []
},
{
"method": "POST",
"route": "/api/p/trend/github/run",
"params": [
"number",
"daterange"
],
"entitiesType": "builtIn"
},
{
"method": "GET",
"route": "/api/p/trend/producthunt/run",
"params": []
},
{
"method": "GET",
"route": "/api/p/videodownloader/youtube/run",
"params": []
}
]
}

View File

@ -21,7 +21,7 @@ socket.on('connect', () => {
console.log('Waiting for hotword...')
})
request.get(`${url}/v1/info`)
request.get(`${url}/api/v1/info`)
.end((err, res) => {
if (err || !res.ok) {
if (!err.response) {

3730
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,12 +22,13 @@
},
"scripts": {
"lint": "babel-node scripts/lint.js",
"test": "npm run test:json && npm run test:unit && npm run test:e2e",
"test": "npm run test:json && npm run test:over-http && npm run test:unit && npm run test:e2e",
"test:unit": "npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --projects test/unit/unit.jest.json && npm run train",
"test:e2e": "npm run test:e2e:nlp-modules && npm run test:e2e:modules",
"test:e2e:modules": "babel-node scripts/run-clean-test-dbs.js && npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --verbose --projects test/e2e/modules/e2e.modules.jest.json && babel-node scripts/run-clean-test-dbs.js && npm run train",
"test:e2e:nlp-modules": "npm run train en && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing jest --forceExit --silent --verbose --setupTestFrameworkScriptFile=./test/paths.setup.js test/e2e/nlp-modules.spec.js && npm run train",
"test:json": "jest --silent --projects test/json/json.jest.json",
"test:over-http": "npm run generate:pkgs-endpoints && cross-env PIPENV_PIPFILE=bridges/python/Pipfile LEON_NODE_ENV=testing LEON_HOST=http://localhost LEON_PORT=1338 LEON_HTTP_API_KEY=72aeb5ba324580963114481144385d7179c106fc jest --forceExit --silent --verbose --notify=false --bail --collectCoverage=false test/e2e/over-http.spec.js",
"test:module": "babel-node scripts/test-module.js",
"setup:offline": "babel-node scripts/setup-offline/setup-offline.js",
"setup:offline-stt": "babel-node scripts/setup-offline/run-setup-stt.js",
@ -36,13 +37,15 @@
"preinstall": "node scripts/setup/preinstall.js",
"postinstall": "babel-node scripts/setup/setup.js",
"dev:app": "vite --config app/vite.config.js",
"dev:server": "npm run train && cross-env LEON_NODE_ENV=development nodemon --watch server ./server/src/index.js --ignore server/src/tmp/ --exec babel-node",
"dev:server": "npm run train && npm run generate:pkgs-endpoints && cross-env LEON_NODE_ENV=development nodemon --watch server ./server/src/index.js --ignore server/src/tmp/ --exec babel-node",
"wake": "cross-env LEON_HOST=http://localhost LEON_PORT=1337 node hotword/index.js",
"delete-dist:server": "shx rm -rf ./server/dist",
"prepare": "husky install",
"generate:pkgs-endpoints": "babel-node scripts/generate/run-generate-pkgs-endpoints.js",
"generate:http-api-key": "babel-node scripts/generate/run-generate-http-api-key.js",
"build": "npm run build:app && npm run build:server",
"build:app": "cross-env LEON_NODE_ENV=production babel-node scripts/app/run-build-app.js",
"build:server": "npm run delete-dist:server && npm run train && babel ./server/src -d ./server/dist --copy-files && shx mkdir -p server/dist/tmp",
"build:server": "npm run delete-dist:server && npm run train && npm run generate:pkgs-endpoints && babel ./server/src -d ./server/dist --copy-files && shx mkdir -p server/dist/tmp",
"start": "cross-env LEON_NODE_ENV=production node ./server/dist/index.js",
"train": "babel-node scripts/run-train.js",
"prepare-release": "babel-node scripts/release/prepare-release.js",
@ -95,9 +98,9 @@
"git-changelog": "^2.0.0",
"husky": "^7.0.0",
"inquirer": "^8.1.0",
"jest": "^27.4.5",
"jest": "^27.4.7",
"jest-canvas-mock": "^2.3.1",
"jest-extended": "^1.2.0",
"jest-extended": "^2.0.0",
"json": "^10.0.0",
"nodemon": "^2.0.7",
"semver": "^7.3.5",

View File

@ -14,7 +14,17 @@
"Check if nodejs.org is up",
"Check if nodejs.org is working",
"Check if amazon.com is up or down"
]
],
"http_api": {
"entities": [
{
"entity": "url",
"resolution": [
"value"
]
}
]
}
}
},
"haveibeenpwned": {

View File

@ -8,7 +8,23 @@
"What are the trends on GH?",
"Give me the GH trends",
"What's trending on GH?"
]
],
"http_api": {
"entities": [
{
"entity": "number",
"resolution": [
"value"
]
},
{
"entity": "daterange",
"resolution": [
"timex"
]
}
]
}
}
},
"producthunt": {

View File

@ -0,0 +1,71 @@
import dotenv from 'dotenv'
import crypto from 'crypto'
import fs from 'fs'
import { prompt } from 'inquirer'
import path from 'path'
import log from '@/helpers/log'
import string from '@/helpers/string'
dotenv.config()
/**
* Generate HTTP API key script
* save it in the .env file
*/
const generateHttpApiKey = () => new Promise(async (resolve, reject) => {
log.info('Generating the HTTP API key...')
try {
const shasum = crypto.createHash('sha1')
const str = string.random(11)
const dotEnvPath = path.join(process.cwd(), '.env')
const envVarKey = 'LEON_HTTP_API_KEY'
let content = fs.readFileSync(dotEnvPath, 'utf8')
shasum.update(str)
const sha1 = shasum.digest('hex')
let lines = content.split('\n')
lines = lines.map((line) => {
if (line.indexOf(`${envVarKey}=`) !== -1) {
line = `${envVarKey}=${sha1}`
}
return line
})
content = lines.join('\n')
fs.writeFileSync(dotEnvPath, content)
log.success('HTTP API key generated')
resolve()
} catch (e) {
log.error(e.message)
reject(e)
}
})
export default () => new Promise(async (resolve, reject) => {
try {
if (process.env.LEON_HTTP_API_KEY === '') {
await generateHttpApiKey()
} else {
const answer = await prompt({
type: 'confirm',
name: 'generate.httpApiKey',
message: 'Do you want to regenerate the HTTP API key?',
default: false
})
if (answer.generate.httpApiKey === true) {
await generateHttpApiKey()
}
}
resolve()
} catch (e) {
reject(e)
}
})

View File

@ -0,0 +1,119 @@
import dotenv from 'dotenv'
import fs from 'fs'
import path from 'path'
import log from '@/helpers/log'
import { langs } from '@@/core/langs.json'
dotenv.config()
/**
* Generate packages endpoints script
* Parse and convert packages configuration into a JSON file understandable by Fastify
* to dynamically generate endpoints so packages can be accessible over HTTP
*/
export default () => new Promise(async (resolve, reject) => {
const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
const packagesDir = 'packages'
const outputFile = '/core/pkgs-endpoints.json'
const outputFilePath = path.join(__dirname, `../..${outputFile}`)
const lang = langs[process.env.LEON_LANG].short.toLowerCase().substr(0, 2)
try {
const packages = fs.readdirSync(packagesDir)
.filter((entity) => fs.statSync(path.join(packagesDir, entity)).isDirectory())
const finalObj = {
endpoints: []
}
let isFileNeedToBeGenerated = true
let pkgObj = { }
// Check if a new routing generation is necessary
if (fs.existsSync(outputFilePath)) {
const mtimeEndpoints = fs.statSync(outputFilePath).mtime.getTime()
for (let i = 0; i < packages.length; i += 1) {
const pkg = packages[i]
const fileInfo = fs.statSync(`${packagesDir}/${pkg}/data/expressions/${lang}.json`)
const mtime = fileInfo.mtime.getTime()
if (mtime > mtimeEndpoints) {
break
}
if ((i + 1) === packages.length) {
log.success(`${outputFile} is already up-to-date`)
isFileNeedToBeGenerated = false
}
}
}
if (isFileNeedToBeGenerated) {
log.info('Parsing packages configuration...')
for (let i = 0; i < packages.length; i += 1) {
const pkg = packages[i]
pkgObj = JSON.parse(fs.readFileSync(`${packagesDir}/${pkg}/data/expressions/${lang}.json`, 'utf8'))
const modules = Object.keys(pkgObj)
for (let j = 0; j < modules.length; j += 1) {
const module = modules[j]
const actions = Object.keys(pkgObj[module])
for (let k = 0; k < actions.length; k += 1) {
const action = actions[k]
const actionObj = pkgObj[module][action]
const { entities, http_api } = actionObj // eslint-disable-line camelcase
let finalMethod = entities || http_api?.entities ? 'POST' : 'GET'
// Only generate this route if it is not disabled from the package config
if (!http_api?.disabled || (http_api?.disabled && http_api?.disabled === false)) {
if (http_api?.method) {
finalMethod = http_api.method.toUpperCase()
}
if (!supportedMethods.includes(finalMethod)) {
reject(`The "${finalMethod}" HTTP method of the ${pkg}/${module}/${action} action is not supported`)
}
const endpoint = {
method: finalMethod.toUpperCase(),
route: `/api/p/${pkg}/${module}/${action}`,
params: []
}
if (http_api?.timeout) {
endpoint.timeout = http_api.timeout
}
if (entities) {
// Handle explicit trim entities
endpoint.entitiesType = 'trim'
endpoint.params = entities.map((entity) => entity.name)
} else if (http_api?.entities) {
// Handle built-in entities
endpoint.entitiesType = 'builtIn'
endpoint.params = http_api.entities.map((entity) => entity.entity)
}
finalObj.endpoints.push(endpoint)
}
}
}
}
log.info(`Writing ${outputFile} file...`)
try {
fs.writeFileSync(outputFilePath, JSON.stringify(finalObj, null, 2))
log.success(`${outputFile} file generated`)
resolve()
} catch (e) {
reject(`Failed to generate ${outputFile} file: ${e.message}`)
}
}
} catch (e) {
log.error(e.message)
reject(e)
}
})

View File

@ -0,0 +1,14 @@
import log from '@/helpers/log'
import generateHttpApiKey from './generate-http-api-key'
/**
* Execute the generating HTTP API key script
*/
(async () => {
try {
await generateHttpApiKey()
} catch (e) {
log.error(`Failed to generate the HTTP API key: ${e}`)
}
})()

View File

@ -0,0 +1,14 @@
import log from '@/helpers/log'
import generatePkgsEndpoints from './generate-pkgs-endpoints'
/**
* Execute the generating packages endpoints script
*/
(async () => {
try {
await generatePkgsEndpoints()
} catch (e) {
log.error(`Failed to generate packages endpoints: ${e}`)
}
})()

View File

@ -2,6 +2,7 @@ import loader from '@/helpers/loader'
import log from '@/helpers/log'
import train from '../train'
import generateHttpApiKey from '../generate/generate-http-api-key'
import setupDotenv from './setup-dotenv'
import setupCore from './setup-core'
import setupPackagesConfig from './setup-packages-config'
@ -26,6 +27,9 @@ import setupPythonPackages from './setup-python-packages'
setupPackagesConfig()
])
await setupPythonPackages()
loader.stop()
await generateHttpApiKey()
loader.start()
await train()
log.default('')

View File

@ -6,7 +6,7 @@ import path from 'path'
import log from '@/helpers/log'
import string from '@/helpers/string'
import { langs } from '../core/langs.json'
import { langs } from '@@/core/langs.json'
dotenv.config()

View File

@ -5,16 +5,16 @@ import { langs } from '@@/core/langs.json'
import log from '@/helpers/log'
import string from '@/helpers/string'
import Synchronizer from '@/core/synchronizer'
import Tts from '@/tts/tts'
class Brain {
constructor (socket, lang) {
this.socket = socket
constructor (lang) {
this.lang = lang
this.broca = JSON.parse(fs.readFileSync(`${__dirname}/../data/en.json`, 'utf8'))
this.process = { }
this.interOutput = { }
this.finalOutput = { }
this._socket = { }
this._tts = { }
// Read into the language file
const file = `${__dirname}/../data/${this.lang}.json`
@ -24,12 +24,22 @@ class Brain {
log.title('Brain')
log.success('New instance')
}
if (process.env.LEON_TTS === 'true') {
// Init TTS
this.tts = new Tts(this.socket, process.env.LEON_TTS_PROVIDER)
this.tts.init()
}
get socket () {
return this._socket
}
set socket (newSocket) {
this._socket = newSocket
}
get tts () {
return this._tts
}
set tts (newTts) {
this._tts = newTts
}
/**
@ -55,10 +65,10 @@ class Brain {
// Stripe HTML to a whitespace. Whitespace to let the TTS respects punctuation
const speech = rawSpeech.replace(/<(?:.|\n)*?>/gm, ' ')
this.tts.add(speech, end)
this._tts.add(speech, end)
}
this.socket.emit('answer', rawSpeech)
this._socket.emit('answer', rawSpeech)
}
}
@ -92,17 +102,34 @@ class Brain {
/**
* Execute Python modules
*/
execute (obj) {
execute (obj, opts) {
const executionTimeStart = Date.now()
opts = opts || {
mute: false // Close Leon mouth e.g. over HTTP
}
return new Promise((resolve, reject) => {
const queryId = `${Date.now()}-${string.random(4)}`
const queryObjectPath = `${__dirname}/../tmp/${queryId}.json`
const speeches = []
// Ask to repeat if Leon is not sure about the request
if (obj.classification.confidence < langs[process.env.LEON_LANG].min_confidence) {
this.talk(`${this.wernicke('random_not_sure')}.`, true)
this.socket.emit('is-typing', false)
if (!opts.mute) {
const speech = `${this.wernicke('random_not_sure')}.`
resolve()
speeches.push(speech)
this.talk(speech, true)
this._socket.emit('is-typing', false)
}
const executionTimeEnd = Date.now()
const executionTime = executionTimeEnd - executionTimeStart
resolve({
speeches,
executionTime
})
} else {
// Ensure the process is empty (to be able to execute other processes outside of Brain)
if (Object.keys(this.process).length === 0) {
@ -146,25 +173,51 @@ class Brain {
log.info(data.toString())
this.interOutput = obj.output
this.talk(obj.output.speech.toString())
const speech = obj.output.speech.toString()
if (!opts.mute) {
this.talk(speech)
}
speeches.push(speech)
} else {
output += data
}
} else {
const executionTimeEnd = Date.now()
const executionTime = executionTimeEnd - executionTimeStart
/* istanbul ignore next */
reject({ type: 'warning', obj: new Error(`The ${moduleName} module of the ${packageName} package is not well configured. Check the configuration file.`) })
reject({
type: 'warning',
obj: new Error(`The ${moduleName} module of the ${packageName} package is not well configured. Check the configuration file.`),
speeches,
executionTime
})
}
})
// Handle error
this.process.stderr.on('data', (data) => {
this.talk(`${this.wernicke('random_package_module_errors', '',
{ '%module_name%': moduleName, '%package_name%': packageName })}!`)
const speech = `${this.wernicke('random_package_module_errors', '',
{ '%module_name%': moduleName, '%package_name%': packageName })}!`
if (!opts.mute) {
this.talk(speech)
this._socket.emit('is-typing', false)
}
speeches.push(speech)
Brain.deleteQueryObjFile(queryObjectPath)
this.socket.emit('is-typing', false)
log.title(packageName)
reject({ type: 'error', obj: new Error(data) })
const executionTimeEnd = Date.now()
const executionTime = executionTimeEnd - executionTimeStart
reject({
type: 'error',
obj: new Error(data),
speeches,
executionTime
})
})
// Catch the end of the module execution
@ -177,7 +230,12 @@ class Brain {
// Check if there is an output (no module error)
if (this.finalOutput !== '') {
this.finalOutput = JSON.parse(this.finalOutput).output
this.talk(this.finalOutput.speech.toString(), true)
const speech = this.finalOutput.speech.toString()
if (!opts.mute) {
this.talk(speech, true)
}
speeches.push(speech)
/* istanbul ignore next */
// Synchronize the downloaded content if enabled
@ -191,14 +249,30 @@ class Brain {
// When the synchronization is finished
sync.synchronize((speech) => {
this.talk(speech)
if (!opts.mute) {
this.talk(speech)
}
speeches.push(speech)
})
}
}
Brain.deleteQueryObjFile(queryObjectPath)
this.socket.emit('is-typing', false)
resolve()
if (!opts.mute) {
this._socket.emit('is-typing', false)
}
const executionTimeEnd = Date.now()
const executionTime = executionTimeEnd - executionTimeStart
resolve({
queryId,
lang: langs[process.env.LEON_LANG].short,
...obj,
speeches,
executionTime // In ms, module execution time only
})
})
// Reset the child process

View File

@ -5,7 +5,7 @@ import log from '@/helpers/log'
import string from '@/helpers/string'
const getDownloads = async (fastify, options) => {
fastify.get(`/${options.apiVersion}/downloads`, (request, reply) => {
fastify.get(`/api/${options.apiVersion}/downloads`, (request, reply) => {
log.title('GET /downloads')
const clean = (dir, files) => {

View File

@ -1,4 +1,4 @@
import getDownloads from '@/api/downloads/get'
import getDownloads from '@/core/http-server/api/downloads/get'
const downloadsPlugin = async (fastify, options) => {
// Get downloads to download module content

View File

@ -3,7 +3,7 @@ import { version } from '@@/package.json'
import log from '@/helpers/log'
const getInfo = async (fastify, options) => {
fastify.get(`/${options.apiVersion}/info`, (_request, reply) => {
fastify.get(`/api/${options.apiVersion}/info`, (request, reply) => {
log.title('GET /info')
const message = 'Information pulled.'

View File

@ -1,4 +1,4 @@
import getInfo from '@/api/info/get'
import getInfo from '@/core/http-server/api/info/get'
const infoPlugin = async (fastify, options) => {
// Get information to init client

View File

@ -1,4 +1,4 @@
const corsMidd = async (_request, reply) => {
const corsMidd = async (request, reply) => {
// Allow only a specific client to request to the API (depending of the env)
if (process.env.LEON_NODE_ENV !== 'production') {
reply.header(

View File

@ -0,0 +1,12 @@
const keyMidd = async (request, reply) => {
const apiKey = request.headers['x-api-key']
if (!apiKey || apiKey !== process.env.LEON_HTTP_API_KEY) {
reply.statusCode = 401
reply.send({
message: 'Unauthorized, please check the HTTP API key is correct',
success: false
})
}
}
export default keyMidd

View File

@ -0,0 +1,305 @@
import Fastify from 'fastify'
import fastifyStatic from 'fastify-static'
import socketio from 'socket.io'
import { join } from 'path'
import { version } from '@@/package.json'
import { langs } from '@@/core/langs.json'
import { endpoints } from '@@/core/pkgs-endpoints.json'
import Nlu from '@/core/nlu'
import Brain from '@/core/brain'
import Asr from '@/core/asr'
import Stt from '@/stt/stt'
import Tts from '@/tts/tts'
import corsMidd from '@/core/http-server/plugins/cors'
import otherMidd from '@/core/http-server/plugins/other'
import keyMidd from '@/core/http-server/plugins/key'
import infoPlugin from '@/core/http-server/api/info'
import downloadsPlugin from '@/core/http-server/api/downloads'
import log from '@/helpers/log'
import date from '@/helpers/date'
const server = { }
let brain = { }
let nlu = { }
server.fastify = Fastify()
server.httpServer = { }
/**
* Generate packages routes
*/
/* istanbul ignore next */
server.generatePackagesRoutes = (instance) => {
// Dynamically expose Leon modules over HTTP
endpoints.forEach((endpoint) => {
instance.route({
method: endpoint.method,
url: endpoint.route,
async handler (request, reply) {
const timeout = endpoint.timeout || 60000
const [, , , pkg, module, action] = endpoint.route.split('/')
const handleRoute = async () => {
const { params } = endpoint
const entities = []
params.forEach((param) => {
const value = request.body[param]
const trimEntity = {
entity: param,
sourceText: value,
utteranceText: value,
resolution: { value }
}
const builtInEntity = {
entity: param,
resolution: { ...value }
}
let entity = endpoint?.entitiesType === 'trim' ? trimEntity : builtInEntity
if (Array.isArray(value)) {
value.forEach((v) => {
entity = {
entity: param,
resolution: { ...v }
}
entities.push(entity)
})
} else {
entities.push(entity)
}
})
const obj = {
query: '',
entities,
classification: {
package: pkg,
module,
action,
confidence: 1
}
}
const responseData = {
package: pkg,
module,
action,
speeches: []
}
try {
const data = await brain.execute(obj, { mute: true })
reply.send({
...data,
success: true
})
} catch (e) /* istanbul ignore next */ {
log[e.type](e.obj.message)
reply.statusCode = 500
reply.send({
...responseData,
speeches: e.speeches,
executionTime: e.executionTime,
message: e.obj.message,
success: false
})
}
}
handleRoute()
setTimeout(() => {
reply.statusCode = 408
reply.send({
package: pkg,
module,
action,
message: 'The action has timed out',
timeout,
success: false
})
}, timeout)
}
})
})
}
/**
* Bootstrap socket
*/
server.handleOnConnection = (socket) => {
log.title('Client')
log.success('Connected')
// Init
socket.on('init', async (data) => {
log.info(`Type: ${data}`)
log.info(`Socket id: ${socket.id}`)
if (data === 'hotword-node') {
// Hotword triggered
socket.on('hotword-detected', (data) => {
log.title('Socket')
log.success(`Hotword ${data.hotword} detected`)
socket.broadcast.emit('enable-record')
})
} else {
const asr = new Asr()
let stt = { }
let tts = { }
let sttState = 'disabled'
let ttsState = 'disabled'
brain.socket = socket
/* istanbul ignore if */
if (process.env.LEON_STT === 'true') {
sttState = 'enabled'
stt = new Stt(socket, process.env.LEON_STT_PROVIDER)
stt.init(() => null)
}
if (process.env.LEON_TTS === 'true') {
ttsState = 'enabled'
tts = new Tts(socket, process.env.LEON_TTS_PROVIDER)
tts.init((ttsInstance) => {
brain.tts = ttsInstance
})
}
log.title('Initialization')
log.success(`STT ${sttState}`)
log.success(`TTS ${ttsState}`)
// Listen for new query
socket.on('query', async (data) => {
log.title('Socket')
log.info(`${data.client} emitted: ${data.value}`)
socket.emit('is-typing', true)
await nlu.process(data.value)
})
// Handle automatic speech recognition
socket.on('recognize', async (data) => {
try {
await asr.run(data, stt)
} catch (e) {
log[e.type](e.obj.message)
}
})
}
})
}
/**
* Launch server
*/
server.listen = async (port) => {
const io = process.env.LEON_NODE_ENV === 'development'
? socketio(server.httpServer, { cors: { origin: `${process.env.LEON_HOST}:3000` } })
: socketio(server.httpServer)
io.on('connection', server.handleOnConnection)
await server.fastify.listen(port, '0.0.0.0')
log.title('Initialization')
log.success(`Server is available at ${process.env.LEON_HOST}:${port}`)
}
/**
* Bootstrap API
*/
server.bootstrap = async () => {
const apiVersion = 'v1'
// Render the web app
server.fastify.register(fastifyStatic, {
root: join(__dirname, '../../../../app/dist'),
prefix: '/'
})
server.fastify.get('/', (request, reply) => {
reply.sendFile('index.html')
})
server.fastify.register(infoPlugin, { apiVersion })
server.fastify.register(downloadsPlugin, { apiVersion })
if (process.env.LEON_OVER_HTTP === 'true') {
server.fastify.register((instance, opts, next) => {
instance.addHook('preHandler', keyMidd)
instance.post('/api/query', async (request, reply) => {
const { query } = request.body
try {
const data = await nlu.process(query, { mute: true })
reply.send({
...data,
success: true
})
} catch (e) {
reply.statusCode = 500
reply.send({
message: e.message,
success: false
})
}
})
server.generatePackagesRoutes(instance)
next()
})
}
server.httpServer = server.fastify.server
try {
await server.listen(process.env.LEON_PORT)
} catch (e) {
log.error(e.message)
}
}
/**
* Server entry point
*/
server.init = async () => {
server.fastify.addHook('onRequest', corsMidd)
server.fastify.addHook('preValidation', otherMidd)
log.title('Initialization')
log.success(`The current env is ${process.env.LEON_NODE_ENV}`)
log.success(`The current version is ${version}`)
if (!Object.keys(langs).includes(process.env.LEON_LANG) === true) {
process.env.LEON_LANG = 'en-US'
log.warning('The language you chose is not supported, then the default language has been applied')
}
log.success(`The current language is ${process.env.LEON_LANG}`)
log.success(`The current time zone is ${date.timeZone()}`)
const sLogger = (process.env.LEON_LOGGER !== 'true') ? 'disabled' : 'enabled'
log.success(`Collaborative logger ${sLogger}`)
brain = new Brain(langs[process.env.LEON_LANG].short)
nlu = new Nlu(brain)
// Train modules expressions
try {
await nlu.loadModel(join(__dirname, '../../data/leon-model.nlp'))
} catch (e) {
log[e.type](e.obj.message)
}
await server.bootstrap()
}
export default server

View File

@ -55,93 +55,125 @@ class Nlu {
* pick-up the right classification
* and extract entities
*/
async process (query) {
log.title('NLU')
log.info('Processing...')
process (query, opts) {
const processingTimeStart = Date.now()
query = string.ucfirst(query)
return new Promise(async (resolve, reject) => {
log.title('NLU')
log.info('Processing...')
if (Object.keys(this.nlp).length === 0) {
this.brain.talk(`${this.brain.wernicke('random_errors')}!`)
this.brain.socket.emit('is-typing', false)
log.error('The NLP model is missing, please rebuild the project or if you are in dev run: npm run train')
return false
}
const lang = langs[process.env.LEON_LANG].short
const result = await this.nlp.process(lang, query)
const {
domain, intent, score
} = result
const [moduleName, actionName] = intent.split('.')
let obj = {
query,
entities: [],
classification: {
package: domain,
module: moduleName,
action: actionName,
confidence: score
opts = opts || {
mute: false // Close Leon mouth e.g. over HTTP
}
}
query = string.ucfirst(query)
/* istanbul ignore next */
if (process.env.LEON_LOGGER === 'true' && process.env.LEON_NODE_ENV !== 'testing') {
this.request
.post('https://logger.getleon.ai/v1/expressions')
.set('X-Origin', 'leon-core')
.send({
version,
query,
if (Object.keys(this.nlp).length === 0) {
if (!opts.mute) {
this.brain.talk(`${this.brain.wernicke('random_errors')}!`)
this.brain.socket.emit('is-typing', false)
}
const msg = 'The NLP model is missing, please rebuild the project or if you are in dev run: npm run train'
log.error(msg)
return reject(msg)
}
const lang = langs[process.env.LEON_LANG].short
const result = await this.nlp.process(lang, query)
const {
domain, intent, score
} = result
const [moduleName, actionName] = intent.split('.')
let obj = {
query,
entities: [],
classification: {
package: domain,
module: moduleName,
action: actionName,
confidence: score
}
}
/* istanbul ignore next */
if (process.env.LEON_LOGGER === 'true' && process.env.LEON_NODE_ENV !== 'testing') {
this.request
.post('https://logger.getleon.ai/v1/expressions')
.set('X-Origin', 'leon-core')
.send({
version,
query,
lang,
classification: obj.classification
})
.then(() => { /* */ })
.catch(() => { /* */ })
}
if (intent === 'None') {
const fallback = Nlu.fallback(obj, langs[process.env.LEON_LANG].fallbacks)
if (fallback === false) {
if (!opts.mute) {
this.brain.talk(`${this.brain.wernicke('random_unknown_queries')}.`, true)
this.brain.socket.emit('is-typing', false)
}
log.title('NLU')
const msg = 'Query not found'
log.warning(msg)
const processingTimeEnd = Date.now()
const processingTime = processingTimeEnd - processingTimeStart
return resolve({
processingTime,
message: msg
})
}
obj = fallback
}
log.title('NLU')
log.success('Query found')
try {
obj.entities = await this.ner.extractEntities(
lang,
classification: obj.classification
})
.then(() => { /* */ })
.catch(() => { /* */ })
}
join(__dirname, '../../../packages', obj.classification.package, `data/expressions/${lang}.json`),
obj
)
} catch (e) /* istanbul ignore next */ {
log[e.type](e.obj.message)
if (intent === 'None') {
const fallback = Nlu.fallback(obj, langs[process.env.LEON_LANG].fallbacks)
if (fallback === false) {
this.brain.talk(`${this.brain.wernicke('random_unknown_queries')}.`, true)
this.brain.socket.emit('is-typing', false)
log.title('NLU')
log.warning('Query not found')
return false
if (!opts.mute) {
this.brain.talk(`${this.brain.wernicke(e.code, '', e.data)}!`)
}
}
obj = fallback
}
try {
// Inject action entities with the others if there is
const data = await this.brain.execute(obj, { mute: opts.mute })
const processingTimeEnd = Date.now()
const processingTime = processingTimeEnd - processingTimeStart
log.title('NLU')
log.success('Query found')
return resolve({
processingTime, // In ms, total time
...data,
nluProcessingTime: processingTime - data?.executionTime // In ms, NLU processing time only
})
} catch (e) /* istanbul ignore next */ {
log[e.type](e.obj.message)
try {
obj.entities = await this.ner.extractEntities(
lang,
join(__dirname, '../../../packages', obj.classification.package, `data/expressions/${lang}.json`),
obj
)
} catch (e) /* istanbul ignore next */ {
log[e.type](e.obj.message)
this.brain.talk(`${this.brain.wernicke(e.code, '', e.data)}!`)
}
if (!opts.mute) {
this.brain.socket.emit('is-typing', false)
}
try {
// Inject action entities with the others if there is
await this.brain.execute(obj)
} catch (e) /* istanbul ignore next */ {
log[e.type](e.obj.message)
this.brain.socket.emit('is-typing', false)
}
return true
return reject(e.obj)
}
})
}
/**

View File

@ -1,168 +0,0 @@
import Fastify from 'fastify'
import fastifyStatic from 'fastify-static'
import socketio from 'socket.io'
import { join } from 'path'
import { version } from '@@/package.json'
import { langs } from '@@/core/langs.json'
import Nlu from '@/core/nlu'
import Brain from '@/core/brain'
import Asr from '@/core/asr'
import Stt from '@/stt/stt'
import corsMidd from '@/plugins/cors'
import otherMidd from '@/plugins/other'
import infoPlugin from '@/api/info/index'
import downloadsPlugin from '@/api/downloads/index'
import log from '@/helpers/log'
import date from '@/helpers/date'
class Server {
constructor () {
this.fastify = Fastify()
this.httpServer = { }
this.brain = { }
this.nlu = { }
this.asr = { }
this.stt = { }
}
/**
* Server entry point
*/
async init () {
this.fastify.addHook('onRequest', corsMidd)
this.fastify.addHook('onRequest', otherMidd)
log.title('Initialization')
log.success(`The current env is ${process.env.LEON_NODE_ENV}`)
log.success(`The current version is ${version}`)
if (!Object.keys(langs).includes(process.env.LEON_LANG) === true) {
process.env.LEON_LANG = 'en-US'
log.warning('The language you chose is not supported, then the default language has been applied')
}
log.success(`The current language is ${process.env.LEON_LANG}`)
log.success(`The current time zone is ${date.timeZone()}`)
const sLogger = (process.env.LEON_LOGGER !== 'true') ? 'disabled' : 'enabled'
log.success(`Collaborative logger ${sLogger}`)
await this.bootstrap()
}
/**
* Bootstrap API
*/
async bootstrap () {
const apiVersion = 'v1'
// Render the web app
this.fastify.register(fastifyStatic, {
root: join(__dirname, '..', '..', '..', 'app', 'dist'),
prefix: '/'
})
this.fastify.get('/', (_request, reply) => {
reply.sendFile('index.html')
})
this.fastify.register(infoPlugin, { apiVersion })
this.fastify.register(downloadsPlugin, { apiVersion })
this.httpServer = this.fastify.server
try {
await this.listen(process.env.LEON_PORT)
} catch (e) {
log.error(e.message)
}
}
/**
* Launch server
*/
async listen (port) {
const io = process.env.LEON_NODE_ENV === 'development'
? socketio(this.httpServer, { cors: { origin: `${process.env.LEON_HOST}:3000` } })
: socketio(this.httpServer)
io.on('connection', this.connection)
await this.fastify.listen(port, '0.0.0.0')
log.success(`Server is available at ${process.env.LEON_HOST}:${port}`)
}
/**
* Bootstrap socket
*/
async connection (socket) {
log.title('Client')
log.success('Connected')
// Init
socket.on('init', async (data) => {
log.info(`Type: ${data}`)
log.info(`Socket id: ${socket.id}`)
if (data === 'hotword-node') {
// Hotword triggered
socket.on('hotword-detected', (data) => {
log.title('Socket')
log.success(`Hotword ${data.hotword} detected`)
socket.broadcast.emit('enable-record')
})
} else {
let sttState = 'disabled'
let ttsState = 'disabled'
this.brain = new Brain(socket, langs[process.env.LEON_LANG].short)
this.nlu = new Nlu(this.brain)
this.asr = new Asr()
/* istanbul ignore if */
if (process.env.LEON_STT === 'true') {
sttState = 'enabled'
this.stt = new Stt(socket, process.env.LEON_STT_PROVIDER)
this.stt.init()
}
if (process.env.LEON_TTS === 'true') {
ttsState = 'enabled'
}
log.title('Initialization')
log.success(`STT ${sttState}`)
log.success(`TTS ${ttsState}`)
// Train modules expressions
try {
await this.nlu.loadModel(join(__dirname, '../data/leon-model.nlp'))
} catch (e) {
log[e.type](e.obj.message)
}
// Listen for new query
socket.on('query', async (data) => {
log.title('Socket')
log.info(`${data.client} emitted: ${data.value}`)
socket.emit('is-typing', true)
await this.nlu.process(data.value)
})
// Handle automatic speech recognition
socket.on('recognize', async (data) => {
try {
await this.asr.run(data, this.stt)
} catch (e) {
log[e.type](e.obj.message)
}
})
}
})
}
}
export default Server

View File

@ -31,7 +31,7 @@ log.error = (value) => {
log.warning = (value) => console.warn('\x1b[33m❗ %s\x1b[0m', value)
log.title = (value) => console.log('\n---\n\n\x1b[7m.: %s :.\x1b[0m\n', value.toUpperCase())
log.title = (value) => console.log('\n\n\x1b[7m.: %s :.\x1b[0m', value.toUpperCase())
log.default = (value) => console.log('%s', value)

View File

@ -1,10 +1,9 @@
import dotenv from 'dotenv'
import Server from '@/core/server'
import server from '@/core/http-server/server'
(async () => {
dotenv.config()
const server = new Server()
await server.init()
})()

View File

@ -21,7 +21,7 @@ class Stt {
/**
* Initialize the STT provider
*/
init () {
init (cb) {
log.info('Initializing STT...')
if (!this.providers.includes(this.provider)) {
@ -48,6 +48,8 @@ class Stt {
log.title('STT')
log.success('STT initialized')
cb(this)
return true
}

View File

@ -24,7 +24,7 @@ class Tts {
/**
* Initialize the TTS provider
*/
init () {
init (cb) {
log.info('Initializing TTS...')
if (!this.providers.includes(this.provider)) {
@ -50,6 +50,8 @@ class Tts {
log.title('TTS')
log.success('TTS initialized')
cb(this)
return true
}

View File

@ -7,7 +7,8 @@ import Brain from '@/core/brain'
jest.setTimeout(60000)
global.nlu = new Nlu()
global.brain = new Brain({ emit: jest.fn() }, 'en')
global.brain = new Brain('en')
global.brain.socket.emit = jest.fn()
global.nlu.brain = { wernicke: jest.fn(), talk: jest.fn(), socket: { emit: jest.fn() } }
global.brain.tts = {
synthesizer: jest.fn(),

View File

@ -26,7 +26,7 @@ describe('NLU modules', () => {
describe(`${langKeys[i]} language`, () => {
const lang = langs[langKeys[i]]
const nlu = new Nlu()
const brain = new Brain({ emit: jest.fn() }, lang.short)
const brain = new Brain(lang.short)
let expressionsObj = { }
nlu.brain = { wernicke: jest.fn(), talk: jest.fn(), socket: { emit: jest.fn() } }

View File

@ -0,0 +1,33 @@
import superagent from 'superagent'
import server from '@/core/http-server/server'
const urlPrefix = `${process.env.LEON_HOST}:${process.env.LEON_PORT}/api`
const queryUrl = `${urlPrefix}/query`
const actionModuleUrl = `${urlPrefix}/p/leon/randomnumber/run`;
/**
* Test the query endpoint endpoint over HTTP
* and a simple module action over HTTP
*/
(async () => {
await server.init()
})()
describe('Over HTTP', () => {
test(`Request query endpoint POST ${queryUrl}`, async () => {
const { body } = await superagent.post(queryUrl)
.send({ query: 'Hello' })
.set('X-API-Key', process.env.LEON_HTTP_API_KEY)
expect(body).toHaveProperty('success', true)
})
test(`Request an action module: GET ${actionModuleUrl}`, async () => {
const { body } = await superagent.get(actionModuleUrl)
.set('X-API-Key', process.env.LEON_HTTP_API_KEY)
expect(body).toHaveProperty('success', true)
})
})

View File

@ -5,7 +5,7 @@ import Brain from '@/core/brain'
describe('brain', () => {
describe('constructor()', () => {
test('creates a new instance of Brain', () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
expect(brain).toBeInstanceOf(Brain)
})
@ -13,15 +13,18 @@ describe('brain', () => {
describe('talk()', () => {
test('does not emit answer to the client when the speech is empty', () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
brain.socket.emit = jest.fn()
brain.talk('')
expect(brain.socket.emit).toHaveBeenCalledTimes(0)
})
test('emits string answer to the client', () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
brain.tts = { add: jest.fn() }
brain.socket.emit = jest.fn()
brain.talk('Hello world')
expect(brain.tts.add).toHaveBeenCalledTimes(1)
@ -31,13 +34,13 @@ describe('brain', () => {
describe('wernicke()', () => {
test('picks specific string according to object properties', () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
expect(brain.wernicke('errors', 'not_found', { })).toBe('Sorry, it seems I cannot find that')
})
test('picks random string from an array', () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
expect(global.enExpressions.answers.random_errors).toIncludeAnyMembers([brain.wernicke('random_errors', '', { })])
})
@ -45,7 +48,8 @@ describe('brain', () => {
describe('execute()', () => {
test('asks to repeat', async () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
brain.socket.emit = jest.fn()
brain.talk = jest.fn()
await brain.execute({ classification: { confidence: 0.1 } })
@ -54,8 +58,9 @@ describe('brain', () => {
.toIncludeAnyMembers([string[0].substr(0, (string[0].length - 1))])
})
test('creates child process', async () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
test('spawns child process', async () => {
const brain = new Brain('en')
brain.socket.emit = jest.fn()
brain.tts = {
synthesizer: jest.fn(),
default: jest.fn(),
@ -80,7 +85,8 @@ describe('brain', () => {
})
test('executes module', async () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
brain.socket.emit = jest.fn()
brain.talk = jest.fn()
const obj = {
@ -107,7 +113,8 @@ describe('brain', () => {
})
test('rejects promise because of spawn failure', async () => {
const brain = new Brain({ emit: jest.fn() }, 'en')
const brain = new Brain('en')
brain.socket.emit = jest.fn()
brain.talk = jest.fn()
const obj = {

View File

@ -42,19 +42,19 @@ describe('NLU', () => {
describe('process()', () => {
const nluFallbackTmp = Nlu.fallback
test('returns false because the NLP model is empty', async () => {
test('rejects because the NLP model is empty', async () => {
const nlu = new Nlu()
nlu.brain = { talk: jest.fn(), wernicke: jest.fn(), socket: { emit: jest.fn() } }
expect(await nlu.process('Hello')).toBeFalsy()
await expect(nlu.process('Hello')).rejects.toEqual('The NLP model is missing, please rebuild the project or if you are in dev run: npm run train')
})
test('returns false because of query not found', async () => {
test('resolves with query not found', async () => {
const nlu = new Nlu()
nlu.brain = { talk: jest.fn(), wernicke: jest.fn(), socket: { emit: jest.fn() } }
await nlu.loadModel(global.paths.nlp_model)
expect(await nlu.process('Unknown query')).toBeFalsy()
await expect(nlu.process('Unknown query')).resolves.toHaveProperty('message', 'Query not found')
expect(nlu.brain.talk).toHaveBeenCalledTimes(1)
})
@ -70,7 +70,8 @@ describe('NLU', () => {
Nlu.fallback = jest.fn(() => fallbackObj)
await nlu.loadModel(global.paths.nlp_model)
expect(await nlu.process(query)).toBeTruthy()
await expect(nlu.process(query)).resolves.toHaveProperty('processingTime')
expect(nlu.brain.execute.mock.calls[0][0]).toBe(fallbackObj)
Nlu.fallback = nluFallbackTmp // Need to give back the real fallback method
})
@ -80,7 +81,7 @@ describe('NLU', () => {
nlu.brain = { execute: jest.fn() }
await nlu.loadModel(global.paths.nlp_model)
expect(await nlu.process('Hello')).toBeTruthy()
await expect(nlu.process('Hello')).toResolve()
expect(nlu.brain.execute).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,77 +1,46 @@
import net from 'net'
import { EventEmitter } from 'events'
import Server from '@/core/server'
import server from '@/core/http-server/server'
describe('server', () => {
describe('constructor()', () => {
test('creates a new instance of Server', () => {
const server = new Server()
expect(server).toBeInstanceOf(Server)
expect(server.brain).toBeEmpty()
})
})
describe('init()', () => {
test('uses default language if there is an unsupported one', async () => {
const server = new Server()
test('uses default language if the given one is unsupported', async () => {
server.bootstrap = jest.fn() // Need to mock bootstrap method to not continue the init
process.env.LEON_LANG = 'fake-lang'
await server.init()
expect(process.env.LEON_LANG).toBe('en-US')
})
test('initializes server configurations', async () => {
await expect(server.init()).resolves.not.toThrow()
})
})
describe('bootstrap()', () => {
test('initializes HTTP server', async () => {
const server = new Server()
await server.bootstrap()
expect(server.httpServer).not.toBeEmpty()
await server.httpServer.close()
expect(server.httpServer).not.toBe({ })
})
})
describe('listen()', () => {
test('listens port already in use', async () => {
const fakeServer = net.createServer()
fakeServer.once('error', (err) => {
expect(err.code).toBe('EADDRINUSE')
fakeServer.close()
})
const server = new Server()
await server.init()
fakeServer.listen(process.env.LEON_PORT)
await server.httpServer.close()
})
test('listens for request', async () => {
const server = new Server()
console.log = jest.fn()
await server.listen(process.env.LEON_PORT)
expect(console.log.mock.calls[0][1].indexOf(process.env.LEON_PORT)).not.toBe(-1)
expect(console.log.mock.calls[1][1].indexOf(`${process.env.LEON_HOST}:${process.env.LEON_PORT}`)).not.toEqual(-1)
})
})
describe('connection()', () => {
describe('handleOnConnection()', () => {
test('initializes main nodes', async () => {
const server = new Server()
await server.init()
// Mock the WebSocket with an EventEmitter
const ee = new EventEmitter()
ee.broadcast = { emit: jest.fn() }
console.log = jest.fn()
await server.connection(ee)
server.handleOnConnection(ee)
expect(console.log.mock.calls[0][1]).toBe('CLIENT')
console.log = jest.fn()
@ -84,9 +53,6 @@ describe('server', () => {
console.log = jest.fn()
ee.emit('init', 'jest')
expect(server.brain).not.toBeEmpty()
expect(server.nlu).not.toBeEmpty()
expect(server.asr).not.toBeEmpty()
/* setTimeout(() => {
ee.emit('query', { client: 'jest', value: 'Hello' })

View File

@ -19,7 +19,7 @@ describe('STT', () => {
test('initializes the STT parser', () => {
const stt = new Stt({ }, 'coqui-stt')
expect(stt.init()).toBeTruthy()
expect(stt.init(() => null)).toBeTruthy()
})
})

View File

@ -19,7 +19,7 @@ describe('TTS', () => {
test('initializes the TTS synthesizer', () => {
const tts = new Tts({ }, 'flite')
expect(tts.init()).toBeTruthy()
expect(tts.init(() => null)).toBeTruthy()
})
})

View File

@ -3,6 +3,7 @@
"verbose": true,
"notify": false,
"collectCoverage": true,
"resetMocks": true,
"rootDir": "../..",
"testMatch": [
"<rootDir>/test/unit/**/*.spec.js"
@ -18,8 +19,8 @@
"coverageDirectory": "<rootDir>/test/coverage",
"collectCoverageFrom": [
"<rootDir>/server/src/**/*.js",
"!<rootDir>/server/src/api/**/*.js",
"!<rootDir>/server/src/plugins/**/*.js",
"!<rootDir>/server/src/core/http-server/api/**/*.js",
"!<rootDir>/server/src/core/http-server/plugins/**/*.js",
"!<rootDir>/server/src/stt/google-cloud-stt/**/*.js",
"!<rootDir>/server/src/stt/watson-stt/**/*.js",
"!<rootDir>/server/src/tts/amazon-polly/**/*.js",