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:
@@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
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
|
||||
end
|
||||
|
||||
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -162,6 +166,10 @@ const variant = computed(() => {
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
@@ -424,6 +432,18 @@ function handleReplyTo() {
|
||||
}
|
||||
|
||||
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 (!props.sender) {
|
||||
return {
|
||||
@@ -451,6 +471,9 @@ const avatarInfo = computed(() => {
|
||||
});
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
@@ -484,7 +507,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
: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"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -253,6 +253,8 @@
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"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",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
|
||||
@@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob
|
||||
# Receive real-time notifications if you send a message to a user.
|
||||
def im_send_msg
|
||||
# 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
|
||||
|
||||
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
|
||||
|
||||
@@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
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
|
||||
when 'whatsapp_cloud'
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
|
||||
@@ -344,10 +344,11 @@ class Message < ApplicationRecord
|
||||
# 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 campaign id is present, it's not a human response
|
||||
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
|
||||
outgoing? &&
|
||||
content_attributes['automation_rule_id'].blank? &&
|
||||
additional_attributes['campaign_id'].blank? &&
|
||||
sender.is_a?(User)
|
||||
(sender.is_a?(User) || content_attributes['external_echo'].present?)
|
||||
end
|
||||
|
||||
def bot_response?
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Tiktok::MessageService
|
||||
include Tiktok::MessagingHelpers
|
||||
|
||||
pattr_initialize [:channel!, :content!]
|
||||
pattr_initialize [:channel!, :content!, :outgoing_echo]
|
||||
|
||||
def perform
|
||||
if outgoing_message?
|
||||
# Skip processing echo messages
|
||||
message = find_message(tt_conversation_id, tt_message_id)
|
||||
return if message.present?
|
||||
end
|
||||
@@ -39,7 +38,7 @@ class Tiktok::MessageService
|
||||
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?
|
||||
|
||||
create_message_attachments(message)
|
||||
@@ -91,6 +90,7 @@ class Tiktok::MessageService
|
||||
attributes = {}
|
||||
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
|
||||
attributes[:is_unsupported] = true unless supported_message?
|
||||
attributes[:external_echo] = true if outgoing_echo
|
||||
attributes
|
||||
end
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ class Whatsapp::FacebookApiClient
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes]
|
||||
}.to_json
|
||||
)
|
||||
|
||||
|
||||
@@ -4,18 +4,23 @@
|
||||
class Whatsapp::IncomingMessageBaseService
|
||||
include ::Whatsapp::IncomingMessageServiceHelpers
|
||||
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
pattr_initialize [:inbox!, :params!, :outgoing_echo]
|
||||
|
||||
def perform
|
||||
processed_params
|
||||
|
||||
if processed_params.try(:[], :statuses).present?
|
||||
process_statuses
|
||||
elsif processed_params.try(:[], :messages).present?
|
||||
elsif messages_data.present?
|
||||
process_messages
|
||||
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
|
||||
|
||||
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
|
||||
# business manager account. While we have not found the core reason yet, the following line ensure that
|
||||
# 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
|
||||
set_contact
|
||||
@@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def create_messages
|
||||
message = @processed_params[:messages].first
|
||||
message = messages_data.first
|
||||
log_error(message) && return if error_webhook_event?(message)
|
||||
|
||||
process_in_reply_to(message)
|
||||
@@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService
|
||||
|
||||
def create_contact_messages(message)
|
||||
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)
|
||||
@message.save!
|
||||
end
|
||||
end
|
||||
|
||||
def create_regular_message(message)
|
||||
create_message(message)
|
||||
create_message(message, source_id: message[:id])
|
||||
attach_files
|
||||
attach_location if message_type == 'location'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
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
|
||||
return if contact_params.blank?
|
||||
|
||||
@@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
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
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
def attach_files
|
||||
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]
|
||||
|
||||
attachment_file = download_attachment_file(attachment_payload)
|
||||
@@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
def attach_location
|
||||
location = @processed_params[:messages].first['location']
|
||||
location = messages_data.first['location']
|
||||
location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
@@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService
|
||||
)
|
||||
end
|
||||
|
||||
def create_message(message)
|
||||
def create_message(message, source_id: nil)
|
||||
@message = @conversation.messages.build(
|
||||
content: message_content(message),
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @contact,
|
||||
source_id: message[:id].to_s,
|
||||
message_type: outgoing_echo ? :outgoing : :incoming,
|
||||
# Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them
|
||||
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
|
||||
)
|
||||
end
|
||||
@@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService
|
||||
end
|
||||
|
||||
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
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
def message_type
|
||||
@processed_params[:messages].first[:type]
|
||||
messages_data.first[:type]
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
@@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -164,7 +164,8 @@ describe Whatsapp::FacebookApiClient do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
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: 200,
|
||||
@@ -184,7 +185,8 @@ describe Whatsapp::FacebookApiClient do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
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)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user