Files
chatwoot/app/models/attachment.rb
T
de696a55cb feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
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>
2026-05-12 11:23:57 +05:30

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')