- Cria modelo LeadClick para registrar cliques das landing pages - Cria modelo LandingHost para mapear hostname → inbox_id - Endpoint público POST /track/click para receber eventos de clique - Leads::AttributionMatcherService para correlacionar clique com conversa - Integração com IncomingMessageWuzapiService para atribuição automática - API REST para gerenciar LandingHosts por inbox (index/create/destroy) - UI: nova aba 'Landing Pages' nas configurações da caixa de entrada - Dashboard API client dedicado (landingHosts.js) - RuboCop: refatora shift_signature_name, TrackingController, AttributionMatcherService e WuzapiService
207 lines
6.8 KiB
Ruby
207 lines
6.8 KiB
Ruby
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
|
|
include Whatsapp::Wuzapi::PayloadParserExtension
|
|
|
|
def perform
|
|
@parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
|
return if ignore_message?
|
|
|
|
@clean_source_id = "WAID:#{@parser.external_id}"
|
|
ActiveRecord::Base.transaction do
|
|
return if duplicate?
|
|
|
|
process_incoming_payload
|
|
end
|
|
rescue StandardError => e
|
|
log_error(e)
|
|
raise e
|
|
end
|
|
|
|
private
|
|
|
|
def ignore_message?
|
|
return true if @parser.message_type == :chat_presence || @parser.message_type == :error || @parser.message_type == :ignore
|
|
return true unless [:text, :image, :audio, :video, :document, :sticker].include?(@parser.message_type)
|
|
return true if @parser.group_message?
|
|
return true if @parser.sender_phone_number.blank? && !@parser.from_me?
|
|
|
|
false
|
|
end
|
|
|
|
def duplicate?
|
|
return false if @parser.external_id.blank?
|
|
return false unless Message.exists?(source_id: @clean_source_id, inbox_id: inbox.id)
|
|
|
|
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{@clean_source_id})"
|
|
true
|
|
end
|
|
|
|
def process_incoming_payload
|
|
@contact = find_or_create_contact
|
|
return if @contact.nil?
|
|
|
|
@conversation = find_or_create_conversation
|
|
|
|
# Processar Match de Leads (Fingerprinting) caso a mensagem venha do Lead
|
|
Leads::AttributionMatcherService.new(@conversation).perform unless @parser.from_me?
|
|
|
|
return if @parser.from_me? && handle_echo_message
|
|
|
|
create_new_message
|
|
end
|
|
|
|
def handle_echo_message
|
|
deduplicated_message = find_outgoing_message_to_deduplicate(@parser, @conversation)
|
|
return false unless deduplicated_message
|
|
|
|
Rails.logger.info "WuzAPI: Merging echo into existing message #{deduplicated_message.id}"
|
|
deduplicated_message.update!(source_id: @clean_source_id)
|
|
true
|
|
end
|
|
|
|
def create_new_message
|
|
@message = build_message(@parser, @conversation, @clean_source_id)
|
|
attach_files if [:image, :audio, :video, :document, :sticker].include?(@parser.message_type)
|
|
@message.save!
|
|
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{@clean_source_id})"
|
|
end
|
|
|
|
def log_error(error)
|
|
Rails.logger.error "WuzAPI Error: #{error.message}"
|
|
Rails.logger.error error.backtrace.join("\n")
|
|
end
|
|
|
|
def find_or_create_contact
|
|
phone_number = @parser.from_me? ? @parser.recipient_phone_number : @parser.sender_phone_number
|
|
return nil if phone_number.blank?
|
|
|
|
contact_inbox = ContactInbox.find_by(inbox_id: inbox.id, source_id: phone_number)
|
|
return contact_inbox.contact if contact_inbox
|
|
|
|
contact = find_existing_contact(phone_number)
|
|
contact ||= create_contact(phone_number)
|
|
|
|
create_contact_inbox(contact, phone_number)
|
|
contact
|
|
end
|
|
|
|
def find_existing_contact(phone_number)
|
|
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
|
inbox.account.contacts.find_by(phone_number: formatted_phone)
|
|
end
|
|
|
|
def create_contact(phone_number)
|
|
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
|
inbox.account.contacts.create!(
|
|
name: @parser.sender_name || phone_number,
|
|
phone_number: formatted_phone,
|
|
custom_attributes: { wuzapi_id: phone_number }
|
|
)
|
|
end
|
|
|
|
def create_contact_inbox(contact, phone_number)
|
|
ContactInbox.create!(contact: contact, inbox: inbox, source_id: phone_number)
|
|
end
|
|
|
|
def find_or_create_conversation
|
|
conversation = inbox.conversations.where(contact_id: @contact.id)
|
|
.where.not(status: :resolved)
|
|
.order(updated_at: :desc).first
|
|
|
|
return conversation if conversation
|
|
|
|
contact_inbox = ContactInbox.find_by(contact_id: @contact.id, inbox_id: inbox.id)
|
|
inbox.conversations.create!(
|
|
contact: @contact,
|
|
contact_inbox: contact_inbox,
|
|
status: :open,
|
|
account_id: inbox.account_id,
|
|
additional_attributes: conversation_attributes
|
|
)
|
|
end
|
|
|
|
def conversation_attributes
|
|
referral = @parser.referral_info
|
|
return {} if referral.blank?
|
|
|
|
{
|
|
'referer' => referral[:source_url].presence || 'meta_ads',
|
|
'source_type' => referral[:source_type],
|
|
'ctwa_clid' => referral[:ctwa_clid]
|
|
}.compact
|
|
end
|
|
|
|
def find_outgoing_message_to_deduplicate(parser, conversation)
|
|
# We are looking for a message that:
|
|
# 1. Is Outgoing (message_type: 1)
|
|
# 2. Was created recently (e.g., last 2 minutes)
|
|
# 3. Has NO source_id (it was created locally by AI/Agent without external ref yet)
|
|
# 4. Has the SAME content as the webhook payload
|
|
#
|
|
# Note: Text matching can be fuzzy due to encoding/whitespace.
|
|
# We compare stripped content.
|
|
|
|
incoming_content = parser.text_content&.strip
|
|
return nil if incoming_content.blank?
|
|
|
|
# Time window to search back
|
|
time_window = 5.minutes.ago
|
|
|
|
conversation.messages.where(message_type: :outgoing, source_id: nil)
|
|
.where('created_at > ?', time_window)
|
|
.find { |msg| msg.content&.strip == incoming_content }
|
|
end
|
|
|
|
def build_message(parser, conversation, clean_source_id)
|
|
is_outgoing = parser.from_me?
|
|
content = parser.text_content
|
|
inbox_obj = inbox
|
|
|
|
# Se a mensagem vier do celular (outgoing) e a assinatura estiver ativa,
|
|
# e o conteúdo não parecer já ter uma assinatura (evita duplicar em ecos)
|
|
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*[') && !content.start_with?('*')
|
|
signature_name = inbox_obj.shift_signature_name
|
|
content = "*[ #{signature_name} ]*\n#{content}" if signature_name.present?
|
|
end
|
|
|
|
msg_params = {
|
|
content: content,
|
|
account_id: inbox.account_id, inbox_id: inbox.id,
|
|
message_type: is_outgoing ? :outgoing : :incoming,
|
|
sender: is_outgoing ? nil : @contact,
|
|
source_id: clean_source_id,
|
|
created_at: parser.timestamp || Time.current
|
|
}
|
|
|
|
add_reply_to_params(msg_params, parser, conversation)
|
|
conversation.messages.build(msg_params)
|
|
end
|
|
|
|
def add_reply_to_params(params, parser, conversation)
|
|
reply_id = parser.in_reply_to_external_id
|
|
return if reply_id.blank?
|
|
|
|
clean_reply_id = "WAID:#{reply_id}"
|
|
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
|
|
|
if original_message
|
|
params[:in_reply_to_id] = original_message.id
|
|
else
|
|
params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
|
end
|
|
end
|
|
|
|
def attach_files
|
|
@attachment_data = @parser.attachment_params
|
|
return if @attachment_data.blank? || @attachment_data[:external_url].blank?
|
|
|
|
Whatsapp::Wuzapi::MediaHandler.new(inbox, @parser).process(@message, @attachment_data)
|
|
rescue StandardError => e
|
|
log_attachment_error(e)
|
|
end
|
|
|
|
def log_attachment_error(error)
|
|
Rails.logger.error "WuzAPI Attachment Error: #{error.message}"
|
|
Rails.logger.error error.backtrace.first(10).join("\n")
|
|
end
|
|
end
|