From d83beb2148ee079bdd7701c3f4c6d51638dd7383 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 2 Apr 2026 11:13:11 +0400 Subject: [PATCH 1/6] fix: Populate `extension` and include `content_type` in attachment webhook payload (#13945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attachment webhook event payloads (`message_created`) were missing the file extension and content type. The `extension` column existed but was never populated, and `content_type` was not included in the payload at all. ## What changed - Added `before_save :set_extension` callback to extract file extension from the filename when saving an attachment. - Added `content_type` (from ActiveStorage) to the `file_metadata` used in `push_event_data`. ### Before ```json { "extension": null, "data_url": "...", "file_size": 11960 } ``` ### After ```json { "extension": "pdf", "content_type": "application/pdf", "data_url": "...", "file_size": 11960 } ``` ## How to reproduce 1. Send a message with a file attachment (e.g., PDF) via any channel 2. Inspect the `message_created` webhook payload 3. Observe `extension` is `null` and `content_type` is missing 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/models/attachment.rb | 9 ++++++++ spec/models/attachment_spec.rb | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index a1cc062a0..c6b4e1d80 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -37,6 +37,7 @@ class Attachment < ApplicationRecord belongs_to :account belongs_to :message has_one_attached :file + before_save :set_extension validate :acceptable_file validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT } enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7, @@ -111,6 +112,7 @@ class Attachment < ApplicationRecord def file_metadata metadata = { extension: extension, + content_type: file.content_type, data_url: file_url, thumb_url: thumb_url, file_size: file.byte_size, @@ -154,6 +156,13 @@ class Attachment < ApplicationRecord } end + def set_extension + return unless file.attached? + return if extension.present? + + self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence + end + def should_validate_file? return unless file.attached? # we are only limiting attachment types in case of website widget diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index a17839174..5e1dd2107 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -187,6 +187,44 @@ RSpec.describe Attachment do end end + describe 'set_extension' do + it 'sets extension from filename on save' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf') + attachment.save! + + expect(attachment.extension).to eq('pdf') + end + + it 'does not overwrite extension if already set' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :file, extension: 'doc') + attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf') + attachment.save! + + expect(attachment.extension).to eq('doc') + end + + it 'handles filenames without extension' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: StringIO.new('fake data'), filename: 'README', content_type: 'text/plain') + attachment.save! + + expect(attachment.extension).to be_nil + end + end + + describe 'push_event_data includes extension and content_type' do + it 'returns extension and content_type for file attachments' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf') + attachment.save! + + event_data = attachment.push_event_data + expect(event_data[:extension]).to eq('pdf') + expect(event_data[:content_type]).to eq('application/pdf') + end + end + describe 'file size validation' do let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) } From b3d0af84c4fa439f9397cd4ae57f39b7d50872b0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 2 Apr 2026 12:09:24 +0400 Subject: [PATCH 2/6] fix(widget): Queue SDK-set conversation attributes and labels for first message (#13912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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. --- .../api/v1/widget/messages_controller.rb | 19 +++- app/javascript/widget/api/conversation.js | 21 +++- app/javascript/widget/api/endPoints.js | 39 +++++--- .../widget/api/specs/endPoints.spec.js | 44 +++++++++ .../widget/components/UserMessage.vue | 7 +- .../store/modules/conversation/actions.js | 59 ++++++++++-- .../store/modules/conversation/getters.js | 2 + .../store/modules/conversation/index.js | 2 + .../store/modules/conversation/mutations.js | 29 ++++++ .../store/modules/conversationLabels.js | 12 ++- .../specs/conversation/actions.spec.js | 96 +++++++++++++++++-- .../specs/conversation/mutations.spec.js | 71 +++++++++++++- .../api/v1/widget/messages_controller_spec.rb | 59 ++++++++++++ 13 files changed, 418 insertions(+), 42 deletions(-) diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) end def set_message diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js index 1060b285d..05e81ff9f 100755 --- a/app/javascript/widget/api/conversation.js +++ b/app/javascript/widget/api/conversation.js @@ -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); }; diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js index b595fdf00..713de56f1 100755 --- a/app/javascript/widget/api/endPoints.js +++ b/app/javascript/widget/api/endPoints.js @@ -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, diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js index 0216caed9..b95b2f659 100644 --- a/app/javascript/widget/api/specs/endPoints.spec.js +++ b/app/javascript/widget/api/specs/endPoints.spec.js @@ -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({ diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue index a920c508d..25e119140 100755 --- a/app/javascript/widget/components/UserMessage.vue +++ b/app/javascript/widget/components/UserMessage.vue @@ -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; diff --git a/app/javascript/widget/store/modules/conversation/actions.js b/app/javascript/widget/store/modules/conversation/actions.js index 6d4c26610..aa08af615 100644 --- a/app/javascript/widget/store/modules/conversation/actions.js +++ b/app/javascript/widget/store/modules/conversation/actions.js @@ -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) { diff --git a/app/javascript/widget/store/modules/conversation/getters.js b/app/javascript/widget/store/modules/conversation/getters.js index 151694b86..ef1cd1cc3 100644 --- a/app/javascript/widget/store/modules/conversation/getters.js +++ b/app/javascript/widget/store/modules/conversation/getters.js @@ -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; diff --git a/app/javascript/widget/store/modules/conversation/index.js b/app/javascript/widget/store/modules/conversation/index.js index 9869b6a87..077a16a04 100755 --- a/app/javascript/widget/store/modules/conversation/index.js +++ b/app/javascript/widget/store/modules/conversation/index.js @@ -14,6 +14,8 @@ const state = { isCreating: false, }, lastMessageId: null, + pendingCustomAttributes: {}, + pendingLabels: [], }; export default { diff --git a/app/javascript/widget/store/modules/conversation/mutations.js b/app/javascript/widget/store/modules/conversation/mutations.js index 781dcd67b..1c23e008d 100644 --- a/app/javascript/widget/store/modules/conversation/mutations.js +++ b/app/javascript/widget/store/modules/conversation/mutations.js @@ -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 = []; + }, }; diff --git a/app/javascript/widget/store/modules/conversationLabels.js b/app/javascript/widget/store/modules/conversationLabels.js index 3ae600082..ec3fc9fa4 100644 --- a/app/javascript/widget/store/modules/conversationLabels.js +++ b/app/javascript/widget/store/modules/conversationLabels.js @@ -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) { diff --git a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js index 39b8afe1a..2ab17cb37 100644 --- a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js @@ -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 }); diff --git a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js index bc6b6bd29..0894c5b52 100644 --- a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js +++ b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js @@ -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([]); }); }); diff --git a/spec/controllers/api/v1/widget/messages_controller_spec.rb b/spec/controllers/api/v1/widget/messages_controller_spec.rb index 11c916514..902b4ec01 100644 --- a/spec/controllers/api/v1/widget/messages_controller_spec.rb +++ b/spec/controllers/api/v1/widget/messages_controller_spec.rb @@ -56,6 +56,65 @@ RSpec.describe '/api/v1/widget/messages', type: :request do expect(json_response['content']).to eq(message_params[:content]) end + it 'creates conversation with custom_attributes when first message is sent' do + conversation.destroy! + message_params = { content: 'hello world', timestamp: Time.current } + custom_attributes = { plan: 'enterprise', source: 'website' } + post api_v1_widget_messages_url, + params: { website_token: web_widget.website_token, message: message_params, custom_attributes: custom_attributes }, + headers: { 'X-Auth-Token' => token }, + as: :json + + expect(response).to have_http_status(:success) + new_conversation = contact.conversations.last + expect(new_conversation.custom_attributes).to include('plan' => 'enterprise', 'source' => 'website') + end + + it 'creates conversation with labels when first message is sent' do + conversation.destroy! + label = create(:label, title: 'vip', account: account) + message_params = { content: 'hello world', timestamp: Time.current } + post api_v1_widget_messages_url, + params: { website_token: web_widget.website_token, message: message_params, labels: [label.title] }, + headers: { 'X-Auth-Token' => token }, + as: :json + + expect(response).to have_http_status(:success) + new_conversation = contact.conversations.last + expect(new_conversation.label_list).to include('vip') + end + + it 'ignores invalid labels when creating conversation with first message' do + conversation.destroy! + create(:label, title: 'valid-label', account: account) + message_params = { content: 'hello world', timestamp: Time.current } + post api_v1_widget_messages_url, + params: { website_token: web_widget.website_token, message: message_params, labels: %w[valid-label nonexistent] }, + headers: { 'X-Auth-Token' => token }, + as: :json + + expect(response).to have_http_status(:success) + new_conversation = contact.conversations.last + expect(new_conversation.label_list).to include('valid-label') + expect(new_conversation.label_list).not_to include('nonexistent') + end + + it 'does not apply labels or custom_attributes when conversation already exists' do + create(:label, title: 'vip', account: account) + message_params = { content: 'hello world', timestamp: Time.current } + custom_attributes = { plan: 'enterprise' } + post api_v1_widget_messages_url, + params: { website_token: web_widget.website_token, message: message_params, + custom_attributes: custom_attributes, labels: ['vip'] }, + headers: { 'X-Auth-Token' => token }, + as: :json + + expect(response).to have_http_status(:success) + conversation.reload + expect(conversation.custom_attributes).not_to include('plan' => 'enterprise') + expect(conversation.label_list).not_to include('vip') + end + it 'does not create the message' do conversation.destroy! # Test all params message_params = { content: "#{'h' * 150 * 1000}a", timestamp: Time.current } From b815eb9ce0e3cf26c1716f8f62a90f1891d03995 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 2 Apr 2026 13:55:05 +0400 Subject: [PATCH 3/6] fix(agent-bot): Dispatch webhook event on agent bot assignment (#13975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an AgentBot is assigned to a conversation after the first message has already been received, the bot does not respond because it never receives any event. The `message_created` event fires before the bot is assigned, and the bot has no way to know it was assigned. Chatwoot already dispatches a `CONVERSATION_UPDATED` event when `assignee_agent_bot_id` changes, but `AgentBotListener` wasn't listening for it. This fix adds a `conversation_updated` handler so the bot receives a webhook with the conversation context when assigned. ## How to reproduce 1. Customer sends a message → conversation created, `message_created` fires 2. System processes the message (adds labels, custom attributes) 3. System assigns an AgentBot to the conversation via API 4. **Before fix:** Bot receives no event and never responds 5. **After fix:** Bot receives `conversation_updated` event with conversation payload ## What changed - **`AgentBotListener`**: Added `conversation_updated` handler that sends the conversation webhook payload to the assigned bot when the conversation is updated ## How to test 1. Create an AgentBot with an `outgoing_url` pointing to a webhook inspector (e.g. webhook.site) 2. Send a message to create a conversation 3. Assign the AgentBot to the conversation via API: ``` POST /api/v1/accounts/{id}/conversations/{id}/assignments { "assignee_id": , "assignee_type": "AgentBot" } ``` 4. Verify the bot receives a `conversation_updated` event at its webhook URL 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/listeners/agent_bot_listener.rb | 8 ++++++ spec/listeners/agent_bot_listener_spec.rb | 33 +++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/app/listeners/agent_bot_listener.rb b/app/listeners/agent_bot_listener.rb index ccac8005f..8fdf964fb 100644 --- a/app/listeners/agent_bot_listener.rb +++ b/app/listeners/agent_bot_listener.rb @@ -15,6 +15,14 @@ class AgentBotListener < BaseListener agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) } end + def conversation_updated(event) + conversation = extract_conversation_and_account(event)[0] + inbox = conversation.inbox + event_name = __method__.to_s + payload = conversation.webhook_data.merge(event: event_name) + agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) } + end + def message_created(event) message = extract_message_and_account(event)[0] inbox = message.inbox diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb index 56c478a1b..24af37383 100644 --- a/spec/listeners/agent_bot_listener_spec.rb +++ b/spec/listeners/agent_bot_listener_spec.rb @@ -57,6 +57,39 @@ describe AgentBotListener do end end + describe '#conversation_updated' do + let(:event_name) { 'conversation.updated' } + let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } + + context 'when agent bot is not configured' do + it 'does not send webhook' do + expect(AgentBots::WebhookJob).not_to receive(:perform_later) + listener.conversation_updated(event) + end + end + + context 'when agent bot is configured on inbox' do + it 'sends webhook to the inbox agent bot' do + create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) + expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, + conversation.webhook_data.merge(event: 'conversation_updated')).once + listener.conversation_updated(event) + end + end + + context 'when conversation is assigned to an agent bot' do + before do + conversation.update!(assignee_agent_bot: agent_bot, assignee: nil) + end + + it 'sends webhook to the assigned agent bot' do + expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, + conversation.webhook_data.merge(event: 'conversation_updated')).once + listener.conversation_updated(event) + end + end + end + describe '#webwidget_triggered' do let(:event_name) { 'webwidget.triggered' } From b9b5a187672a5b925f4333fecb387f9bd491082a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Apr 2026 16:02:22 +0530 Subject: [PATCH 4/6] revert: html background for widget (#13981) Reverts chatwoot/chatwoot#13955 --- app/javascript/widget/assets/scss/woot.scss | 2 +- app/javascript/widget/composables/useDarkMode.js | 6 +----- app/javascript/widget/views/ArticleViewer.vue | 2 +- app/views/layouts/portal.html.erb | 4 ++-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 0044ccdfc..07aa6a0e3 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -7,7 +7,7 @@ html, body { - @apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1; + @apply antialiased h-full; } .is-mobile { diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js index 407d90980..bc19c456b 100644 --- a/app/javascript/widget/composables/useDarkMode.js +++ b/app/javascript/widget/composables/useDarkMode.js @@ -1,4 +1,4 @@ -import { computed, watchEffect } from 'vue'; +import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; const isDarkModeAuto = mode => mode === 'auto'; @@ -23,10 +23,6 @@ export function useDarkMode() { calculatePrefersDarkMode(darkMode.value, systemPreference.value) ); - watchEffect(() => { - document.documentElement.classList.toggle('dark', prefersDarkMode.value); - }); - return { darkMode, prefersDarkMode, diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index bc4cf775c..9289d0546 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 52d8e2789..78418881a 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -58,9 +58,9 @@ By default, it renders: } - +
-
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> <% end %> From 441fe4db1147c4392e0e026c6a97c3d4d5de75ef Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 2 Apr 2026 07:26:23 -0700 Subject: [PATCH 5/6] fix: scope external_url override to Instagram DM conversations only (#13982) Previously, all incoming messages from Facebook channel with instagram_id had their attachment data_url and thumb_url overridden with external_url. This caused issues for non-Instagram conversations originating from Facebook Message where the file URL should be used instead. Narrows the override to only apply when the conversation type is instagram_direct_message, which is the only case where Instagram's CDN URLs need to be used directly. Fixes https://linear.app/chatwoot/issue/CW-6722/videos-are-missing-in-facebook-conversation --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- app/models/attachment.rb | 10 ++++- spec/models/attachment_spec.rb | 82 +++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index c6b4e1d80..769e134be 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -120,7 +120,7 @@ class Attachment < ApplicationRecord height: file.metadata[:height] } - metadata[:data_url] = metadata[:thumb_url] = external_url if message.inbox.instagram? && message.incoming? + metadata[:data_url] = metadata[:thumb_url] = external_url if instagram_incoming_message? metadata end @@ -156,6 +156,14 @@ class Attachment < ApplicationRecord } end + def instagram_incoming_message? + return false unless message.incoming? + + return true if message.inbox.instagram_direct? + + message.inbox.instagram? && message.conversation&.additional_attributes&.dig('type') == 'instagram_direct_message' + end + def set_extension return unless file.attached? return if extension.present? diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index 5e1dd2107..82fb51bf1 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -57,11 +57,6 @@ RSpec.describe Attachment do }.to_json, headers: {}) end - it 'returns external url as data and thumb urls when message is incoming' do - external_url = instagram_message.attachments.first.external_url - expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url - end - it 'returns original attachment url as data url if the message is outgoing' do message = create(:message, :instagram_story_mention, message_type: :outgoing) expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url @@ -155,6 +150,83 @@ RSpec.describe Attachment do end end + describe 'push_event_data for instagram direct message attachments' do + let(:account) { create(:account) } + let(:instagram_inbox) do + create(:inbox, account: account, + channel: create(:channel_instagram_fb_page, account: account, instagram_id: 'instagram-dm-test')) + end + + context 'when conversation type is instagram_direct_message' do + let(:conversation) do + create(:conversation, account: account, inbox: instagram_inbox, + additional_attributes: { 'type' => 'instagram_direct_message' }) + end + let(:instagram_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :incoming) } + + it 'uses external_url for data_url and thumb_url' do + attachment = instagram_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg') + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! + + event_data = attachment.push_event_data + expect(event_data[:data_url]).to eq('https://instagram.com/image.jpg') + expect(event_data[:thumb_url]).to eq('https://instagram.com/image.jpg') + end + end + + context 'when conversation type is not instagram_direct_message' do + let(:conversation) do + create(:conversation, account: account, inbox: instagram_inbox, + additional_attributes: { 'type' => 'other_type' }) + end + let(:instagram_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :incoming) } + + it 'uses file_url for data_url instead of external_url' do + attachment = instagram_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg') + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! + + event_data = attachment.push_event_data + expect(event_data[:data_url]).not_to eq('https://instagram.com/image.jpg') + end + end + + context 'when message is outgoing on instagram DM conversation' do + let(:conversation) do + create(:conversation, account: account, inbox: instagram_inbox, + additional_attributes: { 'type' => 'instagram_direct_message' }) + end + let(:outgoing_message) { create(:message, account: account, inbox: instagram_inbox, conversation: conversation, message_type: :outgoing) } + + it 'does not override data_url with external_url' do + attachment = outgoing_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg') + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! + + event_data = attachment.push_event_data + expect(event_data[:data_url]).not_to eq('https://instagram.com/image.jpg') + end + end + + context 'when inbox is Channel::Instagram (direct login)' do + let(:instagram_channel) { create(:channel_instagram, account: account) } + let(:direct_inbox) { instagram_channel.inbox } + let(:conversation) { create(:conversation, account: account, inbox: direct_inbox) } + let(:incoming_message) { create(:message, account: account, inbox: direct_inbox, conversation: conversation, message_type: :incoming) } + + it 'uses external_url for data_url and thumb_url' do + attachment = incoming_message.attachments.new(account_id: account.id, file_type: :image, external_url: 'https://instagram.com/image.jpg') + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! + + event_data = attachment.push_event_data + expect(event_data[:data_url]).to eq('https://instagram.com/image.jpg') + expect(event_data[:thumb_url]).to eq('https://instagram.com/image.jpg') + end + end + end + describe 'push_event_data for ig_reel attachments' do it 'returns external_url as data_url when no file is attached' do attachment = message.attachments.create!( From 6f5ad8f3724b68fec3780b22047c539c06520fb2 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:58:43 +0530 Subject: [PATCH 6/6] fix: strip manually_managed_features from params in super admin account create (#13983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When a Super Admin creates a new account via the Administrate dashboard, the `manually_managed_features` field (a virtual attribute stored in `internal_attributes` JSON) is passed to `Account.new(...)`, raising `ActiveModel::UnknownAttributeError`. The existing `update` action already strips this param — this fix adds the same handling to `create`. Closes -> https://linear.app/chatwoot/issue/INF-66 Related Sentry -> https://chatwoot-p3.sentry.io/issues/7168237533/?project=6382945&referrer=Linear ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Log in as Super Admin 2. Navigate to Accounts → New 3. Fill in the form (with or without manually managed features selected) 4. Submit → `ActiveModel::UnknownAttributeError: unknown attribute 'manually_managed_features' for Account` ## What changed - Added a `create` override in `Enterprise::SuperAdmin::AccountsController` that strips `manually_managed_features` from params before calling `super`, then persists them via `InternalAttributesService` after the account is saved. --- .../enterprise/super_admin/accounts_controller.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb index 305699dcb..23a8cc5de 100644 --- a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb @@ -1,4 +1,15 @@ module Enterprise::SuperAdmin::AccountsController + def create + manually_managed = params[:account]&.delete(:manually_managed_features) + + super do |resource| + if manually_managed.present? + service = ::Internal::Accounts::InternalAttributesService.new(resource) + service.manually_managed_features = manually_managed + end + end + end + def update # Handle manually managed features from form submission if params[:account] && params[:account][:manually_managed_features].present?