The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to track embedded signup → manual migrations was firing on any WhatsApp credential change — including routine `api_key` rotations on inboxes that were already manually configured — inflating the migration count. The log now fires only for the actual migration, and uses a new tag so log searches don't match the older over-counted entries. ## How to reproduce 1. On a manually configured WhatsApp Cloud inbox, update the API key from inbox settings → Configuration. 2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER] success` line even though no migration happened; after this change it stays silent. 3. Switching an embedded signup inbox to manual setup still logs the migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`). ## What changed - `Channel::Whatsapp#log_credentials_transfer` now keys off the migration's unique signal — `provider_config['source']` changing from `embedded_signup` to absent — instead of diffing credential keys. - Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to `WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the corrected entries are searchable without matching pre-fix false positives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
168 lines
6.4 KiB
Ruby
168 lines
6.4 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: channel_whatsapp
|
|
#
|
|
# id :bigint not null, primary key
|
|
# message_templates :jsonb
|
|
# message_templates_last_updated :datetime
|
|
# phone_number :string not null
|
|
# 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
|
|
#
|
|
|
|
class Channel::Whatsapp < ApplicationRecord
|
|
include Channelable
|
|
include Reauthorizable
|
|
|
|
self.table_name = 'channel_whatsapp'
|
|
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
|
|
|
# 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
|
|
|
|
# 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
|