feat: add per-inbox toggle to disable incoming calls (#14645)

## Description

Adds a per-inbox "Allow incoming calls" toggle for voice-enabled
WhatsApp and Twilio inboxes. When turned off, the setting is persisted
on the channel; actually rejecting inbound calls is handled in a
follow-up PR.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## Screenshot
<img width="804" height="384" alt="Screenshot 2026-06-04 at 11 44 07 AM"
src="https://github.com/user-attachments/assets/df8bb026-0387-4031-bcba-6d9a56872eb7"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Tanmay Deep Sharma
2026-06-11 14:22:44 +05:30
committed by GitHub
parent ce93ddec78
commit 8d5d02ea97
19 changed files with 282 additions and 2 deletions
+6
View File
@@ -60,6 +60,12 @@ class Inboxes extends CacheEnabledApiClient {
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
setInboundCalls(inboxId, enabled) {
return axios.post(`${this.url}/${inboxId}/set_inbound_calls`, {
inbound_calls_enabled: enabled,
});
}
}
export default new Inboxes();
@@ -653,6 +653,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -1,9 +1,11 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxesAPI from 'dashboard/api/inboxes';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
@@ -11,6 +13,7 @@ export default {
SettingsToggleSection,
NextInput,
NextButton,
Spinner,
},
props: {
inbox: {
@@ -21,9 +24,11 @@ export default {
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
inboundCallsEnabled: this.inbox.inbound_calls_enabled !== false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
isTogglingInbound: false,
};
},
computed: {
@@ -62,8 +67,27 @@ export default {
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
'inbox.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async updateVoiceSettings() {
this.isUpdating = true;
try {
@@ -123,6 +147,24 @@ export default {
/>
</div>
<div
v-if="inbox.voice_enabled"
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
@@ -24,10 +24,13 @@ export default {
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
inboundCallsEnabled:
this.inbox.provider_config?.inbound_calls_enabled !== false,
permissionRequestBody:
this.inbox.provider_config?.call_permission_request_body || '',
isUpdating: false,
isTogglingCalling: false,
isTogglingInbound: false,
};
},
computed: {
@@ -44,8 +47,27 @@ export default {
'inbox.provider_config.call_permission_request_body'(val) {
this.permissionRequestBody = val || '';
},
'inbox.provider_config.inbound_calls_enabled'(val) {
this.inboundCallsEnabled = val !== false;
},
},
methods: {
async handleInboundToggle(newValue) {
if (this.isTogglingInbound) return;
const previousValue = this.inboundCallsEnabled;
this.inboundCallsEnabled = newValue;
this.isTogglingInbound = true;
try {
await InboxesAPI.setInboundCalls(this.inbox.id, newValue);
await this.$store.dispatch('inboxes/get', this.inbox.id);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (_) {
this.inboundCallsEnabled = previousValue;
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isTogglingInbound = false;
}
},
async handleCallingToggle(newValue) {
if (this.isTogglingCalling) return;
const previousValue = this.callingEnabled;
@@ -117,6 +139,25 @@ export default {
</div>
<template v-if="callingEnabled">
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isTogglingInbound }"
>
<SettingsToggleSection
:model-value="inboundCallsEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.INBOUND.DESCRIPTION')
"
:hide-toggle="isTogglingInbound"
@update:model-value="handleInboundToggle"
>
<template v-if="isTogglingInbound" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
+6
View File
@@ -12,6 +12,7 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# provider_config :jsonb
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
@@ -54,6 +55,11 @@ class Channel::TwilioSms < ApplicationRecord
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
+5
View File
@@ -49,6 +49,11 @@ class Channel::Whatsapp < ApplicationRecord
account.feature_enabled?('channel_voice')
end
# Mutes only the incoming side of calling; default on, so only an explicit false disables inbound.
def inbound_calls_enabled?
provider_config['inbound_calls_enabled'] != false
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
+4
View File
@@ -77,4 +77,8 @@ class InboxPolicy < ApplicationPolicy
def disable_whatsapp_calling?
@account_user.administrator?
end
def set_inbound_calls?
@account_user.administrator?
end
end
+5 -1
View File
@@ -140,6 +140,7 @@ end
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
@@ -149,4 +150,7 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.inbound_calls_enabled resource.channel.inbound_calls_enabled?
end
+1
View File
@@ -267,6 +267,7 @@ Rails.application.routes.draw do
end
post :enable_whatsapp_calling, on: :member
post :disable_whatsapp_calling, on: :member
post :set_inbound_calls, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
@@ -0,0 +1,5 @@
class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
def change
add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
end
end
+2 -1
View File
@@ -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_05_25_093000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -557,6 +557,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.jsonb "provider_config", default: {}
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -1,4 +1,6 @@
module Enterprise::Api::V1::Accounts::InboxesController
extend ActiveSupport::Concern
def inbox_attributes
super + ee_inbox_attributes
end
@@ -21,6 +23,24 @@ module Enterprise::Api::V1::Accounts::InboxesController
render_could_not_create_error(e.message)
end
# Toggles only the inbound-calls flag in provider_config. Saved with validate: false
# so WhatsApp's remote credential re-check (validate_provider_config) can't reject a
# simple toggle, mirroring enable_voice_calling!. Voice support (WhatsApp calling or
# Twilio voice) is guarded inline by ensure_inbound_calls_supported.
def set_inbound_calls
return unless ensure_inbound_calls_supported
channel = @inbox.channel
channel.provider_config = (channel.provider_config || {}).merge(
'inbound_calls_enabled' => ActiveModel::Type::Boolean.new.cast(params[:inbound_calls_enabled])
)
channel.save!(validate: false)
@inbox.update_account_cache # bump inbox cache key so the cached inbox list refetches the new flag
head :ok
rescue StandardError => e
render_could_not_create_error(e.message)
end
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
@@ -35,6 +55,14 @@ module Enterprise::Api::V1::Accounts::InboxesController
false
end
# Inbound calls can be toggled on any voice-enabled inbox (WhatsApp calling or Twilio voice).
def ensure_inbound_calls_supported
return true if @inbox.channel.try(:voice_enabled?)
render_could_not_create_error('Inbox does not support calling')
false
end
def allowed_channel_types
super + ['voice']
end
@@ -24,6 +24,8 @@ class Twilio::VoiceController < ApplicationController
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
return render xml: reject_twiml if reject_inbound?
call = resolve_call
render xml: conference_twiml(call)
end
@@ -88,6 +90,16 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
# A fresh contact-initiated leg on an inbox with inbound calls turned off.
# Reject it so no conference, conversation, or Call row is created.
def reject_inbound?
twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
end
def reject_twiml
Twilio::TwiML::VoiceResponse.new(&:reject).to_s
end
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
@@ -72,6 +72,12 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
unless inbox.channel.inbound_calls_enabled?
Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
inbox.channel.provider_service.reject_call(payload[:id])
return
end
sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
@@ -49,6 +49,59 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_inbound_calls' do
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
context 'when administrator' do
it 'disables inbound calls on a Twilio voice inbox' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be false
end
it 'enables inbound calls on a WhatsApp inbox without re-validating provider config' do
account.enable_features('channel_voice')
account.save!
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true, 'inbound_calls_enabled' => false))
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: admin.create_new_auth_token,
params: { inbound_calls_enabled: true },
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
context 'when agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'is forbidden' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
headers: agent.create_new_auth_token,
params: { inbound_calls_enabled: false },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(channel.reload.inbound_calls_enabled?).to be true
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
@@ -112,6 +112,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
}
expect(response).to have_http_status(:not_found)
end
it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
channel.update!(provider_config: { 'inbound_calls_enabled' => false })
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
expect do
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
end.not_to change(Call, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
end
end
describe 'POST /twilio/voice/status/:phone' do
@@ -38,6 +38,19 @@ RSpec.describe Channel::TwilioSms do
end
end
describe '#inbound_calls_enabled?' do
it 'returns true by default when nothing has been toggled' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel.inbound_calls_enabled?).to be true
end
it 'returns false only when explicitly disabled in provider_config' do
channel = create(:channel_twilio_sms, :with_voice, account: account,
provider_config: { 'inbound_calls_enabled' => false })
expect(channel.inbound_calls_enabled?).to be false
end
end
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
@@ -30,6 +30,20 @@ describe Whatsapp::IncomingCallService do
end
end
context 'when inbound calls are disabled on the channel' do
it 'rejects the call with Meta without creating a Call or Conversation' do
channel.provider_config = channel.provider_config.merge('inbound_calls_enabled' => false)
channel.save!
provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to not_change(Call, :count).and not_change(Conversation, :count)
expect(provider_service).to have_received(:reject_call).with(provider_call_id)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
+18
View File
@@ -248,4 +248,22 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be false
end
end
describe '#inbound_calls_enabled?' do
let(:account) { create(:account) }
it 'returns true by default when nothing has been toggled' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
expect(channel.inbound_calls_enabled?).to be true
end
it 'returns false only when explicitly disabled in provider_config' do
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('inbound_calls_enabled' => false))
expect(channel.inbound_calls_enabled?).to be false
end
end
end