feat: Whatsapp embedded signup (#11612)
## Description This PR introduces WhatsApp Embedded Signup functionality, enabling users to connect their WhatsApp Business accounts through Meta's streamlined OAuth flow without manual webhook configuration. This significantly improves the user experience by automating the entire setup process. **Key Features:** - Embedded signup flow using Facebook SDK and Meta's OAuth 2.0 - Automatic webhook registration and phone number configuration - Enhanced provider selection UI with card-based design - Real-time progress tracking during signup process - Comprehensive error handling and user feedback ## Required Configuration The following environment variables must be configured by administrators before this feature can be used: Super Admin Configuration (via super_admin/app_config?config=whatsapp_embedded) - `WHATSAPP_APP_ID`: The Facebook App ID for WhatsApp Business API integration - `WHATSAPP_CONFIGURATION_ID`: The Configuration ID for WhatsApp Embedded Signup flow (obtained from Meta Developer Portal) - `WHATSAPP_APP_SECRET`: The App Secret for WhatsApp Embedded Signup flow (required for token exchange)  ## How Has This Been Tested? #### Backend Tests (RSpec): - Authentication validation for embedded signup endpoints - Authorization code validation and error handling - Missing business parameter validation - Proper response format for configuration endpoint - Unauthorized access prevention #### Manual Test Cases: - Complete embedded signup flow (happy path) - Provider selection UI navigation - Facebook authentication popup handling - Error scenarios (cancelled auth, invalid business data, API failures) - Configuration presence/absence behavior ## Related Screenshots:      Fixes https://linear.app/chatwoot/issue/CW-2131/spec-for-whatsapp-cloud-channels-sign-in-with-facebook --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
committed by
GitHub
parent
4378506a35
commit
61d10044a0
75
app/services/whatsapp/channel_creation_service.rb
Normal file
75
app/services/whatsapp/channel_creation_service.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class Whatsapp::ChannelCreationService
|
||||
def initialize(account, waba_info, phone_info, access_token)
|
||||
@account = account
|
||||
@waba_info = waba_info
|
||||
@phone_info = phone_info
|
||||
@access_token = access_token
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
existing_channel = find_existing_channel
|
||||
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
|
||||
|
||||
create_channel_with_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Account is required' if @account.blank?
|
||||
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
|
||||
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_existing_channel
|
||||
Channel::Whatsapp.find_by(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number]
|
||||
)
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
create_inbox(channel)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel
|
||||
Channel::Whatsapp.create!(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: build_provider_config
|
||||
)
|
||||
end
|
||||
|
||||
def build_provider_config
|
||||
{
|
||||
api_key: @access_token,
|
||||
phone_number_id: @phone_info[:phone_number_id],
|
||||
business_account_id: @waba_info[:waba_id],
|
||||
source: 'embedded_signup'
|
||||
}
|
||||
end
|
||||
|
||||
def create_inbox(channel)
|
||||
inbox_name = build_inbox_name
|
||||
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def build_inbox_name
|
||||
business_name = @phone_info[:business_name] || @waba_info[:business_name]
|
||||
"#{business_name} WhatsApp"
|
||||
end
|
||||
end
|
||||
44
app/services/whatsapp/embedded_signup_service.rb
Normal file
44
app/services/whatsapp/embedded_signup_service.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = Whatsapp::TokenExchangeService.new(@code).perform
|
||||
|
||||
# Fetch phone information
|
||||
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
|
||||
# Validate token has access to the WABA
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
|
||||
# Create channel
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
# Webhook setup is now handled in the channel after_create_commit callback
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
missing_params << 'business_id' if @business_id.blank?
|
||||
missing_params << 'waba_id' if @waba_id.blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
86
app/services/whatsapp/facebook_api_client.rb
Normal file
86
app/services/whatsapp/facebook_api_client.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token(code)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/oauth/access_token",
|
||||
query: {
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: code
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token exchange failed')
|
||||
end
|
||||
|
||||
def fetch_phone_numbers(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
query: { access_token: @access_token }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
query: {
|
||||
input_token: input_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token validation failed')
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, pin)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
|
||||
headers: request_headers,
|
||||
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def build_app_access_token
|
||||
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
|
||||
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
"#{app_id}|#{app_secret}"
|
||||
end
|
||||
|
||||
def handle_response(response, error_message)
|
||||
raise "#{error_message}: #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
end
|
||||
57
app/services/whatsapp/phone_info_service.rb
Normal file
57
app/services/whatsapp/phone_info_service.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::PhoneInfoService
|
||||
def initialize(waba_id, phone_number_id, access_token)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
fetch_and_process_phone_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def fetch_and_process_phone_info
|
||||
response = @api_client.fetch_phone_numbers(@waba_id)
|
||||
phone_numbers = response['data']
|
||||
|
||||
phone_data = find_phone_data(phone_numbers)
|
||||
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
|
||||
|
||||
build_phone_info(phone_data)
|
||||
end
|
||||
|
||||
def find_phone_data(phone_numbers)
|
||||
return nil if phone_numbers.blank?
|
||||
|
||||
if @phone_number_id.present?
|
||||
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
|
||||
else
|
||||
phone_numbers.first
|
||||
end
|
||||
end
|
||||
|
||||
def build_phone_info(phone_data)
|
||||
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
|
||||
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: "+#{display_phone_number}",
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
end
|
||||
26
app/services/whatsapp/token_exchange_service.rb
Normal file
26
app/services/whatsapp/token_exchange_service.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::TokenExchangeService
|
||||
def initialize(code)
|
||||
@code = code
|
||||
@api_client = Whatsapp::FacebookApiClient.new
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_code!
|
||||
exchange_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_code!
|
||||
raise ArgumentError, 'Authorization code is required' if @code.blank?
|
||||
end
|
||||
|
||||
def exchange_token
|
||||
response = @api_client.exchange_code_for_token(@code)
|
||||
access_token = response['access_token']
|
||||
|
||||
raise "No access token in response: #{response}" if access_token.blank?
|
||||
|
||||
access_token
|
||||
end
|
||||
end
|
||||
42
app/services/whatsapp/token_validation_service.rb
Normal file
42
app/services/whatsapp/token_validation_service.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(@waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
67
app/services/whatsapp/webhook_setup_service.rb
Normal file
67
app/services/whatsapp/webhook_setup_service.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
register_phone_number
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
pin = fetch_or_create_pin
|
||||
|
||||
@api_client.register_phone_number(phone_number_id, pin)
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
# Check if we have a stored PIN for this phone number
|
||||
existing_pin = @channel.provider_config['verification_pin']
|
||||
return existing_pin.to_i if existing_pin.present?
|
||||
|
||||
# Generate a new 6-digit PIN if none exists
|
||||
SecureRandom.random_number(900_000) + 100_000
|
||||
end
|
||||
|
||||
def store_pin(pin)
|
||||
# Store the PIN in provider_config for future use
|
||||
@channel.provider_config['verification_pin'] = pin
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user