Adds the server-side flow that turns Meta WhatsApp Cloud Calling webhooks into Chatwoot Calls, conversations, voice_call message bubbles, and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider methods); intentionally does not include the HTTP controller, routes, or frontend (those land in PR-4 and PR-9). ## Closes - Part of the WhatsApp Cloud Calling rollout. Linear: TBD ## What changed **Webhook routing** - `app/jobs/webhooks/whatsapp_events_job.rb` — append `prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it without forking. - `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new) — overlay that prepends `handle_message_events` to intercept `field: 'calls'` payloads (route to `Whatsapp::IncomingCallService`) and `interactive.call_permission_reply` messages (route to `Whatsapp::CallPermissionReplyService`); falls through with `super` for regular messages. **Services** - `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) — gated on `provider_config['calling_enabled']`; processes `connect` (creates inbound call via `Voice::InboundCallBuilder` or transitions an existing outbound call to `in_progress`) and `terminate` events; updates conversation `additional_attributes` and broadcasts `voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`. - `enterprise/app/services/whatsapp/call_permission_reply_service.rb` (new) — handles WhatsApp interactive `call_permission_reply` replies; clears the conversation's `call_permission_requested_at` flag and broadcasts `voice_call.permission_granted` so the agent UI can re-enable the call button. **Builder/model adjustments** - `enterprise/app/services/voice/inbound_call_builder.rb` — provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops `account:` (now derived from `inbox.account` to keep the param count under rubocop's ceiling without disabling cops), uses digits-only `source_id` for WhatsApp ContactInbox (validation requires `^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio providers. - `enterprise/app/services/voice/call_message_builder.rb` — adds `create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses direct `Message.create!` (bypasses `Messages::MessageBuilder`'s incoming-on-non-Api-inbox guard, which would otherwise reject the system bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'` for Twilio. Backwards-compatible `perform!` retained for the existing Twilio call sites. - `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the `inbound`/`outbound` strings the FE expects, and `ringing?`/`in_progress?`/`terminal?` predicates used throughout the pipeline. **Outgoing-channel guard** - `app/services/base/send_on_channel_service.rb` — extends `invalid_message?` to skip messages with `content_type == 'voice_call'`. Without this, agent-initiated outbound calls (PR-4) would deliver \"WhatsApp Call\" as a text message to the contact every time. **Twilio call-site update** - `enterprise/app/controllers/twilio/voice_controller.rb` — drops the now-redundant `account: current_account` kwarg from the `Voice::InboundCallBuilder.perform!` call. **Tests** - New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb` (5 examples — calling-disabled, inbound connect, outbound connect, terminate completed, terminate no-answer, unknown event). - New: `spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb` (3 examples — accept, reject, calling-disabled). - Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb` and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to drop the `account:` kwarg from call expectations. ## How to test In `rails console` against an account with a WhatsApp inbox where `provider_config['calling_enabled']` is true: ```ruby inbox = Inbox.find(<id>) params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect', session: { sdp: 'v=0...', sdp_type: 'offer' } }] } Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform # => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble # => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide # Then terminate it: Whatsapp::IncomingCallService.new(inbox: inbox, params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] } ).perform # => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires ``` End-to-end browser flow (Meta → cable → UI) requires the controller from PR-4 and the frontend from PR-9. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
6.1 KiB
Ruby
218 lines
6.1 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: attachments
|
|
#
|
|
# id :integer not null, primary key
|
|
# coordinates_lat :float default(0.0)
|
|
# coordinates_long :float default(0.0)
|
|
# extension :string
|
|
# external_url :string
|
|
# fallback_title :string
|
|
# file_type :integer default("image")
|
|
# meta :jsonb
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :integer not null
|
|
# message_id :integer not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_attachments_on_account_id (account_id)
|
|
# index_attachments_on_message_id (message_id)
|
|
#
|
|
|
|
class Attachment < ApplicationRecord
|
|
include Rails.application.routes.url_helpers
|
|
|
|
ACCEPTABLE_FILE_TYPES = %w[
|
|
text/csv text/plain text/rtf
|
|
application/json application/pdf
|
|
application/zip application/x-7z-compressed application/vnd.rar application/x-tar
|
|
application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf
|
|
application/vnd.oasis.opendocument.text
|
|
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
|
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
|
].freeze
|
|
belongs_to :account
|
|
belongs_to :message
|
|
has_one_attached :file
|
|
before_save :set_extension
|
|
validate :acceptable_file
|
|
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
|
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
|
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11, :embed => 12 }
|
|
|
|
def push_event_data
|
|
return unless file_type
|
|
|
|
base_data.merge(metadata_for_file_type)
|
|
end
|
|
|
|
# NOTE: the URl returned does a 301 redirect to the actual file
|
|
def file_url
|
|
file.attached? ? url_for(file) : ''
|
|
end
|
|
|
|
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
|
|
def download_url
|
|
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
|
|
file.attached? ? file.blob.url : ''
|
|
end
|
|
|
|
def thumb_url
|
|
return '' unless file.attached? && image?
|
|
|
|
begin
|
|
url_for(file.representation(resize_to_fill: [250, nil]))
|
|
rescue ActiveStorage::UnrepresentableError => e
|
|
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
|
|
''
|
|
end
|
|
end
|
|
|
|
def with_attached_file?
|
|
[:image, :audio, :video, :file].include?(file_type.to_sym)
|
|
end
|
|
|
|
private
|
|
|
|
def metadata_for_file_type
|
|
case file_type.to_sym
|
|
when :location
|
|
location_metadata
|
|
when :fallback
|
|
fallback_data
|
|
when :contact
|
|
contact_metadata
|
|
when :audio
|
|
audio_metadata
|
|
when :embed
|
|
embed_data
|
|
else
|
|
file.attached? ? file_metadata : { data_url: external_url, thumb_url: '' }
|
|
end
|
|
end
|
|
|
|
def embed_data
|
|
{
|
|
data_url: external_url
|
|
}
|
|
end
|
|
|
|
def audio_metadata
|
|
audio_file_data = base_data.merge(file_metadata)
|
|
audio_file_data.merge(
|
|
{
|
|
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
|
|
# which makes <audio> elements download instead of play. Force inline so the
|
|
# call-recording chip (and any other audio bubble) can stream directly.
|
|
data_url: inline_audio_url,
|
|
transcribed_text: meta&.[]('transcribed_text') || ''
|
|
}
|
|
)
|
|
end
|
|
|
|
def inline_audio_url
|
|
return '' unless file.attached?
|
|
|
|
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
|
|
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
|
|
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
|
|
end
|
|
|
|
def file_metadata
|
|
metadata = {
|
|
extension: extension,
|
|
content_type: file.content_type,
|
|
data_url: file_url,
|
|
thumb_url: thumb_url,
|
|
file_size: file.byte_size,
|
|
width: file.metadata[:width],
|
|
height: file.metadata[:height]
|
|
}
|
|
|
|
metadata[:data_url] = metadata[:thumb_url] = external_url if instagram_incoming_message?
|
|
metadata
|
|
end
|
|
|
|
def location_metadata
|
|
{
|
|
coordinates_lat: coordinates_lat,
|
|
coordinates_long: coordinates_long,
|
|
fallback_title: fallback_title,
|
|
data_url: external_url
|
|
}
|
|
end
|
|
|
|
def fallback_data
|
|
{
|
|
fallback_title: fallback_title,
|
|
data_url: external_url
|
|
}
|
|
end
|
|
|
|
def base_data
|
|
{
|
|
id: id,
|
|
message_id: message_id,
|
|
file_type: file_type,
|
|
account_id: account_id
|
|
}
|
|
end
|
|
|
|
def contact_metadata
|
|
{
|
|
fallback_title: fallback_title,
|
|
meta: meta || {}
|
|
}
|
|
end
|
|
|
|
def instagram_incoming_message?
|
|
return false unless message.incoming?
|
|
|
|
return true if message.inbox.instagram_direct?
|
|
|
|
message.inbox.instagram? && message.conversation&.additional_attributes&.dig('type') == 'instagram_direct_message'
|
|
end
|
|
|
|
def set_extension
|
|
return unless file.attached?
|
|
return if extension.present?
|
|
|
|
self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence
|
|
end
|
|
|
|
def should_validate_file?
|
|
return unless file.attached?
|
|
# we are only limiting attachment types in case of website widget
|
|
return unless message.inbox.channel_type == 'Channel::WebWidget'
|
|
|
|
true
|
|
end
|
|
|
|
def acceptable_file
|
|
return unless should_validate_file?
|
|
|
|
validate_file_size(file.byte_size)
|
|
validate_file_content_type(file.content_type)
|
|
end
|
|
|
|
def validate_file_content_type(file_content_type)
|
|
errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
|
|
end
|
|
|
|
def validate_file_size(byte_size)
|
|
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', 40).to_i
|
|
limit_mb = 40 if limit_mb <= 0
|
|
|
|
errors.add(:file, 'size is too big') if byte_size > limit_mb.megabytes
|
|
end
|
|
|
|
def media_file?(file_content_type)
|
|
file_content_type.start_with?('image/', 'video/', 'audio/')
|
|
end
|
|
end
|
|
|
|
Attachment.include_mod_with('Concerns::Attachment')
|