fix(widget): Queue SDK-set conversation attributes and labels for first message (#13912)

### Description

When integrating the web widget via the JS SDK, customers call
setConversationCustomAttributes and setLabel on chatwoot:ready — before
any conversation exists. These API calls silently fail because the
backend endpoints require an existing conversation. When the visitor
sends their first message, the conversation is created without those
attributes/labels, so the message_created webhook payload is missing the
expected metadata.

This change queues SDK-set conversation custom attributes and labels in
the widget store when no conversation exists yet, and includes them in
the API request when the first message (or attachment) creates the
conversation. The backend now permits and applies these params during
conversation creation — before the message is saved and webhooks fire.

###  How to test

  1. Configure a web widget without a pre-chat form.
2. Open the widget on a test page and run the following in the browser
console after chatwoot:ready:
`window.$chatwoot.setConversationCustomAttributes({ plan: 'enterprise'
});`
`window.$chatwoot.setLabel('vip');` // must be a label that exists in
the account
  3. Send the first message from the widget.
4. Verify in the Chatwoot dashboard that the conversation has plan:
enterprise in custom attributes and the vip label applied.
5. Set up a webhook subscriber for `message_created` confirm the first
payload includes the conversation metadata.
6. Verify that calling `setConversationCustomAttributes` / `setLabel` on
an existing conversation still works as before (direct API path, no
regression).
  7. Verify the pre-chat form flow still works as expected.
This commit is contained in:
Muhsin Keloth
2026-04-02 12:09:24 +04:00
committed by GitHub
parent d83beb2148
commit b3d0af84c4
13 changed files with 418 additions and 42 deletions

View File

@@ -6,13 +6,26 @@ const createConversationAPI = async content => {
return API.post(urlData.url, urlData.params);
};
const sendMessageAPI = async (content, replyTo = null) => {
const urlData = endPoints.sendMessage(content, replyTo);
const sendMessageAPI = async (
content,
replyTo = null,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendMessage(content, replyTo, {
customAttributes,
labels,
});
return API.post(urlData.url, urlData.params);
};
const sendAttachmentAPI = async (attachment, replyTo = null) => {
const urlData = endPoints.sendAttachment(attachment, replyTo);
const sendAttachmentAPI = async (
attachment,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendAttachment(attachment, {
customAttributes,
labels,
});
return API.post(urlData.url, urlData.params);
};

View File

@@ -22,23 +22,30 @@ const createConversation = params => {
};
};
const sendMessage = (content, replyTo) => {
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
return {
url: `/api/v1/widget/messages${search}`,
params: {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
const params = {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
};
if (customAttributes && Object.keys(customAttributes).length > 0) {
params.custom_attributes = customAttributes;
}
if (labels && labels.length > 0) {
params.labels = labels;
}
return { url: `/api/v1/widget/messages${search}`, params };
};
const sendAttachment = ({ attachment, replyTo = null }) => {
const sendAttachment = (
{ attachment, replyTo = null },
{ customAttributes, labels } = {}
) => {
const { referrerURL = '' } = window;
const timestamp = new Date().toString();
const { file } = attachment;
@@ -55,6 +62,16 @@ const sendAttachment = ({ attachment, replyTo = null }) => {
if (replyTo !== null) {
formData.append('message[reply_to]', replyTo);
}
if (customAttributes && Object.keys(customAttributes).length > 0) {
Object.entries(customAttributes).forEach(([key, value]) => {
formData.append(`custom_attributes[${key}]`, value);
});
}
if (labels && labels.length > 0) {
labels.forEach(label => {
formData.append('labels[]', label);
});
}
return {
url: `/api/v1/widget/messages${window.location.search}`,
params: formData,

View File

@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
});
});
describe('#sendMessage with pending metadata', () => {
it('includes custom_attributes and labels in payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello', null, {
customAttributes: { plan: 'enterprise' },
labels: ['vip'],
});
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
expect(result.params.labels).toEqual(['vip']);
spy.mockRestore();
});
it('does not include metadata keys when not provided', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello');
expect(result.params.custom_attributes).toBeUndefined();
expect(result.params.labels).toBeUndefined();
spy.mockRestore();
});
});
describe('#getConversation', () => {
it('returns correct payload', () => {
vi.spyOn(window, 'location', 'get').mockReturnValue({

View File

@@ -85,10 +85,9 @@ export default {
},
methods: {
async retrySendMessage() {
await this.$store.dispatch(
'conversation/sendMessageWithData',
this.message
);
await this.$store.dispatch('conversation/sendMessageWithData', {
message: this.message,
});
},
onImageLoadError() {
this.hasImageError = true;

View File

@@ -30,18 +30,37 @@ export const actions = {
commit('setConversationUIFlag', { isCreating: false });
}
},
sendMessage: async ({ dispatch }, params) => {
sendMessage: async ({ dispatch, state: conversationState }, params) => {
const { content, replyTo } = params;
const message = createTemporaryMessage({ content, replyTo });
dispatch('sendMessageWithData', message);
const { pendingCustomAttributes, pendingLabels } = conversationState;
dispatch('sendMessageWithData', {
message,
pendingCustomAttributes,
pendingLabels,
});
},
sendMessageWithData: async ({ commit }, message) => {
sendMessageWithData: async (
{ commit },
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
) => {
const { id, content, replyTo, meta = {} } = message;
const hasPendingMetadata =
Object.keys(pendingCustomAttributes).length > 0 ||
pendingLabels.length > 0;
commit('pushMessageToConversation', message);
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
try {
const { data } = await sendMessageAPI(content, replyTo);
const { data } = await sendMessageAPI(content, replyTo, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
// commit('deleteMessage', message.id);
@@ -59,7 +78,7 @@ export const actions = {
commit('setLastMessageId');
},
sendAttachment: async ({ commit }, params) => {
sendAttachment: async ({ commit, state: conversationState }, params) => {
const {
attachment: { thumbUrl, fileType },
meta = {},
@@ -74,9 +93,22 @@ export const actions = {
attachments: [attachment],
replyTo: params.replyTo,
});
const { pendingCustomAttributes, pendingLabels } = conversationState;
const hasPendingMetadata =
Object.keys(pendingCustomAttributes).length > 0 ||
pendingLabels.length > 0;
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
const { data } = await sendAttachmentAPI(params, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
@@ -180,7 +212,14 @@ export const actions = {
await toggleStatus();
},
setCustomAttributes: async (_, customAttributes = {}) => {
setCustomAttributes: async (
{ commit, rootGetters },
customAttributes = {}
) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('setPendingCustomAttributes', customAttributes);
return;
}
try {
await setCustomAttributes(customAttributes);
} catch (error) {
@@ -188,7 +227,11 @@ export const actions = {
}
},
deleteCustomAttribute: async (_, customAttribute) => {
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('removePendingCustomAttribute', customAttribute);
return;
}
try {
await deleteCustomAttribute(customAttribute);
} catch (error) {

View File

@@ -33,6 +33,8 @@ export const getters = {
messages: groupConversationBySender(conversationGroupedByDate[date]),
}));
},
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
getPendingLabels: _state => _state.pendingLabels,
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getMessageCount: _state => {
return Object.values(_state.conversations).length;

View File

@@ -14,6 +14,8 @@ const state = {
isCreating: false,
},
lastMessageId: null,
pendingCustomAttributes: {},
pendingLabels: [],
};
export default {

View File

@@ -4,6 +4,8 @@ import { findUndeliveredMessage } from './helpers';
export const mutations = {
clearConversations($state) {
$state.conversations = {};
$state.pendingCustomAttributes = {};
$state.pendingLabels = [];
},
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
@@ -113,4 +115,31 @@ export const mutations = {
const { id } = lastMessage;
$state.lastMessageId = id;
},
setPendingCustomAttributes($state, data) {
$state.pendingCustomAttributes = {
...$state.pendingCustomAttributes,
...data,
};
},
setPendingLabels($state, label) {
if (!$state.pendingLabels.includes(label)) {
$state.pendingLabels.push(label);
}
},
removePendingCustomAttribute($state, key) {
const { [key]: _, ...rest } = $state.pendingCustomAttributes;
$state.pendingCustomAttributes = rest;
},
removePendingLabel($state, label) {
$state.pendingLabels = $state.pendingLabels.filter(l => l !== label);
},
clearPendingConversationMetadata($state) {
$state.pendingCustomAttributes = {};
$state.pendingLabels = [];
},
};

View File

@@ -5,14 +5,22 @@ const state = {};
export const getters = {};
export const actions = {
create: async (_, label) => {
create: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/setPendingLabels', label, { root: true });
return;
}
try {
await conversationLabels.create(label);
} catch (error) {
// Ignore error
}
},
destroy: async (_, label) => {
destroy: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/removePendingLabel', label, { root: true });
return;
}
try {
await conversationLabels.destroy(label);
} catch (error) {

View File

@@ -111,20 +111,45 @@ describe('#actions', () => {
search: '?param=1',
},
}));
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
await actions.sendMessage(
{ commit, dispatch },
{ commit, dispatch, state },
{ content: 'hello', replyTo: 124 }
);
spy.mockRestore();
windowSpy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
message: {
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
},
pendingCustomAttributes: {},
pendingLabels: [],
});
});
it('includes pending metadata when available', async () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '2222');
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const state = {
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
await actions.sendMessage(
{ commit, dispatch, state },
{ content: 'hello' }
);
spy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
message: expect.objectContaining({ content: 'hello' }),
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
});
});
});
@@ -136,9 +161,10 @@ describe('#actions', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const thumbUrl = '';
const attachment = { thumbUrl, fileType: 'file' };
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
actions.sendAttachment(
{ commit, dispatch },
{ commit, dispatch, state },
{ attachment, replyTo: 135 }
);
spy.mockRestore();
@@ -180,6 +206,58 @@ describe('#actions', () => {
});
});
describe('#setCustomAttributes', () => {
it('queues to pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
plan: 'enterprise',
});
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).not.toBeCalledWith(
'setPendingCustomAttributes',
expect.anything()
);
});
});
describe('#deleteCustomAttribute', () => {
it('removes from pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).not.toBeCalledWith(
'removePendingCustomAttribute',
expect.anything()
);
});
});
describe('#clearConversations', () => {
it('sends correct mutations', () => {
actions.clearConversations({ commit });

View File

@@ -169,10 +169,77 @@ describe('#mutations', () => {
});
describe('#clearConversations', () => {
it('clears the state', () => {
const state = { conversations: { 1: { id: 1 } } };
it('clears conversations and pending metadata', () => {
const state = {
conversations: { 1: { id: 1 } },
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
mutations.clearConversations(state);
expect(state.conversations).toEqual({});
expect(state.pendingCustomAttributes).toEqual({});
expect(state.pendingLabels).toEqual([]);
});
});
describe('#setPendingCustomAttributes', () => {
it('merges custom attributes into pending state', () => {
const state = { pendingCustomAttributes: { existing: 'value' } };
mutations.setPendingCustomAttributes(state, { plan: 'enterprise' });
expect(state.pendingCustomAttributes).toEqual({
existing: 'value',
plan: 'enterprise',
});
});
});
describe('#setPendingLabels', () => {
it('adds label to pending state', () => {
const state = { pendingLabels: [] };
mutations.setPendingLabels(state, 'vip');
expect(state.pendingLabels).toEqual(['vip']);
});
it('does not add duplicate labels', () => {
const state = { pendingLabels: ['vip'] };
mutations.setPendingLabels(state, 'vip');
expect(state.pendingLabels).toEqual(['vip']);
});
});
describe('#removePendingCustomAttribute', () => {
it('removes a single key from pending custom attributes', () => {
const state = {
pendingCustomAttributes: { plan: 'enterprise', region: 'us' },
};
mutations.removePendingCustomAttribute(state, 'plan');
expect(state.pendingCustomAttributes).toEqual({ region: 'us' });
});
});
describe('#removePendingLabel', () => {
it('removes a label from pending labels', () => {
const state = { pendingLabels: ['vip', 'premium'] };
mutations.removePendingLabel(state, 'vip');
expect(state.pendingLabels).toEqual(['premium']);
});
it('does nothing if label not present', () => {
const state = { pendingLabels: ['vip'] };
mutations.removePendingLabel(state, 'premium');
expect(state.pendingLabels).toEqual(['vip']);
});
});
describe('#clearPendingConversationMetadata', () => {
it('clears pending custom attributes and labels', () => {
const state = {
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
mutations.clearPendingConversationMetadata(state);
expect(state.pendingCustomAttributes).toEqual({});
expect(state.pendingLabels).toEqual([]);
});
});