249 lines
8.3 KiB
Ruby
249 lines
8.3 KiB
Ruby
class Whatsapp::IncomingMessageWuzapiService < IncomingMessageBaseService
|
|
def perform
|
|
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
|
|
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
|
|
|
|
# 1. Message Type Check
|
|
if parser.message_type == :ignore
|
|
Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)'
|
|
return
|
|
end
|
|
|
|
allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence]
|
|
unless allowed_types.include?(parser.message_type)
|
|
Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}"
|
|
return
|
|
end
|
|
|
|
# 2. V1 Scope: Ignore Groups
|
|
if parser.group_message?
|
|
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
|
|
return
|
|
end
|
|
|
|
# 3. Strong Dedupe (Using WAID prefix)
|
|
clean_source_id = "WAID:#{parser.external_id}"
|
|
if parser.message_type != :chat_presence && 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
|
|
|
|
if parser.sender_phone_number.blank?
|
|
Rails.logger.warn "WuzAPI: Skipping processing for event with no valid phone (Type: #{parser.message_type})"
|
|
return
|
|
end
|
|
|
|
# 4. Process
|
|
Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})"
|
|
|
|
ActiveRecord::Base.transaction do
|
|
@contact = find_or_create_contact(parser)
|
|
@conversation = find_or_create_conversation(@contact)
|
|
|
|
if parser.message_type == :chat_presence
|
|
status = parser.presence_state == 'composing' ? 'on' : 'off'
|
|
@conversation.toggle_typing_status(status)
|
|
return
|
|
end
|
|
|
|
# Create Message First (Clean source_id) - Build, not save yet
|
|
@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)
|
|
phone = parser.sender_phone_number
|
|
normalized_phone = "+#{phone}"
|
|
|
|
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
|
source_id: phone,
|
|
inbox: inbox,
|
|
contact_attributes: {
|
|
name: parser.push_name || phone,
|
|
phone_number: normalized_phone
|
|
}
|
|
).perform
|
|
|
|
contact_inbox.contact
|
|
end
|
|
|
|
def find_or_create_conversation(contact)
|
|
conversation = inbox.conversations.where(contact_id: contact.id)
|
|
.where.not(status: :resolved)
|
|
.last
|
|
|
|
conversation || ::Conversation.create!(
|
|
account_id: inbox.account_id,
|
|
inbox_id: inbox.id,
|
|
contact_id: contact.id,
|
|
contact_inbox_id: ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)&.id
|
|
)
|
|
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,
|
|
sender: is_outgoing ? nil : @contact,
|
|
source_id: clean_source_id,
|
|
created_at: parser.timestamp
|
|
}
|
|
|
|
# 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: Try local decryption if we have mediaKey
|
|
if attachment_data[:media_key].present?
|
|
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
|
|
decrypted = Whatsapp::DecryptionService.new(
|
|
media_url,
|
|
attachment_data[:media_key],
|
|
file_content_type(message_type)
|
|
).decrypt
|
|
|
|
return decrypted if decrypted
|
|
|
|
Rails.logger.warn 'WuzAPI: Local decryption failed...'
|
|
end
|
|
|
|
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs)
|
|
Rails.logger.info "WuzAPI: Direct download from #{media_url}"
|
|
Down.download(
|
|
media_url,
|
|
open_timeout: 10,
|
|
read_timeout: 30,
|
|
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
|
|
)
|
|
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
|