diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 28776a8d9..586a4b4cb 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue'; import LocationBubble from './bubbles/Location.vue'; import CSATBubble from './bubbles/CSAT.vue'; import FormBubble from './bubbles/Form.vue'; +import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; @@ -280,6 +281,10 @@ const componentToRender = computed(() => { return FormBubble; } + if (props.contentType === CONTENT_TYPES.VOICE_CALL) { + return VoiceCallBubble; + } + if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) { return EmailBubble; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index aaf0cb1ba..f66f272de 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +const props = defineProps({ + hideMeta: { type: Boolean, default: false }, +}); + const { variant, orientation, inReplyTo, shouldGroupWithNext } = useMessageContext(); const { t } = useI18n(); @@ -64,6 +68,13 @@ const scrollToMessage = () => { }); }; +const shouldShowMeta = computed( + () => + !props.hideMeta && + !shouldGroupWithNext.value && + variant.value !== MESSAGE_VARIANTS.ACTIVITY +); + const replyToPreview = computed(() => { if (!inReplyTo) return ''; @@ -93,16 +104,16 @@ const replyToPreview = computed(() => { >
diff --git a/app/javascript/dashboard/composables/useVoiceCallStatus.js b/app/javascript/dashboard/composables/useVoiceCallStatus.js
new file mode 100644
index 000000000..111dab2ca
--- /dev/null
+++ b/app/javascript/dashboard/composables/useVoiceCallStatus.js
@@ -0,0 +1,161 @@
+import { computed, unref } from 'vue';
+
+const CALL_STATUSES = {
+ IN_PROGRESS: 'in-progress',
+ RINGING: 'ringing',
+ NO_ANSWER: 'no-answer',
+ BUSY: 'busy',
+ FAILED: 'failed',
+ COMPLETED: 'completed',
+ CANCELED: 'canceled',
+};
+
+const CALL_DIRECTIONS = {
+ INBOUND: 'inbound',
+ OUTBOUND: 'outbound',
+};
+
+/**
+ * Composable for handling voice call status display logic
+ * @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
+ * @param {Ref|string} directionRef - Call direction (inbound, outbound)
+ * @returns {Object} UI properties for displaying call status
+ */
+export function useVoiceCallStatus(statusRef, directionRef) {
+ const status = computed(() => unref(statusRef)?.toString());
+ const direction = computed(() => unref(directionRef)?.toString());
+
+ // Status group helpers
+ const isFailedStatus = computed(() =>
+ [
+ CALL_STATUSES.NO_ANSWER,
+ CALL_STATUSES.BUSY,
+ CALL_STATUSES.FAILED,
+ ].includes(status.value)
+ );
+ const isEndedStatus = computed(() =>
+ [CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
+ );
+ const isOutbound = computed(
+ () => direction.value === CALL_DIRECTIONS.OUTBOUND
+ );
+
+ const labelKey = computed(() => {
+ const s = status.value;
+
+ if (s === CALL_STATUSES.IN_PROGRESS) {
+ return isOutbound.value
+ ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
+ : 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
+ }
+
+ if (s === CALL_STATUSES.RINGING) {
+ return isOutbound.value
+ ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
+ : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
+ }
+
+ if (s === CALL_STATUSES.NO_ANSWER) {
+ return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
+ }
+
+ if (isFailedStatus.value) {
+ return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
+ }
+
+ if (isEndedStatus.value) {
+ return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
+ }
+
+ return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
+ });
+
+ const subtextKey = computed(() => {
+ const s = status.value;
+
+ if (s === CALL_STATUSES.RINGING) {
+ return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
+ }
+
+ if (s === CALL_STATUSES.IN_PROGRESS) {
+ return isOutbound.value
+ ? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
+ : 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
+ }
+
+ if (isFailedStatus.value) {
+ return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
+ }
+
+ if (isEndedStatus.value) {
+ return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
+ }
+
+ return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
+ });
+
+ const bubbleIconName = computed(() => {
+ const s = status.value;
+
+ if (s === CALL_STATUSES.IN_PROGRESS) {
+ return isOutbound.value
+ ? 'i-ph-phone-outgoing-fill'
+ : 'i-ph-phone-incoming-fill';
+ }
+
+ if (isFailedStatus.value) {
+ return 'i-ph-phone-x-fill';
+ }
+
+ // For ringing/completed/canceled show direction when possible
+ return isOutbound.value
+ ? 'i-ph-phone-outgoing-fill'
+ : 'i-ph-phone-incoming-fill';
+ });
+
+ const bubbleIconBg = computed(() => {
+ const s = status.value;
+
+ if (s === CALL_STATUSES.IN_PROGRESS) {
+ return 'bg-n-teal-9';
+ }
+
+ if (isFailedStatus.value) {
+ return 'bg-n-ruby-9';
+ }
+
+ if (isEndedStatus.value) {
+ return 'bg-n-slate-11';
+ }
+
+ // default (e.g., ringing)
+ return 'bg-n-teal-9 animate-pulse';
+ });
+
+ const listIconColor = computed(() => {
+ const s = status.value;
+
+ if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
+ return 'text-n-teal-9';
+ }
+
+ if (isFailedStatus.value) {
+ return 'text-n-ruby-9';
+ }
+
+ if (isEndedStatus.value) {
+ return 'text-n-slate-11';
+ }
+
+ return 'text-n-teal-9';
+ });
+
+ return {
+ status,
+ labelKey,
+ subtextKey,
+ bubbleIconName,
+ bubbleIconBg,
+ listIconColor,
+ };
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 93f375e7f..9fd39b70f 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -71,6 +71,17 @@
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
},
+ "VOICE_CALL": {
+ "INCOMING_CALL": "Incoming call",
+ "OUTGOING_CALL": "Outgoing call",
+ "CALL_IN_PROGRESS": "Call in progress",
+ "NO_ANSWER": "No answer",
+ "MISSED_CALL": "Missed call",
+ "CALL_ENDED": "Call ended",
+ "NOT_ANSWERED_YET": "Not answered yet",
+ "THEY_ANSWERED": "They answered",
+ "YOU_ANSWERED": "You answered"
+ },
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
diff --git a/app/models/message.rb b/app/models/message.rb
index 12b12b205..06be665d8 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -95,7 +95,8 @@ class Message < ApplicationRecord
incoming_email: 8,
input_csat: 9,
integrations: 10,
- sticker: 11
+ sticker: 11,
+ voice_call: 12
}
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
# [:submitted_email, :items, :submitted_values] : Used for bot message types
@@ -104,9 +105,10 @@ class Message < ApplicationRecord
# [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
+ # [:data] : Used for structured content types such as voice_call
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
- :translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
+ :translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@@ -114,6 +116,7 @@ class Message < ApplicationRecord
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') }
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
+ scope :voice_calls, -> { where(content_type: :voice_call) }
# TODO: Get rid of default scope
# https://stackoverflow.com/a/1834250/939299
diff --git a/config/routes.rb b/config/routes.rb
index 139476706..5fc602e0b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -524,6 +524,15 @@ Rails.application.routes.draw do
namespace :twilio do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]
+
+ if ChatwootApp.enterprise?
+ resource :voice, only: [], controller: 'voice' do
+ collection do
+ post 'call/:phone', action: :call_twiml
+ post 'status/:phone', action: :status
+ end
+ end
+ end
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
new file mode 100644
index 000000000..436083f14
--- /dev/null
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -0,0 +1,38 @@
+class Twilio::VoiceController < ApplicationController
+ before_action :set_inbox!
+
+ def status
+ Voice::StatusUpdateService.new(
+ account: @inbox.account,
+ call_sid: params[:CallSid],
+ call_status: params[:CallStatus]
+ ).perform
+ head :no_content
+ end
+
+ def call_twiml
+ account = @inbox.account
+ call_sid = params[:CallSid]
+ from_number = params[:From].to_s
+ to_number = params[:To].to_s
+
+ builder = Voice::InboundCallBuilder.new(
+ account: account,
+ inbox: @inbox,
+ from_number: from_number,
+ to_number: to_number,
+ call_sid: call_sid
+ ).perform
+ render xml: builder.twiml_response
+ end
+
+ private
+
+ def set_inbox!
+ # Resolve from the digits in the route param and look up exact E.164 match
+ digits = params[:phone].to_s.gsub(/\D/, '')
+ e164 = "+#{digits}"
+ channel = Channel::Voice.find_by!(phone_number: e164)
+ @inbox = channel.inbox
+ end
+end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
index 40f9070be..2662b7284 100644
--- a/enterprise/app/models/channel/voice.rb
+++ b/enterprise/app/models/channel/voice.rb
@@ -44,13 +44,13 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
- base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
- "#{base}/twilio/voice/call/#{phone_number}"
+ digits = phone_number.delete_prefix('+')
+ "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
end
def voice_status_webhook_url
- base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
- "#{base}/twilio/voice/status/#{phone_number}"
+ digits = phone_number.delete_prefix('+')
+ "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
end
private
diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb
index e137c0929..e653ad0ab 100644
--- a/enterprise/app/models/enterprise/conversation.rb
+++ b/enterprise/app/models/enterprise/conversation.rb
@@ -2,4 +2,15 @@ module Enterprise::Conversation
def list_of_keys
super + %w[sla_policy_id]
end
+
+ # Include select additional_attributes keys (call related) for update events
+ def allowed_keys?
+ return true if super
+
+ attrs_change = previous_changes['additional_attributes']
+ return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
+
+ changed_attr_keys = attrs_change[1].keys
+ changed_attr_keys.intersect?(%w[call_status])
+ end
end
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
new file mode 100644
index 000000000..f5ae18801
--- /dev/null
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -0,0 +1,82 @@
+class Voice::InboundCallBuilder
+ pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
+
+ attr_reader :conversation
+
+ def perform
+ contact = find_or_create_contact!
+ contact_inbox = find_or_create_contact_inbox!(contact)
+ @conversation = find_or_create_conversation!(contact, contact_inbox)
+ create_call_message_if_needed!
+ self
+ end
+
+ def twiml_response
+ response = Twilio::TwiML::VoiceResponse.new
+ response.say(message: 'Please wait while we connect you to an agent')
+ response.to_s
+ end
+
+ private
+
+ def find_or_create_conversation!(contact, contact_inbox)
+ account.conversations.find_or_create_by!(
+ account_id: account.id,
+ inbox_id: inbox.id,
+ identifier: call_sid
+ ) do |conv|
+ conv.contact_id = contact.id
+ conv.contact_inbox_id = contact_inbox.id
+ conv.additional_attributes = {
+ 'call_direction' => 'inbound',
+ 'call_status' => 'ringing'
+ }
+ end
+ end
+
+ def create_call_message!
+ content_attrs = call_message_content_attributes
+
+ @conversation.messages.create!(
+ account_id: account.id,
+ inbox_id: inbox.id,
+ message_type: :incoming,
+ sender: @conversation.contact,
+ content: 'Voice Call',
+ content_type: 'voice_call',
+ content_attributes: content_attrs
+ )
+ end
+
+ def create_call_message_if_needed!
+ return if @conversation.messages.voice_calls.exists?
+
+ create_call_message!
+ end
+
+ def call_message_content_attributes
+ {
+ data: {
+ call_sid: call_sid,
+ status: 'ringing',
+ conversation_id: @conversation.display_id,
+ call_direction: 'inbound',
+ from_number: from_number,
+ to_number: to_number,
+ meta: {
+ created_at: Time.current.to_i,
+ ringing_at: Time.current.to_i
+ }
+ }
+ }
+ end
+
+ def find_or_create_contact!
+ account.contacts.find_by(phone_number: from_number) ||
+ account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
+ end
+
+ def find_or_create_contact_inbox!(contact)
+ ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create!
+ end
+end
diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb
new file mode 100644
index 000000000..18152c549
--- /dev/null
+++ b/enterprise/app/services/voice/status_update_service.rb
@@ -0,0 +1,29 @@
+class Voice::StatusUpdateService
+ pattr_initialize [:account!, :call_sid!, :call_status]
+
+ def perform
+ conversation = account.conversations.find_by(identifier: call_sid)
+ return unless conversation
+ return if call_status.to_s.strip.empty?
+
+ update_conversation!(conversation)
+ update_last_call_message!(conversation)
+ end
+
+ private
+
+ def update_conversation!(conversation)
+ attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
+ conversation.update!(additional_attributes: attrs)
+ end
+
+ def update_last_call_message!(conversation)
+ msg = conversation.messages.voice_calls.order(created_at: :desc).first
+ return unless msg
+
+ data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
+ data['data'] ||= {}
+ data['data']['status'] = call_status
+ msg.update!(content_attributes: data)
+ end
+end
diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
new file mode 100644
index 000000000..0f5e4d00a
--- /dev/null
+++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Twilio::VoiceController', type: :request do
+ let(:account) { create(:account) }
+ let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
+ let(:inbox) { channel.inbox }
+ let(:digits) { channel.phone_number.delete_prefix('+') }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
+ end
+
+ describe 'POST /twilio/voice/call/:phone' do
+ let(:call_sid) { 'CA_test_call_sid_123' }
+ let(:from_number) { '+15550003333' }
+ let(:to_number) { channel.phone_number }
+
+ it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do
+ builder_double = instance_double(Voice::InboundCallBuilder)
+ expect(Voice::InboundCallBuilder).to receive(:new).with(
+ hash_including(
+ account: account,
+ inbox: inbox,
+ from_number: from_number,
+ to_number: to_number,
+ call_sid: call_sid
+ )
+ ).and_return(builder_double)
+ expect(builder_double).to receive(:perform).and_return(builder_double)
+ expect(builder_double).to receive(:twiml_response).and_return('