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
175 lines
3.7 KiB
Ruby
175 lines
3.7 KiB
Ruby
class Tiktok::MessageService
|
|
include Tiktok::MessagingHelpers
|
|
|
|
pattr_initialize [:channel!, :content!, :outgoing_echo]
|
|
|
|
def perform
|
|
if outgoing_message?
|
|
message = find_message(tt_conversation_id, tt_message_id)
|
|
return if message.present?
|
|
end
|
|
|
|
create_message
|
|
end
|
|
|
|
private
|
|
|
|
def contact_inbox
|
|
@contact_inbox ||= create_contact_inbox(channel, tt_conversation_id, incoming_message? ? from : to, incoming_message? ? from_id : to_id)
|
|
end
|
|
|
|
def contact
|
|
contact_inbox.contact
|
|
end
|
|
|
|
def conversation
|
|
@conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id)
|
|
end
|
|
|
|
def create_message
|
|
message = conversation.messages.build(
|
|
content: message_content,
|
|
account_id: channel.inbox.account_id,
|
|
inbox_id: channel.inbox.id,
|
|
message_type: incoming_message? ? :incoming : :outgoing,
|
|
content_attributes: message_content_attributes,
|
|
source_id: tt_message_id,
|
|
created_at: tt_message_time,
|
|
updated_at: tt_message_time
|
|
)
|
|
|
|
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
|
|
message.status = :delivered if outgoing_message?
|
|
|
|
create_message_attachments(message)
|
|
message.save!
|
|
end
|
|
|
|
def message_content
|
|
return unless text_message?
|
|
|
|
tt_text_body
|
|
end
|
|
|
|
def create_message_attachments(message)
|
|
create_image_message_attachment(message) if image_message?
|
|
create_share_post_message_attachment(message) if share_post_message?
|
|
end
|
|
|
|
def create_image_message_attachment(message)
|
|
return unless image_message?
|
|
|
|
attachment_file = fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
|
|
|
|
message.attachments.new(
|
|
account_id: message.account_id,
|
|
file_type: :image,
|
|
file: {
|
|
io: attachment_file,
|
|
filename: attachment_file.original_filename,
|
|
content_type: attachment_file.content_type
|
|
}
|
|
)
|
|
end
|
|
|
|
def create_share_post_message_attachment(message)
|
|
return unless share_post_message?
|
|
|
|
message.attachments.new(
|
|
account_id: message.account_id,
|
|
file_type: :embed,
|
|
external_url: tt_share_post_embed_url
|
|
)
|
|
end
|
|
|
|
def supported_message?
|
|
text_message? || image_message? || share_post_message?
|
|
end
|
|
|
|
def message_content_attributes
|
|
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
|
|
|
|
def text_message?
|
|
tt_message_type == 'text'
|
|
end
|
|
|
|
def image_message?
|
|
tt_message_type == 'image'
|
|
end
|
|
|
|
def sticker_message?
|
|
tt_message_type == 'sticker'
|
|
end
|
|
|
|
def share_post_message?
|
|
tt_message_type == 'share_post'
|
|
end
|
|
|
|
def tt_text_body
|
|
return unless text_message?
|
|
|
|
content[:text][:body]
|
|
end
|
|
|
|
def tt_image_media_id
|
|
return unless image_message?
|
|
|
|
content[:image][:media_id]
|
|
end
|
|
|
|
def tt_share_post_embed_url
|
|
return unless share_post_message?
|
|
|
|
content[:share_post][:embed_url]
|
|
end
|
|
|
|
def tt_referenced_message_id
|
|
content[:referenced_message_info]&.[](:referenced_message_id)
|
|
end
|
|
|
|
def tt_message_type
|
|
content[:type]
|
|
end
|
|
|
|
def tt_message_id
|
|
content[:message_id]
|
|
end
|
|
|
|
def tt_message_time
|
|
Time.zone.at(content[:timestamp] / 1000).utc
|
|
end
|
|
|
|
def tt_conversation_id
|
|
content[:conversation_id]
|
|
end
|
|
|
|
def from
|
|
content[:from]
|
|
end
|
|
|
|
def from_id
|
|
content[:from_user][:id]
|
|
end
|
|
|
|
def to
|
|
content[:to]
|
|
end
|
|
|
|
def to_id
|
|
content[:to_user][:id]
|
|
end
|
|
|
|
def incoming_message?
|
|
channel.business_id == to_id
|
|
end
|
|
|
|
def outgoing_message?
|
|
!incoming_message?
|
|
end
|
|
end
|