From 12138f3c10fb0ad0ed535e56d198b99e470ff1ed Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Thu, 12 Jun 2025 14:39:28 +0530 Subject: [PATCH] feat: add WhatsApp Business App coexistence feature --- .../whatsapp/embedded_controller.rb | 3 +- .../composables/useWhatsappEmbeddedSignup.js | 26 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 3 + app/jobs/webhooks/whatsapp_events_job.rb | 29 +- app/services/whatsapp/contact_sync_service.rb | 97 ++++++ .../whatsapp/conversation_sync_service.rb | 288 ++++++++++++++++++ .../whatsapp/embedded_signup_service.rb | 77 ++++- 7 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 app/services/whatsapp/contact_sync_service.rb create mode 100644 app/services/whatsapp/conversation_sync_service.rb diff --git a/app/controllers/whatsapp/embedded_controller.rb b/app/controllers/whatsapp/embedded_controller.rb index 625a11f3e..6ba4a2f39 100644 --- a/app/controllers/whatsapp/embedded_controller.rb +++ b/app/controllers/whatsapp/embedded_controller.rb @@ -57,7 +57,8 @@ class Whatsapp::EmbeddedController < ApplicationController code: params[:code], business_id: params[:business_id], waba_id: params[:waba_id], - phone_number_id: params[:phone_number_id] + phone_number_id: params[:phone_number_id], + is_business_app_onboarding: params[:is_business_app_onboarding] ) service.perform diff --git a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js index dc8da1fe8..a624d2e3b 100644 --- a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js +++ b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js @@ -122,9 +122,17 @@ export function useWhatsappEmbeddedSignup() { } isProcessing.value = true; - processingMessage.value = t( - 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING' - ); + + // Show different processing messages based on the signup type + if (businessDataParam.is_business_app_onboarding) { + processingMessage.value = t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING_BUSINESS_APP' + ); + } else { + processingMessage.value = t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING_NEW_NUMBER' + ); + } try { // Send both auth code and business info together (synchronous flow) @@ -144,6 +152,8 @@ export function useWhatsappEmbeddedSignup() { business_id: businessDataParam.business_id, waba_id: businessDataParam.waba_id, phone_number_id: businessDataParam.phone_number_id, + is_business_app_onboarding: + businessDataParam.is_business_app_onboarding || false, }), }); @@ -174,7 +184,10 @@ export function useWhatsappEmbeddedSignup() { // Message handling const handleEmbeddedSignupData = async data => { // Handle different embedded signup events per Facebook documentation - if (data.event === 'FINISH') { + if ( + data.event === 'FINISH' || + data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING' + ) { // Facebook might send business data in different structures let businessDataLocal = data.data; @@ -194,6 +207,9 @@ export function useWhatsappEmbeddedSignup() { businessDataLocal.phone_number_id || businessDataLocal.phoneNumberId || businessDataLocal.phone_id, + // Indicate if this is an existing WhatsApp Business App onboarding (coexistence) + is_business_app_onboarding: + data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING', }; // Store business data @@ -312,7 +328,7 @@ export function useWhatsappEmbeddedSignup() { override_default_response_type: true, extras: { setup: {}, - featureType: '', // Leave empty for default flow + featureType: 'whatsapp_business_app_onboarding', // Enable WhatsApp Business App coexistence sessionInfoVersion: '3', }, }); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 3a0faaed5..57d485925 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -288,9 +288,12 @@ "LOADING_SDK": "Loading Facebook SDK...", "CANCELLED": "WhatsApp Signup was cancelled", "SUCCESS_TITLE": "WhatsApp Business Account Connected!", + "SUCCESS_TITLE_BUSINESS_APP": "WhatsApp Business App Connected!", "WAITING_FOR_AUTH": "Waiting for authentication...", "INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.", "SIGNUP_ERROR": "Signup error occurred", + "PROCESSING_BUSINESS_APP": "Connecting your existing WhatsApp Business App...", + "PROCESSING_NEW_NUMBER": "Setting up your new WhatsApp Business number...", "AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured" }, diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index 55c0969a2..fedea32ff 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -10,6 +10,33 @@ class Webhooks::WhatsappEventsJob < ApplicationJob return end + # Handle different webhook event types + if sync_event?(params) + handle_sync_events(channel, params) + else + handle_message_events(channel, params) + end + end + + private + + def sync_event?(params) + field = params.dig(:entry, 0, :changes, 0, :field) + %w[smb_app_state_sync smb_message_echoes history].include?(field) + end + + def handle_sync_events(channel, params) + field = params.dig(:entry, 0, :changes, 0, :field) + + case field + when 'smb_app_state_sync' + Whatsapp::ContactSyncService.new(inbox: channel.inbox, params: params).perform + when 'smb_message_echoes', 'history' + Whatsapp::ConversationSyncService.new(inbox: channel.inbox, params: params).perform + end + end + + def handle_message_events(channel, params) case channel.provider when 'whatsapp_cloud' Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform @@ -18,8 +45,6 @@ class Webhooks::WhatsappEventsJob < ApplicationJob end end - private - def channel_is_inactive?(channel) return true if channel.blank? return true if channel.reauthorization_required? diff --git a/app/services/whatsapp/contact_sync_service.rb b/app/services/whatsapp/contact_sync_service.rb new file mode 100644 index 000000000..84a885639 --- /dev/null +++ b/app/services/whatsapp/contact_sync_service.rb @@ -0,0 +1,97 @@ +class Whatsapp::ContactSyncService + include Rails.application.routes.url_helpers + + pattr_initialize [:inbox!, :params!] + + def perform + return unless valid_sync_event? + + sync_contacts_from_webhook + end + + private + + def valid_sync_event? + return false unless params.dig(:entry, 0, :changes, 0, :field) == 'smb_app_state_sync' + return false if params.dig(:entry, 0, :changes, 0, :value, :state_sync).blank? + + true + end + + def sync_contacts_from_webhook + state_sync_data = params.dig(:entry, 0, :changes, 0, :value, :state_sync) + + state_sync_data.each do |sync_item| + next unless sync_item[:type] == 'contact' + + process_contact_sync(sync_item) + end + end + + def process_contact_sync(sync_item) + contact_data = sync_item[:contact] + action = sync_item[:action] + phone_number = contact_data[:phone_number] + + case action + when 'add' + create_or_update_contact(contact_data, phone_number) + when 'remove' + remove_contact(phone_number) + else + Rails.logger.warn "[WHATSAPP] Unknown contact sync action: #{action}" + end + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Contact sync failed for #{phone_number}: #{e.message}" + end + + def create_or_update_contact(contact_data, phone_number) + formatted_phone = format_phone_number(phone_number) + + # Find existing contact or create new one + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: phone_number, # WhatsApp uses phone number without + as source_id + inbox: inbox, + contact_attributes: { + name: contact_data[:full_name] || contact_data[:first_name] || formatted_phone, + phone_number: formatted_phone, + additional_attributes: { + whatsapp_contact_synced: true, + whatsapp_contact_sync_timestamp: Time.current.to_i, + whatsapp_contact_first_name: contact_data[:first_name], + whatsapp_contact_full_name: contact_data[:full_name] + } + } + ).perform + + Rails.logger.info "[WHATSAPP] Contact synced: #{contact_data[:full_name]} (#{formatted_phone})" + contact_inbox + end + + def remove_contact(phone_number) + # Find the contact inbox for this phone number + contact_inbox = inbox.contact_inboxes.find_by(source_id: phone_number) + + if contact_inbox + # Mark contact as removed from WhatsApp Business App (don't delete to preserve conversation history) + contact = contact_inbox.contact + contact.additional_attributes ||= {} + contact.additional_attributes[:whatsapp_contact_removed] = true + contact.additional_attributes[:whatsapp_contact_removed_timestamp] = Time.current.to_i + contact.save! + + Rails.logger.info "[WHATSAPP] Contact marked as removed: #{contact.name} (#{contact.phone_number})" + else + Rails.logger.warn "[WHATSAPP] Contact not found for removal: #{phone_number}" + end + end + + def format_phone_number(phone_number) + # Ensure phone number starts with + for consistency + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + + def account + @account ||= inbox.account + end +end \ No newline at end of file diff --git a/app/services/whatsapp/conversation_sync_service.rb b/app/services/whatsapp/conversation_sync_service.rb new file mode 100644 index 000000000..c44e032fb --- /dev/null +++ b/app/services/whatsapp/conversation_sync_service.rb @@ -0,0 +1,288 @@ +class Whatsapp::ConversationSyncService + include Rails.application.routes.url_helpers + include ::Whatsapp::IncomingMessageServiceHelpers + + pattr_initialize [:inbox!, :params!] + + def perform + return unless valid_sync_event? + + if history_sync_event? + process_history_sync + elsif message_echo_event? + process_message_echo + end + end + + private + + def valid_sync_event? + # Check for history sync or message echo events + history_sync_event? || message_echo_event? + end + + def history_sync_event? + params.dig(:entry, 0, :changes, 0, :field) == 'history' + end + + def message_echo_event? + params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes' + end + + def process_history_sync + history_data = params.dig(:entry, 0, :changes, 0, :value, :history) + + return handle_history_sync_error if history_data&.dig(0, :errors).present? + + history_data.each do |history_item| + next if history_item[:data].blank? + + sync_historical_data(history_item[:data]) + end + end + + def handle_history_sync_error + error_info = params.dig(:entry, 0, :changes, 0, :value, :history, 0, :errors, 0) + Rails.logger.warn "[WHATSAPP] History sync declined or failed: #{error_info[:message]}" + end + + def sync_historical_data(data) + contacts = data[:contacts] || [] + messages = data[:messages] || [] + + # First, ensure contacts exist + contacts.each { |contact_data| create_contact_from_history(contact_data) } + + # Then, sync historical messages + messages.each { |message_data| create_message_from_history(message_data) } + end + + def create_contact_from_history(contact_data) + phone_number = contact_data[:wa_id] + contact_name = contact_data.dig(:profile, :name) || phone_number + + ::ContactInboxWithContactBuilder.new( + source_id: phone_number, + inbox: inbox, + contact_attributes: { + name: contact_name, + phone_number: format_phone_number(phone_number), + additional_attributes: { + whatsapp_contact_synced: true, + whatsapp_history_synced: true, + whatsapp_history_sync_timestamp: Time.current.to_i + } + } + ).perform + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Failed to create contact from history: #{e.message}" + end + + def create_message_from_history(message_data) + return unless valid_message_data?(message_data) + + phone_number = message_data[:from] + contact_inbox = find_or_create_contact_inbox(phone_number) + return unless contact_inbox + + conversation = find_or_create_conversation(contact_inbox) + return unless conversation + + create_historical_message(message_data, conversation, contact_inbox.contact) + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Failed to create historical message: #{e.message}" + end + + def process_message_echo + message_echoes = params.dig(:entry, 0, :changes, 0, :value, :message_echoes) || [] + + message_echoes.each do |echo_data| + create_echo_message(echo_data) + end + end + + def create_echo_message(echo_data) + return unless valid_echo_data?(echo_data) + + to_phone_number = echo_data[:to] + echo_data[:from] + + # Find the contact this message was sent to + contact_inbox = find_contact_inbox_by_phone(to_phone_number) + return unless contact_inbox + + conversation = find_or_create_conversation(contact_inbox) + return unless conversation + + # Create outgoing message (sent from business via WhatsApp Business App) + create_echo_message_record(echo_data, conversation, contact_inbox.contact) + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Failed to create echo message: #{e.message}" + end + + def valid_message_data?(message_data) + message_data[:id].present? && + message_data[:from].present? && + message_data[:timestamp].present? && + message_data[:type].present? + end + + def valid_echo_data?(echo_data) + echo_data[:id].present? && + echo_data[:to].present? && + echo_data[:from].present? && + echo_data[:timestamp].present? && + echo_data[:type].present? + end + + def find_or_create_contact_inbox(phone_number) + contact_inbox = inbox.contact_inboxes.find_by(source_id: phone_number) + return contact_inbox if contact_inbox + + # Create contact if not exists + ::ContactInboxWithContactBuilder.new( + source_id: phone_number, + inbox: inbox, + contact_attributes: { + name: format_phone_number(phone_number), + phone_number: format_phone_number(phone_number), + additional_attributes: { + whatsapp_history_synced: true + } + } + ).perform + end + + def find_contact_inbox_by_phone(phone_number) + # For echo messages, we need to find contact by their WhatsApp ID (without +) + inbox.contact_inboxes.find_by(source_id: phone_number) + end + + def find_or_create_conversation(contact_inbox) + # Look for existing conversation or create new one + conversation = if inbox.lock_to_single_conversation + contact_inbox.conversations.last + else + contact_inbox.conversations.where.not(status: :resolved).last + end + + return conversation if conversation + + ::Conversation.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + contact_id: contact_inbox.contact_id, + contact_inbox_id: contact_inbox.id, + additional_attributes: { + whatsapp_history_synced: true + } + ) + end + + def create_historical_message(message_data, conversation, contact) + # Skip if message already exists + return if conversation.messages.find_by(source_id: message_data[:id]) + + message_content = extract_message_content(message_data) + external_timestamp = Time.zone.at(message_data[:timestamp].to_i) + + message = conversation.messages.build( + content: message_content, + account_id: inbox.account_id, + inbox_id: inbox.id, + message_type: :incoming, + sender: contact, + source_id: message_data[:id], + created_at: external_timestamp, + content_attributes: { + external_created_at: external_timestamp.iso8601, + whatsapp_history_synced: true + } + ) + + # Handle different message types + case message_data[:type] + when 'image', 'audio', 'video', 'document' + attach_media_from_history(message, message_data) + when 'location' + attach_location_from_history(message, message_data) + end + + message.save! + Rails.logger.info "[WHATSAPP] Historical message synced: #{message_data[:id]}" + end + + def create_echo_message_record(echo_data, conversation, _contact) + # Skip if message already exists + return if conversation.messages.find_by(source_id: echo_data[:id]) + + message_content = extract_message_content(echo_data) + external_timestamp = Time.zone.at(echo_data[:timestamp].to_i) + + message = conversation.messages.build( + content: message_content, + account_id: inbox.account_id, + inbox_id: inbox.id, + message_type: :outgoing, # This is a message sent by the business + sender: nil, # Business messages don't have a sender contact + source_id: echo_data[:id], + created_at: external_timestamp, + content_attributes: { + external_created_at: external_timestamp.iso8601, + whatsapp_echo_message: true + } + ) + + message.save! + Rails.logger.info "[WHATSAPP] Echo message synced: #{echo_data[:id]}" + end + + def extract_message_content(message_data) + case message_data[:type] + when 'text' + message_data.dig(:text, :body) + when 'image', 'audio', 'video', 'document' + message_data.dig(message_data[:type].to_sym, :caption) || message_data[:type].humanize + when 'location' + location = message_data[:location] + "Location: #{location[:name] || 'Shared location'}" + else + message_data[:type].humanize + end + end + + def attach_media_from_history(message, message_data) + # NOTE: Historical media might not be available for download + # We'll just record the media info in content_attributes + media_data = message_data[message_data[:type].to_sym] || {} + + message.content_attributes[:media_info] = { + type: message_data[:type], + caption: media_data[:caption], + filename: media_data[:filename], + mime_type: media_data[:mime_type], + sha256: media_data[:sha256], + id: media_data[:id] + } + end + + def attach_location_from_history(message, message_data) + location = message_data[:location] || {} + + message.content_attributes[:location_info] = { + latitude: location[:latitude], + longitude: location[:longitude], + name: location[:name], + address: location[:address], + url: location[:url] + } + end + + def format_phone_number(phone_number) + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + + def account + @account ||= inbox.account + end +end \ No newline at end of file diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 95ecfac2b..e1c08267a 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -1,12 +1,13 @@ class Whatsapp::EmbeddedSignupService include Rails.application.routes.url_helpers - def initialize(account:, code:, business_id:, waba_id:, phone_number_id:) + def initialize(account:, code:, business_id:, waba_id:, phone_number_id:, is_business_app_onboarding: false) @account = account @code = code @business_id = business_id @waba_id = waba_id @phone_number_id = phone_number_id + @is_business_app_onboarding = is_business_app_onboarding end def perform @@ -27,7 +28,12 @@ class Whatsapp::EmbeddedSignupService waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] } - create_or_update_channel(waba_info, phone_info, access_token) + channel = create_or_update_channel(waba_info, phone_info, access_token) + + # Enable sync features for WhatsApp Business App coexistence + enable_sync_features(channel, access_token) if channel + + channel rescue StandardError => e Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}") raise e @@ -90,7 +96,11 @@ class Whatsapp::EmbeddedSignupService raise "Channel already exists: #{existing_channel.phone_number}" else channel = create_new_channel(channel_attributes, phone_info) - register_phone_number(phone_info[:phone_number_id], access_token) + + # Skip phone number registration for WhatsApp Business App onboarding (coexistence) + # as the number is already registered in the WhatsApp Business App + register_phone_number(phone_info[:phone_number_id], access_token) unless @is_business_app_onboarding + override_waba_webhook(waba_info[:waba_id], channel, access_token) channel end @@ -200,7 +210,9 @@ class Whatsapp::EmbeddedSignupService }, body: { override_callback_uri: callback_url, - verify_token: verify_token + verify_token: verify_token, + # Enable sync features for WhatsApp Business App coexistence + webhook_fields: %w[messages smb_app_state_sync smb_message_echoes] }.to_json } ) @@ -210,4 +222,61 @@ class Whatsapp::EmbeddedSignupService Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}") raise "Webhook override failed: #{response.body}" end + + # Enable sync features for WhatsApp Business App coexistence + def enable_sync_features(channel, access_token) + # Initialize contact sync for existing WhatsApp Business App contacts + sync_contacts(channel, access_token) + + # Request conversation history sync (if business allows) + request_conversation_history_sync(channel, access_token) + end + + def sync_contacts(channel, access_token) + Rails.logger.info("[WHATSAPP] Initiating contact sync for channel #{channel.id}") + + # Make API call to sync contacts from WhatsApp Business App + response = HTTParty.post( + "https://graph.facebook.com/#{whatsapp_api_version}/#{channel.provider_config['phone_number_id']}/request_sync", + { + headers: { + 'Authorization' => "Bearer #{access_token}", + 'Content-Type' => 'application/json' + }, + body: { + sync_type: 'contact' + }.to_json + } + ) + + if response.success? + Rails.logger.info("[WHATSAPP] Contact sync initiated successfully for channel #{channel.id}") + else + Rails.logger.warn("[WHATSAPP] Contact sync failed for channel #{channel.id}: #{response.body}") + end + end + + def request_conversation_history_sync(channel, access_token) + Rails.logger.info("[WHATSAPP] Initiating conversation history sync for channel #{channel.id}") + + # Make API call to sync conversation history from WhatsApp Business App + response = HTTParty.post( + "https://graph.facebook.com/#{whatsapp_api_version}/#{channel.provider_config['phone_number_id']}/request_sync", + { + headers: { + 'Authorization' => "Bearer #{access_token}", + 'Content-Type' => 'application/json' + }, + body: { + sync_type: 'history' + }.to_json + } + ) + + if response.success? + Rails.logger.info("[WHATSAPP] Conversation history sync initiated successfully for channel #{channel.id}") + else + Rails.logger.warn("[WHATSAPP] Conversation history sync failed for channel #{channel.id}: #{response.body}") + end + end end