From ad4ec9e93b792ca18c73654c07fad354e546bcab Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 12 Aug 2025 16:42:18 +0200 Subject: [PATCH 01/10] fix: Flaky Instagram webhook specs (#12170) ### Summary Fixed flaky Instagram webhook specs that failed intermittently in cloud environments due to shared let blocks creating conflicting inboxes. The Instagram channel factory already creates an inbox automatically, but tests were adding extra ones in shared contexts. Moved channel/inbox creation to isolated test contexts to prevent race conditions between Facebook Page and Instagram Direct tests. ### Testing ``` for i in {1..30}; do echo "=== Run $i ===" RAILS_ENV=test bundle exec rspec spec/jobs/webhooks/instagram_events_job_spec.rb --fail-fast || break done ``` Previously, intermittent failures could be reproduced locally. With these changes, tests achieve ~100% pass rate. --- .../webhooks/instagram_events_job_spec.rb | 125 ++++++++---------- 1 file changed, 57 insertions(+), 68 deletions(-) diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb index 9edd9a34d..21f042f1f 100644 --- a/spec/jobs/webhooks/instagram_events_job_spec.rb +++ b/spec/jobs/webhooks/instagram_events_job_spec.rb @@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do end let!(:account) { create(:account) } - let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } - let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) } - let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') } - let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } - # Combined message events into one helper - let(:message_events) do - { - dm: build(:instagram_message_create_event).with_indifferent_access, - standby: build(:instagram_message_standby_event).with_indifferent_access, - unsend: build(:instagram_message_unsend_event).with_indifferent_access, - attachment: build(:instagram_message_attachment_event).with_indifferent_access, - story_mention: build(:instagram_story_mention_event).with_indifferent_access, - story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access, - messaging_seen: build(:messaging_seen_event).with_indifferent_access, - unsupported: build(:instagram_message_unsupported_event).with_indifferent_access - } - end def return_object_for(sender_id) { name: 'Jane', @@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do describe '#perform' do context 'when handling messaging events for Instagram via Facebook page' do + let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) } let(:fb_object) { double } - before do - instagram_inbox.destroy - end - it 'creates incoming message in the instagram inbox' do + dm_event = build(:instagram_message_create_event).with_indifferent_access + sender_id = dm_event[:entry][0][:messaging][0][:sender][:id] + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( return_object_for(sender_id).with_indifferent_access ) - instagram_webhook.perform_now(message_events[:dm][:entry]) - - instagram_messenger_inbox.reload + instagram_webhook.perform_now(dm_event[:entry]) expect(instagram_messenger_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' @@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do end it 'creates standby message in the instagram inbox' do + standby_event = build(:instagram_message_standby_event).with_indifferent_access + sender_id = standby_event[:entry][0][:standby][0][:sender][:id] + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( return_object_for(sender_id).with_indifferent_access ) - instagram_webhook.perform_now(message_events[:standby][:entry]) - - instagram_messenger_inbox.reload + instagram_webhook.perform_now(standby_event[:entry]) expect(instagram_messenger_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' @@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do end it 'handle instagram unsend message event' do + unsend_event = build(:instagram_message_unsend_event).with_indifferent_access + sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id] + message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete') allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( { name: 'Jane', @@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do expect(instagram_messenger_inbox.messages.count).to be 1 - instagram_webhook.perform_now(message_events[:unsend][:entry]) + instagram_webhook.perform_now(unsend_event[:entry]) expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted' expect(instagram_messenger_inbox.messages.last.deleted).to be true @@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do end it 'creates incoming message with attachments in the instagram inbox' do + attachment_event = build(:instagram_message_attachment_event).with_indifferent_access + sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id] + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( return_object_for(sender_id).with_indifferent_access ) - instagram_webhook.perform_now(message_events[:attachment][:entry]) - - instagram_messenger_inbox.reload + instagram_webhook.perform_now(attachment_event[:entry]) expect(instagram_messenger_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.messages.count).to be 1 @@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do end it 'creates incoming message with attachments in the instagram inbox for story mention' do + story_mention_event = build(:instagram_story_mention_event).with_indifferent_access + sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id] + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( return_object_for(sender_id).with_indifferent_access, { story: @@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do id: 'instagram-message-id-1234' }.with_indifferent_access ) - instagram_webhook.perform_now(message_events[:story_mention][:entry]) - - instagram_messenger_inbox.reload + instagram_webhook.perform_now(story_mention_event[:entry]) expect(instagram_messenger_inbox.messages.count).to be 1 expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1 @@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do end it 'does not create contact or messages when Facebook API call fails' do + story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError) - instagram_webhook.perform_now(message_events[:story_mention_echo][:entry]) - - instagram_messenger_inbox.reload + instagram_webhook.perform_now(story_mention_echo_event[:entry]) expect(instagram_messenger_inbox.contacts.count).to be 0 expect(instagram_messenger_inbox.contact_inboxes.count).to be 0 @@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do end it 'handle messaging_seen callback' do - expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0], + messaging_seen_event = build(:messaging_seen_event).with_indifferent_access + + expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0], channel: instagram_messenger_inbox.channel).and_call_original - instagram_webhook.perform_now(message_events[:messaging_seen][:entry]) + instagram_webhook.perform_now(messaging_seen_event[:entry]) end it 'handles unsupported message' do + unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access + sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id] + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) - sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id] allow(fb_object).to receive(:get_object).and_return( return_object_for(sender_id).with_indifferent_access ) - instagram_webhook.perform_now(message_events[:unsupported][:entry]) - instagram_messenger_inbox.reload - + instagram_webhook.perform_now(unsupported_event[:entry]) expect(instagram_messenger_inbox.contacts.count).to be 1 expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' expect(instagram_messenger_inbox.conversations.count).to be 1 @@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do end context 'when handling messaging events for Instagram via Instagram login' do + let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { instagram_channel.inbox } + before do instagram_channel.update(access_token: 'valid_instagram_token') @@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do end it 'creates incoming message with correct contact info in the instagram direct inbox' do - instagram_webhook.perform_now(message_events[:dm][:entry]) - instagram_inbox.reload - + dm_event = build(:instagram_message_create_event).with_indifferent_access + instagram_webhook.perform_now(dm_event[:entry]) expect(instagram_inbox.contacts.count).to eq 1 expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' expect(instagram_inbox.conversations.count).to eq 1 @@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do end it 'sets correct instagram attributes on contact' do - instagram_webhook.perform_now(message_events[:dm][:entry]) + dm_event = build(:instagram_message_create_event).with_indifferent_access + instagram_webhook.perform_now(dm_event[:entry]) instagram_inbox.reload contact = instagram_inbox.contacts.last @@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do end it 'handle instagram unsend message event' do + unsend_event = build(:instagram_message_unsend_event).with_indifferent_access + message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text') # Create attachment correctly with account association @@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do expect(instagram_inbox.messages.count).to be 1 - instagram_webhook.perform_now(message_events[:unsend][:entry]) + instagram_webhook.perform_now(unsend_event[:entry]) message.reload @@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do end it 'creates incoming message with attachments in the instagram direct inbox' do - instagram_webhook.perform_now(message_events[:attachment][:entry]) - - instagram_inbox.reload + attachment_event = build(:instagram_message_attachment_event).with_indifferent_access + instagram_webhook.perform_now(attachment_event[:entry]) expect(instagram_inbox.contacts.count).to be 1 expect(instagram_inbox.messages.count).to be 1 @@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do end it 'handles unsupported message' do - instagram_webhook.perform_now(message_events[:unsupported][:entry]) - instagram_inbox.reload - + unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access + instagram_webhook.perform_now(unsupported_event[:entry]) expect(instagram_inbox.contacts.count).to be 1 expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name' expect(instagram_inbox.conversations.count).to be 1 @@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do end it 'does not create contact or messages when Instagram API call fails' do + story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access + stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*}) .to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json) - instagram_webhook.perform_now(message_events[:story_mention_echo][:entry]) - - instagram_inbox.reload + instagram_webhook.perform_now(story_mention_echo_event[:entry]) expect(instagram_inbox.contacts.count).to be 0 expect(instagram_inbox.contact_inboxes.count).to be 0 @@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do end it 'handles messaging_seen callback' do - expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0], + messaging_seen_event = build(:messaging_seen_event).with_indifferent_access + + expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0], channel: instagram_inbox.channel).and_call_original - instagram_webhook.perform_now(message_events[:messaging_seen][:entry]) + instagram_webhook.perform_now(messaging_seen_event[:entry]) end it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*}) .to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json) - sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id] - instagram_webhook.perform_now(message_events[:dm][:entry]) - - instagram_inbox.reload + dm_event = build(:instagram_message_create_event).with_indifferent_access + sender_id = dm_event[:entry][0][:messaging][0][:sender][:id] + instagram_webhook.perform_now(dm_event[:entry]) expect(instagram_inbox.contacts.count).to be 1 expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})" From 0c101b1f6b2a07ab898de4a69dd03e3347c351ac Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:21:05 +0530 Subject: [PATCH 02/10] chore: UI improvements to compose new conversation form (#12173) --- .../NewConversation/ComposeConversation.vue | 19 ++++++++++++++----- .../components-next/sidebar/Sidebar.vue | 6 ++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 610d11dab..d3686cfd7 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, watch } from 'vue'; import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useI18n } from 'vue-i18n'; +import { useWindowSize } from '@vueuse/core'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { vOnClickOutside } from '@vueuse/components'; import { useAlert } from 'dashboard/composables'; @@ -15,6 +16,7 @@ import { processContactableInboxes, mergeInboxDetails, } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper'; +import wootConstants from 'dashboard/constants/globals'; import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue'; @@ -37,9 +39,16 @@ const emit = defineEmits(['close']); const store = useStore(); const { t } = useI18n(); +const { width: windowWidth } = useWindowSize(); const { fetchSignatureFlagFromUISettings } = useUISettings(); +const isSmallScreen = computed( + () => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT +); + +const viewInModal = computed(() => props.isModal || isSmallScreen.value); + const contacts = ref([]); const selectedContact = ref(null); const targetInbox = ref(null); @@ -67,7 +76,7 @@ const directUploadsEnabled = computed( const activeContact = computed(() => contactById.value(props.contactId)); const composePopoverClass = computed(() => { - if (props.isModal) return ''; + if (viewInModal.value) return ''; return props.alignPosition === 'right' ? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]' @@ -202,7 +211,7 @@ const handleClickOutside = () => { }; const onModalBackdropClick = () => { - if (!props.isModal) return; + if (!viewInModal.value) return; handleClickOutside(); }; @@ -231,7 +240,7 @@ useKeyboardEvents(keyboardEvents); ]" class="relative" :class="{ - 'z-40': showComposeNewConversation, + 'z-50': showComposeNewConversation && !viewInModal, }" > { class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0" :class="[ { - 'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none': - isMobileSidebarOpen, - 'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0': - !isMobileSidebarOpen, + 'shadow-lg md:shadow-none': isMobileSidebarOpen, + 'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen, }, ]" > From 469e724e3a4ec7b70066189f8ab1ae739a146044 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 Aug 2025 20:25:09 +0530 Subject: [PATCH 03/10] docs: add swagger spec for whatsapp templates changes (#12169) Added swagger changes for the PR https://github.com/chatwoot/chatwoot/pull/11997 --- .../conversation/create_message_payload.yml | 60 +++++++++++-- .../conversation/messages/create.yml | 52 +++++++++++- swagger/swagger.json | 84 +++++++++++++++++-- swagger/tag_groups/application_swagger.json | 84 +++++++++++++++++-- swagger/tag_groups/client_swagger.json | 82 ++++++++++++++++-- swagger/tag_groups/other_swagger.json | 82 ++++++++++++++++-- swagger/tag_groups/platform_swagger.json | 82 ++++++++++++++++-- 7 files changed, 479 insertions(+), 47 deletions(-) diff --git a/swagger/definitions/request/conversation/create_message_payload.yml b/swagger/definitions/request/conversation/create_message_payload.yml index 4b1851293..71d073d75 100644 --- a/swagger/definitions/request/conversation/create_message_payload.yml +++ b/swagger/definitions/request/conversation/create_message_payload.yml @@ -30,22 +30,64 @@ properties: example: 1 template_params: type: object - description: The template params for the message in case of whatsapp Channel + description: WhatsApp template parameters for sending structured messages + required: + - name + - category + - language + - processed_params properties: name: type: string - description: Name of the template - example: 'sample_issue_resolution' + description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager) + example: 'purchase_receipt' category: type: string + enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION'] description: Category of the template - example: UTILITY + example: 'UTILITY' language: type: string - description: Language of the template - example: en_US + description: Language code of the template (BCP 47 format) + example: 'en_US' processed_params: type: object - description: The processed param values for template variables in template - example: - 1: 'Chatwoot' \ No newline at end of file + description: Processed template parameters organized by component type + properties: + body: + type: object + description: Body component parameters with variable placeholders + additionalProperties: + type: string + example: + '1': 'Visa' + '2': 'Nike' + '3': 'Bill' + header: + type: object + description: Header component parameters for media templates + properties: + media_url: + type: string + format: uri + description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers + example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' + media_type: + type: string + enum: ['image', 'video', 'document'] + description: Type of media for the header + example: 'document' + buttons: + type: array + description: Button component parameters for interactive templates + items: + type: object + properties: + type: + type: string + enum: ['url', 'copy_code'] + description: Type of button parameter + parameter: + type: string + description: Dynamic parameter value for the button + example: 'SSFSDFSD' \ No newline at end of file diff --git a/swagger/paths/application/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml index f8cd35f3c..1b8272585 100644 --- a/swagger/paths/application/conversation/messages/create.yml +++ b/swagger/paths/application/conversation/messages/create.yml @@ -2,7 +2,57 @@ tags: - Messages operationId: create-a-new-message-in-a-conversation summary: Create New Message -description: Create a new message in the conversation +description: | + Create a new message in the conversation. + + ## WhatsApp Template Messages + + For WhatsApp channels, you can send structured template messages using the `template_params` field. + Templates must be pre-approved in WhatsApp Business Manager. + + ### Example Templates + + **Text with Image Header:** + ```json + { + "content": "Hi your order 121212 is confirmed. Please wait for further updates", + "template_params": { + "name": "order_confirmation", + "category": "MARKETING", + "language": "en", + "processed_params": { + "body": { + "1": "121212" + }, + "header": { + "media_url": "https://picsum.photos/200/300", + "media_type": "image" + } + } + } + } + ``` + + **Text with Copy Code Button:** + ```json + { + "content": "Special offer! Get 30% off your next purchase. Use the code below", + "template_params": { + "name": "discount_coupon", + "category": "MARKETING", + "language": "en", + "processed_params": { + "body": { + "discount_percentage": "30" + }, + "buttons": [{ + "type": "copy_code", + "parameter": "SAVE20" + }] + } + } + } + ``` security: - userApiKey: [] - agentBotApiKey: [] diff --git a/swagger/swagger.json b/swagger/swagger.json index e849f8119..8da8e2c5b 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -5937,7 +5937,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation", + "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -10148,28 +10148,96 @@ }, "template_params": { "type": "object", - "description": "The template params for the message in case of whatsapp Channel", + "description": "WhatsApp template parameters for sending structured messages", + "required": [ + "name", + "category", + "language", + "processed_params" + ], "properties": { "name": { "type": "string", - "description": "Name of the template", - "example": "sample_issue_resolution" + "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)", + "example": "purchase_receipt" }, "category": { "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "SHIPPING_UPDATE", + "TICKET_UPDATE", + "ISSUE_RESOLUTION" + ], "description": "Category of the template", "example": "UTILITY" }, "language": { "type": "string", - "description": "Language of the template", + "description": "Language code of the template (BCP 47 format)", "example": "en_US" }, "processed_params": { "type": "object", - "description": "The processed param values for template variables in template", - "example": { - "1": "Chatwoot" + "description": "Processed template parameters organized by component type", + "properties": { + "body": { + "type": "object", + "description": "Body component parameters with variable placeholders", + "additionalProperties": { + "type": "string" + }, + "example": { + "1": "Visa", + "2": "Nike", + "3": "Bill" + } + }, + "header": { + "type": "object", + "description": "Header component parameters for media templates", + "properties": { + "media_url": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers", + "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "video", + "document" + ], + "description": "Type of media for the header", + "example": "document" + } + } + }, + "buttons": { + "type": "array", + "description": "Button component parameters for interactive templates", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url", + "copy_code" + ], + "description": "Type of button parameter" + }, + "parameter": { + "type": "string", + "description": "Dynamic parameter value for the button", + "example": "SSFSDFSD" + } + } + } + } } } } diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index f06819d1d..23a9ab0b2 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -4334,7 +4334,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation", + "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -8509,28 +8509,96 @@ }, "template_params": { "type": "object", - "description": "The template params for the message in case of whatsapp Channel", + "description": "WhatsApp template parameters for sending structured messages", + "required": [ + "name", + "category", + "language", + "processed_params" + ], "properties": { "name": { "type": "string", - "description": "Name of the template", - "example": "sample_issue_resolution" + "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)", + "example": "purchase_receipt" }, "category": { "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "SHIPPING_UPDATE", + "TICKET_UPDATE", + "ISSUE_RESOLUTION" + ], "description": "Category of the template", "example": "UTILITY" }, "language": { "type": "string", - "description": "Language of the template", + "description": "Language code of the template (BCP 47 format)", "example": "en_US" }, "processed_params": { "type": "object", - "description": "The processed param values for template variables in template", - "example": { - "1": "Chatwoot" + "description": "Processed template parameters organized by component type", + "properties": { + "body": { + "type": "object", + "description": "Body component parameters with variable placeholders", + "additionalProperties": { + "type": "string" + }, + "example": { + "1": "Visa", + "2": "Nike", + "3": "Bill" + } + }, + "header": { + "type": "object", + "description": "Header component parameters for media templates", + "properties": { + "media_url": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers", + "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "video", + "document" + ], + "description": "Type of media for the header", + "example": "document" + } + } + }, + "buttons": { + "type": "array", + "description": "Button component parameters for interactive templates", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url", + "copy_code" + ], + "description": "Type of button parameter" + }, + "parameter": { + "type": "string", + "description": "Dynamic parameter value for the button", + "example": "SSFSDFSD" + } + } + } + } } } } diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index c6a3ba408..6bbae4fb2 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -3132,28 +3132,96 @@ }, "template_params": { "type": "object", - "description": "The template params for the message in case of whatsapp Channel", + "description": "WhatsApp template parameters for sending structured messages", + "required": [ + "name", + "category", + "language", + "processed_params" + ], "properties": { "name": { "type": "string", - "description": "Name of the template", - "example": "sample_issue_resolution" + "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)", + "example": "purchase_receipt" }, "category": { "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "SHIPPING_UPDATE", + "TICKET_UPDATE", + "ISSUE_RESOLUTION" + ], "description": "Category of the template", "example": "UTILITY" }, "language": { "type": "string", - "description": "Language of the template", + "description": "Language code of the template (BCP 47 format)", "example": "en_US" }, "processed_params": { "type": "object", - "description": "The processed param values for template variables in template", - "example": { - "1": "Chatwoot" + "description": "Processed template parameters organized by component type", + "properties": { + "body": { + "type": "object", + "description": "Body component parameters with variable placeholders", + "additionalProperties": { + "type": "string" + }, + "example": { + "1": "Visa", + "2": "Nike", + "3": "Bill" + } + }, + "header": { + "type": "object", + "description": "Header component parameters for media templates", + "properties": { + "media_url": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers", + "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "video", + "document" + ], + "description": "Type of media for the header", + "example": "document" + } + } + }, + "buttons": { + "type": "array", + "description": "Button component parameters for interactive templates", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url", + "copy_code" + ], + "description": "Type of button parameter" + }, + "parameter": { + "type": "string", + "description": "Dynamic parameter value for the button", + "example": "SSFSDFSD" + } + } + } + } } } } diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index e2a16245b..414a44e63 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -2547,28 +2547,96 @@ }, "template_params": { "type": "object", - "description": "The template params for the message in case of whatsapp Channel", + "description": "WhatsApp template parameters for sending structured messages", + "required": [ + "name", + "category", + "language", + "processed_params" + ], "properties": { "name": { "type": "string", - "description": "Name of the template", - "example": "sample_issue_resolution" + "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)", + "example": "purchase_receipt" }, "category": { "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "SHIPPING_UPDATE", + "TICKET_UPDATE", + "ISSUE_RESOLUTION" + ], "description": "Category of the template", "example": "UTILITY" }, "language": { "type": "string", - "description": "Language of the template", + "description": "Language code of the template (BCP 47 format)", "example": "en_US" }, "processed_params": { "type": "object", - "description": "The processed param values for template variables in template", - "example": { - "1": "Chatwoot" + "description": "Processed template parameters organized by component type", + "properties": { + "body": { + "type": "object", + "description": "Body component parameters with variable placeholders", + "additionalProperties": { + "type": "string" + }, + "example": { + "1": "Visa", + "2": "Nike", + "3": "Bill" + } + }, + "header": { + "type": "object", + "description": "Header component parameters for media templates", + "properties": { + "media_url": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers", + "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "video", + "document" + ], + "description": "Type of media for the header", + "example": "document" + } + } + }, + "buttons": { + "type": "array", + "description": "Button component parameters for interactive templates", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url", + "copy_code" + ], + "description": "Type of button parameter" + }, + "parameter": { + "type": "string", + "description": "Dynamic parameter value for the button", + "example": "SSFSDFSD" + } + } + } + } } } } diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index b4feb0024..215fcd38b 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -3308,28 +3308,96 @@ }, "template_params": { "type": "object", - "description": "The template params for the message in case of whatsapp Channel", + "description": "WhatsApp template parameters for sending structured messages", + "required": [ + "name", + "category", + "language", + "processed_params" + ], "properties": { "name": { "type": "string", - "description": "Name of the template", - "example": "sample_issue_resolution" + "description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)", + "example": "purchase_receipt" }, "category": { "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "SHIPPING_UPDATE", + "TICKET_UPDATE", + "ISSUE_RESOLUTION" + ], "description": "Category of the template", "example": "UTILITY" }, "language": { "type": "string", - "description": "Language of the template", + "description": "Language code of the template (BCP 47 format)", "example": "en_US" }, "processed_params": { "type": "object", - "description": "The processed param values for template variables in template", - "example": { - "1": "Chatwoot" + "description": "Processed template parameters organized by component type", + "properties": { + "body": { + "type": "object", + "description": "Body component parameters with variable placeholders", + "additionalProperties": { + "type": "string" + }, + "example": { + "1": "Visa", + "2": "Nike", + "3": "Bill" + } + }, + "header": { + "type": "object", + "description": "Header component parameters for media templates", + "properties": { + "media_url": { + "type": "string", + "format": "uri", + "description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers", + "example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" + }, + "media_type": { + "type": "string", + "enum": [ + "image", + "video", + "document" + ], + "description": "Type of media for the header", + "example": "document" + } + } + }, + "buttons": { + "type": "array", + "description": "Button component parameters for interactive templates", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url", + "copy_code" + ], + "description": "Type of button parameter" + }, + "parameter": { + "type": "string", + "description": "Dynamic parameter value for the button", + "example": "SSFSDFSD" + } + } + } + } } } } From 48fa7bf72b7b029bb9315f43bd6b9fd0e1872a41 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 12 Aug 2025 22:56:53 +0530 Subject: [PATCH 04/10] fix: Handle nil `processed_params` for WhatsApp templates without params (#12177) WhatsApp templates without parameters (body-only templates like notifications, confirmations) were failing to send with the error: ArgumentError (Unknown legacy format: NilClass). This affected all parameter-less templates across marketing messages, notifications, and utility templates. --- .../template_parameter_converter_service.rb | 3 + spec/factories/channel/channel_whatsapp.rb | 19 ++++++ ...mplate_parameter_converter_service_spec.rb | 62 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/app/services/whatsapp/template_parameter_converter_service.rb b/app/services/whatsapp/template_parameter_converter_service.rb index b9a9d55d9..641a29b95 100644 --- a/app/services/whatsapp/template_parameter_converter_service.rb +++ b/app/services/whatsapp/template_parameter_converter_service.rb @@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService # Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}} body_params = convert_hash_to_body_params(legacy_params) enhanced['body'] = body_params unless body_params.empty? + when NilClass + # Templates without parameters (nil processed_params) + # Return empty enhanced structure else raise ArgumentError, "Unknown legacy format: #{legacy_params.class}" end diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index ad2bab241..dae7eb04f 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -63,6 +63,25 @@ FactoryBot.define do ], 'sub_category' => 'CUSTOM', 'parameter_format' => 'NAMED' + }, + { + 'name' => 'test_no_params_template', + 'status' => 'APPROVED', + 'category' => 'UTILITY', + 'language' => 'en', + 'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9', + 'id' => '9876543210987654', + 'length' => 1, + 'parameter_format' => 'POSITIONAL', + 'previous_category' => 'MARKETING', + 'sub_category' => 'CUSTOM', + 'components' => [ + { + 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂', + 'type' => 'BODY' + } + ], + 'rejected_reason' => 'NONE' }] end message_templates_last_updated { Time.now.utc } diff --git a/spec/services/whatsapp/template_parameter_converter_service_spec.rb b/spec/services/whatsapp/template_parameter_converter_service_spec.rb index 2994bb472..570c5c6cc 100644 --- a/spec/services/whatsapp/template_parameter_converter_service_spec.rb +++ b/spec/services/whatsapp/template_parameter_converter_service_spec.rb @@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do end end + context 'when processed_params is nil (parameter-less templates)' do + let(:nil_params) do + { + 'processed_params' => nil + } + end + + let(:parameterless_template) do + { + 'name' => 'test_no_params_template', + 'language' => 'en', + 'parameter_format' => 'POSITIONAL', + 'id' => '9876543210987654', + 'status' => 'APPROVED', + 'category' => 'UTILITY', + 'previous_category' => 'MARKETING', + 'sub_category' => 'CUSTOM', + 'components' => [ + { + 'type' => 'BODY', + 'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂' + } + ] + } + end + + it 'converts nil to empty enhanced format' do + converter = described_class.new(nil_params, parameterless_template) + result = converter.normalize_to_enhanced + + expect(result['processed_params']).to eq({}) + expect(result['format_version']).to eq('legacy') + end + + it 'does not raise ArgumentError for nil processed_params' do + expect do + converter = described_class.new(nil_params, parameterless_template) + converter.normalize_to_enhanced + end.not_to raise_error + end + end + context 'when invalid format' do let(:invalid_params) do { @@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do end describe 'simplified conversion methods' do + describe '#convert_legacy_to_enhanced' do + it 'handles nil processed_params without raising error' do + converter = described_class.new({}, template) + result = converter.send(:convert_legacy_to_enhanced, nil, template) + expect(result).to eq({}) + end + + it 'returns empty hash for parameter-less templates' do + parameterless_template = { + 'name' => 'no_params_template', + 'language' => 'en', + 'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }] + } + + converter = described_class.new({}, parameterless_template) + result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template) + expect(result).to eq({}) + end + end + describe '#convert_array_to_body_params' do it 'converts empty array' do converter = described_class.new({}, template) From 9a7318a9dbf01b4b9d6da8d691f590a886bf576c Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 13 Aug 2025 07:56:58 +0530 Subject: [PATCH 05/10] fix: cw-5411 handle unrepresentable image attachments (#12178) # Pull Request Template ## Description Fixes https://linear.app/chatwoot/issue/CW-5411/actionviewtemplateerror-activestorageunrepresentableerror ### Problem API endpoints return 500 errors when conversations contain image attachments that can't be processed by ActiveStorage (e.g., files with non-ASCII filenames, corrupted images, or malicious XSS filenames). Root Cause: Commit 6cab74139 removed the representable? safety check from thumb_url, causing `ActiveStorage::UnrepresentableError` to bubble up and crash the API when it encountered a malformed image file. Fix: Rescue `thumb_url` method to catch UnrepresentableError and return an empty string while logging problematic names for future debugging. This ensures the messages/attachments api does not break due to a single corrupted image file. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Added specs ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --- app/models/attachment.rb | 7 ++++++- spec/models/attachment_spec.rb | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 8c5750148..42ca79d6c 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -62,7 +62,12 @@ class Attachment < ApplicationRecord def thumb_url return '' unless file.attached? && image? - url_for(file.representation(resize_to_fill: [250, nil])) + begin + url_for(file.representation(resize_to_fill: [250, nil])) + rescue ActiveStorage::UnrepresentableError => e + Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}" + '' + end end def with_attached_file? diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index 0b03a56ad..cc00eab5d 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -82,6 +82,16 @@ RSpec.describe Attachment do expect(attachment.thumb_url).to be_present end + + it 'handles unrepresentable images gracefully' do + attachment = message.attachments.create!(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg') + + allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent')) + + expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/) + expect(attachment.thumb_url).to eq('') + end end describe 'meta data handling' do From 42af4b1d01399937dabcf0c95cce872bebe3f5b5 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:42:57 +0530 Subject: [PATCH 06/10] fix: Reset inbox on conversation switch in compose conversation modal (#12174) --- .../NewConversation/ComposeConversation.vue | 14 +++++++++----- .../components/ComposeNewConversationForm.vue | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index d3686cfd7..fa9102d59 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -188,14 +188,18 @@ const toggle = () => { watch( activeContact, - () => { - if (activeContact.value && props.contactId) { - const contactInboxes = activeContact.value?.contactInboxes || []; + (currentContact, previousContact) => { + if (currentContact && props.contactId) { + // Reset on contact change + if (currentContact?.id !== previousContact?.id) clearSelectedContact(); + // First process the contactable inboxes to get the right structure - const processedInboxes = processContactableInboxes(contactInboxes); + const processedInboxes = processContactableInboxes( + currentContact.contactInboxes || [] + ); // Then Merge processedInboxes with the inboxes list selectedContact.value = { - ...activeContact.value, + ...currentContact, contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value), }; } diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 5540940cd..e3e063740 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {