Files
moreminimore-chat/app/models/channel/whatsapp.rb
Muhsin Keloth 59eac9a7c5 feat(whatsapp): add cloud template management token (#15218)
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>
2026-07-29 13:37:59 +04:00

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