Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,157 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo: false)
super()
@response = response
@inbox = inbox
@outgoing_echo = outgoing_echo
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (@outgoing_echo ? :outgoing : :incoming)
@attachments = (@response.attachments || [])
end
def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact_inbox
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end
private
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @sender_id,
inbox: @inbox,
contact_attributes: contact_params
).perform
end
def build_message
@message = conversation.messages.create!(message_params)
@attachments.each do |attachment|
process_attachment(attachment)
end
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def build_conversation
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
{
external_url: attachment['url'],
coordinates_lat: lat,
coordinates_long: long,
fallback_title: attachment['title']
}
end
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact_inbox.contact_id
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
def process_contact_params_result(result)
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
}
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages
if e.message.include?('2018218')
Rails.logger.warn e
else
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
end
rescue StandardError => e
result = {}
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end
process_contact_params_result(result)
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end

View File

@@ -0,0 +1,201 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
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,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present?
end
def find_message_by_source_id(source_id)
return unless source_id
@message = Message.find_by(source_id: source_id)
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end

View File

@@ -0,0 +1,42 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
end
end

View File

@@ -0,0 +1,33 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end

View File

@@ -0,0 +1,227 @@
class Messages::MessageBuilder
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
@params = params
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
end
def perform
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save!
@message
end
private
# Extracts content attributes from the given params.
# - Converts ActionController::Parameters to a regular hash if needed.
# - Attempts to parse a JSON string if content is a string.
# - Returns an empty hash if content is not present, if there's a parsing error, or if it's an unexpected type.
def content_attributes
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
def process_attachments
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.build(
account_id: @message.account_id,
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = process_email_string(@params[:cc_emails])
bcc_emails = process_email_string(@params[:bcc_emails])
to_emails = process_email_string(@params[:to_emails])
all_email_addresses = cc_emails + bcc_emails + to_emails
validate_email_addresses(all_email_addresses)
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
@message.content_attributes[:to_emails] = to_emails
end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string)
return [] if email_string.blank?
email_string.gsub(/\s+/, '').split(',')
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
end
@message_type
end
def sender
message_type == 'outgoing' ? (message_sender || @user) : @conversation.contact
end
def external_created_at
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end
def automation_rule_id
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
end
def campaign_id
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
end
def template_params
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end
def message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: message_type,
content: @params[:content],
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(processed_content)
end
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')

View File

@@ -0,0 +1,106 @@
class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: url,
remote_file_url: url
}
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
attachment.save!
end
def fetch_story_link(attachment)
message = attachment.message
result = get_story_object_from_source_id(message.source_id)
return if result.blank?
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end