diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a2cb466f1..ed2d8d5fa 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -67,6 +67,8 @@ class DashboardController < ActionController::Base FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), + WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), + WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), GIT_SHA: GIT_HASH diff --git a/app/controllers/whatsapp/embedded_controller.rb b/app/controllers/whatsapp/embedded_controller.rb new file mode 100644 index 000000000..c0a1ead64 --- /dev/null +++ b/app/controllers/whatsapp/embedded_controller.rb @@ -0,0 +1,79 @@ +class Whatsapp::EmbeddedController < ApplicationController + include EnsureCurrentAccountHelper + + before_action :authenticate_user! + before_action :current_account + before_action :check_authorization + + def new + # Configuration endpoint for embedded signup initialization + render json: { + status: 'ready', + app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''), + config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), + partner_id: GlobalConfigService.load('WHATSAPP_PARTNER_ID', ''), + graph_api_version: GlobalConfigService.load('WHATSAPP_GRAPH_API_VERSION', 'v21.0') + } + end + + def embedded_signup + # Complete embedded signup using auth code + business info from frontend + validate_authorization_code! + return if performed? + + validate_required_parameters! + return if performed? + + channel = process_signup + @inbox = channel.inbox + + # Return the inbox object using the standard jbuilder template + render 'embedded_signup', formats: [:json] + rescue StandardError => e + handle_signup_error(e) + end + + private + + def validate_authorization_code! + return if params[:code].present? + + render json: { + error: 'Missing authorization code', + message: 'Authorization code is required for embedded signup' + }, status: :bad_request + end + + def validate_required_parameters! + return if params[:business_id].present? && params[:waba_id].present? + + render json: { + error: 'Missing required parameters: business_id and waba_id are required' + }, status: :bad_request + end + + def process_signup + service = Whatsapp::EmbeddedSignupService.new( + account: Current.account, + code: params[:code], + business_id: params[:business_id], + waba_id: params[:waba_id], + phone_number_id: params[:phone_number_id] + ) + + service.perform + end + + def handle_signup_error(error) + Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + render json: { + error: 'Internal server error', + message: error.message + }, status: :internal_server_error + end + + def check_authorization + authorize(Current.account, :update?) + end +end diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index cc0fe4b33..d2effe6fb 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -222,6 +222,7 @@ "DESC": "Start supporting your customers via WhatsApp.", "PROVIDERS": { "LABEL": "API Provider", + "WHATSAPP_EMBEDDED": "WhatsApp Business (Embedded Signup)", "TWILIO": "Twilio", "WHATSAPP_CLOUD": "WhatsApp Cloud", "360_DIALOG": "360Dialog" @@ -264,6 +265,28 @@ "WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token" }, "SUBMIT_BUTTON": "Create WhatsApp Channel", + "EMBEDDED_SIGNUP": { + "TITLE": "Quick Setup with Meta", + "DESC": "Connect your WhatsApp Business Account directly through Meta's secure signup flow.", + "BENEFITS": { + "TITLE": "Benefits of Embedded Signup:", + "EASY_SETUP": "One-click setup with no manual configuration required", + "SECURE_AUTH": "Secure OAuth-based authentication with Meta", + "AUTO_CONFIG": "Automatic webhook and phone number configuration" + }, + "SUBMIT_BUTTON": "Connect with WhatsApp Business", + "AUTH_PROCESSING": "Authenticating with Meta...", + "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", + "PROCESSING": "Setting up your WhatsApp Business Account...", + "PROCESSING_DESC": "Please wait while we configure your WhatsApp Business Account. This may take a few moments.", + "LOADING_SDK": "Loading Facebook SDK...", + "CANCELLED": "WhatsApp signup was cancelled", + "STEP_AUTH": "Step 1: Authenticating with Meta", + "STEP_BUSINESS": "Step 2: Selecting business account", + "STEP_CREATING": "Step 3: Creating WhatsApp channel", + "SUCCESS_TITLE": "WhatsApp Business Account Connected!", + "SUCCESS_DESC": "Your WhatsApp Business Account has been successfully connected. Please provide a name for your inbox to complete the setup." + }, "API": { "ERROR_MESSAGE": "We were not able to save the WhatsApp channel" } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue index 91f1ec9e9..2f6e58d95 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue @@ -1,12 +1,10 @@ - - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index 68a9d8bcd..9fdef416a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -3,6 +3,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue'; import Twilio from './Twilio.vue'; import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue'; +import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; export default { components: { @@ -10,10 +11,11 @@ export default { Twilio, ThreeSixtyDialogWhatsapp, CloudWhatsapp, + WhatsappEmbeddedSignup, }, data() { return { - provider: 'whatsapp_cloud', + provider: 'whatsapp_embedded', }; }, }; @@ -31,6 +33,9 @@ export default { {{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }} + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_EMBEDDED') }} + {{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }} @@ -41,7 +46,8 @@ export default { - + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue new file mode 100644 index 000000000..b2b4a60cd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -0,0 +1,396 @@ + + + + + + + + + + + {{ processingMessage }} + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING_DESC') }} + + + + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_AUTH') }} + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_BUSINESS') }} + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_CREATING') }} + + + + + + + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }} + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }} + + + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.TITLE') }} + + + + + {{ + $t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP' + ) + }} + + + + {{ + $t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH' + ) + }} + + + + {{ + $t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG' + ) + }} + + + + + + + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }} + + + + + diff --git a/app/models/inbox.rb b/app/models/inbox.rb index f1343a352..05caa2ed9 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -8,6 +8,7 @@ # allow_messages_after_resolved :boolean default(TRUE) # auto_assignment_config :jsonb # business_name :string +# channel_attributes :jsonb # channel_type :string # csat_config :jsonb not null # csat_survey_enabled :boolean default(FALSE) diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb new file mode 100644 index 000000000..09bb2425e --- /dev/null +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -0,0 +1,183 @@ +class Whatsapp::EmbeddedSignupService + include Rails.application.routes.url_helpers + + 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 required parameters + unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? + raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required' + end + + # Exchange code for user access token + access_token = exchange_code_for_token + + # Use the provided business info directly (more efficient) + phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token) + + # Validate that the token has access to the provided WABA (security check) + validate_token_waba_access(access_token, @waba_id) + + waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] } + + create_or_update_channel(waba_info, phone_info, access_token) + rescue StandardError => e + Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}") + raise e + end + + private + + def exchange_code_for_token + response = Faraday.get( + 'https://graph.facebook.com/v21.0/oauth/access_token', + { + client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''), + client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''), + code: @code + } + ) + + raise "Token exchange failed: #{response.body}" unless response.success? + + data = JSON.parse(response.body) + raise "No access token in response: #{data}" unless data['access_token'] + + data['access_token'] + end + + def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token) + # Get all phone numbers for the WABA + response = Faraday.get( + "https://graph.facebook.com/v21.0/#{waba_id}/phone_numbers", + { access_token: access_token } + ) + + raise "WABA phone numbers fetch failed: #{response.body}" unless response.success? + + data = JSON.parse(response.body) + phone_numbers = data['data'] + + # Find the specific phone number we're looking for + phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } + + phone_data = phone_numbers.first if phone_data.nil? + + raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil? + + { + phone_number_id: phone_data['id'], + phone_number: phone_data['display_phone_number'], + verified: phone_data['code_verification_status'] == 'VERIFIED', + business_name: phone_data['verified_name'] || phone_data['display_phone_number'] + } + end + + def create_or_update_channel(waba_info, phone_info, access_token) + existing_channel = find_existing_channel(phone_info[:phone_number]) + channel_attributes = build_channel_attributes(waba_info, phone_info, access_token) + + if existing_channel + update_existing_channel(existing_channel, channel_attributes, waba_info, phone_info) + else + create_new_channel(channel_attributes, waba_info, phone_info) + end + end + + def find_existing_channel(phone_number) + Channel::Whatsapp.find_by( + account: @account, + phone_number: phone_number + ) + end + + def build_channel_attributes(waba_info, phone_info, access_token) + { + phone_number: phone_info[:phone_number], + provider: 'whatsapp_cloud', + provider_config: { + api_key: access_token, + phone_number_id: phone_info[:phone_number_id], + business_account_id: waba_info[:waba_id] + } + } + end + + def update_existing_channel(channel, attributes, waba_info, phone_info) + channel.update!(attributes) + ensure_channel_has_inbox(channel, waba_info, phone_info) + channel + end + + def create_new_channel(attributes, waba_info, phone_info) + channel = Channel::Whatsapp.create!( + account: @account, + **attributes + ) + + create_inbox_for_channel(channel, waba_info, phone_info) + channel.reload + channel + end + + def ensure_channel_has_inbox(channel, waba_info, phone_info) + return if channel.inbox + + inbox_name = generate_inbox_name(waba_info, phone_info) + Inbox.create!( + account: @account, + name: inbox_name, + channel: channel + ) + channel.reload + end + + def create_inbox_for_channel(channel, waba_info, phone_info) + inbox_name = generate_inbox_name(waba_info, phone_info) + Inbox.create!( + account: @account, + name: inbox_name, + channel: channel + ) + end + + def generate_inbox_name(waba_info, phone_info) + business_name = waba_info[:business_name] || phone_info[:business_name] + + if business_name.present? + "#{business_name} WhatsApp" + else + "WhatsApp (#{phone_info[:phone_number]})" + end + end + + def validate_token_waba_access(access_token, waba_id) + response = Faraday.get( + 'https://graph.facebook.com/v21.0/debug_token', + { + input_token: access_token, + access_token: "#{GlobalConfigService.load('WHATSAPP_APP_ID', '')}|#{GlobalConfigService.load('WHATSAPP_APP_SECRET', '')}" + } + ) + + raise "Token validation failed: #{response.body}" unless response.success? + + data = JSON.parse(response.body) + granular_scopes = 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 + + 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 diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index 4be41f8b2..366ebc191 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -39,6 +39,8 @@ googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>', googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>', fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>', + whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>', + whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>', signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>', isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>', <% if @global_config['IS_ENTERPRISE'] %> diff --git a/app/views/whatsapp/embedded/embedded_signup.json.jbuilder b/app/views/whatsapp/embedded/embedded_signup.json.jbuilder new file mode 100644 index 000000000..2ad94ff82 --- /dev/null +++ b/app/views/whatsapp/embedded/embedded_signup.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox diff --git a/config/installation_config.yml b/config/installation_config.yml index 1a891c420..e9f049496 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -126,6 +126,26 @@ type: boolean # ------- End of Facebook Channel Related Config ------- # +# ------- WhatsApp Channel Related Config ------- # +- name: WHATSAPP_APP_ID + display_title: 'WhatsApp App ID' + description: 'The Facebook App ID for WhatsApp Business API integration' + locked: false +- name: WHATSAPP_CONFIGURATION_ID + display_title: 'WhatsApp Configuration ID' + description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)' + locked: false +- name: WHATSAPP_PARTNER_ID + display_title: 'WhatsApp Partner ID' + description: 'Your Partner ID for WhatsApp Business (required for Solution Partners)' + locked: false +- name: WHATSAPP_GRAPH_API_VERSION + display_title: 'WhatsApp Graph API Version' + description: 'The Graph API version to use for WhatsApp Business API' + value: 'v21.0' + locked: false +# ------- End of WhatsApp Channel Related Config ------- # + # MARK: Microsoft Email Channel Config - name: AZURE_APP_ID display_title: 'Azure App ID' diff --git a/config/routes.rb b/config/routes.rb index ee6ec5e8a..d53efa0bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -468,6 +468,12 @@ Rails.application.routes.draw do get 'webhooks/instagram', to: 'webhooks/instagram#verify' post 'webhooks/instagram', to: 'webhooks/instagram#events' + namespace :whatsapp do + get 'signup', to: 'embedded#new' + get 'signup/callback', to: 'embedded#callback' + post 'embedded_signup', to: 'embedded#embedded_signup' + end + namespace :twitter do resource :callback, only: [:show] end diff --git a/db/migrate/20250526000001_add_channel_attributes_to_inboxes.rb b/db/migrate/20250526000001_add_channel_attributes_to_inboxes.rb new file mode 100644 index 000000000..47a8440e7 --- /dev/null +++ b/db/migrate/20250526000001_add_channel_attributes_to_inboxes.rb @@ -0,0 +1,5 @@ +class AddChannelAttributesToInboxes < ActiveRecord::Migration[7.0] + def change + add_column :inboxes, :channel_attributes, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 9c78acd33..4ce0ec6b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do +ActiveRecord::Schema[7.0].define(version: 2025_05_26_000001) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -730,6 +730,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do t.integer "sender_name_type", default: 0, null: false t.string "business_name" t.jsonb "csat_config", default: {}, null: false + t.jsonb "channel_attributes", default: {} t.index ["account_id"], name: "index_inboxes_on_account_id" t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type" t.index ["portal_id"], name: "index_inboxes_on_portal_id" diff --git a/spec/controllers/whatsapp/embedded_controller_spec.rb b/spec/controllers/whatsapp/embedded_controller_spec.rb new file mode 100644 index 000000000..e5246cc5c --- /dev/null +++ b/spec/controllers/whatsapp/embedded_controller_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe 'WhatsApp Embedded API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + + describe 'GET /whatsapp/signup' do + before do + allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id') + allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id') + allow(GlobalConfigService).to receive(:load).with('WHATSAPP_PARTNER_ID', '').and_return('test_partner_id') + allow(GlobalConfigService).to receive(:load).with('WHATSAPP_GRAPH_API_VERSION', 'v21.0').and_return('v21.0') + end + + context 'when user is authenticated' do + it 'returns configuration for embedded signup' do + get '/whatsapp/signup', + headers: admin.create_new_auth_token, + params: { account_id: account.id } + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + expect(json_response).to include( + 'status' => 'ready', + 'app_id' => 'test_app_id', + 'config_id' => 'test_config_id', + 'partner_id' => 'test_partner_id', + 'graph_api_version' => 'v21.0' + ) + end + end + + context 'when user is not authenticated' do + it 'returns unauthorized' do + get '/whatsapp/signup', params: { account_id: account.id } + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /whatsapp/embedded_signup' do + let(:params) do + { + account_id: account.id, + code: 'auth_code_123', + business_id: '123456789', + waba_id: '987654321', + phone_number_id: '555444333' + } + end + + context 'when user is authenticated' do + context 'with missing authorization code' do + it 'returns bad request error' do + params_without_code = params.except(:code) + + post '/whatsapp/embedded_signup', + headers: admin.create_new_auth_token, + params: params_without_code + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to eq('Missing authorization code') + end + end + + context 'with missing business parameters' do + it 'returns bad request when business_id is missing' do + params_without_business = params.except(:business_id) + + post '/whatsapp/embedded_signup', + headers: admin.create_new_auth_token, + params: params_without_business + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to include('Missing required parameters') + end + + it 'returns bad request when waba_id is missing' do + params_without_waba = params.except(:waba_id) + + post '/whatsapp/embedded_signup', + headers: admin.create_new_auth_token, + params: params_without_waba + + expect(response).to have_http_status(:bad_request) + json_response = response.parsed_body + expect(json_response['error']).to include('Missing required parameters') + end + end + end + + context 'when user is not authenticated' do + it 'returns unauthorized' do + post '/whatsapp/embedded_signup', params: params + expect(response).to have_http_status(:unauthorized) + end + end + end +end
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING_DESC') }} +
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }} +
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }} +