diff --git a/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb new file mode 100644 index 000000000..02c2d67d0 --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -0,0 +1,67 @@ +class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController + before_action :set_whatsapp_call, only: [:accept, :reject, :terminate] + + def accept + sdp_answer = params[:sdp_answer] + return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank? + + wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).pre_accept_and_accept(sdp_answer) + render json: { id: wa_call.id, status: wa_call.status } + rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e + render json: { error: e.message }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "[WHATSAPP CALL] accept failed: #{e.message}" + render json: { error: 'Failed to accept call' }, status: :internal_server_error + end + + def reject + wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).reject + render json: { id: wa_call.id, status: wa_call.status } + rescue StandardError => e + Rails.logger.error "[WHATSAPP CALL] reject failed: #{e.message}" + render json: { error: 'Failed to reject call' }, status: :internal_server_error + end + + def terminate + wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).terminate + render json: { id: wa_call.id, status: wa_call.status } + rescue StandardError => e + Rails.logger.error "[WHATSAPP CALL] terminate failed: #{e.message}" + render json: { error: 'Failed to terminate call' }, status: :internal_server_error + end + + def initiate + conversation = current_account.conversations.find(params[:conversation_id]) + error = validate_whatsapp_calling(conversation) + return render json: { error: error }, status: :unprocessable_entity if error + + contact_phone = conversation.contact&.phone_number + return render json: { error: 'Contact phone number not available' }, status: :unprocessable_entity if contact_phone.blank? + + result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+')) + return render json: { error: 'Failed to initiate call' }, status: :internal_server_error unless result + + render json: { status: 'calling', call_id: result['call_id'] } + rescue ActiveRecord::RecordNotFound + render json: { error: 'Conversation not found' }, status: :not_found + rescue StandardError => e + Rails.logger.error "[WHATSAPP CALL] initiate failed: #{e.message}" + render json: { error: 'Failed to initiate call' }, status: :internal_server_error + end + + private + + def validate_whatsapp_calling(conversation) + channel = conversation.inbox.channel + return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud' + return 'Calling is not enabled for this inbox' unless channel.provider_config['calling_enabled'] + + nil + end + + def set_whatsapp_call + @whatsapp_call = current_account.whatsapp_calls.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Call not found' }, status: :not_found + end +end diff --git a/app/javascript/dashboard/api/whatsappCalls.js b/app/javascript/dashboard/api/whatsappCalls.js new file mode 100644 index 000000000..cadb16325 --- /dev/null +++ b/app/javascript/dashboard/api/whatsappCalls.js @@ -0,0 +1,42 @@ +/* global axios */ +class WhatsappCallsAPI { + constructor() { + this.apiVersion = '/api/v1'; + } + + // eslint-disable-next-line class-methods-use-this + get accountIdFromRoute() { + const isInsideAccountScopedURLs = + window.location.pathname.includes('/app/accounts'); + if (isInsideAccountScopedURLs) { + return window.location.pathname.split('/')[3]; + } + return ''; + } + + get baseUrl() { + return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/whatsapp_calls`; + } + + accept(callId, sdpAnswer) { + return axios.post(`${this.baseUrl}/${callId}/accept`, { + sdp_answer: sdpAnswer, + }); + } + + reject(callId) { + return axios.post(`${this.baseUrl}/${callId}/reject`); + } + + terminate(callId) { + return axios.post(`${this.baseUrl}/${callId}/terminate`); + } + + initiate(conversationId) { + return axios.post(`${this.baseUrl}/initiate`, { + conversation_id: conversationId, + }); + } +} + +export default new WhatsappCallsAPI(); diff --git a/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue b/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue new file mode 100644 index 000000000..1eba314db --- /dev/null +++ b/app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue @@ -0,0 +1,203 @@ + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 55252d6da..15ffe62d6 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -13,6 +13,9 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { useInbox } from 'dashboard/composables/useInbox'; import { useI18n } from 'vue-i18n'; +import WhatsappCallsAPI from 'dashboard/api/whatsappCalls'; +import { emitter } from 'shared/helpers/mitt'; +import { BUS_EVENTS } from 'shared/constants/busEvents'; const props = defineProps({ chat: { @@ -30,7 +33,8 @@ const store = useStore(); const route = useRoute(); const conversationHeader = ref(null); const { width } = useElementSize(conversationHeader); -const { isAWebWidgetInbox } = useInbox(); +const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox(); +const isInitiatingCall = ref(false); const currentChat = computed(() => store.getters.getSelectedChat); const accountId = computed(() => store.getters.getCurrentAccountId); @@ -90,6 +94,32 @@ const hasMultipleInboxes = computed( ); const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); + +const canInitiateWhatsappCall = computed(() => { + if (!isAWhatsAppCloudChannel.value) return false; + return !!inbox.value?.callingEnabled; +}); + +const initiateWhatsappCall = async () => { + if (isInitiatingCall.value || !currentChat.value?.id) return; + isInitiatingCall.value = true; + try { + await WhatsappCallsAPI.initiate(currentChat.value.id); + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: t('WHATSAPP_CALL.CALLING'), + type: 'success', + }); + } catch (err) { + const errorMessage = + err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED'); + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: errorMessage, + type: 'error', + }); + } finally { + isInitiatingCall.value = false; + } +}; + +
+ + +
+
({ + // Incoming ringing calls waiting for agent action + incomingCalls: [], + // The single active call (accepted + audio connected) + activeCall: null, + }), + + getters: { + hasIncomingCall: state => state.incomingCalls.length > 0, + hasActiveCall: state => state.activeCall !== null, + firstIncomingCall: state => state.incomingCalls[0] || null, + }, + + actions: { + addIncomingCall(callData) { + const exists = this.incomingCalls.some(c => c.callId === callData.callId); + if (exists) return; + this.incomingCalls.push(callData); + }, + + removeIncomingCall(callId) { + this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId); + }, + + setActiveCall(callData) { + this.activeCall = callData; + }, + + clearActiveCall() { + this.activeCall = null; + }, + + handleCallAcceptedByOther(callId) { + // Another agent accepted — remove from incoming list for this agent + this.removeIncomingCall(callId); + }, + + handleCallEnded(callId) { + this.removeIncomingCall(callId); + if (this.activeCall?.callId === callId) { + this.activeCall = null; + } + }, + }, +}); diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index bf8fc5425..25ba1d996 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -59,6 +59,11 @@ class Webhooks::WhatsappEventsJob < ApplicationJob end def handle_message_events(channel, params) + if call_event?(params) + handle_call_events(channel, params) + return + end + case channel.provider when 'whatsapp_cloud' Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform @@ -67,6 +72,21 @@ class Webhooks::WhatsappEventsJob < ApplicationJob end end + def handle_call_events(channel, params) + Whatsapp::IncomingCallService.new( + inbox: channel.inbox, + params: extract_call_params(params) + ).perform + end + + def call_event?(params) + params.dig(:entry, 0, :changes, 0, :field) == 'calls' + end + + def extract_call_params(params) + params.dig(:entry, 0, :changes, 0, :value) || {} + end + private def channel_is_inactive?(channel) diff --git a/app/models/account.rb b/app/models/account.rb index eabaa5c26..09fc2a9de 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -132,6 +132,7 @@ class Account < ApplicationRecord has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile' has_many :users, through: :account_users has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget' + has_many :whatsapp_calls, dependent: :destroy_async has_many :webhooks, dependent: :destroy_async has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp' has_many :working_hours, dependent: :destroy_async diff --git a/app/models/whatsapp_call.rb b/app/models/whatsapp_call.rb new file mode 100644 index 000000000..1b76abe7b --- /dev/null +++ b/app/models/whatsapp_call.rb @@ -0,0 +1,36 @@ +class WhatsappCall < ApplicationRecord + STATUSES = %w[ringing accepted rejected missed ended failed].freeze + DIRECTIONS = %w[inbound outbound].freeze + + belongs_to :account + belongs_to :inbox + belongs_to :conversation + belongs_to :accepted_by_agent, class_name: 'User', optional: true + + validates :call_id, presence: true, uniqueness: true + validates :direction, inclusion: { in: DIRECTIONS } + validates :status, inclusion: { in: STATUSES } + + scope :active, -> { where(status: %w[ringing accepted]) } + scope :ringing, -> { where(status: 'ringing') } + + def accepted? + status == 'accepted' + end + + def ringing? + status == 'ringing' + end + + def terminal? + %w[rejected missed ended failed].include?(status) + end + + def sdp_offer + meta['sdp_offer'] + end + + def ice_servers + meta['ice_servers'] || [] + end +end diff --git a/app/services/whatsapp/call_service.rb b/app/services/whatsapp/call_service.rb new file mode 100644 index 000000000..8290b0758 --- /dev/null +++ b/app/services/whatsapp/call_service.rb @@ -0,0 +1,90 @@ +class Whatsapp::CallService + pattr_initialize [:wa_call!, :agent!] + + def pre_accept_and_accept(sdp_answer) + ensure_ringing! + ensure_not_already_taken! + + provider = wa_call.inbox.channel.provider_service + call_id = wa_call.call_id + + # Step 1: pre_accept + pre_response = provider.pre_accept_call(call_id) + raise "pre_accept failed: #{pre_response}" unless pre_response + + # Step 2: accept with SDP answer (fix setup attribute as required by Meta) + fixed_sdp = fix_sdp_setup(sdp_answer) + accept_response = provider.accept_call(call_id, fixed_sdp) + raise "accept failed: #{accept_response}" unless accept_response + + wa_call.update!( + status: 'accepted', + accepted_by_agent_id: agent.id + ) + + broadcast_accepted + wa_call + end + + def reject + return if wa_call.terminal? + + provider = wa_call.inbox.channel.provider_service + provider.reject_call(wa_call.call_id) + + wa_call.update!(status: 'rejected') + broadcast_call_ended + wa_call + end + + def terminate + return if wa_call.terminal? + + provider = wa_call.inbox.channel.provider_service + provider.terminate_call(wa_call.call_id) + + wa_call.update!(status: 'ended') + broadcast_call_ended + wa_call + end + + private + + def ensure_ringing! + raise Whatsapp::CallErrors::NotRinging, 'Call is not in ringing state' unless wa_call.ringing? + end + + def ensure_not_already_taken! + raise Whatsapp::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if wa_call.accepted? + end + + def fix_sdp_setup(sdp) + sdp.gsub('a=setup:actpass', 'a=setup:active') + end + + def broadcast_accepted + payload = { + event: 'whatsapp_call.accepted', + data: { + id: wa_call.id, + call_id: wa_call.call_id, + accepted_by_agent_id: agent.id, + conversation_id: wa_call.conversation_id + } + } + ActionCable.server.broadcast("account_#{wa_call.account_id}", payload) + end + + def broadcast_call_ended + payload = { + event: 'whatsapp_call.ended', + data: { + id: wa_call.id, + call_id: wa_call.call_id, + status: wa_call.status, + conversation_id: wa_call.conversation_id + } + } + ActionCable.server.broadcast("account_#{wa_call.account_id}", payload) + end +end diff --git a/app/services/whatsapp/incoming_call_service.rb b/app/services/whatsapp/incoming_call_service.rb new file mode 100644 index 000000000..3aa605907 --- /dev/null +++ b/app/services/whatsapp/incoming_call_service.rb @@ -0,0 +1,181 @@ +class Whatsapp::IncomingCallService + pattr_initialize [:inbox!, :params!] + + def perform + calls = params[:calls] + return if calls.blank? + + calls.each do |call_payload| + process_call_event(call_payload.with_indifferent_access) + end + end + + private + + def process_call_event(call_payload) + event = call_payload[:event] + + case event + when 'call_connect' + handle_call_connect(call_payload) + when 'call_terminate' + handle_call_terminate(call_payload) + end + end + + def handle_call_connect(call_payload) + contact = find_or_create_contact("+#{call_payload[:from]}") + return unless contact + + conversation = find_or_create_conversation(contact) + return unless conversation + + direction = call_payload.fetch(:direction, 'inbound') + wa_call = create_call_record(call_payload, conversation, direction) + create_call_activity_message(conversation, 'incoming_call', direction) + broadcast_incoming_call(wa_call, contact, call_payload.dig(:session, :sdp)) + rescue ActiveRecord::RecordNotUnique + Rails.logger.warn "[WHATSAPP CALL] Duplicate call_id received: #{call_payload[:id]}" + end + + def create_call_record(call_payload, conversation, direction) + WhatsappCall.create!( + account: inbox.account, + inbox: inbox, + conversation: conversation, + call_id: call_payload[:id], + direction: direction, + status: 'ringing', + meta: { sdp_offer: call_payload.dig(:session, :sdp), ice_servers: default_ice_servers } + ) + end + + def handle_call_terminate(call_payload) + call_id = call_payload[:id] + duration = call_payload[:duration]&.to_i + end_reason = call_payload[:terminate_reason] + + wa_call = WhatsappCall.find_by(call_id: call_id) + return unless wa_call + + final_status = wa_call.accepted? ? 'ended' : 'missed' + wa_call.update!( + status: final_status, + duration_seconds: duration, + end_reason: end_reason + ) + + call_event = duration.to_i.positive? ? 'call_ended' : 'call_missed' + create_call_activity_message(wa_call.conversation, call_event, wa_call.direction, duration: duration) + broadcast_call_ended(wa_call) + end + + def find_or_create_contact(phone_number) + waid = phone_number.delete('+') + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: waid, + inbox: inbox, + contact_attributes: { + name: phone_number, + phone_number: phone_number + } + ).perform + + contact_inbox&.contact + end + + def find_or_create_conversation(contact) + contact_inbox = contact.contact_inboxes.find_by(inbox: inbox) + return unless contact_inbox + + conversation = contact_inbox.conversations.where.not(status: :resolved).last + return conversation if conversation + + ::Conversation.create!( + account_id: inbox.account_id, + inbox: inbox, + contact: contact, + contact_inbox: contact_inbox, + additional_attributes: { channel: 'whatsapp' } + ) + end + + def create_call_activity_message(conversation, event, direction, duration: nil) + content = call_activity_content(event, direction, duration) + conversation.messages.create!( + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: content, + content_attributes: { + call_event: event, + call_direction: direction, + call_duration_seconds: duration + } + ) + end + + def call_activity_content(event, direction, duration) + case event + when 'incoming_call' + direction == 'inbound' ? 'Incoming WhatsApp call' : 'Outgoing WhatsApp call' + when 'call_ended' + formatted = format_duration(duration) + "WhatsApp call ended — #{formatted}" + when 'call_missed' + 'Missed WhatsApp call' + else + 'WhatsApp call' + end + end + + def format_duration(seconds) + return '0s' if seconds.nil? || seconds.zero? + + minutes = seconds / 60 + secs = seconds % 60 + minutes.positive? ? "#{minutes}m #{secs}s" : "#{secs}s" + end + + def broadcast_incoming_call(wa_call, contact, sdp_offer) + payload = { + event: 'whatsapp_call.incoming', + data: { + id: wa_call.id, + call_id: wa_call.call_id, + direction: wa_call.direction, + inbox_id: wa_call.inbox_id, + conversation_id: wa_call.conversation_id, + caller: { + name: contact.name, + phone: contact.phone_number, + avatar: contact.avatar_url + }, + sdp_offer: sdp_offer, + ice_servers: default_ice_servers + } + } + + ActionCable.server.broadcast("account_#{inbox.account_id}", payload) + end + + def broadcast_call_ended(wa_call) + payload = { + event: 'whatsapp_call.ended', + data: { + id: wa_call.id, + call_id: wa_call.call_id, + status: wa_call.status, + duration_seconds: wa_call.duration_seconds, + conversation_id: wa_call.conversation_id + } + } + + ActionCable.server.broadcast("account_#{inbox.account_id}", payload) + end + + def default_ice_servers + [{ urls: 'stun:stun.l.google.com:19302' }] + end +end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 5b4c26196..3ac764d43 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -79,6 +79,58 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi "#{api_base_path}/v13.0/#{media_id}" end + def pre_accept_call(call_id) + response = HTTParty.post( + "#{phone_id_path}/calls/#{call_id}", + headers: api_headers, + body: { action: 'pre_accept' }.to_json + ) + response.success? + end + + def accept_call(call_id, sdp_answer) + response = HTTParty.post( + "#{phone_id_path}/calls/#{call_id}", + headers: api_headers, + body: { + action: 'accept', + sdp: sdp_answer, + sdp_type: 'answer' + }.to_json + ) + response.success? + end + + def reject_call(call_id) + response = HTTParty.post( + "#{phone_id_path}/calls/#{call_id}", + headers: api_headers, + body: { action: 'reject' }.to_json + ) + response.success? + end + + def terminate_call(call_id) + response = HTTParty.post( + "#{phone_id_path}/calls/#{call_id}", + headers: api_headers, + body: { action: 'terminate' }.to_json + ) + response.success? + end + + def initiate_call(to_phone_number) + response = HTTParty.post( + "#{phone_id_path}/calls", + headers: api_headers, + body: { + to: to_phone_number, + type: 'audio' + }.to_json + ) + response.parsed_response if response.success? + end + private def csat_template_service diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index f0b5b1ea8..181ed20c5 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -128,6 +128,7 @@ 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?) + json.calling_enabled resource.channel.try(:provider_config)&.dig('calling_enabled') || false end ## Voice Channel Attributes diff --git a/config/routes.rb b/config/routes.rb index 0992d21a2..2c6337ba6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -296,6 +296,17 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + resources :whatsapp_calls, only: [] do + member do + post :accept + post :reject + post :terminate + end + collection do + post :initiate + end + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] diff --git a/db/migrate/20260305193732_create_whatsapp_calls.rb b/db/migrate/20260305193732_create_whatsapp_calls.rb new file mode 100644 index 000000000..361ae18e2 --- /dev/null +++ b/db/migrate/20260305193732_create_whatsapp_calls.rb @@ -0,0 +1,21 @@ +class CreateWhatsappCalls < ActiveRecord::Migration[7.1] + def change + create_table :whatsapp_calls do |t| + t.bigint :account_id, null: false + t.bigint :inbox_id, null: false + t.bigint :conversation_id, null: false + t.bigint :accepted_by_agent_id + t.string :call_id, null: false + t.string :direction, null: false + t.string :status, null: false, default: 'ringing' + t.integer :duration_seconds + t.string :end_reason + t.jsonb :meta, null: false, default: {} + t.timestamps + end + + add_index :whatsapp_calls, :call_id, unique: true + add_index :whatsapp_calls, [:account_id, :conversation_id] + add_index :whatsapp_calls, [:inbox_id, :status] + end +end diff --git a/db/schema.rb b/db/schema.rb index 4bb0ca3af..a2859279d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do +ActiveRecord::Schema[7.1].define(version: 2026_03_05_193732) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -73,6 +73,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do t.integer "status", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} + t.integer "open_conversations_count", default: 0, null: false + t.integer "resolved_conversations_count", default: 0, null: false + t.integer "pending_conversations_count", default: 0, null: false + t.integer "snoozed_conversations_count", default: 0, null: false t.index ["status"], name: "index_accounts_on_status" end @@ -199,8 +203,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do t.text "description" t.integer "assignment_order", default: 0, null: false t.integer "conversation_priority", default: 0, null: false - t.integer "fair_distribution_limit", default: 100, null: false - t.integer "fair_distribution_window", default: 3600, null: false + t.integer "fair_distribution_limit", default: 1, null: false + t.integer "fair_distribution_window", default: 60, null: false t.boolean "enabled", default: true, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -442,6 +446,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do t.jsonb "provider_config", default: {} t.string "provider" t.boolean "verified_for_sending", default: false, null: false + t.integer "imap_retry_count", default: 0, null: false + t.datetime "imap_retry_after" t.index ["email"], name: "index_channel_email_on_email", unique: true t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true end @@ -1254,6 +1260,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true end + create_table "whatsapp_calls", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "inbox_id", null: false + t.bigint "conversation_id", null: false + t.bigint "accepted_by_agent_id" + t.string "call_id", null: false + t.string "direction", null: false + t.string "status", default: "ringing", null: false + t.integer "duration_seconds" + t.string "end_reason" + t.jsonb "meta", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "conversation_id"], name: "index_whatsapp_calls_on_account_id_and_conversation_id" + t.index ["call_id"], name: "index_whatsapp_calls_on_call_id", unique: true + t.index ["inbox_id", "status"], name: "index_whatsapp_calls_on_inbox_id_and_status" + end + create_table "working_hours", force: :cascade do |t| t.bigint "inbox_id" t.bigint "account_id" diff --git a/lib/whatsapp/call_errors.rb b/lib/whatsapp/call_errors.rb new file mode 100644 index 000000000..077d9e1d0 --- /dev/null +++ b/lib/whatsapp/call_errors.rb @@ -0,0 +1,5 @@ +module Whatsapp::CallErrors + class NotRinging < StandardError; end + class AlreadyAccepted < StandardError; end + class CallFailed < StandardError; end +end