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>
149 lines
6.5 KiB
Ruby
149 lines
6.5 KiB
Ruby
json.id resource.id
|
|
json.avatar_url resource.try(:avatar_url)
|
|
json.channel_id resource.channel_id
|
|
json.name resource.name
|
|
json.channel_type resource.channel_type
|
|
json.greeting_enabled resource.greeting_enabled
|
|
json.greeting_message resource.greeting_message
|
|
json.working_hours_enabled resource.working_hours_enabled
|
|
json.enable_email_collect resource.enable_email_collect
|
|
json.csat_survey_enabled resource.csat_survey_enabled
|
|
json.csat_config resource.csat_config
|
|
json.enable_auto_assignment resource.enable_auto_assignment
|
|
json.auto_assignment_config resource.auto_assignment_config
|
|
json.out_of_office_message resource.out_of_office_message
|
|
json.working_hours resource.weekly_schedule
|
|
json.timezone resource.timezone
|
|
json.callback_webhook_url resource.callback_webhook_url
|
|
json.allow_messages_after_resolved resource.allow_messages_after_resolved
|
|
json.lock_to_single_conversation resource.lock_to_single_conversation
|
|
json.sender_name_type resource.sender_name_type
|
|
json.business_name resource.business_name
|
|
|
|
if resource.portal.present?
|
|
json.help_center do
|
|
json.name resource.portal.name
|
|
json.slug resource.portal.slug
|
|
end
|
|
end
|
|
|
|
## Channel specific settings
|
|
## TODO : Clean up and move the attributes into channel sub section
|
|
|
|
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
|
|
|
|
## WebWidget Attributes
|
|
json.allowed_domains resource.channel.try(:allowed_domains)
|
|
json.widget_color resource.channel.try(:widget_color)
|
|
json.website_url resource.channel.try(:website_url)
|
|
json.hmac_mandatory resource.channel.try(:hmac_mandatory)
|
|
json.welcome_title resource.channel.try(:welcome_title)
|
|
json.welcome_tagline resource.channel.try(:welcome_tagline)
|
|
json.web_widget_script resource.channel.try(:web_widget_script)
|
|
json.website_token resource.channel.try(:website_token)
|
|
json.selected_feature_flags resource.channel.try(:selected_feature_flags)
|
|
json.reply_time resource.channel.try(:reply_time)
|
|
if resource.web_widget?
|
|
json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator?
|
|
json.pre_chat_form_enabled resource.channel.try(:pre_chat_form_enabled)
|
|
json.pre_chat_form_options resource.channel.try(:pre_chat_form_options)
|
|
json.continuity_via_email resource.channel.try(:continuity_via_email)
|
|
end
|
|
|
|
## Facebook Attributes
|
|
if resource.facebook?
|
|
json.page_id resource.channel.try(:page_id)
|
|
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
|
end
|
|
|
|
## Instagram Attributes
|
|
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
|
|
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
|
|
|
## Tiktok Attributes
|
|
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
|
|
|
|
## Twilio Attributes
|
|
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
|
json.phone_number resource.channel.try(:phone_number)
|
|
json.medium resource.channel.try(:medium) if resource.twilio?
|
|
if resource.twilio?
|
|
json.content_templates resource.channel.try(:content_templates)
|
|
if Current.account_user&.administrator?
|
|
json.auth_token resource.channel.try(:auth_token)
|
|
json.account_sid resource.channel.try(:account_sid)
|
|
json.api_key_sid resource.channel.try(:api_key_sid)
|
|
end
|
|
end
|
|
|
|
if resource.email?
|
|
## Email Channel Attributes
|
|
json.email resource.channel.try(:email)
|
|
json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
|
json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
|
|
|
## IMAP
|
|
if Current.account_user&.administrator?
|
|
json.imap_login resource.channel.try(:imap_login)
|
|
json.imap_password resource.channel.try(:imap_password)
|
|
json.imap_address resource.channel.try(:imap_address)
|
|
json.imap_port resource.channel.try(:imap_port)
|
|
json.imap_enabled resource.channel.try(:imap_enabled)
|
|
json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
|
|
json.imap_authentication resource.channel.try(:imap_authentication)
|
|
|
|
if resource.channel.try(:microsoft?) || resource.channel.try(:google?) || resource.channel.try(:legacy_google?)
|
|
json.reauthorization_required resource.channel.try(:provider_config).empty? || resource.channel.try(:reauthorization_required?)
|
|
end
|
|
end
|
|
|
|
## SMTP
|
|
if Current.account_user&.administrator?
|
|
json.smtp_login resource.channel.try(:smtp_login)
|
|
json.smtp_password resource.channel.try(:smtp_password)
|
|
json.smtp_address resource.channel.try(:smtp_address)
|
|
json.smtp_port resource.channel.try(:smtp_port)
|
|
json.smtp_enabled resource.channel.try(:smtp_enabled)
|
|
json.smtp_domain resource.channel.try(:smtp_domain)
|
|
json.smtp_enable_ssl_tls resource.channel.try(:smtp_enable_ssl_tls)
|
|
json.smtp_enable_starttls_auto resource.channel.try(:smtp_enable_starttls_auto)
|
|
json.smtp_openssl_verify_mode resource.channel.try(:smtp_openssl_verify_mode)
|
|
json.smtp_authentication resource.channel.try(:smtp_authentication)
|
|
end
|
|
end
|
|
|
|
## API Channel Attributes
|
|
if resource.api?
|
|
json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator?
|
|
json.secret resource.channel.try(:secret) if Current.account_user&.administrator?
|
|
json.webhook_url resource.channel.try(:webhook_url)
|
|
json.inbox_identifier resource.channel.try(:identifier)
|
|
json.additional_attributes resource.channel.try(:additional_attributes)
|
|
end
|
|
|
|
json.provider resource.channel.try(:provider)
|
|
|
|
## Telegram Attributes
|
|
json.bot_name resource.channel.try(:bot_name) if resource.telegram?
|
|
|
|
### WhatsApp Channel
|
|
if resource.whatsapp?
|
|
json.message_templates resource.channel.try(:message_templates)
|
|
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
|
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
|
end
|
|
|
|
## Voice attributes for TwilioSms
|
|
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
|
json.voice_enabled resource.channel.voice_enabled?
|
|
json.voice_configured resource.channel.try(:twiml_app_sid).present?
|
|
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
|
|
if resource.channel.try(:twiml_app_sid).present?
|
|
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
|
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
|
end
|
|
end
|
|
|
|
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
|
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|