From 86582569ee3b70d8dcf1389aa0624f4fe3965065 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:35:18 +0400 Subject: [PATCH] feat(whatsapp): add guided manual setup flow --- .../whatsapp/manual_setup_controller.rb | 78 ++ .../concerns/meta_token_verify_concern.rb | 1 + .../webhooks/whatsapp_controller.rb | 8 + .../dashboard/api/channel/whatsappChannel.js | 20 + .../dashboard/i18n/locale/en/inboxMgmt.json | 105 +++ .../dashboard/settings/inbox/FinishSetup.vue | 3 +- .../settings/inbox/InboxChannels.vue | 2 +- .../settings/inbox/channels/Whatsapp.vue | 15 +- .../inbox/channels/WhatsappManualSetup.vue | 852 ++++++++++++++++++ app/models/channel/whatsapp.rb | 8 +- app/services/whatsapp/facebook_api_client.rb | 48 + app/services/whatsapp/manual_setup_service.rb | 57 ++ .../manual_setup_validation_service.rb | 79 ++ .../whatsapp/manual_webhook_status_service.rb | 39 + config/routes.rb | 4 + ...webhook_verified_at_to_channel_whatsapp.rb | 5 + db/schema.rb | 3 +- .../manual-setup/add-phone-number-poster.jpg | Bin 0 -> 172498 bytes .../manual-setup/add-phone-number.mp4 | Bin 0 -> 3891259 bytes .../manual-setup/create-meta-app-poster.jpg | Bin 0 -> 151168 bytes .../whatsapp/manual-setup/create-meta-app.mp4 | Bin 0 -> 5605841 bytes .../generate-access-token-poster.jpg | Bin 0 -> 132301 bytes .../manual-setup/generate-access-token.mp4 | Bin 0 -> 1972904 bytes whatsapp_manual_connect_v2.md | 333 +++++++ 24 files changed, 1649 insertions(+), 11 deletions(-) create mode 100644 app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappManualSetup.vue create mode 100644 app/services/whatsapp/manual_setup_service.rb create mode 100644 app/services/whatsapp/manual_setup_validation_service.rb create mode 100644 app/services/whatsapp/manual_webhook_status_service.rb create mode 100644 db/migrate/20260711090000_add_webhook_verified_at_to_channel_whatsapp.rb create mode 100644 public/videos/whatsapp/manual-setup/add-phone-number-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/add-phone-number.mp4 create mode 100644 public/videos/whatsapp/manual-setup/create-meta-app-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/create-meta-app.mp4 create mode 100644 public/videos/whatsapp/manual-setup/generate-access-token-poster.jpg create mode 100644 public/videos/whatsapp/manual-setup/generate-access-token.mp4 create mode 100644 whatsapp_manual_connect_v2.md diff --git a/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb new file mode 100644 index 000000000..b8c225017 --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb @@ -0,0 +1,78 @@ +class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController + before_action :authorize_create, only: [:preview, :connect] + before_action :fetch_inbox, only: [:webhook_status, :setup_webhook] + + def preview + render json: validation_service.perform + rescue StandardError => e + render_setup_error(e) + end + + def connect + setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform + render json: connection_response(setup), status: :created + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) + rescue StandardError => e + render_setup_error(e) + end + + def webhook_status + render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform + rescue StandardError => e + render_setup_error(e) + end + + def setup_webhook + channel = @inbox.channel + Whatsapp::WebhookSetupService.new(channel).register_callback + render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform + rescue StandardError => e + render_setup_error(e) + end + + private + + def authorize_create + authorize ::Inbox, :create? + end + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :update? + channel = @inbox.channel + return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2' + + raise ActiveRecord::RecordNotFound + end + + def validation_service + Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys) + end + + def connection_params + params.permit(:waba_id, :phone_number_id, :access_token) + end + + def connect_params + params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name) + end + + def connection_response(setup) + channel = setup.channel.reload + { + id: channel.inbox.id, + name: channel.inbox.name, + number_access: true, + template_access: true, + webhook_setup: setup.webhook_setup?, + webhook_verified: channel.webhook_verified_at.present?, + webhook_error: setup.webhook_error + } + end + + def render_setup_error(error) + Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}" + render json: { message: error.message }, status: :unprocessable_entity + end +end diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index 42fe918cc..eb20ecdca 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -9,6 +9,7 @@ module MetaTokenVerifyConcern def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) + mark_webhook_verified if respond_to?(:mark_webhook_verified, true) Rails.logger.info("#{service.capitalize} webhook verified") render json: params['hub.challenge'] else diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..eecc0dac3 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -22,6 +22,14 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def mark_webhook_verified + channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + # The verification callback must not trigger remote provider validation. + # rubocop:disable Rails/SkipsModelValidations + channel&.update_column(:webhook_verified_at, Time.current) + # rubocop:enable Rails/SkipsModelValidations + end + def meta_app_secrets [ *channel_meta_app_secrets(whatsapp_channel), diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index 8f51f4878..e5e59f221 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -16,6 +16,26 @@ class WhatsappChannel extends ApiClient { inbox_id: inboxId, }); } + + previewManualSetup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/manual/preview`, params); + } + + connectManualSetup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/manual/connect`, params); + } + + getManualWebhookStatus(inboxId) { + return axios.get( + `${this.baseUrl()}/whatsapp/manual/${inboxId}/webhook_status` + ); + } + + setupManualWebhook(inboxId) { + return axios.post( + `${this.baseUrl()}/whatsapp/manual/${inboxId}/setup_webhook` + ); + } } export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 5db865c06..779d8710e 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -294,6 +294,111 @@ "WEBHOOK_URL": "Webhook URL", "WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token" }, + "MANUAL_SETUP": { + "HEADER": { + "TITLE": "Connect WhatsApp manually", + "DESCRIPTION": "Follow these steps to prepare your Meta account and connect your number to Chatwoot." + }, + "PROGRESS": "Step {current} of {total}", + "STEPS": { + "1": { "LABEL": "Create Meta app" }, + "2": { "LABEL": "Add phone number and get IDs" }, + "3": { "LABEL": "Generate access token" }, + "4": { "LABEL": "Review and connect" }, + "5": { "LABEL": "Verify connection" } + }, + "APP": { + "TITLE": "Create or select a Meta app", + "DESCRIPTION": "Your WhatsApp number must belong to a Meta app with the WhatsApp use case enabled.", + "ITEM_1": "Open {metaDevelopers} and sign in with an administrator account.", + "META_DEVELOPERS": "Meta Developers", + "ITEM_2": "Create a new app, or select the app you already use for this WhatsApp number.", + "ITEM_3": "Choose the option to connect with customers through WhatsApp.", + "ITEM_4": "Select the business portfolio that owns, or will own, the WhatsApp number.", + "VIDEO_TITLE": "Watch: Create a Meta app", + "VIDEO_DESCRIPTION": "This short walkthrough shows where to start a new app in Meta Developers." + }, + "NUMBER": { + "TITLE": "Add your phone number and get its IDs", + "DESCRIPTION": "Add and verify the production number in your Meta app, then copy the two identifiers shown in API Setup.", + "ITEM_1": "Open the WhatsApp use case in your Meta app and choose API Setup.", + "ITEM_2": "In the Send and receive messages section, open the From selector.", + "ITEM_3": "Select an existing production number, or choose Add phone number.", + "ITEM_4": "Complete the WhatsApp business profile requested by Meta.", + "ITEM_5": "Verify the phone number using the OTP sent by SMS or voice call.", + "ITEM_6": "Copy the Phone Number ID and WhatsApp Business Account ID shown in API Setup.", + "VIDEO_TITLE": "Watch: Add a phone number and find its IDs", + "VIDEO_DESCRIPTION": "This walkthrough shows how to add or select a production number and copy the identifiers from Meta." + }, + "TOKEN": { + "TITLE": "Generate a permanent access token", + "DESCRIPTION": "Create a Meta system user with access to your app and WhatsApp Business Account.", + "ITEM_1": "Open Meta Business Settings and go to Users → System users.", + "ITEM_2": "Create an admin system user, or select an existing one.", + "ITEM_3": "Assign your Meta app and WhatsApp Business Account to the system user.", + "ITEM_4": "Grant full control for the assigned WhatsApp assets.", + "ITEM_5": "Generate a token for your Meta app and set its expiration to Never.", + "ITEM_6": "Select whatsapp_business_management and whatsapp_business_messaging, then copy the token.", + "VIDEO_TITLE": "Watch: Generate a permanent access token", + "VIDEO_DESCRIPTION": "This walkthrough shows how to select your Meta app, choose a non-expiring token, and grant the required WhatsApp permissions.", + "WARNING": "Meta shows the token only once. Copy it before closing the dialog." + }, + "DETAILS": { + "WABA_LABEL": "WhatsApp Business Account ID", + "WABA_PLACEHOLDER": "Enter WABA ID", + "WABA_HELP": "Copy this from the API Setup page in your Meta app.", + "PHONE_ID_LABEL": "Phone Number ID", + "PHONE_ID_PLACEHOLDER": "Enter Phone Number ID", + "PHONE_ID_HELP": "Copy the ID shown beside your production phone number in Meta.", + "TOKEN_LABEL": "Permanent access token", + "TOKEN_PLACEHOLDER": "Paste access token", + "TOKEN_HELP": "Use a permanent system-user token with WhatsApp messaging and management permissions." + }, + "REVIEW": { + "TITLE": "Review and connect your number", + "DESCRIPTION": "These details were retrieved directly from Meta.", + "VERIFIED": "The number and token were verified with Meta.", + "BUSINESS_NAME": "Business name", + "PHONE_NUMBER": "Phone number", + "PHONE_ID": "Phone Number ID", + "WABA_ID": "WhatsApp Business Account ID", + "INBOX_NAME": "Inbox name", + "INBOX_NAME_HELP": "We generated this name from your verified Meta business name. You can change it." + }, + "VERIFY": { + "TITLE": "Verify your connection", + "DESCRIPTION": "Chatwoot is checking number access and configuring your Meta webhook.", + "NUMBER_ACCESS": "Number access", + "TEMPLATE_ACCESS": "Template access", + "CALLBACK": "Webhook callback", + "SUBSCRIPTION": "Webhook subscription", + "WEBHOOK_URL": "Webhook URL", + "COPY": "Copy", + "COPY_SUCCESS": "Webhook URL copied to clipboard", + "COMPLETE": "Verified", + "PENDING": "Pending", + "SUCCESS": "Your WhatsApp number is connected and ready for agent assignment." + }, + "ACTIONS": { + "EXIT": "Change provider", + "BACK": "Back", + "OPEN_META_APPS": "Open Meta Apps", + "APP_READY": "My Meta app is ready", + "NEXT": "Next", + "OPEN_BUSINESS_SETTINGS": "Open Meta Business Settings", + "SHOW_TOKEN": "Show token", + "HIDE_TOKEN": "Hide token", + "VERIFY_DETAILS": "Verify details", + "CONNECT": "Connect number", + "RETRY_WEBHOOK": "Retry webhook setup", + "CONTINUE": "Continue to add agents" + }, + "ERRORS": { + "IDS_REQUIRED": "Enter the Phone Number ID and WhatsApp Business Account ID.", + "REQUIRED": "Enter the WABA ID, Phone Number ID, and permanent access token.", + "GENERIC": "We could not complete the WhatsApp setup. Check your details and try again." + } + }, "SUBMIT_BUTTON": "Create WhatsApp Channel", "EMBEDDED_SIGNUP": { "TITLE": "Quick setup with Meta", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index ec89ec930..410683387 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -49,9 +49,10 @@ const hasDuplicateInstagramInbox = computed(() => { }); const shouldShowWhatsAppWebhookDetails = computed(() => { + const source = currentInbox.value.provider_config?.source; return ( isAWhatsAppCloudChannel.value && - currentInbox.value.provider_config?.source !== 'embedded_signup' + !['embedded_signup', 'manual_setup_v2'].includes(source) ); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue index 2cebedebe..c7e7c2da6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue @@ -69,7 +69,7 @@ const items = computed(() => { :global-config="globalConfig" :items="items" /> -
+
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 3e822f796..0dc5f0c25 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -5,6 +5,7 @@ import { useI18n, I18nT } from 'vue-i18n'; import Twilio from './Twilio.vue'; import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue'; import CloudWhatsapp from './CloudWhatsapp.vue'; +import WhatsappManualSetup from './WhatsappManualSetup.vue'; import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import { useAccount } from 'dashboard/composables/useAccount'; @@ -71,14 +72,21 @@ const shouldShowCloudWhatsapp = provider => { ); }; +const isManualSetup = computed( + () => + showConfiguration.value && shouldShowCloudWhatsapp(selectedProvider.value) +); + const handleManualLinkClick = () => { selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL); };