Merge feat/whatsapp-call-meta-bridge into feat/whatsapp-call-ui
This commit is contained in:
@@ -300,6 +300,9 @@ en:
|
||||
issue_created: 'Linear issue %{issue_id} was created by %{user_name}'
|
||||
issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}'
|
||||
issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}'
|
||||
whatsapp_call:
|
||||
permission_requested: 'Sent a call permission request to %{contact_name}.'
|
||||
permission_granted: '%{contact_name} accepted the call permission request.'
|
||||
csat:
|
||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
||||
auto_resolve:
|
||||
|
||||
@@ -125,6 +125,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
sent = send_permission_request_safely
|
||||
if sent
|
||||
record_permission_request_wamid(sent)
|
||||
emit_permission_requested_activity
|
||||
status = 'permission_requested'
|
||||
else
|
||||
status = 'failed'
|
||||
@@ -143,12 +144,33 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
# Treat transport errors as a falsy return so we render 422 rather than 500.
|
||||
def send_permission_request_safely
|
||||
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
|
||||
provider_service.send_call_permission_request(
|
||||
@conversation.contact.phone_number.delete('+'),
|
||||
*permission_request_body_args
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Pass the inbox-level override only when present so the provider falls back
|
||||
# to the i18n default for inboxes that haven't customized the prompt.
|
||||
def permission_request_body_args
|
||||
custom_body = @conversation.inbox.channel.provider_config&.dig('call_permission_request_body').presence
|
||||
custom_body ? [custom_body] : []
|
||||
end
|
||||
|
||||
def emit_permission_requested_activity
|
||||
content = I18n.t(
|
||||
'conversations.activity.whatsapp_call.permission_requested',
|
||||
contact_name: @conversation.contact.name
|
||||
)
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
@conversation,
|
||||
{ account_id: @conversation.account_id, inbox_id: @conversation.inbox_id, message_type: :activity, content: content }
|
||||
)
|
||||
end
|
||||
|
||||
# Stash the outbound wamid so the reply webhook can match context.id back here.
|
||||
def record_permission_request_wamid(sent)
|
||||
attrs = (@conversation.additional_attributes || {}).merge(
|
||||
|
||||
@@ -23,16 +23,34 @@ module Enterprise::Webhooks::WhatsappEventsJob
|
||||
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
|
||||
end
|
||||
|
||||
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
|
||||
# Per-call_id mutex so connect/status/terminate for the same call serialize
|
||||
# across batches. Meta delivers two payload shapes under field=calls:
|
||||
# - value.calls[] → event-based (connect, terminate)
|
||||
# - value.statuses[] → status-based (RINGING, ACCEPTED) — the real pickup
|
||||
# signal for outbound; without this, only `connect` (tunnel-up) is seen
|
||||
# and timer/recorder kick off before the contact actually answers.
|
||||
def handle_call_events(channel, params)
|
||||
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
|
||||
calls.each do |call_payload|
|
||||
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
|
||||
with_lock(lock_key, 30.seconds) do
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
|
||||
Array(value[:calls]).each do |call_payload|
|
||||
with_call_lock(channel, call_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||
end
|
||||
end
|
||||
|
||||
Array(value[:statuses]).each do |status_payload|
|
||||
next unless status_payload[:type] == 'call'
|
||||
|
||||
with_call_lock(channel, status_payload[:id]) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { statuses: [status_payload] }).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_call_lock(channel, call_id, &)
|
||||
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||
inbox_id: channel.inbox.id, sender_id: "call:#{call_id}")
|
||||
with_lock(lock_key, 30.seconds, &)
|
||||
end
|
||||
|
||||
def handle_call_permission_reply(channel, params)
|
||||
|
||||
@@ -11,11 +11,23 @@ class Whatsapp::CallPermissionReplyService
|
||||
return unless conversation
|
||||
|
||||
clear_permission_flag(conversation)
|
||||
emit_permission_granted_activity(conversation)
|
||||
broadcast_permission_granted(conversation.contact, conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def emit_permission_granted_activity(conversation)
|
||||
content = I18n.t(
|
||||
'conversations.activity.whatsapp_call.permission_granted',
|
||||
contact_name: conversation.contact.name
|
||||
)
|
||||
::Conversations::ActivityMessageJob.perform_later(
|
||||
conversation,
|
||||
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, content: content }
|
||||
)
|
||||
end
|
||||
|
||||
def extract_reply_data
|
||||
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
|
||||
reply = message&.dig(:interactive, :call_permission_reply)
|
||||
|
||||
@@ -5,6 +5,7 @@ class Whatsapp::IncomingCallService
|
||||
return unless inbox.channel.voice_enabled?
|
||||
|
||||
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
|
||||
Array(params[:statuses]).each { |s| handle_status(s.with_indifferent_access) }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -17,6 +18,32 @@ class Whatsapp::IncomingCallService
|
||||
end
|
||||
end
|
||||
|
||||
# Meta's `connect` event for outbound calls fires when the WebRTC tunnel is
|
||||
# up — empirically ~20s before the contact actually answers. The real pickup
|
||||
# is reported as a separate webhook with status=ACCEPTED, and is what
|
||||
# `terminate.start_time` aligns to. Treat ACCEPTED as the pickup transition.
|
||||
def handle_status(payload)
|
||||
return unless payload[:type] == 'call'
|
||||
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
return unless call
|
||||
|
||||
case payload[:status]
|
||||
when 'ACCEPTED' then mark_outbound_accepted(call, payload)
|
||||
when 'RINGING' then nil # informational
|
||||
else Rails.logger.info "[WHATSAPP CALL] Unhandled call status: #{payload[:status]} for #{payload[:id]}"
|
||||
end
|
||||
end
|
||||
|
||||
def mark_outbound_accepted(call, payload)
|
||||
return unless call.outgoing?
|
||||
return if call.in_progress? || call.terminal?
|
||||
|
||||
started_at = Time.zone.at(payload[:timestamp].to_i) if payload[:timestamp].present?
|
||||
update_call!(call, 'in_progress', started_at: started_at || Time.current)
|
||||
broadcast(call, 'voice_call.outbound_accepted')
|
||||
end
|
||||
|
||||
def handle_connect(payload)
|
||||
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
|
||||
return create_inbound_call(payload) if call.nil?
|
||||
@@ -38,14 +65,15 @@ class Whatsapp::IncomingCallService
|
||||
broadcast_incoming(call, sdp_offer)
|
||||
end
|
||||
|
||||
# `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply
|
||||
# Meta's SDP answer so the handshake completes during ringing; the call
|
||||
# stays in `ringing` until status=ACCEPTED arrives.
|
||||
def accept_outbound_call(call, payload)
|
||||
return if call.in_progress? || call.terminal?
|
||||
|
||||
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
|
||||
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
|
||||
update_call!(call, 'in_progress',
|
||||
started_at: Time.current,
|
||||
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
call.update!(meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
|
||||
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user