feat: add WhatsApp Business App coexistence feature

This commit is contained in:
Tanmay Deep Sharma
2025-06-12 14:39:28 +05:30
parent a1a3f62d1b
commit 12138f3c10
7 changed files with 511 additions and 12 deletions
@@ -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
@@ -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',
},
});
@@ -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"
},
+27 -2
View File
@@ -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?
@@ -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
@@ -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
@@ -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