From 28ec1794f436821f1dd8c59096a47bf834764591 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 4 May 2026 12:44:19 +0700 Subject: [PATCH] feat(voice): add WhatsApp Cloud Calling provider methods (#14312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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', '') ``` Failure path: `initiate_call` against a contact who has not granted call permission should raise `Voice::CallErrors::NoCallPermission` with Meta's user-facing message. --- .../providers/whatsapp_cloud_service.rb | 2 + config/locales/en.yml | 1 + .../providers/whatsapp_cloud_service.rb | 85 +++++++++++++++++++ enterprise/lib/voice/call_errors.rb | 11 +++ .../providers/whatsapp_cloud_service_spec.rb | 69 +++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb create mode 100644 enterprise/lib/voice/call_errors.rb create mode 100644 spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 5b4c26196..225242fc5 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -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') diff --git a/config/locales/en.yml b/config/locales/en.yml index e4b348664..8a53e12c5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb new file mode 100644 index 000000000..552c3c389 --- /dev/null +++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb @@ -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 diff --git a/enterprise/lib/voice/call_errors.rb b/enterprise/lib/voice/call_errors.rb new file mode 100644 index 000000000..6b53ddbdc --- /dev/null +++ b/enterprise/lib/voice/call_errors.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb new file mode 100644 index 000000000..9c90d9ccb --- /dev/null +++ b/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -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: '502 Bad Gateway', + 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