class Whatsapp::Providers::Wuzapi::PayloadParser attr_reader :params def initialize(params) @params = params.with_indifferent_access end def external_id params.dig(:event, :Info, :ID) end def from_me? # A flag comes primarily from 'IsFromMe' or nested in 'Info' is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe) # However, WuzAPI might be inconsistent. We also check if the sender matches the instance phone. # But if the API explicitly says "IsFromMe: true", we trust it first. return true if is_api_from_me.present? && is_api_from_me.to_s == 'true' # Fallback check: Sender JID prefix matches instance phone number instance_phone = params['phone_number'] sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) if instance_phone.present? && sender_jid.present? sender_phone = sender_jid.split('@').first return true if sender_phone == instance_phone end false end # Extracts the CUSTOMER phone number when the message is FROM ME (outgoing). # In this case, the 'Chat' field contains the recipient (customer) JID. # When WuzAPI uses LIDs, we fallback to RecipientAlt which has the real number. def recipient_phone_number chat_id = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat) # If Chat is a real number, use it return chat_id.split('@').first.split(':').first if chat_id&.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.split('@').first.split(':').first if recipient_alt&.include?('@s.whatsapp.net') nil end def message_type return :chat_presence if webhook_event_type == 'ChatPresence' return :ignore if ignorable_webhook_event_type? # Info: Type contains the general classification (text, image, etc) type = raw_info_type.to_s.downcase media_type = params.dig(:event, :Info, :MediaType).to_s.downcase # WuzAPI sometimes sends 'media' in Type and the actual type in MediaType type = media_type if type == 'media' && media_type.present? case type when 'text' then :text when 'image' then :image when 'audio' then :audio when 'video' then :video when 'document' then :document when 'sticker' then :sticker when 'readreceipt' then :ignore else fallback_message_type_from_payload end end def presence_state 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 def sender_phone_number jid = extract_jid # Reject LIDs as they aren't valid E164 phone numbers return nil if jid.blank? || jid.include?('@lid') # Format: 556182098580:1@s.whatsapp.net -> 556182098580 # MD accounts include a device index suffix (eg. :1) that we must strip jid.split('@').first.split(':').first end def timestamp timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp) return Time.current if timestamp_val.blank? begin Time.zone.parse(timestamp_val.to_s) rescue ArgumentError Time.current end end def sender_name params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName) end def group_message? params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup) end private def webhook_event_type params[:type].to_s end def raw_info_type params.dig(:event, :Info, :Type) || params.dig(:event, :Type) end def ignorable_webhook_event_type? # These are provider/system updates and should not be treated as incoming user messages. ignorable = %w[ ReadReceipt UserAbout IdentityChange Picture Connected Disconnected OfflineSyncCompleted Presence PresenceUpdate Ack ] ignorable.include?(webhook_event_type) 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? :unknown end def unwrap_ephemeral_message(msg) return {} unless msg msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg end 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) 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 end end end