From 56e30102ebc3b97e4eb576868fdec5eab2d13886 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 25 May 2026 16:43:59 +0400 Subject: [PATCH 1/7] fix(whatsapp): store and surface unavailable coexistence messages (CW-7166) (#14547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In WhatsApp coexistence setups (Business App + Cloud API on the same number), some inbound customer messages arrive from Meta as `type: unsupported` with error `131060` ("This message is unavailable") and no content — typically the first message of a Click-to-WhatsApp / Instagram-ad conversation, or a message synced from a companion device. Chatwoot was dropping these webhooks entirely, so no contact, conversation, or message was created. The conversation only surfaced once an agent replied (via an `smb_message_echoes` event), starting "headless" with zero customer context. This change persists a placeholder message for these events so the contact and conversation are created, and renders it with the dedicated unsupported-message bubble that points agents to the WhatsApp app — where the original message is still visible. Fixes https://linear.app/chatwoot/issue/CW-7166/whatsapp-coexistence-inbound-messages-are-silently-dropped and https://github.com/chatwoot/chatwoot/issues/13464 CleanShot 2026-05-22 at 17 49
35@2x ## How to reproduce 1. Connect a WhatsApp Cloud (coexistence) inbox. 2. Receive an inbound message that Meta delivers as `type: unsupported` with error `131060` (e.g. a Click-to-WhatsApp ad message, or a message handled on a companion/primary device that fails to sync to the API). 3. **Before:** nothing is created — the conversation only appears after an agent replies, with no record of the customer's first message. 4. **After:** the contact and conversation are created with an incoming placeholder message rendered as the amber "unsupported" bubble: _"This message is unsupported. You can view this message on the WhatsApp app." --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Sojan Jose --- .../message/bubbles/Unsupported.vue | 11 ++++++++--- .../dashboard/i18n/locale/en/conversation.json | 1 + .../whatsapp/incoming_message_base_service.rb | 14 ++++++++++++++ .../whatsapp/incoming_message_service_helpers.rb | 2 +- config/locales/en.yml | 1 + .../whatsapp/incoming_message_service_spec.rb | 11 +++++++---- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue index be67f85ca..5f6544738 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Unsupported.vue @@ -6,9 +6,12 @@ import BaseBubble from './Base.vue'; const { inboxId } = useMessageContext(); -const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox( - inboxId.value -); +const { + isAFacebookInbox, + isAnInstagramChannel, + isATiktokChannel, + isAWhatsAppChannel, +} = useInbox(inboxId.value); const unsupportedMessageKey = computed(() => { if (isAFacebookInbox.value) @@ -16,6 +19,8 @@ const unsupportedMessageKey = computed(() => { if (isAnInstagramChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM'; if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK'; + if (isAWhatsAppChannel.value) + return 'CONVERSATION.UNSUPPORTED_MESSAGE_WHATSAPP'; return 'CONVERSATION.UNSUPPORTED_MESSAGE'; }); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index c7017608d..f0ff6811a 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -62,6 +62,7 @@ "UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.", "UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.", "UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.", + "UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.", "SUCCESS_DELETE_MESSAGE": "Message deleted successfully", "FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again", "NO_RESPONSE": "No response", diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 82aa7ab18..722ac3e4d 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -67,6 +67,8 @@ class Whatsapp::IncomingMessageBaseService def create_messages message = messages_data.first + return create_unsupported_message(message) if message_type == 'unsupported' + log_error(message) && return if error_webhook_event?(message) process_in_reply_to(message) @@ -74,6 +76,18 @@ class Whatsapp::IncomingMessageBaseService message_type == 'contacts' ? create_contact_messages(message) : create_regular_message(message) end + # WhatsApp delivers messages it cannot render (e.g. coexistence companion-device syncs that + # fail with error 131060) as type: unsupported with no content. We still persist a placeholder + # so the contact/conversation isn't created "headless" and agents know to check the WhatsApp app. + def create_unsupported_message(message) + log_error(message) if error_webhook_event?(message) + process_in_reply_to(message) + create_message(message, source_id: message[:id]) + @message.content = I18n.t('conversations.messages.whatsapp.unsupported_message') + @message.content_attributes = @message.content_attributes.merge(is_unsupported: true) + @message.save! + end + def create_contact_messages(message) message['contacts'].each do |contact| # Pass source_id from parent message since contact objects don't have :id diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index 8ec884268..27a854479 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -44,7 +44,7 @@ module Whatsapp::IncomingMessageServiceHelpers end def unprocessable_message_type?(message_type) - %w[reaction ephemeral unsupported request_welcome].include?(message_type) + %w[reaction ephemeral request_welcome].include?(message_type) end def processed_waid(waid) diff --git a/config/locales/en.yml b/config/locales/en.yml index 18b721caf..b11a4fdae 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -258,6 +258,7 @@ en: whatsapp: list_button_label: 'Choose an item' call_permission_request_body: 'We would like to call you regarding your conversation.' + unsupported_message: 'This message is unavailable.' voice_call: twilio: 'Voice Call' whatsapp: 'WhatsApp Call' diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb index dbaea621c..fe6b179c1 100644 --- a/spec/services/whatsapp/incoming_message_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -206,7 +206,7 @@ describe Whatsapp::IncomingMessageService do expect(whatsapp_channel.inbox.messages.count).to eq(0) end - it 'ignores type unsupported and does not create ghost conversation' do + it 'stores type unsupported as a placeholder message so the conversation is not headless' do params = { 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], 'messages' => [{ @@ -217,9 +217,12 @@ describe Whatsapp::IncomingMessageService do }.with_indifferent_access described_class.new(inbox: whatsapp_channel.inbox, params: params).perform - expect(whatsapp_channel.inbox.conversations.count).to eq(0) - expect(Contact.count).to eq(0) - expect(whatsapp_channel.inbox.messages.count).to eq(0) + expect(whatsapp_channel.inbox.conversations.count).to eq(1) + expect(Contact.count).to eq(1) + expect(whatsapp_channel.inbox.messages.count).to eq(1) + message = whatsapp_channel.inbox.messages.last + expect(message.content).to eq('This message is unavailable.') + expect(message.content_attributes['is_unsupported']).to be(true) end end From 75c2f910191f5a9ce49eb1d72d4d3bec9d78fd85 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 25 May 2026 17:12:55 +0400 Subject: [PATCH 2/7] chore: Enable Tiktok on paid plans Automatically (#13628) This PR add the ability enable Tiktok integration on all paid plans. --- .../enterprise/billing/reconcile_plan_features_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index 932cee661..d543adad0 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -11,6 +11,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService channel_facebook channel_email channel_instagram + channel_tiktok captain_integration advanced_search_indexing advanced_search From 37c8e7e6997f02e3dc76af4c92477bfe83b3fea6 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 26 May 2026 14:07:07 +0530 Subject: [PATCH 3/7] fix: firecrawl long external link (#14566) # Pull Request Template ## Description Fixes urls going past 255 chars, this is because of arabic urls, where each character balloons to 8-9 characters and goes past the 255 limit ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. 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 - [ ] 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 --- ...nge_captain_document_external_link_to_text.rb | 16 ++++++++++++++++ db/schema.rb | 6 +++--- enterprise/app/models/captain/document.rb | 14 +++++++------- .../captain/tools/firecrawl_parser_job_spec.rb | 10 ++++++++++ 4 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20260525093000_change_captain_document_external_link_to_text.rb diff --git a/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb new file mode 100644 index 000000000..50cee2b0d --- /dev/null +++ b/db/migrate/20260525093000_change_captain_document_external_link_to_text.rb @@ -0,0 +1,16 @@ +class ChangeCaptainDocumentExternalLinkToText < ActiveRecord::Migration[7.0] + OLD_INDEX_NAME = 'index_captain_documents_on_assistant_id_and_external_link'.freeze + NEW_INDEX_NAME = 'idx_captain_documents_on_assistant_id_and_external_link_md5'.freeze + + def up + remove_index :captain_documents, name: OLD_INDEX_NAME, if_exists: true + change_column :captain_documents, :external_link, :text, null: false + add_index :captain_documents, 'assistant_id, md5(external_link)', unique: true, name: NEW_INDEX_NAME, if_not_exists: true + end + + def down + remove_index :captain_documents, name: NEW_INDEX_NAME, if_exists: true + change_column :captain_documents, :external_link, :string, null: false + add_index :captain_documents, [:assistant_id, :external_link], unique: true, name: OLD_INDEX_NAME, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..f2c479571 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -370,7 +370,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do create_table "captain_documents", force: :cascade do |t| t.string "name" - t.string "external_link", null: false + t.text "external_link", null: false t.text "content" t.bigint "assistant_id", null: false t.bigint "account_id", null: false @@ -381,10 +381,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.integer "sync_status" t.datetime "last_synced_at" t.datetime "last_sync_attempted_at" + t.index "assistant_id, md5(external_link)", name: "idx_captain_documents_on_assistant_id_and_external_link_md5", unique: true t.index ["account_id", "assistant_id", "sync_status", "last_synced_at"], name: "idx_captain_documents_on_account_assistant_sync_stats" t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status" t.index ["account_id"], name: "index_captain_documents_on_account_id" - t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id" t.index ["status"], name: "index_captain_documents_on_status" end diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb index 0fe14813b..6eda0eb5a 100644 --- a/enterprise/app/models/captain/document.rb +++ b/enterprise/app/models/captain/document.rb @@ -5,7 +5,7 @@ # id :bigint not null, primary key # content :text # content_fingerprint :string -# external_link :string not null +# external_link :text not null # last_sync_attempted_at :datetime # last_sync_error_code :string # last_synced_at :datetime @@ -20,12 +20,12 @@ # # Indexes # -# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at) -# index_captain_documents_on_account_id (account_id) -# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status) -# index_captain_documents_on_assistant_id (assistant_id) -# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE -# index_captain_documents_on_status (status) +# idx_captain_documents_on_account_assistant_sync_stats (account_id,assistant_id,sync_status,last_synced_at) +# idx_captain_documents_on_assistant_id_and_external_link_md5 (assistant_id, md5(external_link)) UNIQUE +# index_captain_documents_on_account_id (account_id) +# index_captain_documents_on_account_id_and_sync_status (account_id,sync_status) +# index_captain_documents_on_assistant_id (assistant_id) +# index_captain_documents_on_status (status) # class Captain::Document < ApplicationRecord class LimitExceededError < StandardError; end diff --git a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb index e12efc54b..6aed60385 100644 --- a/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb +++ b/spec/enterprise/jobs/captain/tools/firecrawl_parser_job_spec.rb @@ -61,6 +61,16 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do end end + it 'stores external links longer than 255 characters' do + long_url = "https://example.com/#{'arabic-product-slug-' * 300}" + payload[:metadata]['url'] = long_url + + described_class.perform_now(assistant_id: assistant.id, payload: payload) + + expect(assistant.documents.last.external_link).to eq(long_url) + expect(assistant.documents.last.external_link.length).to be > 255 + end + context 'when an error occurs' do it 'raises an error with a descriptive message' do allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound) From b981ba766f2416f5fc30762b2270f5655cf50924 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 26 May 2026 15:23:51 +0530 Subject: [PATCH 4/7] feat: support bulk label removal (#14534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bulk label removal alongside the existing assign-label action for conversations and contacts, so teams can clean up labels across selected records without opening each item individually. For conversations, the remove dropdown is scoped to labels that are actually applied across the current selection — so agents no longer see (or accidentally "remove") labels that aren't on any of the selected items. For contacts, the dropdown still lists all account labels for now; label data isn't carried on the contact list payload today, so scoping the contact remove menu cleanly is being tracked as a follow-up. ## Closes N/A ## How to test - Open the conversation list, select multiple conversations, open **Remove labels**, and confirm the dropdown only lists labels that are applied to at least one selected conversation. Pick a label and confirm it's removed from the selection. - Open Contacts, select multiple contacts, use **Remove Labels**, choose a label, and confirm the selected contacts are refreshed without that label. - Verify **Assign Labels** still works for conversations and contacts, and continues to show every available label. ## What changed - Adds an `action` prop to the shared `BulkLabelActions` dropdown so it can render in `assign` or `remove` mode. - Wires conversation bulk remove to the existing `labels.remove` backend path and filters the dropdown to the union of labels applied across the selected conversations. - Adds contact bulk remove support through `Contacts::BulkRemoveLabelsService`, routed by `Contacts::BulkActionService`. - Raises contact label save failures instead of reporting a successful bulk action when a contact update is invalid. ## Follow-ups - Scope the contact remove dropdown to applied labels (needs a lightweight endpoint, or eventually `cached_label_list` on `Contact`). ## Verification Conversation bulk remove selector: Conversation bulk remove label
selector Contact bulk remove selector: Contact bulk remove label selector Video proof: https://github.com/user-attachments/assets/fffafe19-4e1c-4e2a-a135-c7182c06bb4d --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../BulkLabelActions.vue | 59 +++++++++++++++---- .../conversationBulkActions/Index.vue | 19 ++++++ .../composables/chatlist/useBulkActions.js | 26 +++++--- .../dashboard/i18n/locale/en/bulkActions.json | 6 +- .../dashboard/i18n/locale/en/contact.json | 3 + .../components/ContactsBulkActionBar.vue | 12 ++++ .../contacts/pages/ContactsIndex.vue | 23 ++++++++ app/services/contacts/bulk_action_service.rb | 13 ++++ .../contacts/bulk_remove_labels_service.rb | 19 ++++++ .../accounts/bulk_actions_controller_spec.rb | 25 ++++++++ .../contacts/bulk_action_service_spec.rb | 14 +++++ .../bulk_remove_labels_service_spec.rb | 54 +++++++++++++++++ theme/icons.js | 5 ++ 13 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 app/services/contacts/bulk_remove_labels_service.rb create mode 100644 spec/services/contacts/bulk_remove_labels_service_spec.rb diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue index bbbf30090..e46f45da5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue @@ -14,6 +14,11 @@ const props = defineProps({ type: String, default: 'conversation', }, + action: { + type: String, + default: 'assign', + validator: value => ['assign', 'remove'].includes(value), + }, isLoading: { type: Boolean, default: false, @@ -22,9 +27,13 @@ const props = defineProps({ type: Boolean, default: false, }, + appliedLabels: { + type: Array, + default: null, + }, }); -const emit = defineEmits(['assign']); +const emit = defineEmits(['assign', 'remove']); const { t } = useI18n(); @@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false); const selectedLabels = ref([]); const isTypeContact = computed(() => props.type === 'contact'); +const isRemoveAction = computed(() => props.action === 'remove'); -const buttonLabel = computed(() => - props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : '' +const buttonLabel = computed(() => { + if (!isTypeContact.value) return ''; + + return isRemoveAction.value + ? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS') + : t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS'); +}); + +const tooltipLabel = computed(() => + isRemoveAction.value + ? t('BULK_ACTION.LABELS.REMOVE_LABELS') + : t('BULK_ACTION.LABELS.ASSIGN_LABELS') +); + +const confirmLabel = computed(() => + isRemoveAction.value + ? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS') + : t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS') ); const isLabelSelected = labelTitle => { return selectedLabels.value.includes(labelTitle); }; +const visibleLabels = computed(() => { + if (!isRemoveAction.value || props.appliedLabels === null) { + return labels.value; + } + + const applied = new Set(props.appliedLabels); + return labels.value.filter(label => applied.has(label.title)); +}); + const labelMenuItems = computed(() => { - return labels.value.map(label => ({ + return visibleLabels.value.map(label => ({ action: 'select', value: label.title, label: label.title, @@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => { } }; -const handleAssign = () => { +const handleApply = () => { if (selectedLabels.value.length > 0) { - emit('assign', selectedLabels.value); + if (isRemoveAction.value) { + emit('remove', selectedLabels.value); + } else { + emit('assign', selectedLabels.value); + } toggleDropdown(false); selectedLabels.value = []; } @@ -81,9 +120,9 @@ const handleDismiss = () => { diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue index 145e89bde..eef70cf02 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue @@ -1,5 +1,6 @@