UNPKG

bot-handoff

Version:

Bot hand off module for the Microsoft Bot Framework. It allows you to transfer a customer from talking to a bot to talking to a human.

486 lines (404 loc) 24.7 kB
import * as Promise from 'bluebird'; import { IAddress, Message } from 'botbuilder'; import { expect } from 'chai'; import { ConversationState, IConversation, ITranscriptLine } from './../src/IConversation'; import { addAgentAddressToMessage, addCustomerAddressToMessage } from './../src/IHandoffMessage'; import { AgentAlreadyInConversationError } from './../src/provider/errors/AgentAlreadyInConversationError'; import { ConnectingAgentIsNotWatching } from './../src/provider/errors/AgentConnectingIsNotSameAsWatching'; import { AgentNotInConversationError } from './../src/provider/errors/AgentNotInConversationError'; import { BotAttemptedToRecordMessageWhileAgentHasConnection } from './../src/provider/errors/BotAttemptedToRecordMessageWhileAgentHasConnection'; import { IProvider } from './../src/provider/IProvider'; const ADDRESS_1: IAddress = { channelId: 'console', user: { id: 'userId1', name: 'user1' }, bot: { id: 'bot', name: 'Bot' }, conversation: { id: 'user1Conversation' } }; const ADDRESS_2: IAddress = { channelId: 'console', user: { id: 'userId2', name: 'user2' }, bot: { id: 'bot', name: 'Bot' }, conversation: { id: 'user2Conversation' } }; const ADDRESS_3: IAddress = { channelId: 'console', user: { id: 'userId3', name: 'user3' }, bot: { id: 'bot', name: 'Bot' }, conversation: { id: 'user3Conversation' } }; export function providerTest(getNewProvider: () => Promise<IProvider>, providerName: string): void { const customer1Address = ADDRESS_1; const customer2Address = ADDRESS_2; const agent1Address1 = ADDRESS_3; // agent1Adress1 and agent2Address 2 both belong to user agent1, however they have different conversation ids const agent1Address2 = Object.assign({}, agent1Address1, {conversation: { id: `${agent1Address1.conversation.id}2`}}); let provider: IProvider; describe(providerName, () => { beforeEach(() => { return getNewProvider() .then((newProvider: IProvider) => { provider = newProvider; }); }); describe('customer messages', () => { const message1Address1 = new Message() .address(ADDRESS_1) .text('first message') .toMessage(); const message2Address1 = new Message() .address(ADDRESS_1) .text('second message') .toMessage(); const message3Address1 = new Message() .address(ADDRESS_1) .text('third message') .toMessage(); const message1Address2 = new Message() .address(ADDRESS_2) .text('first message') .toMessage(); const message2Address2 = new Message() .address(ADDRESS_2) .text('second message') .toMessage(); const message3Address2 = new Message() .address(ADDRESS_2) .text('third message') .toMessage(); addCustomerAddressToMessage(message1Address1, ADDRESS_1); addCustomerAddressToMessage(message2Address1, ADDRESS_1); addCustomerAddressToMessage(message3Address1, ADDRESS_1); addCustomerAddressToMessage(message1Address2, ADDRESS_2); addCustomerAddressToMessage(message2Address2, ADDRESS_2); addCustomerAddressToMessage(message3Address2, ADDRESS_2); beforeEach(() => { return Promise.join( provider.addCustomerMessageToTranscript(message1Address1), provider.addCustomerMessageToTranscript(message2Address1), provider.addCustomerMessageToTranscript(message3Address1), provider.addCustomerMessageToTranscript(message1Address2), provider.addCustomerMessageToTranscript(message2Address2), provider.addCustomerMessageToTranscript(message3Address2) ); }); const verifyConversationForAddress = (convo: IConversation, address: IAddress): void => { expect(convo.customerAddress).to.deep.equal(address); expect(convo.agentAddress).to.be.undefined; expect(convo.transcript.length).to.be.equal(3); const transcript = convo.transcript; transcript.forEach((t: ITranscriptLine) => expect(t.from).to.be.equal(address)); expect(transcript[0].text).to.be.equal('first message'); expect(transcript[1].text).to.be.equal('second message'); expect(transcript[2].text).to.be.equal('third message'); }; it('can be retrieved in a conversation form', () => { return provider.getConversationFromCustomerAddress(ADDRESS_1) .then((convo: IConversation) => verifyConversationForAddress(convo, ADDRESS_1)); }); it('can be uniquely retrieved for separate customers', () => { return Promise.join( provider.getConversationFromCustomerAddress(ADDRESS_1), provider.getConversationFromCustomerAddress(ADDRESS_2) ) .spread((convo1: IConversation, convo2: IConversation) => { verifyConversationForAddress(convo1, ADDRESS_1); verifyConversationForAddress(convo2, ADDRESS_2); }); }); }); describe('customer queue for agent', () => { const message = new Message() .address(customer1Address) .text('message') .toMessage(); addCustomerAddressToMessage(message, customer1Address); beforeEach(() => { return provider.addCustomerMessageToTranscript(message) .then(() => provider.queueCustomerForAgent(customer1Address)); }); it('updates conversation state to be wait', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((conversation: IConversation) => { expect(conversation.agentAddress).to.be.undefined; expect(conversation.conversationState).to.be.equal(ConversationState.Wait); expect(conversation.transcript.length).to.be.equal(1); }); }); it('allows bot messages to be recorded withbout affecting state', () => { const msg = Object.assign({}, message, { text: 'bot message' }); return provider.addBotMessageToTranscript(msg) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => { expect(convo.agentAddress).to.be.undefined; expect(convo.conversationState).to.be.equal(ConversationState.Wait); expect(convo.transcript.length).to.be.equal(2); expect(convo.transcript[1].text).to.be.equal('bot message'); expect(convo.transcript[1].from).to.be.undefined; }); }); it('sets conversation state to wait and watch if agent watches conversation', () => { return provider.connectCustomerToAgent(customer1Address, agent1Address1) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState === ConversationState.WatchAndWait)); }); }); describe('conversation is in wait and watch state', () => { const customerMessage = new Message() .address(customer1Address) .text('customer 1') .toMessage(); addCustomerAddressToMessage(customerMessage, customer1Address); beforeEach(() => { return provider.addCustomerMessageToTranscript(customerMessage) .then(() => Promise.join( // js maintains thread safety, this is fine provider.queueCustomerForAgent(customer1Address), provider.watchConversation(customer1Address, agent1Address1) )).then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.WatchAndWait)); }); // note, it is not possible to go directly from a wait and watch state to bot state. States need to be decomposed. // Bot is the default it('can be set to an only wait state', () => { return provider.unwatchConversation(customer1Address, agent1Address1) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Wait)); }); it('can be set to an only watch state', () => { return provider.dequeueCustomerForAgent(customer1Address) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Watch)); }); it('can connect to agent', () => { return provider.connectCustomerToAgent(customer1Address, agent1Address1) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Agent)); }); it('is connected to agent implicitly if an agent sends a message to the user', () => { const agentMessage = new Message() .address(agent1Address1) .text('agent') .toMessage(); addAgentAddressToMessage(agentMessage, agent1Address1); return provider.addAgentMessageToTranscript(agentMessage) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Agent)); }); }); describe('customer connecting to agent', () => { const customer1Message = new Message() .address(customer1Address) .text('message') .toMessage(); const agentMessage = new Message() .address(agent1Address1) .text('agent message') .toMessage(); addCustomerAddressToMessage(customer1Message, customer1Address); addAgentAddressToMessage(agentMessage, agent1Address1); beforeEach(() => { return provider.addCustomerMessageToTranscript(customer1Message) .then(() => provider.connectCustomerToAgent(customer1Address, agent1Address1)); }); it('updates the customer conversation state to "Agent"', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Agent)); }); it('receives messages from agent after connection is established', () => { return provider.addAgentMessageToTranscript(agentMessage) .then((convo: IConversation) => { expect(convo.transcript.length).to.be.equal(2); expect(convo.transcript[0].from).to.be.equal(customer1Address); expect(convo.transcript[1].from).to.be.equal(agent1Address1); expect(convo.transcript[1].text).to.be.equal('agent message'); }); }); it('throws an error if a bot message is recorded while agent-customer connection is established', () => { // can reuse the customer address. Addresses are idempotent between bots and users return provider.addBotMessageToTranscript(customer1Message) .then(() => expect.fail(null, null, 'expected error thrown when bot sends message to customer in convo with agent')) .catch(BotAttemptedToRecordMessageWhileAgentHasConnection, (e: BotAttemptedToRecordMessageWhileAgentHasConnection) => { expect(e).to.be.an.instanceOf(BotAttemptedToRecordMessageWhileAgentHasConnection); expect(e.message) .to.be.equal(new BotAttemptedToRecordMessageWhileAgentHasConnection(customer1Address.conversation.id).message); }); }); it('throws an error if the agent\'s conversation id is alredy occupied', () => { const expectedErrorMessage = `agent ${agent1Address1.user.name} with conversation id ${agent1Address1.conversation.id} is already occupied`; const customer2Message = new Message() .address(customer2Address) .text('customer 2 message') .toMessage(); addCustomerAddressToMessage(customer2Message, customer2Address); return provider.addCustomerMessageToTranscript(customer2Message) .then(() => provider.connectCustomerToAgent(customer2Address, agent1Address1)) .then(() => expect.fail('didn\'t throw error when attempting to connect on an agent conversation id that is occupied')) .catch(AgentAlreadyInConversationError, (e: AgentAlreadyInConversationError) => { expect(e).to.be.an.instanceOf(AgentAlreadyInConversationError); }); }); }); describe('agent messages', () => { it('are recorded to the customer transcript', () => { const customer1Message = new Message() .address(customer1Address) .text('customer 1') .toMessage(); const customer2Message = new Message() .address(customer2Address) .text('customer 2') .toMessage(); const agentMessageConvo1 = new Message() .address(agent1Address1) .text('agent 1') .toMessage(); const agentMessageConvo2 = new Message() .address(agent1Address2) .text('agent 2') .toMessage(); addCustomerAddressToMessage(customer1Message, customer1Address); addCustomerAddressToMessage(customer2Message, customer2Address); addAgentAddressToMessage(agentMessageConvo1, agent1Address1); addAgentAddressToMessage(agentMessageConvo2, agent1Address2); const expectConversationBetweenAgentAndCustomer = (convo: IConversation, customerAddress: IAddress, agentAddress: IAddress, idCounter: number) => { expect(convo.agentAddress).to.deep.equal(agentAddress); expect(convo.transcript.length).to.be.equal(2); const firstTranscript = convo.transcript[0]; const secondTranscript = convo.transcript[1]; expect(firstTranscript.from).to.be.equal(customerAddress); expect(secondTranscript.from).to.be.equal(agentAddress); expect(firstTranscript.text).to.be.equal(`customer ${idCounter}`); expect(secondTranscript.text).to.be.equal(`agent ${idCounter}`); }; return Promise.join( provider.addCustomerMessageToTranscript(customer1Message), provider.addCustomerMessageToTranscript(customer2Message)) .then(() => Promise.join( provider.connectCustomerToAgent(customer1Address, agent1Address1), provider.connectCustomerToAgent(customer2Address, agent1Address2))) .then(() => Promise.join( provider.addAgentMessageToTranscript(agentMessageConvo1), provider.addAgentMessageToTranscript(agentMessageConvo2))) .then(() => Promise.join( provider.getConversationFromCustomerAddress(customer1Address), provider.getConversationFromCustomerAddress(customer2Address))) .spread((convo1: IConversation, convo2: IConversation) => { expectConversationBetweenAgentAndCustomer(convo1, customer1Address, agent1Address1, 1); expectConversationBetweenAgentAndCustomer(convo2, customer2Address, agent1Address2, 2); }); }); it('that are sent to customer conversations that do not have a connection get an error', () => { const agentMessage = new Message() .address(agent1Address1) .text('This is an agent, how can I help?') .toMessage(); addAgentAddressToMessage(agentMessage, agent1Address1); return provider.addAgentMessageToTranscript(agentMessage) .then(() => expect.fail('did not throw an error as expected')) .catch((e: Error) => expect(e).to.be.an.instanceOf(AgentNotInConversationError)); }); }); describe('agents watching conversation', () => { const customerMessage = new Message() .address(customer1Address) .text('customer message') .toMessage(); const agentMessage = new Message() .address(agent1Address1) .text('agent message') .toMessage(); addCustomerAddressToMessage(customerMessage, customer1Address); addAgentAddressToMessage(agentMessage, agent1Address1); beforeEach(() => { return provider.addCustomerMessageToTranscript(customerMessage) .then(() => provider.watchConversation(customer1Address, agent1Address1)); }); it('update conversation state to watching', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((convo: IConversation) => { expect(convo.conversationState).to.be.equal(ConversationState.Watch); }); }); it('binds agent address to conversation', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((convo: IConversation) => { expect(convo.agentAddress).not.to.be.undefined; }); }); it('set conversation state to Agent implicitly if an agent sends a message', () => { return provider.addAgentMessageToTranscript(agentMessage) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => { expect(convo.agentAddress).not.to.be.undefined; expect(convo.conversationState).to.be.equal(ConversationState.Agent); expect(convo.transcript.length).to.be.equal(2); const firstMessage = convo.transcript[0]; const secondMessage = convo.transcript[1]; expect(firstMessage.from).to.be.equal(customer1Address); expect(secondMessage.from).to.be.equal(agent1Address1); expect(firstMessage.text).to.be.equal('customer message'); expect(secondMessage.text).to.be.equal('agent message'); }); }); // this is a weird case that shouldn't be hit, but I wanted to cover my bases it('throws error if different agent conversation than watching agent conversation attempts to connect to customer', () => { return provider.connectCustomerToAgent(customer1Address, agent1Address2) .then(() => expect.fail(null, null, 'Should have thrown error or wrong convo id to connect')) .catch(ConnectingAgentIsNotWatching, (e: ConnectingAgentIsNotWatching) => { expect(e).to.be.an.instanceOf(ConnectingAgentIsNotWatching); }); }); it('can have conversation set to wait and watch if customer queues for agent', () => { return provider.queueCustomerForAgent(customer1Address) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.WatchAndWait)); }); }); describe('disconnecting user from agent', () => { const customerMessage = new Message() .address(customer1Address) .text('I need to speak to an agent') .toMessage(); const agentMessage = new Message() .address(agent1Address1) .text('This is an agent, how can I help?') .toMessage(); addCustomerAddressToMessage(customerMessage, customer1Address); addAgentAddressToMessage(agentMessage, agent1Address1); beforeEach(() => { return provider.addCustomerMessageToTranscript(customerMessage) .then(() => provider.connectCustomerToAgent(customer1Address, agent1Address1)) .then(() => provider.addAgentMessageToTranscript(agentMessage)) .then(() => provider.getConversationFromCustomerAddress(customer1Address)) .then((conversation: IConversation) => { expect(conversation.agentAddress).not.to.be.undefined; expect(conversation.transcript.length).to.be.equal(2); const firstTranscript = conversation.transcript[0]; const secondTranscript = conversation.transcript[1]; expect(firstTranscript.from).to.be.equal(customer1Address); expect(secondTranscript.from).to.be.equal(agent1Address1); }) .then(() => provider.disconnectCustomerFromAgent(customer1Address, agent1Address1)); }); it('changes conversation state to bot', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((convo: IConversation) => expect(convo.conversationState).to.be.equal(ConversationState.Bot)); }); it('removes agent address from conversation', () => { return provider.getConversationFromCustomerAddress(customer1Address) .then((convo: IConversation) => expect(convo.agentAddress).to.be.undefined); }); it('causes agent to throw error if they attempt to send another message', () => { const expectedErrorMessage = `no customer conversation found for agent with conversation id ${agent1Address1.conversation.id}`; return provider.addAgentMessageToTranscript(agentMessage) .then(() => expect.fail(null, null, 'agent sending message to user they are not connected to didn\'t throw exception')) .catch(AgentNotInConversationError, (e: AgentNotInConversationError) => { expect(e).to.be.an.instanceOf(AgentNotInConversationError); expect(e.message).to.be.equal(new AgentNotInConversationError(agent1Address1.conversation.id).message); }); }); }); }); }