Chatwoot Cloud customers can now provide a dedicated WhatsApp business management token when their Embedded Signup credential cannot access message templates. Once validated, the token is stored securely and used only for template synchronization. Existing inboxes continue using their configured WhatsApp API key when no business management token is present. Sending, receiving, webhooks, phone-number health, and other WhatsApp operations remain unchanged. ### Things to know - This option is available only on Chatwoot Cloud. - Saving the token verifies that `whatsapp_business_management` is granted through Meta's permissions endpoint; template synchronization still verifies access to the configured WhatsApp Business Account. - The token is encrypted using the existing external-credentials encryption mechanism. - Self-hosted installations continue using the existing API key flow. ### How to test 1. On Chatwoot Cloud, open a WhatsApp Cloud inbox and go to **Configuration**. 2. Enter a token with `whatsapp_business_management` access and save it. 3. Confirm the token is accepted and the value is not exposed again in the UI or API. 4. Select **Sync Templates** and confirm templates are fetched with the saved business management token. 5. Remove the token and confirm template synchronization falls back to the inbox API key. 6. Confirm the business management token controls are not shown on a self-hosted installation. ### What changed - Added an encrypted `business_management_token` credential to WhatsApp channels. - Added Cloud-only endpoints and UI controls to validate the required permission, save, and remove the token. - Added template-sync credential selection with API-key fallback. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
184 lines
7.0 KiB
Ruby
184 lines
7.0 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: channel_whatsapp
|
|
#
|
|
# id :bigint not null, primary key
|
|
# business_management_token :text
|
|
# message_templates :jsonb
|
|
# message_templates_last_updated :datetime
|
|
# phone_number :string not null
|
|
# phone_number_health :jsonb not null
|
|
# phone_number_health_checked_at :datetime
|
|
# phone_number_health_error :string
|
|
# provider :string default("default")
|
|
# provider_config :jsonb
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :integer not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
|
# index_channel_whatsapp_on_phone_number_health_checked_at (phone_number_health_checked_at)
|
|
#
|
|
|
|
class Channel::Whatsapp < ApplicationRecord
|
|
include Channelable
|
|
include Reauthorizable
|
|
|
|
self.table_name = 'channel_whatsapp'
|
|
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
|
encrypts :business_management_token if Chatwoot.encryption_configured?
|
|
|
|
# default at the moment is 360dialog lets change later.
|
|
PROVIDERS = %w[default whatsapp_cloud].freeze
|
|
before_validation :ensure_webhook_verify_token
|
|
|
|
validates :provider, inclusion: { in: PROVIDERS }
|
|
validates :phone_number, presence: true, uniqueness: true
|
|
validate :validate_provider_config
|
|
|
|
after_create :sync_templates
|
|
after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
|
|
before_destroy :teardown_webhooks
|
|
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
|
|
|
|
def name
|
|
'Whatsapp'
|
|
end
|
|
|
|
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
|
|
# Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys);
|
|
# only 360dialog (default provider) can't reach the call APIs.
|
|
def voice_enabled?
|
|
voice_calling_supported? &&
|
|
provider_config['calling_enabled'].present? &&
|
|
account.feature_enabled?('channel_voice')
|
|
end
|
|
|
|
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
|
|
def inbound_calls_enabled?
|
|
provider_config['inbound_calls_enabled'] != false
|
|
end
|
|
|
|
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
|
|
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
|
|
# on even though calling_enabled would persist.
|
|
def voice_calling_supported?
|
|
provider == 'whatsapp_cloud'
|
|
end
|
|
|
|
def provider_service
|
|
if provider == 'whatsapp_cloud'
|
|
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
|
else
|
|
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
|
|
end
|
|
end
|
|
|
|
def template_access_token
|
|
return provider_config['api_key'] unless ChatwootApp.chatwoot_cloud? && provider_config['source'] == 'embedded_signup'
|
|
|
|
business_management_token.presence || provider_config['api_key']
|
|
end
|
|
|
|
def serializable_hash(options = nil)
|
|
super.except('business_management_token')
|
|
end
|
|
|
|
# Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks
|
|
# with the in-memory calling_enabled flag so the `calls` field is subscribed. The
|
|
# flag is persisted only after registration succeeds, so a webhook failure can't
|
|
# leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls.
|
|
# Saved with validate: false to skip validate_provider_config's remote credential
|
|
# re-check, which could spuriously fail and desync the flag from Meta.
|
|
def enable_voice_calling!
|
|
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
|
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
|
|
|
|
provider_service.update_calling_status('ENABLED')
|
|
self.provider_config = provider_config.merge('calling_enabled' => true)
|
|
webhook_setup_service.register_callback
|
|
save!(validate: false)
|
|
end
|
|
|
|
# Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers
|
|
# webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage
|
|
# can't trap admins). Leaves Meta's WABA calling.status untouched.
|
|
def disable_voice_calling!
|
|
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
|
|
|
self.provider_config = provider_config.merge('calling_enabled' => false)
|
|
save!(validate: false)
|
|
begin
|
|
webhook_setup_service.register_callback
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
|
|
end
|
|
end
|
|
|
|
# Whether the pending (unsaved) provider_config change drops the embedded_signup
|
|
# source marker, i.e. this save is an embedded signup → manual setup transfer.
|
|
def embedded_to_manual_transfer_pending?
|
|
before, after = provider_config_change
|
|
before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
|
|
end
|
|
|
|
def mark_message_templates_updated
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
update_column(:message_templates_last_updated, Time.zone.now)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
|
|
delegate :send_message, to: :provider_service
|
|
delegate :send_template, to: :provider_service
|
|
delegate :sync_templates, to: :provider_service
|
|
delegate :media_url, to: :provider_service
|
|
delegate :api_headers, to: :provider_service
|
|
|
|
def setup_webhooks
|
|
perform_webhook_setup
|
|
rescue StandardError => e
|
|
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
|
|
prompt_reauthorization!
|
|
end
|
|
|
|
private
|
|
|
|
def ensure_webhook_verify_token
|
|
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud'
|
|
end
|
|
|
|
def validate_provider_config
|
|
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
|
end
|
|
|
|
# Logs only the embedded signup → manual migration (the save drops the
|
|
# embedded_signup source marker), so credential rotations on inboxes that are
|
|
# already manual stay silent.
|
|
def log_credentials_transfer
|
|
before, after = saved_change_to_provider_config
|
|
return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
|
|
|
|
Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}")
|
|
end
|
|
|
|
def perform_webhook_setup
|
|
webhook_setup_service.perform
|
|
end
|
|
|
|
def webhook_setup_service
|
|
Whatsapp::WebhookSetupService.new(self, provider_config['business_account_id'], provider_config['api_key'])
|
|
end
|
|
|
|
def teardown_webhooks
|
|
Whatsapp::WebhookTeardownService.new(self).perform
|
|
end
|
|
|
|
def should_auto_setup_webhooks?
|
|
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
|
|
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
|
|
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
|
|
end
|
|
end
|