339 lines
13 KiB
Ruby
339 lines
13 KiB
Ruby
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
|
|
def perform
|
|
# 1. Parse Payload
|
|
# ----------------
|
|
# Extract all necessary data from the WuzAPI webhook payload
|
|
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
|
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
|
|
|
|
# 2. Basic Validation
|
|
# -------------------
|
|
# Ignore statuses, presence updates, and errors for now
|
|
if parser.message_type == :chat_presence || parser.message_type == :error || parser.message_type == :ignore
|
|
Rails.logger.info "WuzAPI: Ignoring presence/error/ignore update (Type: #{parser.message_type})"
|
|
return
|
|
end
|
|
|
|
allowed_types = [:text, :image, :audio, :video, :document, :sticker]
|
|
unless allowed_types.include?(parser.message_type)
|
|
Rails.logger.info(
|
|
"WuzAPI: Unsupported message type: #{parser.message_type} " \
|
|
"(webhook.type=#{params[:type]}, event.Info.Type=#{params.dig(:event, :Info, :Type)}, event.Type=#{params.dig(:event, :Type)})"
|
|
)
|
|
return
|
|
end
|
|
|
|
# 2.1 V1 Scope: Ignore Groups
|
|
if parser.group_message?
|
|
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
|
|
return
|
|
end
|
|
|
|
if parser.sender_phone_number.blank? && !parser.from_me?
|
|
Rails.logger.warn "WuzAPI: Skipping processing for event with no valid sender phone (Type: #{parser.message_type})"
|
|
return
|
|
end
|
|
|
|
# 3. Strong Dedupe (Existing External ID)
|
|
# ---------------------------------------
|
|
# If we already have a message with this WAID, ignore it immediately.
|
|
# This catches standard retries from WuzAPI or webhook re-deliveries.
|
|
clean_source_id = "WAID:#{parser.external_id}"
|
|
|
|
# 4. Find/Create Resources
|
|
# ------------------------
|
|
# Based on the sender (customer) or recipient (if it's a mobile reply)
|
|
ActiveRecord::Base.transaction do
|
|
# Strong dedupe inside transaction to prevent TOCTOU race condition
|
|
if parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
|
|
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
|
|
return
|
|
end
|
|
@contact = find_or_create_contact(parser)
|
|
return if @contact.nil? # If contact couldn't be determined, stop processing
|
|
|
|
@conversation = find_or_create_conversation(@contact, parser)
|
|
|
|
# 5. Echo/AI Deduplication Logic
|
|
# ------------------------------
|
|
# If this is an outgoing message (from_me=true), it might be:
|
|
# A) A reply sent from the physical phone (needs to be created as outgoing)
|
|
# B) A confirmation echo of a message Chatwoot/AI just sent (needs to be merged)
|
|
if parser.from_me?
|
|
deduplicated_message = find_outgoing_message_to_deduplicate(parser, @conversation)
|
|
if deduplicated_message
|
|
# Merging logic: Update the local temporary message with the real WuzAPI ID
|
|
Rails.logger.info "WuzAPI: Merging echo into existing message #{deduplicated_message.id}"
|
|
deduplicated_message.update!(source_id: clean_source_id)
|
|
return # Stop processing, we successfully merged.
|
|
end
|
|
end
|
|
|
|
# 6. Create Message
|
|
# -----------------
|
|
# If it wasn't a duplicate, create the new message (Incoming or Outgoing)
|
|
@message = build_message(parser, @conversation, clean_source_id)
|
|
|
|
# Attach media BEFORE saving (Chatwoot pattern)
|
|
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
|
|
|
|
# Now save with attachments
|
|
@message.save!
|
|
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
|
|
end
|
|
rescue StandardError => e
|
|
Rails.logger.error "WuzAPI Error: #{e.message}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
raise e
|
|
end
|
|
|
|
private
|
|
|
|
def find_or_create_contact(parser)
|
|
# If from_me is true, the sender is US (the business).
|
|
# The CONTACT for the conversation is properly the RECIPIENT (the customer).
|
|
# If from_me is false, the sender is the CUSTOMER.
|
|
phone_number = if parser.from_me?
|
|
parser.recipient_phone_number # Extracted from Chat ID
|
|
else
|
|
parser.sender_phone_number # Extracted from Sender ID
|
|
end
|
|
|
|
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
|
|
|
|
# Create or Find existing contact in the account
|
|
# We use find_by to avoid uniqueness validation errors if the contact exists in another inbox
|
|
formatted_phone = "+#{phone_number.to_s.delete('+')}"
|
|
contact = inbox.account.contacts.find_by(phone_number: formatted_phone)
|
|
|
|
contact ||= inbox.account.contacts.create!(
|
|
name: parser.sender_name || phone_number,
|
|
phone_number: formatted_phone,
|
|
custom_attributes: { wuzapi_id: phone_number }
|
|
)
|
|
|
|
ContactInbox.create!(
|
|
contact: contact,
|
|
inbox: inbox,
|
|
source_id: phone_number
|
|
)
|
|
|
|
contact
|
|
end
|
|
|
|
def find_or_create_conversation(contact, parser = nil)
|
|
# Find the LAST open conversation for this contact to append to
|
|
conversation = inbox.conversations.where(contact_id: contact.id)
|
|
.where.not(status: :resolved)
|
|
.order(updated_at: :desc)
|
|
.first
|
|
|
|
return conversation if conversation
|
|
|
|
# Find the ContactInbox association to linking
|
|
contact_inbox = ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)
|
|
|
|
# Build additional_attributes — include referral info from Click-to-WhatsApp ads if present
|
|
extra_attrs = {}
|
|
if parser
|
|
referral = parser.referral_info
|
|
if referral.present?
|
|
# "referer" is the field Chatwoot automations use for "Link de origem" condition
|
|
extra_attrs['referer'] = referral[:source_url].presence || 'meta_ads'
|
|
extra_attrs['source_type'] = referral[:source_type] if referral[:source_type].present?
|
|
extra_attrs['ctwa_clid'] = referral[:ctwa_clid] if referral[:ctwa_clid].present?
|
|
Rails.logger.info "WuzAPI: Setting conversation referer='#{extra_attrs['referer']}' from ad referral"
|
|
end
|
|
end
|
|
|
|
# If no open conversation, create a new one
|
|
inbox.conversations.create!(
|
|
contact: contact,
|
|
contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation
|
|
status: :open,
|
|
account_id: inbox.account_id,
|
|
additional_attributes: extra_attrs
|
|
)
|
|
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?
|
|
|
|
msg_params = {
|
|
content: parser.text_content,
|
|
account_id: inbox.account_id,
|
|
inbox_id: inbox.id,
|
|
message_type: is_outgoing ? :outgoing : :incoming,
|
|
# If outgoing, sender is nil (system/agent). If incoming, sender is the contact.
|
|
sender: is_outgoing ? nil : @contact,
|
|
source_id: clean_source_id,
|
|
created_at: parser.timestamp || Time.current
|
|
}
|
|
|
|
# Handle Replies
|
|
# Handle Reply Logic (Aligned with Reference)
|
|
if (reply_id = parser.in_reply_to_external_id).present?
|
|
clean_reply_id = "WAID:#{reply_id}"
|
|
|
|
# Strict lookup within conversation to prevent cross-inbox leaks
|
|
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
|
|
|
if original_message
|
|
msg_params[:in_reply_to_id] = original_message.id
|
|
else
|
|
# Fallback: Store ID for UI "Replying to..." display even if not linked
|
|
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
|
|
end
|
|
end
|
|
|
|
# Use .build so we can attach files before .save!
|
|
conversation.messages.build(msg_params)
|
|
end
|
|
|
|
def attach_files(parser)
|
|
attachment_data = parser.attachment_params
|
|
return if attachment_data.blank? || attachment_data[:external_url].blank?
|
|
|
|
begin
|
|
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
|
|
|
|
# 1. Download/Decrypt to get a file
|
|
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
|
|
return if file_io.blank?
|
|
|
|
# 2. Determine filename
|
|
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
|
|
extension = File.extname(original_filename)
|
|
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
|
|
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
|
|
|
|
# 3. Attach using Chatwoot's standard pattern
|
|
@message.attachments.new(
|
|
account_id: @message.account_id,
|
|
file_type: file_content_type(parser.message_type),
|
|
file: {
|
|
io: file_io,
|
|
filename: final_filename,
|
|
content_type: attachment_data[:mimetype] || 'application/octet-stream'
|
|
}
|
|
)
|
|
|
|
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
|
|
|
|
rescue StandardError => e
|
|
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
|
|
Rails.logger.error e.backtrace.first(10).join("\n")
|
|
end
|
|
end
|
|
|
|
def download_or_decrypt_media(attachment_data, message_type)
|
|
media_url = attachment_data[:external_url]
|
|
|
|
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
|
|
# This is the equivalent of Cloud API's media download
|
|
begin
|
|
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
|
|
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
|
|
|
|
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
|
|
# WuzAPI returns base64 in 'data' field
|
|
image_data = wuzapi_response['data']
|
|
# Strip data URI prefix if present
|
|
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
|
|
|
|
decoded = Base64.decode64(image_data)
|
|
if decoded.bytesize > 1000 # Valid image should be > 1KB
|
|
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
|
|
return StringIO.new(decoded)
|
|
end
|
|
end
|
|
rescue StandardError => e
|
|
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
|
|
end
|
|
|
|
# METHOD 2+3: Download from CDN (follows redirects) then decrypt if mediaKey available
|
|
Rails.logger.info "WuzAPI: Downloading from CDN #{media_url}"
|
|
encrypted_tempfile = Down.download(
|
|
media_url,
|
|
open_timeout: 10,
|
|
read_timeout: 30,
|
|
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
|
|
)
|
|
encrypted_bytes = encrypted_tempfile.read.b
|
|
Rails.logger.info "WuzAPI: Downloaded #{encrypted_bytes.bytesize} bytes from CDN"
|
|
|
|
if attachment_data[:media_key].present?
|
|
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
|
|
decrypted = Whatsapp::DecryptionService.new(
|
|
attachment_data[:media_key],
|
|
file_content_type(message_type)
|
|
).decrypt_bytes(encrypted_bytes)
|
|
|
|
return decrypted if decrypted
|
|
|
|
Rails.logger.warn 'WuzAPI: Local decryption failed, returning raw bytes'
|
|
end
|
|
|
|
StringIO.new(encrypted_bytes)
|
|
rescue StandardError => e
|
|
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
|
|
nil
|
|
end
|
|
|
|
def wuzapi_client
|
|
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
|
|
end
|
|
|
|
def wuzapi_token
|
|
@inbox.channel.wuzapi_user_token
|
|
end
|
|
|
|
def detect_extension(mimetype, message_type)
|
|
return '.jpg' if message_type == :image || message_type == :sticker
|
|
return '.mp3' if message_type == :audio
|
|
return '.mp4' if message_type == :video
|
|
|
|
case mimetype
|
|
when 'image/png' then '.png'
|
|
when 'image/webp' then '.webp'
|
|
when 'image/gif' then '.gif'
|
|
when 'audio/ogg' then '.ogg'
|
|
when 'video/webm' then '.webm'
|
|
else '.bin'
|
|
end
|
|
end
|
|
|
|
def file_content_type(message_type)
|
|
case message_type
|
|
when :image, :sticker then :image
|
|
when :audio then :audio
|
|
when :video then :video
|
|
else :file
|
|
end
|
|
end
|
|
end
|