* feat(whatsapp): show contact typing and recording indicators via baileys presence Subscribe to WhatsApp presence updates via the baileys-api provider to display real-time typing and recording indicators in the dashboard. - Handle presence.update webhook events (composing, recording, paused, available) and broadcast via ActionCable - Add conversation.recording event to ActionCable, webhook, and channel listeners for parity with typing_on/typing_off - Show "typing..." / "recording..." in green text on the chat list, replacing the message preview - Show "X is typing" / "X is recording audio" in the conversation view - Add presence_subscribe provider config option (default off) to gate all subscription calls to the baileys-api - Subscribe to presence on conversation open and periodically (1 min) for the top 10 chat list conversations - Consolidate contact LID from presence.update jidAlt payload - Prevent echo-back of contact typing events to the channel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback - Filter chat list typing indicator to contact-only events - Add dedupe to presence subscribe bulk calls - Use strong parameters for conversation_ids - Remove redundant YAML quotes in swagger webhook enum Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback - Extract phone from data[:id] when JID is @s.whatsapp.net (fallback when jidAlt is absent) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback - Filter recording users in getTypingUsersText to show correct names - Add 10s timeout to presence_subscribe HTTP request Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scope typing timer per user instead of per conversation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: make presence subscribe best-effort with rescue per channel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback - Add messagePreviewClass to typing preview for consistent padding - Fix specs to use WebMock assertions instead of instance spying Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
234 lines
8.5 KiB
Ruby
234 lines
8.5 KiB
Ruby
# rubocop:disable Layout/LineLength
|
|
# == 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
|
|
# provider_connection :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_provider_connection (provider_connection) WHERE ((provider)::text = ANY (ARRAY[('baileys'::character varying)::text, ('zapi'::character varying)::text])) USING gin
|
|
#
|
|
# rubocop:enable Layout/LineLength
|
|
|
|
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 baileys zapi].freeze
|
|
before_validation :ensure_webhook_verify_token
|
|
|
|
validates :provider, inclusion: { in: PROVIDERS }
|
|
validates :phone_number, presence: true, uniqueness: true
|
|
validate :validate_provider_config
|
|
|
|
has_one :inbox, as: :channel, dependent: :destroy
|
|
|
|
after_create :sync_templates
|
|
before_destroy :teardown_webhooks
|
|
before_destroy :disconnect_channel_provider, if: -> { provider_service.respond_to?(:disconnect_channel_provider) }
|
|
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
|
|
|
|
def name
|
|
'Whatsapp'
|
|
end
|
|
|
|
def provider_service
|
|
case provider
|
|
when 'whatsapp_cloud'
|
|
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
|
when 'baileys'
|
|
Whatsapp::Providers::WhatsappBaileysService.new(whatsapp_channel: self)
|
|
when 'zapi'
|
|
Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: self)
|
|
else
|
|
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
|
|
end
|
|
end
|
|
|
|
def use_internal_host?
|
|
provider == 'baileys' && ENV.fetch('BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL', false)
|
|
end
|
|
|
|
def mark_message_templates_updated
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
update_column(:message_templates_last_updated, Time.zone.now)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
|
|
def update_provider_connection!(provider_connection)
|
|
assign_attributes(provider_connection: provider_connection)
|
|
# NOTE: Skip `validate_provider_config?` check
|
|
save!(validate: false)
|
|
end
|
|
|
|
def provider_connection_data
|
|
data = { connection: provider_connection['connection'] }
|
|
if Current.account_user&.administrator?
|
|
data[:qr_data_url] = provider_connection['qr_data_url']
|
|
data[:error] = provider_connection['error']
|
|
end
|
|
data
|
|
end
|
|
|
|
def toggle_typing_status(typing_status, conversation:)
|
|
return unless provider_service.respond_to?(:toggle_typing_status)
|
|
|
|
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
|
|
last_message = conversation.messages.last
|
|
provider_service.toggle_typing_status(typing_status, last_message: last_message, recipient_id: recipient_id)
|
|
end
|
|
|
|
def update_presence(status)
|
|
return unless provider_service.respond_to?(:update_presence)
|
|
|
|
provider_service.update_presence(status)
|
|
end
|
|
|
|
def read_messages(messages, conversation:)
|
|
return unless provider_service.respond_to?(:read_messages)
|
|
# NOTE: This is the default behavior, so `mark_as_read` being `nil` is the same as `true`.
|
|
return if provider_config&.dig('mark_as_read') == false
|
|
|
|
recipient_id = if provider == 'zapi'
|
|
conversation.contact.phone_number
|
|
else
|
|
conversation.contact.identifier || conversation.contact.phone_number
|
|
end
|
|
|
|
provider_service.read_messages(messages, recipient_id: recipient_id)
|
|
end
|
|
|
|
def unread_conversation(conversation)
|
|
return unless provider_service.respond_to?(:unread_message)
|
|
|
|
# NOTE: For the Baileys provider, the last message is required even if it is an outgoing message.
|
|
last_message = conversation.messages.last
|
|
provider_service.unread_message(conversation.contact.phone_number, last_message) if last_message
|
|
end
|
|
|
|
def disconnect_channel_provider
|
|
provider_service.disconnect_channel_provider
|
|
rescue StandardError => e
|
|
# NOTE: Don't prevent destruction if disconnect fails
|
|
Rails.logger.error "Failed to disconnect channel provider: #{e.message}"
|
|
end
|
|
|
|
def received_messages(messages, conversation)
|
|
return unless provider_service.respond_to?(:received_messages)
|
|
|
|
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
|
|
provider_service.received_messages(recipient_id, messages)
|
|
end
|
|
|
|
def on_whatsapp(phone_number)
|
|
return unless provider_service.respond_to?(:on_whatsapp)
|
|
|
|
provider_service.on_whatsapp(phone_number)
|
|
end
|
|
|
|
def delete_message(message, conversation:)
|
|
return unless provider_service.respond_to?(:delete_message)
|
|
|
|
recipient_id = if provider == 'zapi'
|
|
conversation.contact.phone_number.presence || conversation.contact.identifier
|
|
else
|
|
conversation.contact.identifier || conversation.contact.phone_number
|
|
end
|
|
return if recipient_id.blank?
|
|
|
|
provider_service.delete_message(recipient_id, message)
|
|
end
|
|
|
|
def edit_message(message, new_content, conversation:)
|
|
return unless provider_service.respond_to?(:edit_message)
|
|
|
|
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
|
|
provider_service.edit_message(recipient_id, message, new_content)
|
|
end
|
|
|
|
def sync_group(conversation, soft: false)
|
|
return unless provider_service.respond_to?(:sync_group)
|
|
|
|
provider_service.sync_group(conversation, soft: soft)
|
|
end
|
|
|
|
def allow_group_creation?
|
|
provider_service.respond_to?(:allow_group_creation?) && provider_service.allow_group_creation?
|
|
end
|
|
|
|
delegate :setup_channel_provider, to: :provider_service
|
|
delegate :presence_subscribe, to: :provider_service
|
|
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
|
|
delegate :create_group, to: :provider_service
|
|
delegate :update_group_subject, to: :provider_service
|
|
delegate :update_group_description, to: :provider_service
|
|
delegate :update_group_picture, to: :provider_service
|
|
delegate :update_group_participants, to: :provider_service
|
|
delegate :group_invite_code, to: :provider_service
|
|
delegate :revoke_group_invite, to: :provider_service
|
|
delegate :group_join_requests, to: :provider_service
|
|
delegate :handle_group_join_requests, to: :provider_service
|
|
delegate :group_leave, to: :provider_service
|
|
delegate :group_setting_update, to: :provider_service
|
|
delegate :group_join_approval_mode, to: :provider_service
|
|
delegate :group_member_add_mode, 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.in?(%w[whatsapp_cloud baileys])
|
|
end
|
|
|
|
def validate_provider_config
|
|
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
|
end
|
|
|
|
def perform_webhook_setup
|
|
business_account_id = provider_config['business_account_id']
|
|
api_key = provider_config['api_key']
|
|
|
|
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
|
|
end
|
|
|
|
def teardown_webhooks
|
|
# NOTE: Guard against double execution during destruction due to the
|
|
# `has_one :inbox, dependent: :destroy` relationship which will trigger this callback circularly
|
|
return if @webhook_teardown_initiated
|
|
|
|
@webhook_teardown_initiated = true
|
|
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
|