refactor(whatsapp): modulariza processamento de mídias e payloads para conformidade com RuboCop
This commit is contained in:
parent
6b214b38db
commit
c48047ba50
@ -18,37 +18,15 @@ class Whatsapp::DecryptionService
|
||||
end
|
||||
|
||||
def decrypt_bytes(encrypted_bytes)
|
||||
return nil unless @media_key && encrypted_bytes && encrypted_bytes.bytesize > 10
|
||||
|
||||
Rails.logger.info "WuzAPI Decrypt: Processing #{encrypted_bytes.bytesize} bytes"
|
||||
|
||||
# Derive keys using HKDF SHA-256 (112 bytes total)
|
||||
expanded_key = OpenSSL::KDF.hkdf(
|
||||
@media_key,
|
||||
salt: ''.b,
|
||||
info: @info,
|
||||
length: 112,
|
||||
hash: 'sha256'
|
||||
)
|
||||
return nil unless valid_input?(encrypted_bytes)
|
||||
|
||||
expanded_key = derive_keys
|
||||
iv = expanded_key[0...16]
|
||||
cipher_key = expanded_key[16...48]
|
||||
|
||||
# WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
|
||||
cipher_text = encrypted_bytes[0...-10]
|
||||
|
||||
decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
|
||||
decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text)
|
||||
|
||||
return nil unless decrypted
|
||||
|
||||
if valid_media?(decrypted)
|
||||
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
|
||||
StringIO.new(decrypted)
|
||||
else
|
||||
Rails.logger.warn "WuzAPI Decrypt: Decrypted but invalid format (first bytes: #{decrypted.bytes[0..3].map { |b| format('%02X', b) }.join(' ')})"
|
||||
nil
|
||||
end
|
||||
decrypted = try_aes_cbc(cipher_key, iv, cipher_text) || try_aes_ctr(cipher_key, iv, cipher_text)
|
||||
validate_decrypted_media(decrypted)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
|
||||
nil
|
||||
@ -56,6 +34,37 @@ class Whatsapp::DecryptionService
|
||||
|
||||
private
|
||||
|
||||
def valid_input?(bytes)
|
||||
@media_key && bytes && bytes.bytesize > 10
|
||||
end
|
||||
|
||||
def derive_keys
|
||||
OpenSSL::KDF.hkdf(
|
||||
@media_key,
|
||||
salt: ''.b,
|
||||
info: @info,
|
||||
length: 112,
|
||||
hash: 'sha256'
|
||||
)
|
||||
end
|
||||
|
||||
def validate_decrypted_media(decrypted)
|
||||
return nil unless decrypted
|
||||
|
||||
if valid_media?(decrypted)
|
||||
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
|
||||
StringIO.new(decrypted)
|
||||
else
|
||||
log_invalid_media(decrypted)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def log_invalid_media(decrypted)
|
||||
first_bytes = decrypted.bytes[0..3].map { |b| format('%02X', b) }.join(' ')
|
||||
Rails.logger.warn "WuzAPI Decrypt: Decrypted but invalid format (first bytes: #{first_bytes})"
|
||||
end
|
||||
|
||||
def try_aes_cbc(key, iv, data)
|
||||
decipher = OpenSSL::Cipher.new('AES-256-CBC')
|
||||
decipher.decrypt
|
||||
@ -63,7 +72,9 @@ class Whatsapp::DecryptionService
|
||||
decipher.iv = iv
|
||||
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
|
||||
|
||||
# rubocop:disable Rails/SaveBang
|
||||
decipher.update(data) + decipher.final
|
||||
# rubocop:enable Rails/SaveBang
|
||||
|
||||
rescue OpenSSL::Cipher::CipherError => e
|
||||
Rails.logger.debug { "AES-CBC failed: #{e.message}" }
|
||||
@ -76,7 +87,9 @@ class Whatsapp::DecryptionService
|
||||
decipher.key = key
|
||||
decipher.iv = iv
|
||||
|
||||
# rubocop:disable Rails/SaveBang
|
||||
decipher.update(data) + decipher.final
|
||||
# rubocop:enable Rails/SaveBang
|
||||
|
||||
rescue OpenSSL::Cipher::CipherError => e
|
||||
Rails.logger.debug { "AES-CTR failed: #{e.message}" }
|
||||
@ -88,28 +101,26 @@ class Whatsapp::DecryptionService
|
||||
|
||||
bytes = data.bytes[0..7]
|
||||
|
||||
# JPEG: FF D8 FF
|
||||
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF]
|
||||
|
||||
# PNG: 89 50 4E 47
|
||||
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47]
|
||||
|
||||
# WebP: RIFF....WEBP
|
||||
return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
|
||||
|
||||
# MP4/MOV: ftyp
|
||||
return true if data[4..7] == 'ftyp'
|
||||
|
||||
# MP3: ID3 or FF FB/FF FA
|
||||
return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA]
|
||||
|
||||
# OGG: OggS
|
||||
return true if data[0..3] == 'OggS'
|
||||
|
||||
# PDF: %PDF
|
||||
return true if data[0..3] == '%PDF'
|
||||
# Quick header checks for common WhatsApp media types
|
||||
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] # JPEG
|
||||
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] # PNG
|
||||
return true if webp?(data)
|
||||
return true if mp4?(data)
|
||||
return true if audio?(data, bytes)
|
||||
return true if data[0..3] == 'OggS' || data[0..3] == '%PDF'
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def webp?(data)
|
||||
data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
|
||||
end
|
||||
|
||||
def mp4?(data)
|
||||
data[4..7] == 'ftyp'
|
||||
end
|
||||
|
||||
def audio?(data, bytes)
|
||||
data[0..2] == 'ID3' || [0xFF, 0xFB].include?(bytes[0..1]) || [0xFF, 0xFA].include?(bytes[0..1])
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,164 +1,132 @@
|
||||
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
|
||||
include Whatsapp::Wuzapi::PayloadParserExtension
|
||||
|
||||
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}"
|
||||
@parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
||||
return if ignore_message?
|
||||
|
||||
# 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)
|
||||
@clean_source_id = "WAID:#{@parser.external_id}"
|
||||
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
|
||||
return if duplicate?
|
||||
|
||||
@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})"
|
||||
process_incoming_payload
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "WuzAPI Error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
log_error(e)
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 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 = find_existing_contact(phone_number)
|
||||
contact ||= create_contact(phone_number)
|
||||
|
||||
create_contact_inbox(contact, 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)
|
||||
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
|
||||
.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
|
||||
contact_inbox = ContactInbox.find_by(contact_id: @contact.id, inbox_id: inbox.id)
|
||||
inbox.conversations.create!(
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation
|
||||
contact: @contact,
|
||||
contact_inbox: contact_inbox,
|
||||
status: :open,
|
||||
account_id: inbox.account_id,
|
||||
additional_attributes: extra_attrs
|
||||
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)
|
||||
@ -182,157 +150,44 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
||||
|
||||
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,
|
||||
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!
|
||||
add_reply_to_params(msg_params, parser, conversation)
|
||||
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?
|
||||
def add_reply_to_params(params, parser, conversation)
|
||||
reply_id = parser.in_reply_to_external_id
|
||||
return if reply_id.blank?
|
||||
|
||||
begin
|
||||
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
|
||||
clean_reply_id = "WAID:#{reply_id}"
|
||||
original_message = conversation.messages.find_by(source_id: clean_reply_id)
|
||||
|
||||
# 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")
|
||||
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 download_or_decrypt_media(attachment_data, message_type)
|
||||
media_url = attachment_data[:external_url]
|
||||
def attach_files
|
||||
@attachment_data = @parser.attachment_params
|
||||
return if @attachment_data.blank? || @attachment_data[:external_url].blank?
|
||||
|
||||
# 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)
|
||||
Whatsapp::Wuzapi::MediaHandler.new(inbox, @parser).process(@message, @attachment_data)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
|
||||
nil
|
||||
log_attachment_error(e)
|
||||
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
|
||||
def log_attachment_error(error)
|
||||
Rails.logger.error "WuzAPI Attachment Error: #{error.message}"
|
||||
Rails.logger.error error.backtrace.first(10).join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@ -73,132 +73,7 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
||||
params.dig(:event, :State)
|
||||
end
|
||||
|
||||
def in_reply_to_external_id
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
return nil unless msg.is_a?(Hash)
|
||||
|
||||
# DEBUG: Log the message structure to understand reply context
|
||||
Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}"
|
||||
|
||||
# 1. Extended text
|
||||
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
||||
if ctx.present?
|
||||
Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}"
|
||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||
return stanza if stanza.present?
|
||||
end
|
||||
|
||||
# 2. Media Types direct contextInfo
|
||||
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
|
||||
ctx = msg.dig(key, :contextInfo)
|
||||
next if ctx.blank?
|
||||
|
||||
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
|
||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||
return stanza if stanza.present?
|
||||
end
|
||||
|
||||
# 3. Document With Caption
|
||||
if msg.key?(:documentWithCaptionMessage)
|
||||
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
|
||||
if ctx.present?
|
||||
Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}"
|
||||
return ctx[:stanzaID] || ctx[:stanzaId]
|
||||
end
|
||||
end
|
||||
|
||||
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage)
|
||||
if msg[:conversation].present? && msg[:contextInfo].present?
|
||||
ctx = msg[:contextInfo]
|
||||
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
|
||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||
return stanza if stanza.present?
|
||||
end
|
||||
|
||||
Rails.logger.info 'WuzAPI Reply Debug: No reply context found'
|
||||
nil
|
||||
end
|
||||
|
||||
def text_content
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
# Legacy fallback used by some WuzAPI payload variants
|
||||
return params.dig(:event, :Text) if params.dig(:event, :Text).present?
|
||||
return nil unless msg.is_a?(Hash)
|
||||
|
||||
# 1. Simple text
|
||||
return msg[:conversation] if msg[:conversation].present?
|
||||
|
||||
# 2. Extended Text
|
||||
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
|
||||
|
||||
# 3. Media Captions (Image, Video, Document)
|
||||
[:imageMessage, :videoMessage, :documentMessage].each do |media_key|
|
||||
caption = msg.dig(media_key, :caption)
|
||||
return caption if caption.present?
|
||||
end
|
||||
|
||||
# 4. Document With Caption
|
||||
return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def attachment_params
|
||||
media_key = case message_type
|
||||
when :image then :imageMessage
|
||||
when :audio then :audioMessage
|
||||
when :video then :videoMessage
|
||||
when :document then :documentMessage
|
||||
when :sticker then :stickerMessage
|
||||
end
|
||||
return nil unless media_key
|
||||
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
data = msg[media_key]
|
||||
return nil unless data.is_a?(Hash)
|
||||
|
||||
{
|
||||
external_url: data['URL'],
|
||||
file_name: data['fileName'] || "file_#{external_id}",
|
||||
mimetype: data['mimetype'],
|
||||
thumbnail: data['JPEGThumbnail'],
|
||||
media_key: data['mediaKey']
|
||||
}
|
||||
end
|
||||
|
||||
# Returns referral/ad tracking info for Click-to-WhatsApp Meta Ads messages.
|
||||
# WuzAPI/whatsmeow may include this in extendedTextMessage.contextInfo.externalAdReply
|
||||
# or via Info.Category = "business". Returns nil if no referral data found.
|
||||
def referral_info
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
|
||||
# Check externalAdReply in extendedTextMessage (Click-to-WhatsApp ad flow)
|
||||
if msg.is_a?(Hash)
|
||||
ad_reply = msg.dig(:extendedTextMessage, :contextInfo, :externalAdReply)
|
||||
ad_reply ||= msg.dig('extendedTextMessage', 'contextInfo', 'externalAdReply')
|
||||
|
||||
if ad_reply.is_a?(Hash) && ad_reply.present?
|
||||
Rails.logger.info "WuzAPI: Click-to-WhatsApp referral detected: #{ad_reply.inspect}"
|
||||
return {
|
||||
source_url: ad_reply['sourceUrl'] || ad_reply[:sourceUrl],
|
||||
source_id: ad_reply['sourceId'] || ad_reply[:sourceId],
|
||||
source_type: 'ad',
|
||||
ctwa_clid: ad_reply['ctwaClid'] || ad_reply[:ctwaClid],
|
||||
headline: ad_reply['title'] || ad_reply[:title],
|
||||
body: ad_reply['body'] || ad_reply[:body]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Check Info.Category — some WuzAPI versions mark business-initiated as "business"
|
||||
category = params.dig(:event, :Info, :Category).to_s.downcase
|
||||
if category == 'business'
|
||||
Rails.logger.info 'WuzAPI: Business category message detected (possible CTWA ad)'
|
||||
return { source_type: 'ad', source_url: nil }
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
include Whatsapp::Wuzapi::PayloadParserExtension
|
||||
|
||||
def sender_phone_number
|
||||
jid = extract_jid
|
||||
@ -259,19 +134,16 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
||||
end
|
||||
|
||||
def fallback_message_type_from_payload
|
||||
# Fallback: detect type from message body shape, even when Info.Type is missing or inconsistent.
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
|
||||
if msg.is_a?(Hash)
|
||||
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? || msg.dig(:extendedTextMessage, :text).present?
|
||||
return :image if msg[:imageMessage].present?
|
||||
return :audio if msg[:audioMessage].present?
|
||||
return :video if msg[:videoMessage].present?
|
||||
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
|
||||
return :sticker if msg[:stickerMessage].present?
|
||||
end
|
||||
|
||||
return :text if params.dig(:event, :Text).present?
|
||||
return :unknown unless msg.is_a?(Hash)
|
||||
|
||||
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present?
|
||||
return :image if msg[:imageMessage].present?
|
||||
return :audio if msg[:audioMessage].present?
|
||||
return :video if msg[:videoMessage].present?
|
||||
return :sticker if msg[:stickerMessage].present?
|
||||
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
|
||||
|
||||
:unknown
|
||||
end
|
||||
@ -284,27 +156,24 @@ class Whatsapp::Providers::Wuzapi::PayloadParser
|
||||
|
||||
def extract_jid
|
||||
if from_me?
|
||||
# For outgoing messages, prefer Chat if it's a real number
|
||||
chat = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
||||
return chat if chat&.include?('@s.whatsapp.net')
|
||||
|
||||
# Fallback to RecipientAlt when Chat uses LID format
|
||||
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
|
||||
return recipient_alt if recipient_alt&.include?('@s.whatsapp.net')
|
||||
|
||||
chat # Return original Chat even if LID (will be filtered later)
|
||||
extract_recipient_jid
|
||||
else
|
||||
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
||||
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
|
||||
|
||||
# Prefer @s.whatsapp.net over @lid
|
||||
if sender&.include?('@s.whatsapp.net')
|
||||
sender
|
||||
elsif sender_alt&.include?('@s.whatsapp.net')
|
||||
sender_alt
|
||||
else
|
||||
sender
|
||||
end
|
||||
extract_sender_jid
|
||||
end
|
||||
end
|
||||
|
||||
def extract_recipient_jid
|
||||
chat = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
|
||||
return chat if chat&.include?('@s.whatsapp.net')
|
||||
|
||||
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
|
||||
recipient_alt&.include?('@s.whatsapp.net') ? recipient_alt : chat
|
||||
end
|
||||
|
||||
def extract_sender_jid
|
||||
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
|
||||
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
|
||||
|
||||
sender&.include?('@s.whatsapp.net') ? sender : (sender_alt || sender)
|
||||
end
|
||||
end
|
||||
|
||||
105
app/services/whatsapp/wuzapi/media_handler.rb
Normal file
105
app/services/whatsapp/wuzapi/media_handler.rb
Normal file
@ -0,0 +1,105 @@
|
||||
class Whatsapp::Wuzapi::MediaHandler
|
||||
def initialize(inbox, parser)
|
||||
@inbox = inbox
|
||||
@parser = parser
|
||||
end
|
||||
|
||||
def process(message, attachment_data)
|
||||
file_io = download_or_decrypt_media(attachment_data, @parser.message_type)
|
||||
return if file_io.blank?
|
||||
|
||||
message.attachments.new(
|
||||
account_id: message.account_id,
|
||||
file_type: file_content_type(@parser.message_type),
|
||||
file: {
|
||||
io: file_io,
|
||||
filename: final_filename(attachment_data),
|
||||
content_type: attachment_data[:mimetype] || 'application/octet-stream'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def download_or_decrypt_media(data, type)
|
||||
url = data[:external_url]
|
||||
decoded = download_via_wuzapi(url)
|
||||
return StringIO.new(decoded) if decoded
|
||||
|
||||
encrypted_bytes = download_from_cdn(url)
|
||||
return nil if encrypted_bytes.blank?
|
||||
|
||||
if data[:media_key].present?
|
||||
decrypted = decrypt_media(encrypted_bytes, data[:media_key], type)
|
||||
return decrypted if decrypted
|
||||
end
|
||||
|
||||
StringIO.new(encrypted_bytes)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "WuzAPI: Media handling failed - #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def download_via_wuzapi(url)
|
||||
response = wuzapi_client.download_media(wuzapi_token, url)
|
||||
return nil unless response.is_a?(Hash) && response['data'].present?
|
||||
|
||||
image_data = response['data'].sub(/^data:.*?;base64,/, '')
|
||||
decoded = Base64.decode64(image_data)
|
||||
decoded.bytesize > 1000 ? decoded : nil
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def download_from_cdn(url)
|
||||
tempfile = Down.download(url, open_timeout: 10, read_timeout: 30, ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE)
|
||||
tempfile.read.b
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "WuzAPI: CDN download failed - #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def decrypt_media(bytes, key, type)
|
||||
Whatsapp::DecryptionService.new(key, file_content_type(type)).decrypt_bytes(bytes)
|
||||
end
|
||||
|
||||
def final_filename(data)
|
||||
name = data[:file_name] || "wuzapi_#{Time.now.to_i}"
|
||||
ext = File.extname(name)
|
||||
ext = detect_extension(data[:mimetype], @parser.message_type) if ext.blank?
|
||||
"#{File.basename(name, '.*')}#{ext}"
|
||||
end
|
||||
|
||||
def detect_extension(mimetype, type)
|
||||
return '.jpg' if type == :image || type == :sticker
|
||||
return '.mp3' if type == :audio
|
||||
return '.mp4' if 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(type)
|
||||
case type
|
||||
when :image, :sticker then :image
|
||||
when :audio then :audio
|
||||
when :video then :video
|
||||
else :file
|
||||
end
|
||||
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
|
||||
end
|
||||
67
app/services/whatsapp/wuzapi/payload_parser_extension.rb
Normal file
67
app/services/whatsapp/wuzapi/payload_parser_extension.rb
Normal file
@ -0,0 +1,67 @@
|
||||
module Whatsapp::Wuzapi::PayloadParserExtension
|
||||
def in_reply_to_external_id
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
return nil unless msg.is_a?(Hash)
|
||||
|
||||
reply_id_from_extended_text(msg) ||
|
||||
reply_id_from_media(msg) ||
|
||||
reply_id_from_document_with_caption(msg) ||
|
||||
reply_id_from_conversation(msg)
|
||||
end
|
||||
|
||||
def referral_info
|
||||
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
|
||||
return nil unless msg.is_a?(Hash)
|
||||
|
||||
ad_reply = msg.dig(:extendedTextMessage, :contextInfo, :externalAdReply)
|
||||
ad_reply ||= msg.dig('extendedTextMessage', 'contextInfo', 'externalAdReply')
|
||||
|
||||
return parse_ad_reply(ad_reply) if ad_reply.is_a?(Hash) && ad_reply.present?
|
||||
return { source_type: 'ad', source_url: nil } if business_category?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reply_id_from_extended_text(msg)
|
||||
ctx = msg.dig(:extendedTextMessage, :contextInfo)
|
||||
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||
end
|
||||
|
||||
def reply_id_from_media(msg)
|
||||
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
|
||||
ctx = msg.dig(key, :contextInfo)
|
||||
next if ctx.blank?
|
||||
|
||||
stanza = ctx[:stanzaID] || ctx[:stanzaId]
|
||||
return stanza if stanza.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def reply_id_from_document_with_caption(msg)
|
||||
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
|
||||
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||
end
|
||||
|
||||
def reply_id_from_conversation(msg)
|
||||
ctx = msg[:contextInfo] if msg[:conversation].present?
|
||||
ctx ? (ctx[:stanzaID] || ctx[:stanzaId]) : nil
|
||||
end
|
||||
|
||||
def parse_ad_reply(ad_reply)
|
||||
{
|
||||
source_url: ad_reply['sourceUrl'] || ad_reply[:sourceUrl],
|
||||
source_id: ad_reply['sourceId'] || ad_reply[:sourceId],
|
||||
source_type: 'ad',
|
||||
ctwa_clid: ad_reply['ctwaClid'] || ad_reply[:ctwaClid],
|
||||
headline: ad_reply['title'] || ad_reply[:title],
|
||||
body: ad_reply['body'] || ad_reply[:body]
|
||||
}
|
||||
end
|
||||
|
||||
def business_category?
|
||||
params.dig(:event, :Info, :Category).to_s.downcase == 'business'
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user