diff --git a/README.md b/README.md index f45ec79..19dd7ad 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,19 @@ botpress install api.ai The API.AI module should now be available in your bot UI ## Features +### Multi-Language Agent +You can specify different agents and the languages they supports. + +This module will try to query the agent that support the locale of the user. +If no agent contains the locale, it will look for the root language (eg: en instead of en-GB) otherwise the default language parameter is used. + +For more information see https://api.ai/docs/multi-language + +### Mode This module has two modes: **Default** (amend incoming events) and **Fulfillment** (respond automatically). -### Default Mode +#### Default Mode This mode will inject understanding metadata inside incoming messages through the API.AI middleware. @@ -33,7 +42,7 @@ bp.hear({'nlp.source': 'agent'}, (event, next) => { }) ``` -### Fulfillment Mode +#### Fulfillment Mode This mode will check if there's an available response in the `fulfillment` property of the API.AI response and respond automatically. No code required. @@ -54,7 +63,7 @@ Get an invite and join us now! 👉[https://slack.botpress.io](https://slack.bot | ENV | Default | Description | |---|---|---| | BOTPRESS_HTTP_TIMEOUT | 5000 | The timeout to API.AI requests | -| APIAI_TOKEN | null | Override the API token | +| APIAI_TOKEN | null | Override the API token (depreciated after v2.1.3)| ## License diff --git a/src/index.js b/src/index.js index a391dd4..7f32a41 100644 --- a/src/index.js +++ b/src/index.js @@ -9,35 +9,69 @@ import axios from 'axios' let config = null let service = null -const getClient = () => { +const getAgent = (lang) => { + if (config.agents.length == 0) { + // back compatibility + return {clientToken: config.accessToken} + } + + for (const agent of config.agents) { + if (agent.langs.includes(lang)) { + return agent + } + } +} + +const getAvailableLang = (lang) => { + if (!lang) { + return config.lang + } + lang = lang.replace('_', '-') // convert bp locale format to api.ai format + + for (const agent of config.agents) { + if (agent.langs.includes(lang)) { + return lang + } + } + + const l = lang.split('-') + if (l.length == 2) { + return getAvailableLang(l[0]) + } + + return config.lang +} + +const getClient = (lang) => { return axios.create({ baseURL: 'https://api.api.ai/v1', timeout: process.env.BOTPRESS_HTTP_TIMEOUT || 5000, - headers: {'Authorization': 'Bearer ' + config.accessToken} + headers: {'Authorization': 'Bearer ' + getAgent(lang).clientToken} }) } const setService = () => { - service = (userId, text) => { - return getClient().post('/query?v=20170101', { + service = (userId, lang, text) => { + return getClient(lang).post('/query?v=20170101', { query: text, - lang: config.lang, + lang: lang, sessionId: userId }) } } -const contextAdd = userId => (name, lifespan = 1) => { - return getClient().post('/contexts?v=20170101', [ +const contextAdd = (userId, lang) => (name, lifespan = 1) => { + return getClient(lang).post('/contexts?v=20170101', [ { name, lifespan } ], { params: {sessionId: userId } }) } -const contextRemove = userId => name => { - return getClient().delete('/contexts/' + name, { params: { sessionId: userId } }) +const contextRemove = (userId, lang) => name => { + return getClient(lang).delete('/contexts/' + name, { params: { sessionId: userId } }) } const incomingMiddleware = (event, next) => { + const lang = getAvailableLang(event.user.locale) let shortUserId = _.get(event, 'user.id') || '' if (shortUserId.length > 36) { shortUserId = crypto.createHash('md5').update(shortUserId).digest("hex") @@ -45,7 +79,7 @@ const incomingMiddleware = (event, next) => { if (["message", "postback", "text", "quick_reply"].includes(event.type)) { - service(shortUserId, event.payload || event.text) + service(shortUserId, lang, event.payload || event.text) .then(({data}) => { const {result} = data if (config.mode === 'fulfillment' @@ -67,8 +101,8 @@ const incomingMiddleware = (event, next) => { } else { event.nlp = Object.assign(result, { context: { - add: contextAdd(shortUserId), - remove: contextRemove(shortUserId) + add: contextAdd(shortUserId, lang), + remove: contextRemove(shortUserId, lang) } }) next() @@ -86,14 +120,14 @@ const incomingMiddleware = (event, next) => { console.log(error.stack) - event.bp.logger.warn('botpress-api.ai', 'API Error. Could not process incoming text: ' + err); + event.bp.logger.warn('botpress-api.ai', 'API Error. Could not process incoming text: ' + err) next() }) } else { event.nlp = { context: { - add: contextAdd(shortUserId), - remove: contextRemove(shortUserId) + add: contextAdd(shortUserId, lang), + remove: contextRemove(shortUserId, lang) } } @@ -104,7 +138,8 @@ const incomingMiddleware = (event, next) => { module.exports = { config: { - accessToken: { type: 'string', env: 'APIAI_TOKEN' }, + accessToken: { type: 'string', env: 'APIAI_TOKEN' }, // back compatibility + agents: { type: 'any', required: true, default: [], validation: v => _.isArray(v) }, lang: { type: 'string', default: 'en' }, mode: { type: 'choice', validation: ['fulfillment', 'default'], default: 'default' } }, @@ -133,8 +168,8 @@ module.exports = { }) router.post('/config', async (req, res) => { - const { accessToken, lang, mode } = req.body - await configurator.saveAll({ accessToken, lang, mode }) + const { agents, lang, mode } = req.body + await configurator.saveAll({ agents, lang, mode }) config = await configurator.loadAll() setService() res.sendStatus(200) diff --git a/src/views/index.jsx b/src/views/index.jsx index ef8a151..6d2e78e 100644 --- a/src/views/index.jsx +++ b/src/views/index.jsx @@ -10,7 +10,10 @@ import { FormGroup, FormControl, Alert, - Button + Button, + Glyphicon, + ListGroup, + ListGroupItem } from 'react-bootstrap' import Markdown from 'react-markdown' @@ -22,8 +25,14 @@ const supportedLanguages = { 'zh-CN': "Chinese (Simplified)", 'zh-TW': "Chinese (Traditional)", 'en': "English", + 'en-AU': "English (Australian)", + 'en-CA': "English (Canadian)", + 'en-GB': "English (Great Britain)", + 'en-US': "English (United States)", 'nl': "Dutch", 'fr': "French", + 'fr-FR': "French (French)", + 'fr-CA': "French (Canadian)", 'de': "German", 'it': "Italian", 'ja': "Japanese", @@ -35,8 +44,20 @@ const supportedLanguages = { } const documentation = { + languages: ` + ### Multi-Language Agent + + You can specify different agents and the languages they support. + For each agent you need to specify a name (doesn't need to correspond to api.ai agent name), the client access token of the agent, and the languages it support based on [api.ai reference](https://api.ai/docs/reference/language) seperated by commas (eg: fr,en,it). + + This module will try to query the agent that support the locale of the user based on \`event.user.locale\`. + If no agent contains the locale, it will look for the root language (eg: en instead of en-GB) otherwise the fallback language setting is used. + + For more information, see "[Multi-language Agents](https://api.ai/docs/multi-language)" on api.ai. + ` + , default: ` - ### Default + ### Mode Default This mode will inject understanding metadata inside incoming messages through the API.AI middleware. @@ -51,7 +72,7 @@ const documentation = { \`\`\` ` , - fulfillment: `### Fulfillment + fulfillment: `### Mode Fulfillment This mode will check if there's an available response in the \`fulfillment\` property of the API.AI response and respond automatically. No code required. @@ -72,18 +93,19 @@ export default class ApiModule extends React.Component { initialStateHash: null } - this.renderAccessToken = this.renderAccessToken.bind(this) + this.renderAgent = this.renderAgent.bind(this) this.renderRadioButton = this.renderRadioButton.bind(this) this.renderLanguage = this.renderLanguage.bind(this) - this.handleAccesTokenChange = this.handleAccesTokenChange.bind(this) + this.handleAddToAgentList = this.handleAddToAgentList.bind(this) + this.handleRemoveFromAgentList = this.handleRemoveFromAgentList.bind(this) this.handleSaveChanges = this.handleSaveChanges.bind(this) this.handleRadioChange = this.handleRadioChange.bind(this) this.handleLanguageChange = this.handleLanguageChange.bind(this) } getStateHash() { - return this.state.accessToken + ' ' + this.state.lang + ' ' + this.state.mode + return this.state.agents + ' ' + this.state.lang + ' ' + this.state.mode } getAxios() { @@ -105,10 +127,52 @@ export default class ApiModule extends React.Component { }) }) } + + handleAddToAgentList() { + const name = ReactDOM.findDOMNode(this.newAgentName) + const clientToken = ReactDOM.findDOMNode(this.newAgentClientToken) + const langs = ReactDOM.findDOMNode(this.newAgentLangs) + const item = { + name: name && name.value, + clientToken: clientToken && clientToken.value, + langs: langs && langs.value.replace(/\s/g,'').split(',') + } - handleAccesTokenChange(event) { + let errors = [] + if (_.some(_.values(item), _.isEmpty)) { + errors.push("Fields can not be empty") + } + if (_.some(item.langs, v => !(v in supportedLanguages))) { + errors.push("A locale is not a supported language") + } + if (_.some(_.map(this.state.agents, 'langs'), a => _.some(a, v => item.langs.includes(v)))) { + errors.push("A language can only be part of one agent") + } + if (_.map(this.state.agents, 'clientToken').includes(item.clientToken)) { + errors.push("A client token must be unique") + } + if (errors.length > 0) { + this.setState({ + message: { + type: 'danger', + text: errors.join("; ") + } + }) + return + } + this.setState({ - accessToken: event.target.value + agents: _.concat(this.state.agents, item) + }) + + name.value = '' + clientToken.value = '' + lang.value = '' + } + + handleRemoveFromAgentList(value) { + this.setState({ + agents: _.without(this.state.agents, value) }) } @@ -119,16 +183,25 @@ export default class ApiModule extends React.Component { } handleLanguageChange(event) { - this.setState({ - lang: event.target.value - }) + if (_.some(_.map(this.state.agents, 'langs'), a => a.includes(event.target.value))) { + this.setState({ + lang: event.target.value + }) + } else { + this.setState({ + message: { + type: 'danger', + text: "The fallback language " + event.target.key + " is not present in any agents" + } + }) + } } handleSaveChanges() { this.setState({ loading:true }) return this.getAxios().post('/api/botpress-apiai/config', { - accessToken: this.state.accessToken, + agents: this.state.agents, lang: this.state.lang, mode: this.state.mode }) @@ -149,16 +222,47 @@ export default class ApiModule extends React.Component { }) }) } - - renderAccessToken() { + + renderAgent(item) { + const handleRemove = () => this.handleRemoveFromAgentList(item) + return + {item.name + ' | ' + item.clientToken + ' | ' + item.langs.join(', ')} + + + } + + renderAgentList() { return ( - Access Token + Agents - +
+ + + Current agents: + + {this.state.agents.map(this.renderAgent)} + + + + + + Add a new agent: + this.newAgentName = r} type="text" placeholder="name"/> + this.newAgentClientToken = r} type="text" placeholder="client access token"/> + this.newAgentLangs = r} type="text" placeholder="en,fr"/> + + + +
@@ -200,13 +304,18 @@ export default class ApiModule extends React.Component { } renderLanguage() { - const supportedLanguageOptions = _.mapValues(supportedLanguages, this.renderLanguageOption) + const langs = _.flatten(_.map(this.state.agents, 'langs')) + let availableLanguages = {} + for (const lang of langs) { + availableLanguages[lang] = supportedLanguages[lang] + } + const supportedLanguageOptions = _.mapValues(availableLanguages, this.renderLanguageOption) return ( - Language + Fallback Language @@ -222,6 +331,7 @@ export default class ApiModule extends React.Component { return ( + @@ -239,7 +349,7 @@ export default class ApiModule extends React.Component { ? {opacity:1} : {opacity:0} - return + return } render() { @@ -255,7 +365,7 @@ export default class ApiModule extends React.Component { {this.renderSaveButton()}
- {this.renderAccessToken()} + {this.renderAgentList()} {this.renderLanguage()} {this.renderMode()}
diff --git a/src/views/style.scss b/src/views/style.scss index b7690ed..faf09ed 100644 --- a/src/views/style.scss +++ b/src/views/style.scss @@ -27,7 +27,7 @@ padding-top: 7px; } - button { + .saveButton { position: absolute; top: 7px; right: 10px;