feat(voice): add WhatsApp Cloud Calling provider methods (#14312)

Adds the Meta WhatsApp Cloud API surface needed for browser-based
calling. This is the second slice of the WhatsApp calling feature,
sitting on top of `feat/voice-call-model-wiring` and consumed by later
PRs (incoming-webhook pipeline, call service, frontend).

This PR ships only the provider-level HTTP wrapper and one error class.
It is feature-flag-free and does not change any user-visible behaviour
on its own — without later PRs, no caller invokes these methods.

## Linear
-
https://linear.app/chatwoot/issue/PLA-148/pr-2-meta-cloud-api-provider-methods

## What changed
- Add `Whatsapp::Providers::WhatsappCloudCallMethods`
(`enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb`)
wrapping six Meta endpoints:
- `pre_accept_call`, `accept_call`, `reject_call`, `terminate_call` —
`POST /{phone_id}/calls` with the relevant action payload.
- `send_call_permission_request` — `POST /{phone_id}/messages`
interactive `call_permission_request`.
- `initiate_call` — `POST /{phone_id}/calls` with `audio`/`offer`
session.
- Prepend the module into `Whatsapp::Providers::WhatsappCloudService`
only if defined, so OSS continues to work without the enterprise
overlay.
- Add `Voice::CallErrors::NoCallPermission`
(`enterprise/lib/voice/call_errors.rb`) — raised when Meta returns error
code `138006` from `initiate_call`. The remaining call-service errors
(`NotRinging`, `AlreadyAccepted`, `CallFailed`) will land with PR-4.

## How to test
There is no UI in this PR. Smoke-test from a Rails console with a
WhatsApp inbox configured for calling:

```ruby
inbox = Inbox.find(<id>)
svc = inbox.channel.provider_service
svc.respond_to?(:initiate_call)            # => true
svc.respond_to?(:send_call_permission_request) # => true

# Optional live calls (require a real phone + Meta call-permission opt-in):
svc.send_call_permission_request('15551234567')
svc.initiate_call('15551234567', '<sdp_offer>')
```

Failure path: `initiate_call` against a contact who has not granted call
permission should raise `Voice::CallErrors::NoCallPermission` with
Meta's user-facing message.
This commit is contained in:
Tanmay Deep Sharma
2026-05-04 12:44:19 +07:00
committed by GitHub
parent 64790ea204
commit 28ec1794f4
5 changed files with 168 additions and 0 deletions
@@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
process_response(response, message)
end
end
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
+1
View File
@@ -245,6 +245,7 @@ en:
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
@@ -0,0 +1,85 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
def accept_call(call_id, sdp_answer)
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
end
def reject_call(call_id)
call_api('reject_call', call_action_body(call_id, 'reject'))
end
def terminate_call(call_id)
call_api('terminate_call', call_action_body(call_id, 'terminate'))
end
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
return nil
end
response.parsed_response
end
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
body
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
response.success?
end
def permission_request_body(to_phone_number, body_text)
{
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
type: 'interactive',
interactive: {
type: 'call_permission_request',
action: { name: 'call_permission_request' },
body: { text: body_text }
}
}.to_json
end
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}.to_json
end
def process_initiate_call_response(response)
return response.parsed_response if response.success?
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
error_code = parsed.dig('error', 'code')
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
raise Voice::CallErrors::CallFailed, error_msg
end
end
+11
View File
@@ -0,0 +1,11 @@
module Voice::CallErrors
# Meta WhatsApp Cloud Calling error code returned when the contact has not
# granted call permission yet. See `initiate_call` in
# Enterprise::Whatsapp::Providers::WhatsappCloudService.
NO_CALL_PERMISSION_CODE = 138_006
class NoCallPermission < StandardError; end
class CallFailed < StandardError; end
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
end
@@ -0,0 +1,69 @@
require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
let(:headers) { { 'Content-Type' => 'application/json' } }
before { stub_request(:get, /message_templates/) }
describe 'call action methods' do
it 'POSTs the action body with the SDP answer and returns true on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept',
session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json)
.to_return(status: 200, body: '{}', headers: headers)
expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true
end
it 'returns false when Meta responds with a non-success status' do
stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers)
expect(service.reject_call('WACALL')).to be false
end
end
describe '#send_call_permission_request' do
it 'returns the parsed body on success' do
stub_request(:post, messages_url)
.with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive'))
.to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers)
expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }])
end
end
describe '#initiate_call' do
it 'returns the parsed body on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }])
end
it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do
stub_request(:post, calls_url).to_return(
status: 400,
body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json,
headers: headers
)
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission')
end
it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do
stub_request(:post, calls_url).to_return(status: 502, body: '<html>502 Bad Gateway</html>',
headers: { 'Content-Type' => 'text/html' })
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call')
end
end
end