diff --git a/app/services/whatsapp/decryption_service.rb b/app/services/whatsapp/decryption_service.rb index 6f938ef91..5f0236afb 100644 --- a/app/services/whatsapp/decryption_service.rb +++ b/app/services/whatsapp/decryption_service.rb @@ -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 diff --git a/app/services/whatsapp/incoming_message_wuzapi_service.rb b/app/services/whatsapp/incoming_message_wuzapi_service.rb index 402fcadcf..568b0b88f 100644 --- a/app/services/whatsapp/incoming_message_wuzapi_service.rb +++ b/app/services/whatsapp/incoming_message_wuzapi_service.rb @@ -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 diff --git a/app/services/whatsapp/providers/wuzapi/payload_parser.rb b/app/services/whatsapp/providers/wuzapi/payload_parser.rb index 273abc50d..f59ada021 100644 --- a/app/services/whatsapp/providers/wuzapi/payload_parser.rb +++ b/app/services/whatsapp/providers/wuzapi/payload_parser.rb @@ -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 diff --git a/app/services/whatsapp/wuzapi/media_handler.rb b/app/services/whatsapp/wuzapi/media_handler.rb new file mode 100644 index 000000000..a645e4162 --- /dev/null +++ b/app/services/whatsapp/wuzapi/media_handler.rb @@ -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 diff --git a/app/services/whatsapp/wuzapi/payload_parser_extension.rb b/app/services/whatsapp/wuzapi/payload_parser_extension.rb new file mode 100644 index 000000000..1e14d2c43 --- /dev/null +++ b/app/services/whatsapp/wuzapi/payload_parser_extension.rb @@ -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