Skip to content

Commit

Permalink
chat-core-zendesk: reset event listeners on re-initialization (#58)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
yen-tt authored Dec 9, 2024
1 parent b3dab3d commit d486394
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 120 deletions.
2 changes: 1 addition & 1 deletion packages/chat-core-aws-connect/THIRD-PARTY-NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion packages/chat-core-zendesk/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 17 additions & 8 deletions packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -80,7 +80,9 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk {

private async initializeZendeskSdk(): Promise<void> {
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");
Expand All @@ -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);
});
});
}

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
257 changes: 149 additions & 108 deletions packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
});

Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit d486394

Please sign in to comment.