HH-569: guard replayed frontend realtime messages (#144)

* fix(HH-569): guard replayed realtime messages

* fix(HH-569): gate dashboard message side effects

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-24 00:42:05 +08:00
committed by GitHub
co-authored by rogee
parent 88ac30d0b8
commit 84a1e35294
8 changed files with 398 additions and 15 deletions
@@ -53,14 +53,18 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('conversationAttributes/update', data);
};
onMessageCreated = data => {
onMessageCreated = async data => {
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
return;
}
this.app.$store
.dispatch('conversation/addOrUpdateMessage', data)
.then(() => emitter.emit(ON_AGENT_MESSAGE_RECEIVED));
const { applied, isNew } = await this.app.$store.dispatch(
'conversation/addOrUpdateMessage',
data
);
if (!applied || !isNew) return;
emitter.emit(ON_AGENT_MESSAGE_RECEIVED);
IFrameHelper.sendMessage({
event: 'onEvent',
@@ -72,11 +76,17 @@ class ActionCableConnector extends BaseActionCableConnector {
}
};
onMessageUpdated = data => {
onMessageUpdated = async data => {
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
return;
}
const { applied } = await this.app.$store.dispatch(
'conversation/addOrUpdateMessage',
data
);
if (!applied) return;
if (shouldTriggerMessageUpdateEvent(data)) {
IFrameHelper.sendMessage({
event: 'onEvent',
@@ -84,8 +94,6 @@ class ActionCableConnector extends BaseActionCableConnector {
data,
});
}
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
};
onConversationCreated = () => {
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
import { playNewMessageNotificationInWidget } from '../WidgetAudioNotificationHelper';
import { IFrameHelper } from '../utils';
import { emitter } from 'shared/helpers/mitt';
vi.mock('../WidgetAudioNotificationHelper', () => ({
playNewMessageNotificationInWidget: vi.fn(),
}));
vi.mock('../utils', () => ({
IFrameHelper: {
sendMessage: vi.fn(),
},
}));
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
describe('Widget ActionCableConnector', () => {
let actionCable;
let dispatch;
beforeEach(() => {
dispatch = vi.fn();
actionCable = new ActionCableConnector(
{
$store: {
dispatch,
getters: {
getCurrentAccountId: 1,
getCurrentUserID: 1,
'conversationAttributes/getConversationParams': { id: 7 },
},
},
},
'test-token'
);
});
it('emits message side effects only once when realtime replays a message', async () => {
const message = {
id: 42,
conversation_id: 7,
sender_type: 'User',
};
dispatch
.mockResolvedValueOnce({ applied: true, isNew: true })
.mockResolvedValueOnce({ applied: false, isNew: false });
await actionCable.onMessageCreated(message);
await actionCable.onMessageCreated(message);
expect(emitter.emit).toHaveBeenCalledTimes(1);
expect(IFrameHelper.sendMessage).toHaveBeenCalledTimes(1);
expect(playNewMessageNotificationInWidget).toHaveBeenCalledTimes(1);
});
it('does not emit the SDK callback for an older message update', async () => {
const message = {
id: 42,
conversation_id: 7,
previous_changes: {
content_attributes: [{}, { submitted_values: ['yes'] }],
},
};
dispatch.mockResolvedValue({ applied: false, isNew: false });
await actionCable.onMessageUpdated(message);
expect(IFrameHelper.sendMessage).not.toHaveBeenCalled();
});
});
@@ -173,13 +173,24 @@ export const actions = {
commit('clearConversations');
},
addOrUpdateMessage: async ({ commit }, data) => {
addOrUpdateMessage: async ({ commit, state }, data) => {
const { id, content_attributes } = data;
const currentMessage = state?.conversations?.[id];
const isNew = !currentMessage;
if (
currentMessage?.updated_at &&
data.updated_at &&
data.updated_at <= currentMessage.updated_at
) {
return { applied: false, isNew };
}
if (content_attributes && content_attributes.deleted) {
commit('deleteMessage', id);
return;
return { applied: true, isNew };
}
commit('pushMessageToConversation', data);
return { applied: true, isNew };
},
toggleAgentTyping({ commit }, data) {
@@ -82,6 +82,38 @@ describe('#actions', () => {
message_type: 1,
});
});
it('ignores an older realtime update', async () => {
const data = {
id: 1,
updated_at: '2026-08-24T01:00:00Z',
content_attributes: {},
};
const state = {
conversations: {
1: { id: 1, updated_at: '2026-08-24T02:00:00Z' },
},
};
const result = await actions.addOrUpdateMessage({ commit, state }, data);
expect(result).toEqual({ applied: false, isNew: false });
expect(commit).not.toHaveBeenCalled();
});
it('ignores a replayed message with the same id and version', async () => {
const data = {
id: 1,
updated_at: '2026-08-24T02:00:00Z',
content_attributes: {},
};
const state = { conversations: { 1: data } };
const result = await actions.addOrUpdateMessage({ commit, state }, data);
expect(result).toEqual({ applied: false, isNew: false });
expect(commit).not.toHaveBeenCalled();
});
});
describe('#toggleAgentTyping', () => {