feat: Handle external echo messages from native apps (#13371)

When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
This commit is contained in:
Muhsin Keloth
2026-02-02 14:22:53 +04:00
committed by GitHub
parent 133fb1bcf6
commit b686d14044
11 changed files with 141 additions and 28 deletions

View File

@@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: message_type, message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier, source_id: message_identifier,
content: message_content, content: message_content,
sender: @outgoing_echo ? nil : contact, sender: @outgoing_echo ? nil : contact,
@@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
} }
} }
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params params
end end

View File

@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
import { useTimeoutFn } from '@vueuse/core'; import { useTimeoutFn } from '@vueuse/core';
import { provideMessageContext } from './provider.js'; import { provideMessageContext } from './provider.js';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { import {
MESSAGE_TYPES, MESSAGE_TYPES,
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false); const showContextMenu = ref(false);
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
/** /**
* Computes the message variant based on props * Computes the message variant based on props
@@ -162,6 +166,10 @@ const variant = computed(() => {
if (props.contentAttributes?.isUnsupported) if (props.contentAttributes?.isUnsupported)
return MESSAGE_VARIANTS.UNSUPPORTED; return MESSAGE_VARIANTS.UNSUPPORTED;
if (props.contentAttributes?.externalEcho) {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT; const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT; return MESSAGE_VARIANTS.BOT;
@@ -424,6 +432,18 @@ function handleReplyTo() {
} }
const avatarInfo = computed(() => { const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info // If no sender, return bot info
if (!props.sender) { if (!props.sender) {
return { return {
@@ -451,6 +471,9 @@ const avatarInfo = computed(() => {
}); });
const avatarTooltip = computed(() => { const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return t('CONVERSATION.NATIVE_APP_ADVISORY');
}
if (avatarInfo.value.name === '') return ''; if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
}); });
@@ -484,7 +507,7 @@ provideMessageContext({
<div <div
v-if="shouldRenderMessage" v-if="shouldRenderMessage"
:id="`message${props.id}`" :id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container" class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id" :data-message-id="props.id"
:class="[ :class="[
flexOrientationClass, flexOrientationClass,

View File

@@ -253,6 +253,8 @@
"MESSAGE_ERROR": "Unable to send this message, please try again later", "MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:", "SENT_BY": "Sent by:",
"BOT": "Bot", "BOT": "Bot",
"NATIVE_APP": "Native app",
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
"SEND_FAILED": "Couldn't send message! Try again", "SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry", "TRY_AGAIN": "retry",
"ASSIGNMENT": { "ASSIGNMENT": {

View File

@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
# Receive real-time notifications if you send a message to a user. # Receive real-time notifications if you send a message to a user.
def im_send_msg def im_send_msg
# This can be either an echo message or a message sent directly via tiktok application # This can be either an echo message or a message sent directly via tiktok application
::Tiktok::MessageService.new(channel: channel, content: content).perform ::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform
end end
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you. # Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.

View File

@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return return
end end
if message_echo_event?(params)
handle_message_echo(channel, params)
else
handle_message_events(channel, params)
end
end
# Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app)
# This is part of WhatsApp coexistence feature where businesses can respond from both
# Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot.
#
# Regular message payload (field: "messages"):
# {
# "entry": [{
# "changes": [{
# "field": "messages",
# "value": {
# "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }],
# "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }]
# }
# }]
# }]
# }
#
# Echo message payload (field: "smb_message_echoes"):
# {
# "entry": [{
# "changes": [{
# "field": "smb_message_echoes",
# "value": {
# "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }]
# }
# }]
# }]
# }
#
# Key differences:
# - field: "smb_message_echoes" instead of "messages"
# - message_echoes[] instead of messages[]
# - "from" is the business number, "to" is the contact (reversed from regular messages)
# - No "contacts" array in echo payload
def message_echo_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
end
def handle_message_echo(channel, params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
end
def handle_message_events(channel, params)
case channel.provider case channel.provider
when 'whatsapp_cloud' when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform

View File

@@ -344,10 +344,11 @@ class Message < ApplicationRecord
# if the sender is not a user, it's not a human response # if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response # if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response # if campaign id is present, it's not a human response
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
outgoing? && outgoing? &&
content_attributes['automation_rule_id'].blank? && content_attributes['automation_rule_id'].blank? &&
additional_attributes['campaign_id'].blank? && additional_attributes['campaign_id'].blank? &&
sender.is_a?(User) (sender.is_a?(User) || content_attributes['external_echo'].present?)
end end
def bot_response? def bot_response?

View File

@@ -1,11 +1,10 @@
class Tiktok::MessageService class Tiktok::MessageService
include Tiktok::MessagingHelpers include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!] pattr_initialize [:channel!, :content!, :outgoing_echo]
def perform def perform
if outgoing_message? if outgoing_message?
# Skip processing echo messages
message = find_message(tt_conversation_id, tt_message_id) message = find_message(tt_conversation_id, tt_message_id)
return if message.present? return if message.present?
end end
@@ -39,7 +38,7 @@ class Tiktok::MessageService
updated_at: tt_message_time updated_at: tt_message_time
) )
message.sender = contact_inbox.contact if incoming_message? message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message? message.status = :delivered if outgoing_message?
create_message_attachments(message) create_message_attachments(message)
@@ -91,6 +90,7 @@ class Tiktok::MessageService
attributes = {} attributes = {}
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
attributes[:is_unsupported] = true unless supported_message? attributes[:is_unsupported] = true unless supported_message?
attributes[:external_echo] = true if outgoing_echo
attributes attributes
end end

View File

@@ -66,7 +66,8 @@ class Whatsapp::FacebookApiClient
headers: request_headers, headers: request_headers,
body: { body: {
override_callback_uri: callback_url, override_callback_uri: callback_url,
verify_token: verify_token verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes]
}.to_json }.to_json
) )

View File

@@ -4,18 +4,23 @@
class Whatsapp::IncomingMessageBaseService class Whatsapp::IncomingMessageBaseService
include ::Whatsapp::IncomingMessageServiceHelpers include ::Whatsapp::IncomingMessageServiceHelpers
pattr_initialize [:inbox!, :params!] pattr_initialize [:inbox!, :params!, :outgoing_echo]
def perform def perform
processed_params processed_params
if processed_params.try(:[], :statuses).present? if processed_params.try(:[], :statuses).present?
process_statuses process_statuses
elsif processed_params.try(:[], :messages).present? elsif messages_data.present?
process_messages process_messages
end end
end end
# Returns messages array for both regular messages and echo events
def messages_data
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
end
private private
def process_messages def process_messages
@@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService
# Multiple webhook event can be received against the same message due to misconfigurations in the Meta # Multiple webhook event can be received against the same message due to misconfigurations in the Meta
# business manager account. While we have not found the core reason yet, the following line ensure that # business manager account. While we have not found the core reason yet, the following line ensure that
# there are no duplicate messages created. # there are no duplicate messages created.
return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process? return if find_message_by_source_id(messages_data.first[:id]) || message_under_process?
cache_message_source_id_in_redis cache_message_source_id_in_redis
set_contact set_contact
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
end end
def create_messages def create_messages
message = @processed_params[:messages].first message = messages_data.first
log_error(message) && return if error_webhook_event?(message) log_error(message) && return if error_webhook_event?(message)
process_in_reply_to(message) process_in_reply_to(message)
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message) def create_contact_messages(message)
message['contacts'].each do |contact| message['contacts'].each do |contact|
create_message(contact) # Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
attach_contact(contact) attach_contact(contact)
@message.save! @message.save!
end end
end end
def create_regular_message(message) def create_regular_message(message)
create_message(message) create_message(message, source_id: message[:id])
attach_files attach_files
attach_location if message_type == 'location' attach_location if message_type == 'location'
@message.save! @message.save!
end end
def set_contact def set_contact
if outgoing_echo
set_contact_from_echo
else
set_contact_from_message
end
end
def set_contact_from_echo
# For echo messages, contact phone is in the 'to' field
phone_number = messages_data.first[:to]
waid = processed_waid(phone_number)
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid,
inbox: inbox,
contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" }
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def set_contact_from_message
contact_params = @processed_params[:contacts]&.first contact_params = @processed_params[:contacts]&.first
return if contact_params.blank? return if contact_params.blank?
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
contact_inbox = ::ContactInboxWithContactBuilder.new( contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: waid, source_id: waid,
inbox: inbox, inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" } contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" }
).perform ).perform
@contact_inbox = contact_inbox @contact_inbox = contact_inbox
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_files def attach_files
return if %w[text button interactive location contacts].include?(message_type) return if %w[text button interactive location contacts].include?(message_type)
attachment_payload = @processed_params[:messages].first[message_type.to_sym] attachment_payload = messages_data.first[message_type.to_sym]
@message.content ||= attachment_payload[:caption] @message.content ||= attachment_payload[:caption]
attachment_file = download_attachment_file(attachment_payload) attachment_file = download_attachment_file(attachment_payload)
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
end end
def attach_location def attach_location
location = @processed_params[:messages].first['location'] location = messages_data.first['location']
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : '' location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
@message.attachments.new( @message.attachments.new(
account_id: @message.account_id, account_id: @message.account_id,
@@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService
) )
end end
def create_message(message) def create_message(message, source_id: nil)
@message = @conversation.messages.build( @message = @conversation.messages.build(
content: message_content(message), content: message_content(message),
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
message_type: :incoming, message_type: outgoing_echo ? :outgoing : :incoming,
sender: @contact, # Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
source_id: message[:id].to_s, status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: outgoing_echo ? { external_echo: true } : {},
in_reply_to_external_id: @in_reply_to_external_id in_reply_to_external_id: @in_reply_to_external_id
) )
end end
@@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService
end end
def contact_name_matches_phone_number? def contact_name_matches_phone_number?
phone_number = "+#{@processed_params[:messages].first[:from]}" phone_number = "+#{messages_data.first[:from]}"
formatted_phone_number = TelephoneNumber.parse(phone_number).international_number formatted_phone_number = TelephoneNumber.parse(phone_number).international_number
@contact.name == phone_number || @contact.name == formatted_phone_number @contact.name == phone_number || @contact.name == formatted_phone_number
end end

View File

@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
end end
def message_type def message_type
@processed_params[:messages].first[:type] messages_data.first[:type]
end end
def message_content(message) def message_content(message)
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
end end
def message_under_process? def message_under_process?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
Redis::Alfred.get(key) Redis::Alfred.get(key)
end end
def cache_message_source_id_in_redis def cache_message_source_id_in_redis
return if @processed_params.try(:[], :messages).blank? return if messages_data.blank?
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.setex(key, true) ::Redis::Alfred.setex(key, true)
end end
def clear_message_source_id_from_redis def clear_message_source_id_from_redis
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id])
::Redis::Alfred.delete(key) ::Redis::Alfred.delete(key)
end end
end end

View File

@@ -164,7 +164,8 @@ describe Whatsapp::FacebookApiClient do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with( .with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
) )
.to_return( .to_return(
status: 200, status: 200,
@@ -184,7 +185,8 @@ describe Whatsapp::FacebookApiClient do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with( .with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
) )
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
end end