diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
index 7ea58e3e5..609365e20 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
+import Voice from './channels/Voice.vue';
const channelViewList = {
facebook: Facebook,
@@ -22,6 +23,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
+ voice: Voice,
};
export default defineComponent({
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
index 8482dabc2..71f70cbf7 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
@@ -28,6 +28,7 @@ export default {
{ key: 'whatsapp', name: 'WhatsApp' },
{ key: 'sms', name: 'SMS' },
{ key: 'email', name: 'Email' },
+ { key: 'voice', name: 'Voice' },
{
key: 'api',
name: apiChannelName || 'API',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue
new file mode 100644
index 000000000..1653bc638
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
\ No newline at end of file
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..af964aadf 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
@@ -22,6 +22,7 @@ const i18nMap = {
'Channel::WebWidget': 'WEB_WIDGET',
'Channel::TwitterProfile': 'TWITTER_PROFILE',
'Channel::TwilioSms': 'TWILIO_SMS',
+ 'Channel::Voice': 'VOICE',
'Channel::Whatsapp': 'WHATSAPP',
'Channel::Sms': 'SMS',
'Channel::Email': 'EMAIL',
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 32d91fb8e..ce0f156b4 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -207,6 +207,33 @@ export const actions = {
throw error;
}
},
+ createVoiceChannel: async ({ commit }, params) => {
+ try {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+
+ // Create a formatted payload for the voice channel
+ const inboxParams = {
+ name: params.voice.name || `Voice (${params.voice.phone_number})`,
+ channel: {
+ type: 'Channel::Voice',
+ phone_number: params.voice.phone_number,
+ provider: params.voice.provider,
+ provider_config: params.voice.provider_config,
+ },
+ };
+
+ // Use InboxesAPI to create the channel which handles authentication properly
+ const response = await InboxesAPI.create(inboxParams);
+
+ 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;
+ }
+ },
createFBChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index 273e9f8b4..30112c557 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -3,6 +3,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
REPLY_TO_OUTGOING: 'replyToOutgoing',
+ VOICE_CALL: 'voiceCall',
};
// This is a single source of truth for inbox features
@@ -15,6 +16,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
+ INBOX_TYPES.VOICE,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
@@ -22,22 +24,24 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
+ INBOX_TYPES.VOICE,
],
+ [INBOX_FEATURES.VOICE_CALL]: [INBOX_TYPES.VOICE],
};
export default {
computed: {
channelType() {
- return this.inbox.channel_type;
+ return this.inbox?.channel_type || '';
},
whatsAppAPIProvider() {
- return this.inbox.provider || '';
+ return this.inbox?.provider || '';
},
isAMicrosoftInbox() {
- return this.isAnEmailChannel && this.inbox.provider === 'microsoft';
+ return this.isAnEmailChannel && this.inbox?.provider === 'microsoft';
},
isAGoogleInbox() {
- return this.isAnEmailChannel && this.inbox.provider === 'google';
+ return this.isAnEmailChannel && this.inbox?.provider === 'google';
},
isAPIInbox() {
return this.channelType === INBOX_TYPES.API;
@@ -54,6 +58,9 @@ export default {
isATwilioChannel() {
return this.channelType === INBOX_TYPES.TWILIO;
},
+ isAVoiceChannel() {
+ return this.channelType === INBOX_TYPES.VOICE;
+ },
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
diff --git a/app/jobs/webhooks/voice_transcription_job.rb b/app/jobs/webhooks/voice_transcription_job.rb
new file mode 100644
index 000000000..f4e10a803
--- /dev/null
+++ b/app/jobs/webhooks/voice_transcription_job.rb
@@ -0,0 +1,28 @@
+module Webhooks
+ # This job handles voice transcriptions from Twilio when a voice channel is configured
+ class VoiceTranscriptionJob < ApplicationJob
+ queue_as :default
+
+ def perform(params)
+ call_sid = params['CallSid']
+ transcription_text = params['TranscriptionText']
+
+ return if call_sid.blank? || transcription_text.blank?
+
+ # Find the conversation based on the call_sid stored in message metadata
+ message = Message.find_by('additional_attributes @> ?', { call_sid: call_sid }.to_json)
+ return if message.blank?
+
+ conversation = message.conversation
+
+ Message.create!(
+ account_id: conversation.account_id,
+ inbox_id: conversation.inbox_id,
+ conversation_id: conversation.id,
+ message_type: :incoming,
+ content: transcription_text,
+ sender: conversation.contact
+ )
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/models/account.rb b/app/models/account.rb
index decfc9d2e..afc8620c2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -71,6 +71,7 @@ class Account < ApplicationRecord
has_many :telegram_bots, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
+ has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
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'
diff --git a/app/models/channel/voice.rb b/app/models/channel/voice.rb
new file mode 100644
index 000000000..50a0b3be3
--- /dev/null
+++ b/app/models/channel/voice.rb
@@ -0,0 +1,51 @@
+class Channel::Voice < ApplicationRecord
+ include Channelable
+ include Rails.application.routes.url_helpers
+
+ self.table_name = 'channel_voice'
+
+ validates :phone_number, presence: true, uniqueness: true
+ validates :provider, presence: true
+
+ # Provider-specific configs stored in JSON
+ validates :provider_config, presence: true
+
+ EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze
+
+ def name
+ "#{provider.capitalize} Voice"
+ end
+
+ def initiate_call(to:)
+ case provider
+ when 'twilio'
+ initiate_twilio_call(to)
+ # Add more providers as needed
+ # when 'other_provider'
+ # initiate_other_provider_call(to)
+ else
+ raise "Unsupported voice provider: #{provider}"
+ end
+ end
+
+ private
+
+ def initiate_twilio_call(to)
+ config = provider_config_hash
+ callback_url = Rails.application.routes.url_helpers.twiml_twilio_voice_url(host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
+ params = { from: phone_number, to: to, url: callback_url }
+ twilio_client(config).calls.create(**params)
+ end
+
+ def twilio_client(config)
+ Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
+ end
+
+ def provider_config_hash
+ if provider_config.is_a?(Hash)
+ provider_config
+ else
+ JSON.parse(provider_config.to_s)
+ end
+ end
+end
\ No newline at end of file
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a4341927e..a919510c9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -31,6 +31,33 @@
en:
hello: 'Hello world'
+ INBOX_MGMT:
+ ADD:
+ TWILIO_VOICE:
+ TITLE: 'Add Twilio Voice Channel'
+ DESC: 'Integrate Twilio Voice to accept and make voice calls through Chatwoot.'
+ PHONE_NUMBER:
+ LABEL: 'Phone Number'
+ PLACEHOLDER: '+1234567890'
+ ERROR: 'Please enter a valid phone number'
+ ACCOUNT_SID:
+ LABEL: 'Account SID'
+ PLACEHOLDER: 'Enter your Twilio Account SID'
+ REQUIRED: 'Account SID is required'
+ AUTH_TOKEN:
+ LABEL: 'Auth Token'
+ PLACEHOLDER: 'Enter your Twilio Auth Token'
+ REQUIRED: 'Auth Token is required'
+ SUBMIT_BUTTON: 'Create Channel'
+ API:
+ ERROR_MESSAGE: 'Failed to create Twilio Voice channel'
+ CONVERSATION:
+ VOICE_CALL: 'Voice Call'
+ CALL_ERROR: 'Failed to initiate voice call'
+ CALL_INITIATED: 'Voice call initiated successfully'
+ CONTACT_PANEL:
+ NEW_MESSAGE: 'New Message'
+ MERGE_CONTACT: 'Merge Contact'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
diff --git a/config/routes.rb b/config/routes.rb
index 46199db12..d349f46ac 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -104,6 +104,7 @@ Rails.application.routes.draw do
post :retry
end
end
+ post :call, on: :member
resources :assignments, only: [:create]
resources :labels, only: [:create, :index]
resource :participants, only: [:show, :create, :update, :destroy]
@@ -152,6 +153,7 @@ Rails.application.routes.draw do
resources :contact_inboxes, only: [:create]
resources :labels, only: [:create, :index]
resources :notes
+ post :call, to: 'calls#create'
end
end
resources :csat_survey_responses, only: [:index] do
@@ -477,6 +479,10 @@ Rails.application.routes.draw do
namespace :twilio do
resources :callback, only: [:create]
resources :delivery_status, only: [:create]
+ resource :voice, only: [] do
+ get :twiml
+ post :transcription_callback
+ end
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
diff --git a/db/migrate/20250426130000_create_channel_voice.rb b/db/migrate/20250426130000_create_channel_voice.rb
new file mode 100644
index 000000000..18a3a8cf3
--- /dev/null
+++ b/db/migrate/20250426130000_create_channel_voice.rb
@@ -0,0 +1,15 @@
+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
+
+ t.index :phone_number, unique: true
+ end
+ end
+end
\ No newline at end of file
diff --git a/db/schema.rb b/db/schema.rb
index 494dc4fcb..5f174ffed 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.0].define(version: 2025_04_16_182131) do
+ActiveRecord::Schema[7.0].define(version: 2025_04_26_140000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -442,6 +442,17 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) 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 ["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"
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