Date: Thu, 4 Sep 2025 12:50:49 +0530
Subject: [PATCH 13/18] fix: plain text with valid HTML not rendering [CW-5577]
(#12369)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
---
.../components-next/message/bubbles/Email/Index.vue | 7 +++++--
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
3 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue
index 07c6167f7..0c7e95c70 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t(labelKey) }}
+ {{ $t(subtextKey) }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js
index 71257f66a..77ea25533 100644
--- a/app/javascript/dashboard/components-next/message/constants.js
+++ b/app/javascript/dashboard/components-next/message/constants.js
@@ -64,6 +64,7 @@ export const CONTENT_TYPES = {
INPUT_CSAT: 'input_csat',
INTEGRATIONS: 'integrations',
STICKER: 'sticker',
+ VOICE_CALL: 'voice_call',
};
export const MEDIA_TYPES = [
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index 57958ec0f..5cc59f8c8 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
+import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
+const callStatus = computed(
+ () => props.chat.additional_attributes?.call_status
+);
+const callDirection = computed(
+ () => props.chat.additional_attributes?.call_direction
+);
+
+const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
+ useVoiceCallStatus(callStatus, callDirection);
+
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
@@ -306,14 +317,30 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
+
+
+
+ {{ $t(voiceLabelKey) }}
+
+
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('')
+
+ post "/twilio/voice/call/#{digits}", params: {
+ 'CallSid' => call_sid,
+ 'From' => from_number,
+ 'To' => to_number
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to eq('')
+ end
+
+ it 'raises not found when inbox is not present' do
+ expect(Voice::InboundCallBuilder).not_to receive(:new)
+ post '/twilio/voice/call/19998887777', params: {
+ 'CallSid' => call_sid,
+ 'From' => from_number,
+ 'To' => to_number
+ }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe 'POST /twilio/voice/status/:phone' do
+ let(:call_sid) { 'CA_status_sid_456' }
+
+ it 'invokes Voice::StatusUpdateService with expected params' do
+ service_double = instance_double(Voice::StatusUpdateService, perform: nil)
+ expect(Voice::StatusUpdateService).to receive(:new).with(
+ hash_including(
+ account: account,
+ call_sid: call_sid,
+ call_status: 'completed'
+ )
+ ).and_return(service_double)
+ expect(service_double).to receive(:perform)
+
+ post "/twilio/voice/status/#{digits}", params: {
+ 'CallSid' => call_sid,
+ 'CallStatus' => 'completed'
+ }
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ it 'raises not found when inbox is not present' do
+ expect(Voice::StatusUpdateService).not_to receive(:new)
+ post '/twilio/voice/status/18005550101', params: {
+ 'CallSid' => call_sid,
+ 'CallStatus' => 'busy'
+ }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
new file mode 100644
index 000000000..12e2d7235
--- /dev/null
+++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Voice::InboundCallBuilder do
+ let(:account) { create(:account) }
+ let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') }
+ let(:inbox) { channel.inbox }
+
+ let(:from_number) { '+15550001111' }
+ let(:to_number) { channel.phone_number }
+ let(:call_sid) { 'CA1234567890abcdef' }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
+ end
+
+ def build_and_perform
+ described_class.new(
+ account: account,
+ inbox: inbox,
+ from_number: from_number,
+ to_number: to_number,
+ call_sid: call_sid
+ ).perform
+ end
+
+ it 'creates a new conversation with inbound ringing attributes' do
+ builder = build_and_perform
+ conversation = builder.conversation
+ expect(conversation).to be_present
+ expect(conversation.account_id).to eq(account.id)
+ expect(conversation.inbox_id).to eq(inbox.id)
+ expect(conversation.identifier).to eq(call_sid)
+ expect(conversation.additional_attributes['call_direction']).to eq('inbound')
+ expect(conversation.additional_attributes['call_status']).to eq('ringing')
+ end
+
+ it 'creates a voice_call message with ringing status' do
+ builder = build_and_perform
+ conversation = builder.conversation
+ msg = conversation.messages.voice_calls.last
+ expect(msg).to be_present
+ expect(msg.message_type).to eq('incoming')
+ expect(msg.content_type).to eq('voice_call')
+ expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid)
+ expect(msg.content_attributes.dig('data', 'status')).to eq('ringing')
+ end
+
+ it 'returns TwiML that informs the caller we are connecting' do
+ builder = build_and_perform
+ xml = builder.twiml_response
+ expect(xml).to include('Please wait while we connect you to an agent')
+ expect(xml).to include(' 'inbound', 'call_status' => 'ringing' }
+ )
+ end
+ let(:message) do
+ conversation.messages.create!(
+ account_id: account.id,
+ inbox_id: inbox.id,
+ message_type: :incoming,
+ sender: contact,
+ content: 'Voice Call',
+ content_type: 'voice_call',
+ content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
+ )
+ end
+ let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
+ let(:inbox) { channel.inbox }
+ let(:from_number) { '+15550002222' }
+ let(:call_sid) { 'CATESTSTATUS123' }
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
+ end
+
+ it 'updates conversation and last voice message with call status' do
+ # Ensure records are created after stub setup
+ conversation
+ message
+
+ described_class.new(
+ account: account,
+ call_sid: call_sid,
+ call_status: 'completed'
+ ).perform
+
+ conversation.reload
+ message.reload
+
+ expect(conversation.additional_attributes['call_status']).to eq('completed')
+ expect(message.content_attributes.dig('data', 'status')).to eq('completed')
+ end
+
+ it 'no-ops when conversation not found' do
+ expect do
+ described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
+ end.not_to raise_error
+ end
+end
From 8d12cf0c6b54ff036c9cd06b03bf821032a79849 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Tue, 9 Sep 2025 11:20:58 +0530
Subject: [PATCH 17/18] chore: Upgrade Facebook API version from v17.0 to v18.0
(#12384)
Meta announced that Graph API v17.0 will reach end-of-life on September
12, 2025, requiring migration to v18.0 or higher. Updated the default
Facebook API version from v17.0 to v18.0 across configuration files in
response to Meta's deprecation notice.
---
app/controllers/dashboard_controller.rb | 2 +-
config/installation_config.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 4a2df5ee5..d81b4c9da 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -66,7 +66,7 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
- FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
+ FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 056c0baa2..caba80533 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -120,7 +120,7 @@
- name: FACEBOOK_API_VERSION
display_title: 'Facebook API Version'
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
- value: 'v17.0'
+ value: 'v18.0'
locked: false
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
display_title: 'Enable human agent'
From a4d2cb18f95c6e77c02816030dc4692ec4acfb5b Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 9 Sep 2025 12:13:35 +0530
Subject: [PATCH 18/18] feat: Add `INSTALLATION_NAME` to global config (#12376)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
This PR fixes an issue where the widget and survey page did not show the
correct installation name in the “Powered by” label, and also updates
the Help Center to use the installation name.
Fixes
[CW-5580](https://linear.app/chatwoot/issue/CW-5580/widget-footer-shows-powered-by-chatwoot-after-45x-when-installation),
https://github.com/chatwoot/chatwoot/issues/12375#event-19521953660
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Survey**
**Widget**
**HC**
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
app/controllers/api/v1/widget/configs_controller.rb | 2 +-
app/controllers/public/api/v1/portals/base_controller.rb | 2 +-
app/controllers/survey/responses_controller.rb | 2 +-
app/controllers/widgets_controller.rb | 2 +-
app/views/public/api/v1/portals/_footer.html.erb | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb
index ac88c595a..ecbddd905 100644
--- a/app/controllers/api/v1/widget/configs_controller.rb
+++ b/app/controllers/api/v1/widget/configs_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
def set_contact
diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 66b052b1e..46158bce9 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -58,6 +58,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/survey/responses_controller.rb b/app/controllers/survey/responses_controller.rb
index 8bbd0fe88..afcb3f4c0 100644
--- a/app/controllers/survey/responses_controller.rb
+++ b/app/controllers/survey/responses_controller.rb
@@ -5,6 +5,6 @@ class Survey::ResponsesController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
end
end
diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb
index 70e4c967b..4be690ffe 100644
--- a/app/controllers/widgets_controller.rb
+++ b/app/controllers/widgets_controller.rb
@@ -14,7 +14,7 @@ class WidgetsController < ActionController::Base
private
def set_global_config
- @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED')
+ @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
end
def set_web_widget
diff --git a/app/views/public/api/v1/portals/_footer.html.erb b/app/views/public/api/v1/portals/_footer.html.erb
index bfba6235b..10894d9c7 100644
--- a/app/views/public/api/v1/portals/_footer.html.erb
+++ b/app/views/public/api/v1/portals/_footer.html.erb
@@ -11,7 +11,7 @@
<%= I18n.t('public_portal.footer.made_with') %>
- <%= @global_config['BRAND_NAME'] %>
+ <%= @global_config['INSTALLATION_NAME'] %>