diff --git a/packages/interactions/__tests__/Interactions-unit-test.ts b/packages/interactions/__tests__/Interactions-unit-test.ts index 114b54b051f..d1aba55ca3f 100644 --- a/packages/interactions/__tests__/Interactions-unit-test.ts +++ b/packages/interactions/__tests__/Interactions-unit-test.ts @@ -1,113 +1,123 @@ +/* + * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ import { InteractionsClass as Interactions } from '../src/Interactions'; -import { AWSLexProvider, AbstractInteractionsProvider } from '../src/Providers'; -import { Credentials } from '@aws-amplify/core'; -import { - LexRuntimeServiceClient, - PostContentCommand, - PostTextCommand, -} from '@aws-sdk/client-lex-runtime-service'; +import { AbstractInteractionsProvider } from '../src/Providers'; +import { InteractionsOptions } from '../src/types'; (global as any).Response = () => {}; (global as any).Response.prototype.arrayBuffer = (blob: Blob) => { return Promise.resolve(new ArrayBuffer(0)); }; -// mock stream response -const createBlob = () => { - return new Blob(); +// aws-export config +const awsmobileBot = { + name: 'BookTripMOBILEHUB', + alias: '$LATEST', + region: 'us-east-1', + providerName: 'DummyProvider', + description: 'Bot to make reservations for a visit to a city.', + 'bot-template': 'bot-trips', + 'commands-help': [ + 'Book a car', + 'Reserve a car', + 'Make a car reservation', + 'Book a hotel', + 'Reserve a room', + 'I want to make a hotel reservation', + ], +}; +const awsmobile = { + aws_bots: 'enable', + aws_bots_config: [awsmobileBot], + aws_project_name: 'bots', + aws_project_region: 'us-east-1', }; -LexRuntimeServiceClient.prototype.send = jest.fn((command, callback) => { - if (command instanceof PostTextCommand) { - if (command.input.inputText === 'done') { - const result = { - message: 'echo:' + command.input.inputText, - dialogState: 'ReadyForFulfillment', - slots: { - m1: 'hi', - m2: 'done', - }, - }; - return Promise.resolve(result); - } else { - const result = { - message: 'echo:' + command.input.inputText, - dialogState: 'ElicitSlot', - }; - return Promise.resolve(result); - } - } else if (command instanceof PostContentCommand) { - if (command.input.contentType === 'audio/x-l16; sample-rate=16000') { - if (command.input.inputStream === 'voice:done') { - const result = { - message: 'voice:echo:' + command.input.inputStream, - dialogState: 'ReadyForFulfillment', - slots: { - m1: 'voice:hi', - m2: 'voice:done', - }, - audioStream: createBlob(), - }; - return Promise.resolve(result); - } else { - const result = { - message: 'voice:echo:' + command.input.inputStream, - dialogState: 'ElicitSlot', - audioStream: createBlob(), - }; - return Promise.resolve(result); - } - } else { - if (command.input.inputStream === 'done') { - const result = { - message: 'echo:' + command.input.inputStream, - dialogState: 'ReadyForFulfillment', - slots: { - m1: 'hi', - m2: 'done', - }, - audioStream: createBlob(), - }; - return Promise.resolve(result); - } else { - const result = { - message: 'echo:' + command.input.inputStream, - dialogState: 'ElicitSlot', - audioStream: createBlob(), - }; - return Promise.resolve(result); - } - } - } -}) as any; +// manual config +const manualConfigBots = { + BookTrip: { + name: 'BookTrip', + alias: '$LATEST', + region: 'us-west-2', + providerName: 'DummyProvider', + }, + OrderFlowers: { + name: 'OrderFlowers', + alias: '$LATEST', + region: 'us-west-2', + providerName: 'DummyProvider', + }, +}; +const manualConfig = { + Interactions: { + bots: manualConfigBots, + }, +}; -class AWSLexProvider2 extends AWSLexProvider { +// a sample response from send method +const sampleSendResponse = { + $metadata: { + httpStatusCode: 200, + requestId: '6eed4ad1-141c-4662-a528-3c857de1e1da', + attempts: 1, + totalRetryDelay: 0, + }, + alternativeIntents: '[]', + audioStream: new Blob(), + botVersion: '$LATEST', + contentType: 'audio/mpeg', + dialogState: 'ElicitSlot', + intentName: 'BookCar_dev', + message: 'In what city do you need to rent a car?', + sessionId: '2022-08-11T18:23:01.013Z-sTqDnpGk', + slotToElicit: 'PickUpCity', + slots: + '{"ReturnDate":null,"PickUpDate":null,"DriverAge":null,"CarType":null,"PickUpCity":null,"Location":null}', +}; + +class DummyProvider extends AbstractInteractionsProvider { getProviderName() { - return 'AWSLexProvider2'; + return 'DummyProvider'; } -} -class AWSLexProviderWrong extends AbstractInteractionsProvider { - private onCompleteResolve: Function; - private onCompleteReject: Function; + configure(config: InteractionsOptions = {}): InteractionsOptions { + return super.configure(config); + } + + async sendMessage(message: string | Object): Promise { + return new Promise(async (res, rej) => res(sampleSendResponse)); + } + async onComplete(botname: string, callback: (err, confirmation) => void) { + return new Promise((res, rej) => res({})); + } +} + +class WrongProvider extends AbstractInteractionsProvider { getProviderName() { - return 'AWSLexProviderWrong'; + return 'WrongProvider'; } getCategory() { - return 'IDontKnow'; + return 'WrongCategory'; } - sendMessage(message: string | Object): Promise { - return new Promise(async (res, rej) => {}); + async sendMessage(message: string | Object): Promise { + return new Promise(async (res, rej) => res({})); } async onComplete() { - return new Promise((res, rej) => { - this.onCompleteResolve = res; - this.onCompleteReject = rej; - }); + return new Promise((res, rej) => res({})); } } @@ -116,817 +126,275 @@ afterEach(() => { }); describe('Interactions', () => { - describe('constructor test', () => { - test('happy case', () => { - const interactions = new Interactions({}); + // Test 'configure' API + describe('configure API', () => { + let interactions; + let providerConfigureSpy; + + beforeEach(() => { + interactions = new Interactions({}); + interactions.configure({}); + interactions.addPluggable(new DummyProvider()); + providerConfigureSpy = jest.spyOn(DummyProvider.prototype, 'configure'); }); - }); - - describe('configure test', () => { - test('happy case', () => { - const interactions = new Interactions({}); + test('Check if bot is successfully configured by validating config response', () => { const options = { - key: 'value', + keyA: 'valueA', + keyB: 'valueB', }; const config = interactions.configure(options); - - expect(config).toEqual({ bots: {}, key: 'value' }); + expect(config).toEqual({ ...options, bots: {} }); + expect.assertions(1); }); - test('aws-exports configuration and send message to existing bot', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementationOnce(() => Promise.resolve({ identityId: '1234' })); - - const awsmobile = { - aws_bots: 'enable', - aws_bots_config: [ - { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - description: 'Bot to make reservations for a visit to a city.', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - }; - const interactions = new Interactions({}); - + test('Configure bot using aws-exports configuration', () => { const config = interactions.configure(awsmobile); - expect(config).toEqual({ - aws_bots: 'enable', - aws_bots_config: [ - { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', + ...awsmobile, bots: { - BookTripMOBILEHUB: { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, + BookTripMOBILEHUB: awsmobileBot, }, }); + // check if provider's configure was called + expect(providerConfigureSpy).toBeCalledTimes( + awsmobile.aws_bots_config.length + ); + expect(providerConfigureSpy).toHaveBeenCalledWith({ + BookTripMOBILEHUB: awsmobileBot, + }); + expect.assertions(3); + }); - const response = await interactions.send('BookTripMOBILEHUB', 'hi'); + test('Configure bot using manual configuration', () => { + const config = interactions.configure(manualConfig); + expect(config).toEqual({ + bots: manualConfigBots, + }); - expect(response).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', + // check if provider's configure was called + expect(providerConfigureSpy).toBeCalledTimes( + Object.keys(manualConfigBots).length + ); + + // provider's config get's called for each bot + expect(providerConfigureSpy).toHaveBeenCalledWith({ + BookTrip: manualConfigBots.BookTrip, + }); + expect(providerConfigureSpy).toHaveBeenCalledWith({ + OrderFlowers: manualConfigBots.OrderFlowers, }); + expect.assertions(4); }); - test('aws-exports configuration with two bots and send message to existing bot', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - - const awsmobile = { - aws_bots: 'enable', - aws_bots_config: [ - { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - description: 'Bot to make reservations for a visit to a city.', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - region: 'us-east-1', - }, - { - name: 'BookTripMOBILEHUB2', - alias: '$LATEST', - description: 'Bot to make reservations for a visit to a city.', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', + test('Configure bot using aws-exports and manual configuration', () => { + const combinedConfig = { + ...awsmobile, + ...manualConfig, }; - const interactions = new Interactions({}); - const config = interactions.configure(awsmobile); + const config = interactions.configure(combinedConfig); + // if manualConfig bots are given, aws-export bots are ignored expect(config).toEqual({ - aws_bots: 'enable', - aws_bots_config: [ - { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB2', - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - bots: { - BookTripMOBILEHUB: { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - BookTripMOBILEHUB2: { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB2', - region: 'us-east-1', - }, - }, + bots: manualConfigBots, }); - const response = await interactions.send('BookTripMOBILEHUB', 'hi'); + // check if provider's configure was called + expect(providerConfigureSpy).toBeCalledTimes( + Object.keys(manualConfigBots).length + ); - expect(response).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', + // provider's config get's called for each bot + expect(providerConfigureSpy).toHaveBeenCalledWith({ + BookTrip: manualConfigBots.BookTrip, }); - - const response2 = await interactions.send('BookTripMOBILEHUB2', 'hi2'); - - expect(response2).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi2', + expect(providerConfigureSpy).toHaveBeenCalledWith({ + OrderFlowers: manualConfigBots.OrderFlowers, }); + expect.assertions(4); + }); - const interactionsMessageVoice = { - content: 'voice:hi', - options: { - messageType: 'voice', + test('Check if default provider is AWSLexProvider', async () => { + const myBot = { + MyBot: { + name: 'MyBot', // default provider 'AWSLexProvider' + alias: '$LATEST', + region: 'us-west-2', }, }; - - const interactionsMessageText = { - content: 'hi', - options: { - messageType: 'text', + const myConfig = { + Interactions: { + bots: myBot, }, }; - const responseVoice = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageVoice - ); - expect(responseVoice).toEqual({ - dialogState: 'ElicitSlot', - message: 'voice:echo:voice:hi', - audioStream: new Uint8Array(), - }); - - const responseText = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageText - ); - expect(responseText).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', - audioStream: new Uint8Array(), + expect(interactions.configure(myConfig)).toEqual({ + bots: myBot, }); + expect.assertions(1); }); - test('Interactions configuration with two bots and send message to existing bot and fullfil', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - const configuration = { + test('Configure bot belonging to non-existing plugin', async () => { + const myConfig = { Interactions: { bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - }, - BookTripMOBILEHUB2: { - name: 'BookTripMOBILEHUB2', + MyBot: { + name: 'MyBot', alias: '$LATEST', - region: 'us-east-1', + region: 'us-west-2', + providerName: 'randomProvider', }, }, }, }; - const interactions = new Interactions({}); - - const config = interactions.configure(configuration); - - expect(config).toEqual(configuration.Interactions); - const response = await interactions.send('BookTripMOBILEHUB', 'hi'); - - expect(response).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', - }); - - const response2 = await interactions.send('BookTripMOBILEHUB2', 'hi2'); - - expect(response2).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi2', - }); - - const interactionsMessageVoice = { - content: 'voice:hi', - options: { - messageType: 'voice', - }, - }; + // configuring a bot to a plugin that isn't added yet is allowed + // when the plugin is added the bots belonging to plugin are automatically configured + expect(() => interactions.configure(myConfig)).not.toThrow(); + expect.assertions(1); + }); + }); - const interactionsMessageText = { - content: 'hi', - options: { - messageType: 'text', - }, - }; + // Test 'getModuleName' API + test(`Is provider name 'Interactions'`, () => { + const interactions = new Interactions({}); + const moduleName = interactions.getModuleName(); + expect(moduleName).toEqual('Interactions'); + expect.assertions(1); + }); - const responseVoice = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageVoice - ); - expect(responseVoice).toEqual({ - dialogState: 'ElicitSlot', - message: 'voice:echo:voice:hi', - audioStream: new Uint8Array(), - }); + // Test 'addPluggable' API + describe('addPluggable API', () => { + let interactions; + let providerConfigureSpy; - const responseText = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageText - ); - expect(responseText).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', - audioStream: new Uint8Array(), - }); + beforeEach(() => { + interactions = new Interactions({}); + providerConfigureSpy = jest.spyOn(DummyProvider.prototype, 'configure'); + interactions.configure({}); }); - describe('Sending messages to bot', () => { - jest.useFakeTimers(); - test('onComplete callback from `Interactions.onComplete` called with text', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - - function onCompleteCallback(err, confirmation) { - expect(confirmation).toEqual({ slots: { m1: 'hi', m2: 'done' } }); - } - - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - }, - }, - }, - }; - - const interactions = new Interactions({}); - - const config = interactions.configure(configuration); - - expect(config).toEqual(configuration.Interactions); - interactions.onComplete('BookTripMOBILEHUB', onCompleteCallback); - await interactions.send('BookTripMOBILEHUB', 'hi'); - const response = await interactions.send('BookTripMOBILEHUB', 'done'); - expect(response).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'echo:done', - slots: { - m1: 'hi', - m2: 'done', - }, - }); + test('Add custom pluggable and configure a bot for that plugin successfully', async () => { + // first add custom plugin + // then configure bots for that plugin + expect(() => + interactions.addPluggable(new DummyProvider()) + ).not.toThrow(); - const interactionsMessageText = { - content: 'done', - options: { - messageType: 'text', - }, - }; - - const textResponse = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageText - ); - expect(textResponse).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'echo:done', - slots: { - m1: 'hi', - m2: 'done', - }, - audioStream: new Uint8Array(), - }); - jest.runAllTimers(); + const config = interactions.configure(manualConfig); + expect(config).toEqual({ + bots: manualConfigBots, }); - test('onComplete callback from `Interactions.onComplete` called with voice', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - - function onCompleteCallback(err, confirmation) { - expect(confirmation).toEqual({ - slots: { m1: 'voice:hi', m2: 'voice:done' }, - }); - } - - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - }, - }, - }, - }; - - const interactions = new Interactions({}); - const config = interactions.configure(configuration); - interactions.onComplete('BookTripMOBILEHUB', onCompleteCallback); - - const interactionsMessageVoice = { - content: 'voice:done', - options: { - messageType: 'voice', - }, - }; - - const voiceResponse = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageVoice - ); - expect(voiceResponse).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'voice:echo:voice:done', - slots: { - m1: 'voice:hi', - m2: 'voice:done', - }, - audioStream: new Uint8Array(), - }); - jest.runAllTimers(); + // provider's config get's called for each bot + expect(providerConfigureSpy).toHaveBeenCalledWith({ + BookTrip: manualConfigBots.BookTrip, }); - - test('onComplete callback from configure being called with text', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - - function onCompleteCallback(err, confirmation) { - expect(confirmation).toEqual({ slots: { m1: 'hi', m2: 'done' } }); - } - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - onComplete: onCompleteCallback, - }, - }, - }, - }; - - const interactions = new Interactions({}); - const config = interactions.configure(configuration); - - expect(config).toEqual(configuration.Interactions); - - await interactions.send('BookTripMOBILEHUB', 'hi'); - const response = await interactions.send('BookTripMOBILEHUB', 'done'); - expect(response).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'echo:done', - slots: { - m1: 'hi', - m2: 'done', - }, - }); - const interactionsMessageText = { - content: 'done', - options: { - messageType: 'text', - }, - }; - - const textResponse = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageText - ); - expect(textResponse).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'echo:done', - slots: { - m1: 'hi', - m2: 'done', - }, - audioStream: new Uint8Array(), - }); - jest.runAllTimers(); + expect(providerConfigureSpy).toHaveBeenCalledWith({ + OrderFlowers: manualConfigBots.OrderFlowers, }); + expect.assertions(4); + }); - test('onComplete callback from configure being called with voice', async () => { - const curCredSpyOn = jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - - function onCompleteCallback(err, confirmation) { - expect(confirmation).toEqual({ - slots: { m1: 'voice:hi', m2: 'voice:done' }, - }); - } - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - onComplete: onCompleteCallback, - }, - }, - }, - }; - - const interactions = new Interactions({}); - const config = interactions.configure(configuration); - - expect(config).toEqual(configuration.Interactions); - - const interactionsMessageVoice = { - content: 'voice:done', - options: { - messageType: 'voice', - }, - }; - const voiceResponse = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageVoice - ); - expect(voiceResponse).toEqual({ - dialogState: 'ReadyForFulfillment', - message: 'voice:echo:voice:done', - slots: { - m1: 'voice:hi', - m2: 'voice:done', - }, - audioStream: new Uint8Array(), - }); - jest.runAllTimers(); + test('Configure bot belonging to custom plugin first, then add pluggable for that bot', async () => { + // first configure bots for a custom plugin + // then add the custom plugin + // when the plugin is added the bots belonging to plugin are automatically configured + const config = interactions.configure(manualConfig); + expect(config).toEqual({ + bots: manualConfigBots, }); - test('aws-exports configuration and send message to not existing bot', async () => { - const awsmobile = { - aws_bots: 'enable', - aws_bots_config: [ - { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - description: 'Bot to make reservations for a visit to a city.', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - }; - const interactions = new Interactions({}); - - const config = interactions.configure(awsmobile); - - expect(config).toEqual({ - aws_bots: 'enable', - aws_bots_config: [ - { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - bots: { - BookTripMOBILEHUB: { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - }, - }); + expect(() => + interactions.addPluggable(new DummyProvider()) + ).not.toThrow(); - try { - await interactions.send('BookTrip', 'hi'); - } catch (err) { - expect(err.message).toEqual('Bot BookTrip does not exist'); - } + // after adding pluggin provider's config get's called for each bot + expect(providerConfigureSpy).toHaveBeenCalledWith({ + BookTrip: manualConfigBots.BookTrip, }); - - test('aws-exports configuration and try to add onComplete to not existing bot', async () => { - const awsmobile = { - aws_bots: 'enable', - aws_bots_config: [ - { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - description: 'Bot to make reservations for a visit to a city.', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - }; - const interactions = new Interactions({}); - - const config = interactions.configure(awsmobile); - - expect(config).toEqual({ - aws_bots: 'enable', - aws_bots_config: [ - { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - ], - aws_project_name: 'bots', - aws_project_region: 'us-east-1', - bots: { - BookTripMOBILEHUB: { - alias: '$LATEST', - 'bot-template': 'bot-trips', - 'commands-help': [ - 'Book a car', - 'Reserve a car', - 'Make a car reservation', - 'Book a hotel', - 'Reserve a room', - 'I want to make a hotel reservation', - ], - description: 'Bot to make reservations for a visit to a city.', - name: 'BookTripMOBILEHUB', - region: 'us-east-1', - }, - }, - }); - - try { - await interactions.onComplete('BookTrip', () => {}); - } catch (err) { - expect(err.message).toEqual('Bot BookTrip does not exist'); - } + expect(providerConfigureSpy).toHaveBeenCalledWith({ + OrderFlowers: manualConfigBots.OrderFlowers, }); + expect.assertions(4); }); - describe('Adding pluggins', () => { - test('Adding AWSLexProvider2 bot not found', async () => { - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - providerName: 'AWSLexProvider2', - }, - }, - }, - }; - - const interactions = new Interactions({}); - - const config = interactions.configure(configuration); - - interactions.addPluggable(new AWSLexProvider2()); + test('Add a invalid pluggable', () => { + expect(() => interactions.addPluggable(new WrongProvider())).toThrow( + 'Invalid pluggable' + ); + expect.assertions(1); + }); - try { - await interactions.send('BookTrip', 'hi'); - } catch (err) { - expect(err.message).toEqual('Bot BookTrip does not exist'); - } - }); + test('Add existing pluggable again', () => { + interactions.addPluggable(new DummyProvider()); + expect(() => { + interactions.addPluggable(new DummyProvider()); + }).toThrow('Pluggable DummyProvider already plugged'); + expect.assertions(1); + }); + }); - test('Adding custom plugin happy path', async () => { - jest - .spyOn(Credentials, 'get') - .mockImplementation(() => Promise.resolve({ identityId: '1234' })); - const configuration = { - Interactions: { - bots: { - BookTripMOBILEHUB: { - name: 'BookTripMOBILEHUB', - alias: '$LATEST', - region: 'us-east-1', - providerName: 'AWSLexProvider2', - }, - }, - }, - }; + // Test 'send' API + describe('send API', () => { + let interactions; + let providerSend; + + beforeEach(() => { + interactions = new Interactions({}); + interactions.configure({}); + interactions.addPluggable(new DummyProvider()); + interactions.configure(manualConfig); + providerSend = jest.spyOn(DummyProvider.prototype, 'sendMessage'); + }); - const interactions = new Interactions({}); - const config = interactions.configure(configuration); - expect(config).toEqual({ - bots: { - BookTripMOBILEHUB: { - alias: '$LATEST', - name: 'BookTripMOBILEHUB', - providerName: 'AWSLexProvider2', - region: 'us-east-1', - }, - }, - }); - const pluggin = new AWSLexProvider2({}); + test('send text message to a bot successfully', async () => { + const response = await interactions.send('BookTrip', 'hi'); + expect(response).toEqual(sampleSendResponse); - interactions.addPluggable(pluggin); + // check if provider's send was called + expect(providerSend).toBeCalledTimes(1); + expect(providerSend).toHaveBeenCalledWith('BookTrip', 'hi'); + expect.assertions(3); + }); - const response = await interactions.send('BookTripMOBILEHUB', 'hi'); + test('Send text message to non-existing bot', async () => { + await expect(interactions.send('unknownBot', 'hi')).rejects.toEqual( + 'Bot unknownBot does not exist' + ); + expect.assertions(1); + }); + }); - expect(response).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', - }); + // Test 'onComplete' API + describe('onComplete API', () => { + let interactions; + let providerOnComplete; + const callback = (err, confirmation) => {}; + + beforeEach(() => { + interactions = new Interactions({}); + interactions.configure({}); + interactions.addPluggable(new DummyProvider()); + interactions.configure(manualConfig); + providerOnComplete = jest.spyOn(DummyProvider.prototype, 'onComplete'); + }); - const interactionsMessageVoice = { - content: 'voice:hi', - options: { - messageType: 'voice', - }, - }; + test('Configure onComplete callback for a configured bot successfully', async () => { + expect(() => interactions.onComplete('BookTrip', callback)).not.toThrow(); + // check if provider's onComplete was called + expect(providerOnComplete).toBeCalledTimes(1); + expect(providerOnComplete).toHaveBeenCalledWith('BookTrip', callback); + expect.assertions(3); + }); - const interactionsMessageText = { - content: 'hi', - options: { - messageType: 'text', - }, - }; - - const responseVoice = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageVoice - ); - expect(responseVoice).toEqual({ - dialogState: 'ElicitSlot', - message: 'voice:echo:voice:hi', - audioStream: new Uint8Array(), - }); - - const responseText = await interactions.send( - 'BookTripMOBILEHUB', - interactionsMessageText - ); - expect(responseText).toEqual({ - dialogState: 'ElicitSlot', - message: 'echo:hi', - audioStream: new Uint8Array(), - }); - }); + test('Configure onComplete callback for non-existing bot', async () => { + expect(() => interactions.onComplete('unknownBot', callback)).toThrow( + 'Bot unknownBot does not exist' + ); + expect.assertions(1); }); }); }); diff --git a/packages/interactions/__tests__/providers/AWSLexProvider-unit-test.ts b/packages/interactions/__tests__/providers/AWSLexProvider-unit-test.ts new file mode 100644 index 00000000000..3df97d856be --- /dev/null +++ b/packages/interactions/__tests__/providers/AWSLexProvider-unit-test.ts @@ -0,0 +1,475 @@ +/* + * Copyright 2017-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +import { AWSLexProvider } from '../../src/Providers'; +import { Credentials } from '@aws-amplify/core'; +import { + LexRuntimeServiceClient, + PostContentCommand, + PostTextCommand, + PostTextCommandOutput, +} from '@aws-sdk/client-lex-runtime-service'; + +(global as any).Response = () => {}; +(global as any).Response.prototype.arrayBuffer = (blob: Blob) => { + return Promise.resolve(new ArrayBuffer(0)); +}; + +// mock stream response +const createBlob = () => { + return new Blob(); +}; + +// bot config +const botConfig = { + BookTrip: { + name: 'BookTrip', // default provider 'AWSLexProvider' + alias: '$LATEST', + region: 'us-west-2', + }, + OrderFlowers: { + name: 'OrderFlowers', + alias: '$LATEST', + region: 'us-west-2', + providerName: 'AWSLexProvider', + }, +}; + +LexRuntimeServiceClient.prototype.send = jest.fn((command, callback) => { + if (command instanceof PostTextCommand) { + if (command.input.inputText === 'done') { + const result = { + message: 'echo:' + command.input.inputText, + dialogState: 'ReadyForFulfillment', + slots: { + m1: 'hi', + m2: 'done', + }, + }; + return Promise.resolve(result); + } else if (command.input.inputText === 'error') { + const result = { + message: 'echo:' + command.input.inputText, + dialogState: 'Failed', + }; + return Promise.resolve(result); + } else { + const result = { + message: 'echo:' + command.input.inputText, + dialogState: 'ElicitSlot', + }; + return Promise.resolve(result); + } + } else if (command instanceof PostContentCommand) { + if ( + command.input.contentType === + 'audio/x-l16; sample-rate=16000; channel-count=1' + ) { + const bot = command.input.botName as string; + const [botName, status] = bot.split(':'); + + if (status === 'done') { + // we add the status to the botName + // because inputStream would just be a blob if type is voice + const result = { + message: 'voice:echo:' + command.input.botName, + dialogState: 'ReadyForFulfillment', + slots: { + m1: 'voice:hi', + m2: 'voice:done', + }, + audioStream: createBlob(), + }; + return Promise.resolve(result); + } else if (status === 'error') { + const result = { + message: 'voice:echo:' + command.input.botName, + dialogState: 'Failed', + audioStream: createBlob(), + }; + return Promise.resolve(result); + } else { + const result = { + message: 'voice:echo:' + command.input.botName, + dialogState: 'ElicitSlot', + audioStream: createBlob(), + }; + return Promise.resolve(result); + } + } else { + if (command.input.inputStream === 'done') { + const result = { + message: 'echo:' + command.input.inputStream, + dialogState: 'ReadyForFulfillment', + slots: { + m1: 'hi', + m2: 'done', + }, + audioStream: createBlob(), + }; + return Promise.resolve(result); + } else if (command.input.inputStream === 'error') { + const result = { + message: 'echo:' + command.input.inputStream, + dialogState: 'Failed', + audioStream: createBlob(), + }; + return Promise.resolve(result); + } else { + const result = { + message: 'echo:' + command.input.inputStream, + dialogState: 'ElicitSlot', + audioStream: createBlob(), + }; + return Promise.resolve(result); + } + } + } +}) as any; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Interactions', () => { + // Test 'getProviderName' API + test(`Is provider name 'AWSLexProvider'`, () => { + const provider = new AWSLexProvider(); + expect(provider.getProviderName()).toEqual('AWSLexProvider'); + expect.assertions(1); + }); + + // Test 'getCategory' API + test(`Is category name 'Interactions'`, () => { + const provider = new AWSLexProvider(); + expect(provider.getCategory()).toEqual('Interactions'); + expect.assertions(1); + }); + + // Test 'configure' API + describe('configure API', () => { + const provider = new AWSLexProvider(); + + test('Check if bot is successfully configured by validating config response', () => { + expect(provider.configure(botConfig)).toEqual(botConfig); + expect.assertions(1); + }); + + test('configure multiple bots and re-configure existing bot successfully', () => { + // config 1st bot + expect(provider.configure(botConfig)).toEqual(botConfig); + + const anotherBot = { + BookHotel: { + name: 'BookHotel', + alias: '$LATEST', + region: 'us-west-2', + }, + }; + // config 2nd bot + expect(provider.configure(anotherBot)).toEqual({ + ...botConfig, + ...anotherBot, + }); + + const anotherBotUpdated = { + BookHotel: { + name: 'BookHotel', + alias: 'MyBookHotel', + region: 'us-west-1', + }, + }; + // re-configure updated 2nd bot + // 2nd bot is overridden + expect(provider.configure(anotherBotUpdated)).toEqual({ + ...botConfig, + ...anotherBotUpdated, + }); + expect.assertions(3); + }); + + test('Configure bot with invalid config', () => { + const invalidConfig = { + BookHotel: { + name: 'BookHotel', + region: 'us-west-2', + // alias: '$LATEST', this is required + }, + }; + // @ts-ignore + expect(() => provider.configure(invalidConfig)).toThrow( + 'invalid bot configuration' + ); + expect.assertions(1); + }); + }); + + // Test 'send' API + describe('send API', () => { + let provider; + + beforeEach(() => { + jest + .spyOn(Credentials, 'get') + .mockImplementation(() => Promise.resolve({ identityId: '1234' })); + + provider = new AWSLexProvider(); + provider.configure(botConfig); + }); + + test('send simple text message to bot and fulfill', async () => { + let response = await provider.sendMessage('BookTrip', 'hi'); + expect(response).toEqual({ + dialogState: 'ElicitSlot', + message: 'echo:hi', + }); + + response = await provider.sendMessage('BookTrip', 'done'); + expect(response).toEqual({ + dialogState: 'ReadyForFulfillment', + message: 'echo:done', + slots: { + m1: 'hi', + m2: 'done', + }, + }); + expect.assertions(2); + }); + + test('send obj text message to bot and fulfill', async () => { + let response = await provider.sendMessage('BookTrip', { + content: 'hi', + options: { + messageType: 'text', + }, + }); + expect(response).toEqual({ + dialogState: 'ElicitSlot', + message: 'echo:hi', + audioStream: new Uint8Array(), + }); + + response = await provider.sendMessage('BookTrip', { + content: 'done', + options: { + messageType: 'text', + }, + }); + expect(response).toEqual({ + dialogState: 'ReadyForFulfillment', + message: 'echo:done', + audioStream: new Uint8Array(), + slots: { + m1: 'hi', + m2: 'done', + }, + }); + expect.assertions(2); + }); + + test('send obj voice message to bot and fulfill', async () => { + const botconfig = { + 'BookTrip:hi': { + name: 'BookTrip:hi', + alias: '$LATEST', + region: 'us-west-2', + }, + 'BookTrip:done': { + name: 'BookTrip:done', + alias: '$LATEST', + region: 'us-west-2', + }, + }; + provider.configure(botconfig); + + let response = await provider.sendMessage('BookTrip:hi', { + content: createBlob(), + options: { + messageType: 'voice', + }, + }); + expect(response).toEqual({ + dialogState: 'ElicitSlot', + message: 'voice:echo:BookTrip:hi', + audioStream: new Uint8Array(), + }); + + response = await provider.sendMessage('BookTrip:done', { + content: createBlob(), + options: { + messageType: 'voice', + }, + }); + expect(response).toEqual({ + dialogState: 'ReadyForFulfillment', + message: 'voice:echo:BookTrip:done', + audioStream: new Uint8Array(), + slots: { + m1: 'voice:hi', + m2: 'voice:done', + }, + }); + expect.assertions(2); + }); + + test('send a text message bot But with no credentials', async () => { + jest + .spyOn(Credentials, 'get') + .mockImplementation(() => Promise.reject({ identityId: undefined })); + + await expect(provider.sendMessage('BookTrip', 'hi')).rejects.toEqual( + 'No credentials' + ); + expect.assertions(1); + }); + + test('send message to non-existing bot', async () => { + await expect(provider.sendMessage('unknownBot', 'hi')).rejects.toEqual( + 'Bot unknownBot does not exist' + ); + expect.assertions(1); + }); + }); + + // Test 'onComplete' API + describe('onComplete API', () => { + const callback = (err, confirmation) => {}; + let provider; + + beforeEach(() => { + jest + .spyOn(Credentials, 'get') + .mockImplementation(() => Promise.resolve({ identityId: '1234' })); + + provider = new AWSLexProvider(); + provider.configure(botConfig); + }); + + test('Configure onComplete callback for a configured bot successfully', () => { + expect(() => provider.onComplete('BookTrip', callback)).not.toThrow(); + expect.assertions(1); + }); + + test('Configure onComplete callback for non-existing bot', async () => { + expect(() => provider.onComplete('unknownBot', callback)).toThrow( + 'Bot unknownBot does not exist' + ); + expect.assertions(1); + }); + }); + + // Test 'reportBotStatus' API + describe('reportBotStatus API', () => { + jest.useFakeTimers(); + let provider; + + let inProgressResp; + let completeSuccessResp; + let completeFailResp; + + let inProgressCallback; + let completeSuccessCallback; + let completeFailCallback; + + beforeEach(async () => { + jest + .spyOn(Credentials, 'get') + .mockImplementation(() => Promise.resolve({ identityId: '1234' })); + + provider = new AWSLexProvider(); + provider.configure(botConfig); + + // mock callbacks + inProgressCallback = jest.fn((err, confirmation) => + fail(`callback shouldn't be called`) + ); + + completeSuccessCallback = jest.fn((err, confirmation) => { + expect(err).toEqual(null); + expect(confirmation).toEqual({ slots: { m1: 'hi', m2: 'done' } }); + }); + + completeFailCallback = jest.fn((err, confirmation) => + expect(err).toEqual('Bot conversation failed') + ); + + // mock responses + inProgressResp = (await provider.sendMessage( + 'BookTrip', + 'hi' + )) as PostTextCommandOutput; + + completeSuccessResp = (await provider.sendMessage( + 'BookTrip', + 'done' + )) as PostTextCommandOutput; + + completeFailResp = (await provider.sendMessage( + 'BookTrip', + 'error' + )) as PostTextCommandOutput; + }); + + test('Configure onComplete callback using `Interactions.onComplete` API', async () => { + // 1. In progress, callback shouldn't be called + provider.onComplete('BookTrip', inProgressCallback); + provider.reportBotStatus(inProgressResp, 'BookTrip'); + jest.runAllTimers(); + expect(inProgressCallback).toBeCalledTimes(0); + + // 2. task complete; success, callback be called with response + provider.onComplete('BookTrip', completeSuccessCallback); + provider.reportBotStatus(completeSuccessResp, 'BookTrip'); + jest.runAllTimers(); + expect(completeSuccessCallback).toBeCalledTimes(1); + + // 3. task complete; error, callback be called with error + provider.onComplete('BookTrip', completeFailCallback); + provider.reportBotStatus(completeFailResp, 'BookTrip'); + jest.runAllTimers(); + expect(completeFailCallback).toBeCalledTimes(1); + expect.assertions(6); + }); + + test('Configure onComplete callback using `configuration`', async () => { + const myBot: any = { + BookTrip: { + name: 'BookTrip', + alias: '$LATEST', + region: 'us-west-2', + }, + }; + + // 1. In progress, callback shouldn't be called + myBot.BookTrip.onComplete = inProgressCallback; + provider.configure(myBot); + provider.reportBotStatus(inProgressResp, 'BookTrip'); + jest.runAllTimers(); + expect(inProgressCallback).toBeCalledTimes(0); + + // 2. In progress, callback shouldn't be called + myBot.BookTrip.onComplete = completeSuccessCallback; + provider.configure(myBot); + provider.reportBotStatus(completeSuccessResp, 'BookTrip'); + jest.runAllTimers(); + expect(completeSuccessCallback).toBeCalledTimes(1); + + // 3. In progress, callback shouldn't be called + myBot.BookTrip.onComplete = completeFailCallback; + provider.configure(myBot); + provider.reportBotStatus(completeFailResp, 'BookTrip'); + jest.runAllTimers(); + expect(completeFailCallback).toBeCalledTimes(1); + expect.assertions(6); + }); + }); +}); diff --git a/packages/interactions/src/Interactions.ts b/packages/interactions/src/Interactions.ts index 6d64d0beb76..e965cd40733 100644 --- a/packages/interactions/src/Interactions.ts +++ b/packages/interactions/src/Interactions.ts @@ -31,7 +31,7 @@ export class InteractionsClass { * * @param {InteractionsOptions} options - Configuration object for Interactions */ - constructor(options: InteractionsOptions) { + constructor(options: InteractionsOptions = {}) { this._options = options; logger.debug('Interactions Options', this._options); this._pluggables = {}; @@ -44,9 +44,9 @@ export class InteractionsClass { /** * * @param {InteractionsOptions} options - Configuration object for Interactions - * @return {Object} - The current configuration + * @return {InteractionsOptions} - The current configuration */ - configure(options: InteractionsOptions) { + public configure(options: InteractionsOptions): InteractionsOptions { const opt = options ? options.Interactions || options : {}; logger.debug('configure Interactions', { opt }); this._options = { bots: {}, ...opt, ...opt.Interactions }; @@ -63,35 +63,51 @@ export class InteractionsClass { } } - // Check if AWSLex provider is already on pluggables - if ( - !this._pluggables.AWSLexProvider && - bots_config && - Object.keys(bots_config) - .map(key => bots_config[key]) - .find(bot => !bot.providerName || bot.providerName === 'AWSLexProvider') - ) { - this._pluggables.AWSLexProvider = new AWSLexProvider(); - } - - Object.keys(this._pluggables).map(key => { - this._pluggables[key].configure(this._options.bots); + // configure bots to their specific providers + Object.keys(bots_config).forEach(botKey => { + const bot = bots_config[botKey]; + const providerName = bot.providerName || 'AWSLexProvider'; + if ( + !this._pluggables.AWSLexProvider && + providerName === 'AWSLexProvider' + ) { + this._pluggables.AWSLexProvider = new AWSLexProvider(); + } else if (this._pluggables[providerName]) { + this._pluggables[providerName].configure({ [bot.name]: bot }); + } else { + logger.debug( + `bot ${bot.name} was not configured as ${providerName} provider was not found` + ); + } }); return this._options; } public addPluggable(pluggable: InteractionsProvider) { - if (pluggable && pluggable.getCategory() === 'Interactions') { - if (!this._pluggables[pluggable.getProviderName()]) { - pluggable.configure(this._options.bots); - this._pluggables[pluggable.getProviderName()] = pluggable; - return; - } else { - throw new Error( - 'Bot ' + pluggable.getProviderName() + ' already plugged' - ); - } + if (!(pluggable && pluggable.getCategory() === 'Interactions')) { + throw new Error('Invalid pluggable'); + } + + if (!this._pluggables[pluggable.getProviderName()]) { + // configure bots for the new plugin + Object.keys(this._options.bots) + .filter( + botKey => + this._options.bots[botKey].providerName === + pluggable.getProviderName() + ) + .forEach(botKey => { + const bot = this._options.bots[botKey]; + pluggable.configure({ [bot.name]: bot }); + }); + + this._pluggables[pluggable.getProviderName()] = pluggable; + return; + } else { + throw new Error( + 'Pluggable ' + pluggable.getProviderName() + ' already plugged' + ); } } @@ -112,14 +128,14 @@ export class InteractionsClass { message: string | object ): Promise { if (!this._options.bots || !this._options.bots[botname]) { - throw new Error('Bot ' + botname + ' does not exist'); + return Promise.reject('Bot ' + botname + ' does not exist'); } const botProvider = this._options.bots[botname].providerName || 'AWSLexProvider'; if (!this._pluggables[botProvider]) { - throw new Error( + return Promise.reject( 'Bot ' + botProvider + ' does not have valid pluggin did you try addPluggable first?' @@ -128,7 +144,10 @@ export class InteractionsClass { return await this._pluggables[botProvider].sendMessage(botname, message); } - public onComplete(botname: string, callback: (err, confirmation) => void) { + public onComplete( + botname: string, + callback: (err, confirmation) => void + ): void { if (!this._options.bots || !this._options.bots[botname]) { throw new Error('Bot ' + botname + ' does not exist'); } @@ -146,5 +165,5 @@ export class InteractionsClass { } } -export const Interactions = new InteractionsClass(null); +export const Interactions = new InteractionsClass(); Amplify.register(Interactions); diff --git a/packages/interactions/src/Providers/AWSLexProvider.ts b/packages/interactions/src/Providers/AWSLexProvider.ts index 877bd4bbc74..e7621e7268e 100644 --- a/packages/interactions/src/Providers/AWSLexProvider.ts +++ b/packages/interactions/src/Providers/AWSLexProvider.ts @@ -10,17 +10,21 @@ * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ - import { AbstractInteractionsProvider } from './InteractionsProvider'; import { InteractionsOptions, + AWSLexProviderOptions, InteractionsResponse, InteractionsMessage, } from '../types'; import { LexRuntimeServiceClient, PostTextCommand, + PostTextCommandInput, + PostTextCommandOutput, PostContentCommand, + PostContentCommandInput, + PostContentCommandOutput, } from '@aws-sdk/client-lex-runtime-service'; import { ConsoleLogger as Logger, @@ -44,7 +48,24 @@ export class AWSLexProvider extends AbstractInteractionsProvider { return 'AWSLexProvider'; } - reportBotStatus(data, botname) { + configure(config: AWSLexProviderOptions = {}): AWSLexProviderOptions { + const propertiesToTest = ['name', 'alias', 'region']; + + Object.keys(config).forEach(botKey => { + const botConfig = config[botKey]; + + // is bot config correct + if (!propertiesToTest.every(x => x in botConfig)) { + throw new Error('invalid bot configuration'); + } + }); + return super.configure(config); + } + + reportBotStatus( + data: PostTextCommandOutput | PostContentCommandOutput, + botname: string + ) { // Check if state is fulfilled to resolve onFullfilment promise logger.debug('postContent state', data.dialogState); if ( @@ -94,11 +115,16 @@ export class AWSLexProvider extends AbstractInteractionsProvider { botname: string, message: string | InteractionsMessage ): Promise { + // check if bot exists if (!this._config[botname]) { return Promise.reject('Bot ' + botname + ' does not exist'); } - const credentials = await Credentials.get(); - if (!credentials) { + + // check if credentials are present + let credentials; + try { + credentials = await Credentials.get(); + } catch (error) { return Promise.reject('No credentials'); } @@ -108,7 +134,7 @@ export class AWSLexProvider extends AbstractInteractionsProvider { customUserAgent: getAmplifyUserAgent(), }); - let params; + let params: PostTextCommandInput | PostContentCommandInput; if (typeof message === 'string') { params = { botAlias: this._config[botname].alias, @@ -118,10 +144,10 @@ export class AWSLexProvider extends AbstractInteractionsProvider { }; logger.debug('postText to lex', message); - try { const postTextCommand = new PostTextCommand(params); const data = await this.lexRuntimeServiceClient.send(postTextCommand); + this.reportBotStatus(data, botname); return data; } catch (err) { @@ -133,15 +159,21 @@ export class AWSLexProvider extends AbstractInteractionsProvider { options: { messageType }, } = message; if (messageType === 'voice') { + if (!(content instanceof Blob || content instanceof ReadableStream)) + return Promise.reject('invalid content type'); + params = { botAlias: this._config[botname].alias, botName: botname, - contentType: 'audio/x-l16; sample-rate=16000', - inputStream: content, + contentType: 'audio/x-l16; sample-rate=16000; channel-count=1', + inputStream: await convert(content), userId: credentials.identityId, accept: 'audio/mpeg', }; } else { + if (typeof content !== 'string') + return Promise.reject('invalid content type'); + params = { botAlias: this._config[botname].alias, botName: botname, @@ -157,7 +189,11 @@ export class AWSLexProvider extends AbstractInteractionsProvider { const data = await this.lexRuntimeServiceClient.send( postContentCommand ); - const audioArray = await convert(data.audioStream); + + const audioArray = data.audioStream + ? await convert(data.audioStream) + : undefined; + this.reportBotStatus(data, botname); return { ...data, ...{ audioStream: audioArray } }; } catch (err) { @@ -166,9 +202,10 @@ export class AWSLexProvider extends AbstractInteractionsProvider { } } - onComplete(botname: string, callback) { + onComplete(botname: string, callback: (err, confirmation) => void) { + // does bot exist if (!this._config[botname]) { - throw new ErrorEvent('Bot ' + botname + ' does not exist'); + throw new Error('Bot ' + botname + ' does not exist'); } this._botsCompleteCallback[botname] = callback; } diff --git a/packages/interactions/src/index.ts b/packages/interactions/src/index.ts index 06d7057e430..966e658e38a 100644 --- a/packages/interactions/src/index.ts +++ b/packages/interactions/src/index.ts @@ -18,6 +18,7 @@ import { Interactions } from './Interactions'; export default Interactions; export * from './types'; +export * from './Providers/InteractionsProvider'; export * from './Providers/AWSLexProvider'; export { Interactions }; diff --git a/packages/interactions/src/types/Provider.ts b/packages/interactions/src/types/Provider.ts index 23a056bd91c..1fcbe504095 100644 --- a/packages/interactions/src/types/Provider.ts +++ b/packages/interactions/src/types/Provider.ts @@ -15,7 +15,7 @@ import { InteractionsResponse } from './Response'; export interface InteractionsProvider { // configure your provider - configure(config: object): object; + configure(config: InteractionsOptions): InteractionsOptions; // return 'Interactions' getCategory(): string; diff --git a/packages/interactions/src/types/Providers/AWSLexProvider.ts b/packages/interactions/src/types/Providers/AWSLexProvider.ts new file mode 100644 index 00000000000..dc18eb66de6 --- /dev/null +++ b/packages/interactions/src/types/Providers/AWSLexProvider.ts @@ -0,0 +1,11 @@ +export interface AWSLexProviderOption { + name: string; + alias: string; + region: string; + providerName?: string; + onComplete?(botname: string, callback: (err, confirmation) => void): void; +} + +export interface AWSLexProviderOptions { + [key: string]: AWSLexProviderOption; +} diff --git a/packages/interactions/src/types/index.ts b/packages/interactions/src/types/index.ts index c3c9ffa9733..4f454b615a8 100644 --- a/packages/interactions/src/types/index.ts +++ b/packages/interactions/src/types/index.ts @@ -12,4 +12,5 @@ */ export * from './Interactions'; export * from './Provider'; +export * from './Providers/AWSLexProvider'; export * from './Response';