diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 61d16b2ca..e7b3b197b 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
- return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
+ return unless allowed_channel_types.include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
+ def allowed_channel_types
+ %w[web_widget api email line telegram whatsapp sms]
+ end
+
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
index 9c0a24925..36dd6216e 100644
--- a/app/javascript/dashboard/components-next/icon/provider.js
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
+ 'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
index df30d7138..5860e30ea 100644
--- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
+++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
+ it('returns correct icon for Voice channel', () => {
+ const inbox = { channel_type: 'Channel::Voice' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-phone-fill');
+ });
+
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue
index ea6eb0417..ed6d7a20b 100644
--- a/app/javascript/dashboard/components-next/input/Input.vue
+++ b/app/javascript/dashboard/components-next/input/Input.vue
@@ -1,51 +1,21 @@
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
index b7b0a2c1d..4ff8b1ab1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
+ 'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 32d91fb8e..aef95d1b1 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
-
-const buildInboxData = inboxParams => {
- const formData = new FormData();
- const { channel = {}, ...inboxProperties } = inboxParams;
- Object.keys(inboxProperties).forEach(key => {
- formData.append(key, inboxProperties[key]);
- });
- const { selectedFeatureFlags, ...channelParams } = channel;
- // selectedFeatureFlags needs to be empty when creating a website channel
- if (selectedFeatureFlags) {
- if (selectedFeatureFlags.length) {
- selectedFeatureFlags.forEach(featureFlag => {
- formData.append(`channel[selected_feature_flags][]`, featureFlag);
- });
- } else {
- formData.append('channel[selected_feature_flags][]', '');
- }
- }
- Object.keys(channelParams).forEach(key => {
- formData.append(`channel[${key}]`, channel[key]);
- });
- return formData;
-};
+import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -220,6 +198,12 @@ export const actions = {
throw new Error(error);
}
},
+ ...channelActions,
+ // TODO: Extract other create channel methods to separate files to reduce file size
+ // - createChannel
+ // - createWebsiteChannel
+ // - createTwilioChannel
+ // - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
diff --git a/app/javascript/dashboard/store/modules/inboxes/channelActions.js b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
new file mode 100644
index 000000000..9975d8d1f
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
@@ -0,0 +1,52 @@
+import * as types from '../../mutation-types';
+import InboxesAPI from '../../../api/inboxes';
+import AnalyticsHelper from '../../../helper/AnalyticsHelper';
+import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
+
+export const buildInboxData = inboxParams => {
+ const formData = new FormData();
+ const { channel = {}, ...inboxProperties } = inboxParams;
+ Object.keys(inboxProperties).forEach(key => {
+ formData.append(key, inboxProperties[key]);
+ });
+ const { selectedFeatureFlags, ...channelParams } = channel;
+ // selectedFeatureFlags needs to be empty when creating a website channel
+ if (selectedFeatureFlags) {
+ if (selectedFeatureFlags.length) {
+ selectedFeatureFlags.forEach(featureFlag => {
+ formData.append(`channel[selected_feature_flags][]`, featureFlag);
+ });
+ } else {
+ formData.append('channel[selected_feature_flags][]', '');
+ }
+ }
+ Object.keys(channelParams).forEach(key => {
+ formData.append(`channel[${key}]`, channel[key]);
+ });
+ return formData;
+};
+
+const sendAnalyticsEvent = channelType => {
+ AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
+ channelType,
+ });
+};
+
+export const channelActions = {
+ createVoiceChannel: async ({ commit }, params) => {
+ try {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+ const response = await InboxesAPI.create({
+ name: params.name,
+ channel: { ...params.voice, type: 'voice' },
+ });
+ commit(types.default.ADD_INBOXES, response.data);
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ sendAnalyticsEvent('voice');
+ return response.data;
+ } catch (error) {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ throw error;
+ }
+ },
+};
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
index 8e917467a..a91addaa3 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
+ describe('#createVoiceChannel', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.post.mockResolvedValue({ data: inboxList[0] });
+ await actions.createVoiceChannel({ commit }, inboxList[0]);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.ADD_INBOXES, inboxList[0]],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
+ Error
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index 273e9f8b4..bf8ec492b 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -63,6 +63,9 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
+ isAVoiceChannel() {
+ return this.channelType === INBOX_TYPES.VOICE;
+ },
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
diff --git a/config/features.yml b/config/features.yml
index 131456c72..95f7e33d4 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -168,4 +168,8 @@
enabled: true
- name: crm_integration
display_name: CRM Integration
- enabled: false
\ No newline at end of file
+ enabled: false
+- name: channel_voice
+ display_name: Voice Channel
+ enabled: false
+ chatwoot_internal: true
diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb
new file mode 100644
index 000000000..9e2a25723
--- /dev/null
+++ b/db/migrate/20250620120000_create_channel_voice.rb
@@ -0,0 +1,16 @@
+class CreateChannelVoice < ActiveRecord::Migration[7.0]
+ def change
+ create_table :channel_voice do |t|
+ t.string :phone_number, null: false
+ t.string :provider, null: false, default: 'twilio'
+ t.jsonb :provider_config, null: false
+ t.integer :account_id, null: false
+ t.jsonb :additional_attributes, default: {}
+
+ t.timestamps
+ end
+
+ add_index :channel_voice, :phone_number, unique: true
+ add_index :channel_voice, :account_id
+ end
+end
\ No newline at end of file
diff --git a/db/schema.rb b/db/schema.rb
index d065a8ff4..fdd0cce82 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: 2025_05_23_031839) do
+ActiveRecord::Schema[7.1].define(version: 2025_06_20_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -443,6 +443,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
+ create_table "channel_voice", force: :cascade do |t|
+ t.string "phone_number", null: false
+ t.string "provider", default: "twilio", null: false
+ t.jsonb "provider_config", null: false
+ t.integer "account_id", null: false
+ t.jsonb "additional_attributes", default: {}
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_channel_voice_on_account_id"
+ t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
+ end
+
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -907,7 +919,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "header_text"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.jsonb "config", default: {"allowed_locales"=>["en"]}
+ t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index b39db609d..396c3a91d 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
+
+ private
+
+ def allowed_channel_types
+ super + ['voice']
+ end
+
+ def channel_type_from_params
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Channel::Voice
+ else
+ super
+ end
+ end
+
+ def account_channels_method
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Current.account.voice_channels
+ else
+ super
+ end
+ end
end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
new file mode 100644
index 000000000..a313129e2
--- /dev/null
+++ b/enterprise/app/models/channel/voice.rb
@@ -0,0 +1,64 @@
+# == Schema Information
+#
+# Table name: channel_voice
+#
+# id :bigint not null, primary key
+# additional_attributes :jsonb
+# phone_number :string not null
+# provider :string default("twilio"), not null
+# provider_config :jsonb not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :integer not null
+#
+# Indexes
+#
+# index_channel_voice_on_account_id (account_id)
+# index_channel_voice_on_phone_number (phone_number) UNIQUE
+#
+class Channel::Voice < ApplicationRecord
+ include Channelable
+
+ self.table_name = 'channel_voice'
+
+ validates :phone_number, presence: true, uniqueness: true
+ validates :provider, presence: true
+ validates :provider_config, presence: true
+
+ # Validate phone number format (E.164 format)
+ validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
+
+ # Provider-specific configs stored in JSON
+ validate :validate_provider_config
+
+ EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
+
+ def name
+ "Voice (#{phone_number})"
+ end
+
+ def messaging_window_enabled?
+ false
+ end
+
+ private
+
+ def validate_provider_config
+ return if provider_config.blank?
+
+ case provider
+ when 'twilio'
+ validate_twilio_config
+ end
+ end
+
+ def validate_twilio_config
+ config = provider_config.with_indifferent_access
+ required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
+
+ required_keys.each do |key|
+ errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
+ end
+ end
+end
+
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 4a573a4c4..c31b6c10e 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :copilot_threads, dependent: :destroy_async
+ has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
end
end
diff --git a/public/assets/images/dashboard/channels/voice.png b/public/assets/images/dashboard/channels/voice.png
new file mode 100644
index 000000000..7c9481faf
Binary files /dev/null and b/public/assets/images/dashboard/channels/voice.png differ
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
index 724a7b0cb..498c42abd 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
end
+
+ it 'creates a voice inbox when administrator' do
+ post "/api/v1/accounts/#{account.id}/inboxes",
+ headers: admin.create_new_auth_token,
+ params: { name: 'Voice Inbox',
+ channel: { type: 'voice', phone_number: '+15551234567',
+ provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16) } } },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Voice Inbox')
+ expect(response.body).to include('+15551234567')
+ end
end
end
diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb
new file mode 100644
index 000000000..2e52807b0
--- /dev/null
+++ b/spec/enterprise/models/channel/voice_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Channel::Voice do
+ let(:channel) { create(:channel_voice) }
+
+ it 'has a valid factory' do
+ expect(channel).to be_valid
+ end
+
+ describe 'validations' do
+ it 'validates presence of provider_config' do
+ channel.provider_config = nil
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include("can't be blank")
+ end
+
+ it 'validates presence of account_sid in provider_config' do
+ channel.provider_config = { auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of auth_token in provider_config' do
+ channel.provider_config = { account_sid: 'sid' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_sid in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_secret in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
+ end
+
+ it 'is valid with all required provider_config fields' do
+ channel.provider_config = {
+ account_sid: 'test_sid',
+ auth_token: 'test_token',
+ api_key_sid: 'test_key',
+ api_key_secret: 'test_secret'
+ }
+ expect(channel).to be_valid
+ end
+ end
+
+ describe '#name' do
+ it 'returns Voice with phone number' do
+ expect(channel.name).to include('Voice')
+ expect(channel.name).to include(channel.phone_number)
+ end
+ end
+end
diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb
new file mode 100644
index 000000000..33be75f2e
--- /dev/null
+++ b/spec/factories/channel/channel_voice.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :channel_voice, class: 'Channel::Voice' do
+ sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
+ provider_config do
+ {
+ account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16)
+ }
+ end
+ account
+
+ after(:create) do |channel_voice|
+ create(:inbox, channel: channel_voice, account: channel_voice.account)
+ end
+ end
+end