Merge branch 'develop' into feat/read-only-token
This commit is contained in:
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
name: contact_name,
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
@@ -59,6 +59,11 @@ class ContactInboxWithContactBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def contact_name
|
||||
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
|
||||
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
|
||||
end
|
||||
|
||||
def find_contact
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
|
||||
@@ -40,7 +40,7 @@ class ConversationFinder
|
||||
def perform
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
mine_count, unassigned_count, all_count = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
filter_by_assignee_type
|
||||
@@ -184,6 +184,17 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
return legacy_count_for_all_conversations if @conversations.limit_value || @conversations.offset_value || @conversations.eager_loading?
|
||||
|
||||
counts = @conversations.unscope(:order).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE assignee_id = #{current_user.id})"),
|
||||
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
|
||||
Arel.sql('COUNT(*)')
|
||||
)
|
||||
counts || [0, 0, 0]
|
||||
end
|
||||
|
||||
def legacy_count_for_all_conversations
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+42
@@ -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')"
|
||||
|
||||
+41
@@ -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')"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
MAX_STRING_COLUMN_LENGTH = 255
|
||||
MAX_TEXT_COLUMN_LENGTH = 20_000
|
||||
|
||||
include Events::Types
|
||||
self.abstract_class = true
|
||||
|
||||
@@ -37,7 +40,7 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def validate_content_length(column)
|
||||
max_length = column.type == :text ? 20_000 : 255
|
||||
max_length = column.type == :text ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH
|
||||
return if self[column.name].nil? || self[column.name].length <= max_length
|
||||
|
||||
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -77,4 +77,8 @@ class InboxPolicy < ApplicationPolicy
|
||||
def disable_whatsapp_calling?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def set_inbound_calls?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ class Rack::Attack
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
|
||||
|
||||
class Request < ::Rack::Request
|
||||
# You many need to specify a method to fetch the correct remote IP address
|
||||
# You may need to specify a method to fetch the correct remote IP address
|
||||
# if the web server is behind a load balancer.
|
||||
def remote_ip
|
||||
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
|
||||
@@ -31,9 +31,9 @@ class Rack::Attack
|
||||
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
|
||||
end
|
||||
|
||||
# Rails would allow requests to paths with extentions, so lets compare against the path with extention stripped
|
||||
# Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
|
||||
# example /auth & /auth.json would both work
|
||||
def path_without_extentions
|
||||
def path_without_extensions
|
||||
path[/^[^.]+/]
|
||||
end
|
||||
end
|
||||
@@ -75,11 +75,11 @@ class Rack::Attack
|
||||
|
||||
### Prevent Brute-Force Super Admin Login Attacks ###
|
||||
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
end
|
||||
|
||||
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
|
||||
if req.path_without_extentions == '/super_admin/sign_in' && req.post?
|
||||
if req.path_without_extensions == '/super_admin/sign_in' && req.post?
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
@@ -91,7 +91,7 @@ class Rack::Attack
|
||||
# ### Prevent Brute-Force Login Attacks ###
|
||||
# Exclude MFA verification attempts from regular login throttling
|
||||
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# Skip if this is an MFA verification request
|
||||
req.ip
|
||||
end
|
||||
@@ -99,7 +99,7 @@ class Rack::Attack
|
||||
|
||||
throttle('login/email', limit: 10, period: 15.minutes) do |req|
|
||||
# Skip if this is an MFA verification request
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
@@ -110,11 +110,11 @@ class Rack::Attack
|
||||
|
||||
## Reset password throttling
|
||||
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/password' && req.post?
|
||||
req.ip if req.path_without_extensions == '/auth/password' && req.post?
|
||||
end
|
||||
|
||||
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extentions == '/auth/password' && req.post?
|
||||
if req.path_without_extensions == '/auth/password' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -122,11 +122,11 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (unauthenticated)
|
||||
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
if req.path_without_extensions == '/resend_confirmation' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
@@ -134,25 +134,25 @@ class Rack::Attack
|
||||
|
||||
## Resend confirmation throttling (authenticated)
|
||||
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
## MFA throttling - prevent brute force attacks
|
||||
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/api/v1/profile/mfa'
|
||||
if req.path_without_extensions == '/api/v1/profile/mfa'
|
||||
req.ip if req.delete? # Throttle disable attempts
|
||||
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
req.ip if req.post? # Throttle verify and backup_codes attempts
|
||||
end
|
||||
end
|
||||
|
||||
# Separate rate limiting for MFA verification attempts
|
||||
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
end
|
||||
|
||||
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
if req.path_without_extensions == '/auth/sign_in' && req.post?
|
||||
# Track by MFA token to prevent brute force on a specific token
|
||||
mfa_token = req.params['mfa_token'].presence
|
||||
(mfa_token.presence)
|
||||
@@ -161,7 +161,7 @@ class Rack::Attack
|
||||
|
||||
## Prevent Brute-Force Signup Attacks ###
|
||||
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
|
||||
end
|
||||
|
||||
##-----------------------------------------------##
|
||||
@@ -176,17 +176,17 @@ class Rack::Attack
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||||
## Prevent Conversation Bombing on Widget APIs ###
|
||||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||||
end
|
||||
|
||||
## Prevent Contact update Bombing in Widget API ###
|
||||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||||
end
|
||||
|
||||
## Prevent Conversation Bombing through multiple sessions
|
||||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||||
req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
# Sessions are used only for the super_admin dashboard (flash/CSRF), not for API auth.
|
||||
|
||||
Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
|
||||
secure_cookies = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
|
||||
|
||||
Rails.application.config.session_store :cookie_store,
|
||||
key: '_chatwoot_session',
|
||||
same_site: :lax,
|
||||
secure: secure_cookies,
|
||||
httponly: true
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -559,6 +559,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)
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.21",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.55",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
|
||||
Generated
+5
-5
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.21
|
||||
version: 1.3.21
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.55
|
||||
version: 0.0.55
|
||||
@@ -458,8 +458,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.21':
|
||||
resolution: {integrity: sha512-Y/OfXH1orK14foRcMrUees8lDjnDS9qZylVMyrstbfNyP5hbkBofsGAi6SP+5oIbPZ8iSG0sJyXtscoXpu/OFw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.55':
|
||||
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
|
||||
@@ -5128,7 +5128,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.21':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
|
||||
@@ -39,6 +39,21 @@ describe ContactInboxWithContactBuilder do
|
||||
expect(contact_inbox.inbox_id).to eq(inbox.id)
|
||||
end
|
||||
|
||||
it 'truncates long contact names before creating the contact' do
|
||||
long_name = 'a' * 300
|
||||
|
||||
contact_inbox = described_class.new(
|
||||
source_id: '123456',
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: long_name,
|
||||
email: 'testemail@example.com'
|
||||
}
|
||||
).perform
|
||||
|
||||
expect(contact_inbox.contact.name).to eq(long_name.first(ApplicationRecord::MAX_STRING_COLUMN_LENGTH))
|
||||
end
|
||||
|
||||
it 'doesnot create contact if it already exist with identifier' do
|
||||
contact_inbox = described_class.new(
|
||||
source_id: '123456',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# rubocop:disable RSpec/DescribeClass
|
||||
describe 'Session Store Configuration' do
|
||||
# rubocop:enable RSpec/DescribeClass
|
||||
|
||||
let(:session_options) { Rails.application.config.session_options }
|
||||
|
||||
it 'uses cookie_store as the session store' do
|
||||
expect(Rails.application.config.session_store).to eq(ActionDispatch::Session::CookieStore)
|
||||
end
|
||||
|
||||
it 'sets the session key' do
|
||||
expect(session_options[:key]).to eq('_chatwoot_session')
|
||||
end
|
||||
|
||||
it 'sets same_site to lax' do
|
||||
expect(session_options[:same_site]).to eq(:lax)
|
||||
end
|
||||
|
||||
it 'sets httponly to true' do
|
||||
expect(session_options[:httponly]).to be(true)
|
||||
end
|
||||
|
||||
it 'sets secure flag based on FORCE_SSL' do
|
||||
expected_secure = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
|
||||
expect(session_options[:secure]).to eq(expected_secure)
|
||||
end
|
||||
end
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user