From d486394a5abdc3d5973b139cd6dc0562184621be Mon Sep 17 00:00:00 2001 From: Yen Truong <36055303+yen-tt@users.noreply.github.com> Date: Mon, 9 Dec 2024 14:09:10 -0500 Subject: [PATCH] chat-core-zendesk: reset event listeners on re-initialization (#58) Previously, once a messaging convo/ticket is closed, we reset the session and unbind all event listeners from zendesk to SDK. On re-initialization for another request to speak to agent, we failed to setup the event listeners again. So any messages sent from agent on the second ticket does NOT go through the SDK and display to user. This CR ensures that event listeners are cleared and setup on each init call, and the message events should apply for the latest relevant conversation only. J=CLIP-1664 TEST=manual started a messaging convo with agent, closed the ticket. Request agent again, messaged as user and agent, see that all messages are displayed as expected on both Zendesk and Chat SDK. --- .../chat-core-aws-connect/THIRD-PARTY-NOTICES | 2 +- packages/chat-core-zendesk/package.json | 2 +- .../src/infra/ChatCoreZendeskImpl.ts | 25 +- .../src/models/ChatCoreZendeskConfig.ts | 4 +- .../tests/ChatCoreZendesk.test.ts | 257 ++++++++++-------- 5 files changed, 170 insertions(+), 120 deletions(-) diff --git a/packages/chat-core-aws-connect/THIRD-PARTY-NOTICES b/packages/chat-core-aws-connect/THIRD-PARTY-NOTICES index e690c1b..58b8224 100644 --- a/packages/chat-core-aws-connect/THIRD-PARTY-NOTICES +++ b/packages/chat-core-aws-connect/THIRD-PARTY-NOTICES @@ -1017,7 +1017,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI The following NPM package may be included in this product: - - debug@4.3.7 + - debug@4.4.0 This package contains the following license and notice below: diff --git a/packages/chat-core-zendesk/package.json b/packages/chat-core-zendesk/package.json index 799ec4f..3261a5c 100644 --- a/packages/chat-core-zendesk/package.json +++ b/packages/chat-core-zendesk/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-core-zendesk", - "version": "0.3.0", + "version": "0.3.1", "description": "Typescript Networking Library for the Yext Chat API Integration with Zendesk", "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.mjs", diff --git a/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts b/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts index 2843bd3..d700471 100644 --- a/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts +++ b/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts @@ -63,7 +63,7 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { this.tags = [...new Set(this.tags)]; this.jwt = config.jwt; this.externalId = config.externalId; - this.onInvalidAuth = config.onInvalidAuth + this.onInvalidAuth = config.onInvalidAuth; } /** @@ -80,7 +80,9 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { private async initializeZendeskSdk(): Promise { const divId = "yext-chat-core-zendesk-container"; + // If the div already exists, assume the SDK is already initialized if (window.document.getElementById(divId)) { + this.setupEventListeners(); return; } const div = window.document.createElement("div"); @@ -101,13 +103,15 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { delegate: { onInvalidAuth: this.onInvalidAuth, }, - }).then(() => { - this.setupEventListeners(); - resolve(); - }).catch((e) => { - console.error("Zendesk SDK init error", e); - reject(e); - }); + }) + .then(() => { + this.setupEventListeners(); + resolve(); + }) + .catch((e) => { + console.error("Zendesk SDK init error", e); + reject(e); + }); }); } @@ -167,9 +171,14 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { } private setupEventListeners() { + // @ts-ignore - off() is not in the Smooch types, but does exist + Smooch.off(); // Unbind all previous event listeners, if any, before setting up new ones Smooch.on( "message:received", (message: Message, data: ConversationData) => { + if (data.conversation.id !== this.conversationId) { + return; + } if (message.type !== "text" || message.role !== "business") { return; } diff --git a/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts b/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts index 1cc9e36..9d41f95 100644 --- a/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts +++ b/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts @@ -14,14 +14,14 @@ export interface ChatCoreZendeskConfig { ticketTags?: string[]; /** * The JWT token to authenticate the user with Zendesk. - * + * * @remarks * Should be provided along with the {@link ChatCoreZendeskConfig.externalId} to authenticate the user. */ jwt?: string; /** * The external ID to associate with the user in Zendesk. - * + * * @remarks * Should be provided along with the {@link ChatCoreZendeskConfig.jwt} token to authenticate the user. */ diff --git a/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts b/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts index cedc305..d5229c5 100644 --- a/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts +++ b/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts @@ -40,9 +40,7 @@ beforeEach(() => { jest .mocked(SmoochLib.createConversation) .mockResolvedValue({ id: mockConversationId } as Conversation); - jest - .mocked(SmoochLib.init) - .mockResolvedValue(Promise.resolve()); + jest.mocked(SmoochLib.init).mockResolvedValue(Promise.resolve()); document.body.innerHTML = ""; }); @@ -96,129 +94,172 @@ describe("chat session initialization", () => { mockConversationId ); }); -}); -it("emits typing event", async () => { - const startTypingSpy = jest.spyOn(SmoochLib, "startTyping"); - const stopTypingSpy = jest.spyOn(SmoochLib, "stopTyping"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + it("sets event listeners on chat session re-initialization", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + const dummyFn = jest.fn(); + chatCoreZendesk.on("message", dummyFn); - chatCoreZendesk.emit("typing", true); - expect(startTypingSpy).toBeCalledTimes(1); - expect(stopTypingSpy).toBeCalledTimes(0); + // first initialization, should receive message from the created conversation + const firstConvoId = "first-convo-id"; + jest + .mocked(SmoochLib.createConversation) + .mockResolvedValue({ id: firstConvoId } as Conversation); + await chatCoreZendesk.init(mockMessageResponse()); + // get "message:received" callback + const onMessageFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event + onMessageFn( + { text: "message1", type: "text", role: "business" }, + { conversation: { id: firstConvoId } } + ); + expect(dummyFn).toBeCalledWith("message1"); - chatCoreZendesk.emit("typing", false); - expect(startTypingSpy).toBeCalledTimes(1); - expect(stopTypingSpy).toBeCalledTimes(1); + // re-initialization, should receive ONLY message from the new conversation + dummyFn.mockClear(); + const secondConvoId = "second-convo-id"; + jest + .mocked(SmoochLib.createConversation) + .mockResolvedValue({ id: secondConvoId } as Conversation); + await chatCoreZendesk.init(mockMessageResponse()); + // simulate a message event from old convo and new convo + onMessageFn( + { text: "message1", type: "text", role: "business" }, + { conversation: { id: firstConvoId } } + ); + expect(dummyFn).not.toBeCalled(); + onMessageFn( + { text: "message2", type: "text", role: "business" }, + { conversation: { id: secondConvoId } } + ); + expect(dummyFn).toBeCalledWith("message2"); + }); }); -it("sends message on processMessage", async () => { - const sendMessageSpy = jest.spyOn(SmoochLib, "sendMessage"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); +describe("chat session events", () => { + it("emits typing event", async () => { + const startTypingSpy = jest.spyOn(SmoochLib, "startTyping"); + const stopTypingSpy = jest.spyOn(SmoochLib, "stopTyping"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); - const msg = "hello world!"; - await chatCoreZendesk.processMessage({ - messages: [ - { - source: "USER", - text: msg, - }, - ], + chatCoreZendesk.emit("typing", true); + expect(startTypingSpy).toBeCalledTimes(1); + expect(stopTypingSpy).toBeCalledTimes(0); + + chatCoreZendesk.emit("typing", false); + expect(startTypingSpy).toBeCalledTimes(1); + expect(stopTypingSpy).toBeCalledTimes(1); }); - expect(sendMessageSpy).toBeCalledWith(msg, mockConversationId); -}); -it("returns session on getSession", async () => { - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); - expect(chatCoreZendesk.getSession()).toBe(mockConversationId); -}); + it("sends message on processMessage", async () => { + const sendMessageSpy = jest.spyOn(SmoochLib, "sendMessage"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); -it("triggers message event callbacks", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + const msg = "hello world!"; + await chatCoreZendesk.processMessage({ + messages: [ + { + source: "USER", + text: msg, + }, + ], + }); + expect(sendMessageSpy).toBeCalledWith(msg, mockConversationId); + }); - const text = "hello world!"; - const dummyFn = jest.fn(); - chatCoreZendesk.on("message", dummyFn); - expect(onCbSpy).toBeCalled(); - - // get "message:received" callback - const onMessageFn = onCbSpy.mock.calls[0][1] as any; - // simulate a message event - onMessageFn( - { text, type: "text", role: "business" }, - { conversation: { id: mockConversationId } } - ); - expect(dummyFn).toBeCalledWith(text); -}); + it("triggers message event callbacks", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); -it("triggers close event callbacks", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + const text = "hello world!"; + const dummyFn = jest.fn(); + chatCoreZendesk.on("message", dummyFn); + expect(onCbSpy).toBeCalled(); - const text = "hello world!"; - const conversation = { conversation: { id: mockConversationId } }; - const dummyFn = jest.fn(); - chatCoreZendesk.on("close", dummyFn); - expect(onCbSpy).toBeCalled(); - - // get "message:received" callback - const onCloseFn = onCbSpy.mock.calls[0][1] as any; - // simulate a message event from internal bot indicating agent has left - onCloseFn( - { text, type: "text", role: "business", subroles: ["AI"] }, - conversation - ); - expect(dummyFn).toBeCalledWith(conversation); -}); + // get "message:received" callback + const onMessageFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event + onMessageFn( + { text, type: "text", role: "business" }, + { conversation: { id: mockConversationId } } + ); + expect(dummyFn).toBeCalledWith(text); + }); -it("triggers typing event callbacks", async () => { - const onTypingSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + it("triggers close event callbacks", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + + const text = "hello world!"; + const conversation = { conversation: { id: mockConversationId } }; + const dummyFn = jest.fn(); + chatCoreZendesk.on("close", dummyFn); + expect(onCbSpy).toBeCalled(); + + // get "message:received" callback + const onCloseFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event from internal bot indicating agent has left + onCloseFn( + { text, type: "text", role: "business", subroles: ["AI"] }, + conversation + ); + expect(dummyFn).toBeCalledWith(conversation); + }); + + it("triggers typing event callbacks", async () => { + const onTypingSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + + const dummyFn = jest.fn(); + chatCoreZendesk.on("typing", dummyFn); + expect(onTypingSpy).toBeCalled(); + + // get "typing:start" callback + const onStartTypingFn = onTypingSpy.mock.calls[1][1] as any; + // simulate a typing event + onStartTypingFn(); + expect(dummyFn).toBeCalledWith(true); + + // get "typing:stop" callback + const onStopTypingFn = onTypingSpy.mock.calls[2][1] as any; + // simulate a typing event + onStopTypingFn(); + expect(dummyFn).toBeCalledWith(false); + }); + + it("clear session on close event", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + expect(chatCoreZendesk.getSession()).toBeDefined(); - const dummyFn = jest.fn(); - chatCoreZendesk.on("typing", dummyFn); - expect(onTypingSpy).toBeCalled(); - - // get "typing:start" callback - const onStartTypingFn = onTypingSpy.mock.calls[1][1] as any; - // simulate a typing event - onStartTypingFn(); - expect(dummyFn).toBeCalledWith(true); - - // get "typing:stop" callback - const onStopTypingFn = onTypingSpy.mock.calls[2][1] as any; - // simulate a typing event - onStopTypingFn(); - expect(dummyFn).toBeCalledWith(false); + // get the parameter passed to the onSpy callback + const text = "hello world!"; + const conversation = { conversation: { id: mockConversationId } }; + const dummyFn = jest.fn(); + chatCoreZendesk.on("close", dummyFn); + const onCbFn = onCbSpy.mock.calls[0][1] as any; + + // simulate a session close event via a message event from internal bot + onCbFn( + { text, type: "text", role: "business", subroles: ["AI"] }, + conversation + ); + expect(dummyFn).toBeCalledWith(conversation); + expect(chatCoreZendesk.getSession()).toBeUndefined(); + }); }); -it("clear session on close event", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); +it("returns session on getSession", async () => { const chatCoreZendesk = provideChatCoreZendesk(mockConfig); await chatCoreZendesk.init(mockMessageResponse()); - expect(chatCoreZendesk.getSession()).toBeDefined(); - - // get the parameter passed to the onSpy callback - const text = "hello world!"; - const conversation = { conversation: { id: mockConversationId } }; - const dummyFn = jest.fn(); - chatCoreZendesk.on("close", dummyFn); - const onCbFn = onCbSpy.mock.calls[0][1] as any; - - // simulate a session close event via a message event from internal bot - onCbFn( - { text, type: "text", role: "business", subroles: ["AI"] }, - conversation - ); - expect(dummyFn).toBeCalledWith(conversation); - expect(chatCoreZendesk.getSession()).toBeUndefined(); + expect(chatCoreZendesk.getSession()).toBe(mockConversationId); }); it("clears session on resetSession", async () => {