Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc86f93879 | ||
|
|
baf860016f | ||
|
|
ecc4f1403d | ||
|
|
e58600d1b9 | ||
|
|
3e5b2979eb | ||
|
|
bd698cb12c | ||
|
|
6ea17e700f | ||
|
|
da58cbd24a | ||
|
|
a31f95cf45 | ||
|
|
d932156dec | ||
|
|
2cbc14e366 | ||
|
|
548360583e | ||
|
|
79381a4c5f | ||
|
|
d57170712d | ||
|
|
e7be8c14bd | ||
|
|
62376701da | ||
|
|
d2e6d6aee3 | ||
|
|
53c21e6ad3 | ||
|
|
d408f664cb | ||
|
|
20d97be4c9 | ||
|
|
6365a7ea53 | ||
|
|
2adc040a8f | ||
|
|
86da3f7c06 | ||
|
|
c22a31c198 |
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
|
|||||||
# Set to true if you want to remove stale contact inboxes
|
# Set to true if you want to remove stale contact inboxes
|
||||||
# contact_inboxes with no conversation older than 90 days will be removed
|
# contact_inboxes with no conversation older than 90 days will be removed
|
||||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||||
|
|
||||||
|
# REDIS_ALFRED_SIZE=10
|
||||||
|
|||||||
+16
-13
@@ -140,24 +140,27 @@ GEM
|
|||||||
actionmailbox (>= 7.1.0)
|
actionmailbox (>= 7.1.0)
|
||||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.760.0)
|
aws-partitions (1.1198.0)
|
||||||
aws-sdk-core (3.188.0)
|
aws-sdk-core (3.240.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.64.0)
|
logger
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-kms (1.118.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sdk-core (~> 3, >= 3.239.1)
|
||||||
aws-sdk-s3 (1.126.0)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-core (~> 3, >= 3.174.0)
|
aws-sdk-s3 (1.208.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.234.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-sns (1.70.0)
|
aws-sdk-sns (1.70.0)
|
||||||
aws-sdk-core (~> 3, >= 3.188.0)
|
aws-sdk-core (~> 3, >= 3.188.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (1.5.2)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
multi_json (~> 1)
|
multi_json (~> 1)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.8.0
|
4.9.1
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||||
|
|
||||||
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||||
@automation_rule.actions = params[:actions]
|
@automation_rule.actions = actions
|
||||||
@automation_rule.conditions = params[:conditions]
|
@automation_rule.conditions = params[:conditions]
|
||||||
|
|
||||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
||||||
|
|
||||||
@automation_rule.save!
|
@automation_rule.save!
|
||||||
process_attachments
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
@automation_rule
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
ActiveRecord::Base.transaction do
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
||||||
automation_rule_update
|
return render_could_not_create_error(error) if error
|
||||||
process_attachments
|
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@automation_rule.assign_attributes(automation_rules_permit)
|
||||||
|
@automation_rule.actions = actions if params[:actions]
|
||||||
|
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||||
|
@automation_rule.save!
|
||||||
|
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@automation_rule.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
|||||||
@automation_rule = new_rule
|
@automation_rule = new_rule
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@automation_rule.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def automation_rule_update
|
|
||||||
@automation_rule.update!(automation_rules_permit)
|
|
||||||
@automation_rule.actions = params[:actions] if params[:actions]
|
|
||||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
|
||||||
@automation_rule.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def automation_rules_permit
|
def automation_rules_permit
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :description, :event_name, :account_id, :active,
|
:name, :description, :event_name, :active,
|
||||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:twilio_channel).permit(
|
params.require(:twilio_channel).permit(
|
||||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||||
|
include AttachmentConcern
|
||||||
|
|
||||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||||
|
|
||||||
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
@macro.actions = params[:actions]
|
@macro.actions = actions
|
||||||
|
|
||||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
||||||
|
|
||||||
@macro.save!
|
@macro.save!
|
||||||
process_attachments
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
@macro
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
||||||
|
return render_could_not_create_error(error) if error
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@macro.update!(macros_with_user)
|
@macro.assign_attributes(macros_with_user)
|
||||||
@macro.set_visibility(current_user, permitted_params)
|
@macro.set_visibility(current_user, permitted_params)
|
||||||
process_attachments
|
@macro.actions = actions if params[:actions]
|
||||||
@macro.save!
|
@macro.save!
|
||||||
|
blobs.each { |blob| @macro.files.attach(blob) }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error e
|
Rails.logger.error e
|
||||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
render_could_not_create_error(@macro.errors.messages)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_attachments
|
|
||||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
|
||||||
return if actions.blank?
|
|
||||||
|
|
||||||
actions.each do |action|
|
|
||||||
blob_id = action['action_params']
|
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
|
||||||
@macro.files.attach(blob)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(
|
params.permit(
|
||||||
:name, :account_id, :visibility,
|
:name, :visibility,
|
||||||
actions: [:action_name, { action_params: [] }]
|
actions: [:action_name, { action_params: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def process_attached_logo
|
def process_attached_logo
|
||||||
blob_id = params[:blob_id]
|
blob_id = params[:blob_id]
|
||||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||||
@portal.logo.attach(blob)
|
@portal.logo.attach(blob)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_success(file_blob)
|
def render_success(file_blob)
|
||||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error(message, status)
|
def render_error(message, status)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module AttachmentConcern
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def validate_and_prepare_attachments(actions, record = nil)
|
||||||
|
blobs = []
|
||||||
|
return [blobs, actions, nil] if actions.blank?
|
||||||
|
|
||||||
|
sanitized = actions.map do |action|
|
||||||
|
next action unless action[:action_name] == 'send_attachment'
|
||||||
|
|
||||||
|
result = process_attachment_action(action, record, blobs)
|
||||||
|
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
[blobs, sanitized, nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_attachment_action(action, record, blobs)
|
||||||
|
blob_id = action[:action_params].first
|
||||||
|
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||||
|
|
||||||
|
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||||
|
return action if blob_already_attached?(record, blob_id)
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def blob_already_attached?(record, blob_id)
|
||||||
|
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Device } from '@twilio/voice-sdk';
|
||||||
|
import VoiceAPI from './voiceAPIClient';
|
||||||
|
|
||||||
|
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||||
|
|
||||||
|
class TwilioVoiceClient extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.device = null;
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeDevice(inboxId) {
|
||||||
|
this.destroyDevice();
|
||||||
|
|
||||||
|
const response = await VoiceAPI.getToken(inboxId);
|
||||||
|
const { token, account_id } = response || {};
|
||||||
|
if (!token) throw new Error('Invalid token');
|
||||||
|
|
||||||
|
this.device = new Device(token, {
|
||||||
|
allowIncomingWhileBusy: true,
|
||||||
|
disableAudioContextSounds: true,
|
||||||
|
appParams: { account_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.removeAllListeners();
|
||||||
|
this.device.on('connect', conn => {
|
||||||
|
this.activeConnection = conn;
|
||||||
|
conn.on('disconnect', this.onDisconnect);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
this.device.on('tokenWillExpire', async () => {
|
||||||
|
const r = await VoiceAPI.getToken(this.inboxId);
|
||||||
|
if (r?.token) this.device.updateToken(r.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
this.inboxId = inboxId;
|
||||||
|
|
||||||
|
return this.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveConnection() {
|
||||||
|
return !!this.activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
endClientCall() {
|
||||||
|
if (this.activeConnection) {
|
||||||
|
this.activeConnection.disconnect();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
if (this.device) {
|
||||||
|
this.device.disconnectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyDevice() {
|
||||||
|
if (this.device) {
|
||||||
|
this.device.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.device = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinClientCall({ to, conversationId }) {
|
||||||
|
if (!this.device || !this.initialized || !to) return null;
|
||||||
|
if (this.activeConnection) return this.activeConnection;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
To: to,
|
||||||
|
is_agent: 'true',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = await this.device.connect({ params });
|
||||||
|
this.activeConnection = connection;
|
||||||
|
|
||||||
|
connection.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect = () => {
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.dispatchEvent(createCallDisconnectedEvent());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TwilioVoiceClient();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
import ContactsAPI from '../../contacts';
|
||||||
|
|
||||||
|
class VoiceAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('voice', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveConference(inboxId, conversationId) {
|
||||||
|
return axios
|
||||||
|
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
params: { conversation_id: conversationId },
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinConference({ conversationId, inboxId, callSid }) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
call_sid: callSid,
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(inboxId) {
|
||||||
|
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||||
|
return axios
|
||||||
|
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VoiceAPI();
|
||||||
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
|||||||
syncTemplates(inboxId) {
|
syncTemplates(inboxId) {
|
||||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createCSATTemplate(inboxId, template) {
|
||||||
|
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||||
|
template,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCSATTemplateStatus(inboxId) {
|
||||||
|
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Inboxes();
|
export default new Inboxes();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
|
|||||||
contactId: props.contactId,
|
contactId: props.contactId,
|
||||||
inboxId,
|
inboxId,
|
||||||
});
|
});
|
||||||
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
|
// Add call to store immediately so widget shows
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callDirection: 'outbound',
|
||||||
|
});
|
||||||
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
navigateToConversation(response?.conversation_id);
|
navigateToConversation(response?.conversation_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+7
-4
@@ -171,10 +171,13 @@ const onPaste = e => {
|
|||||||
const files = e.clipboardData?.files;
|
const files = e.clipboardData?.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
|
|
||||||
Array.from(files).forEach(file => {
|
// Filter valid files (non-zero size)
|
||||||
const { name, type, size } = file;
|
Array.from(files)
|
||||||
onFileUpload({ file, name, type, size });
|
.filter(file => file.size > 0)
|
||||||
});
|
.forEach(file => {
|
||||||
|
const { name, type, size } = file;
|
||||||
|
onFileUpload({ file, name, type, size });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEventListener(document, 'paste', onPaste);
|
useEventListener(document, 'paste', onPaste);
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
|
|||||||
solid:
|
solid:
|
||||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||||
outline:
|
outline:
|
||||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||||
|
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||||
|
<span
|
||||||
|
v-dompurify-html="message.content"
|
||||||
|
class="text-sm font-medium prose prose-bubble"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
joinCall,
|
||||||
|
endCall: endCallSession,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
formattedCallDuration,
|
||||||
|
} = useCallSession();
|
||||||
|
|
||||||
|
const getCallInfo = call => {
|
||||||
|
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||||
|
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||||
|
const sender = conversation?.meta?.sender;
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
inbox,
|
||||||
|
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||||
|
inboxName: inbox?.name || 'Customer support',
|
||||||
|
avatar: sender?.avatar || sender?.thumbnail,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = async () => {
|
||||||
|
const call = activeCall.value;
|
||||||
|
if (!call) return;
|
||||||
|
|
||||||
|
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||||
|
if (!inboxId) return;
|
||||||
|
|
||||||
|
await endCallSession({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinCall = async call => {
|
||||||
|
const { conversation } = getCallInfo(call);
|
||||||
|
if (!call || !conversation || isJoining.value) return;
|
||||||
|
|
||||||
|
// End current active call before joining new one
|
||||||
|
if (hasActiveCall.value) {
|
||||||
|
await handleEndCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await joinCall({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId: conversation.inbox_id,
|
||||||
|
callSid: call.callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join outbound calls when window is visible
|
||||||
|
watch(
|
||||||
|
() => incomingCalls.value[0],
|
||||||
|
call => {
|
||||||
|
if (
|
||||||
|
call?.callDirection === 'outbound' &&
|
||||||
|
!hasActiveCall.value &&
|
||||||
|
WindowVisibilityHelper.isWindowVisible()
|
||||||
|
) {
|
||||||
|
handleJoinCall(call);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="incomingCalls.length || hasActiveCall"
|
||||||
|
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||||
|
>
|
||||||
|
<!-- Incoming Calls (shown above active call) -->
|
||||||
|
<div
|
||||||
|
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||||
|
:key="call.callSid"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(call).avatar"
|
||||||
|
:name="getCallInfo(call).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(call).contactName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-n-slate-11 truncate">
|
||||||
|
{{ getCallInfo(call).inboxName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="dismissCall(call.callSid)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(call)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Call Widget -->
|
||||||
|
<div
|
||||||
|
v-if="hasActiveCall || incomingCalls.length"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||||
|
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||||
|
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||||
|
</p>
|
||||||
|
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||||
|
{{ formattedCallDuration }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-n-slate-11">
|
||||||
|
{{
|
||||||
|
incomingCalls[0]?.callDirection === 'outbound'
|
||||||
|
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||||
|
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="
|
||||||
|
hasActiveCall
|
||||||
|
? handleEndCall()
|
||||||
|
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!hasActiveCall"
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(incomingCalls[0])"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -676,11 +676,18 @@ function createEditorView() {
|
|||||||
typingIndicator.stop();
|
typingIndicator.stop();
|
||||||
emit('blur');
|
emit('blur');
|
||||||
},
|
},
|
||||||
paste: (_view, event) => {
|
paste: (view, event) => {
|
||||||
if (props.disabled) return;
|
if (props.disabled) return;
|
||||||
const data = event.clipboardData.files;
|
const { files } = event.clipboardData;
|
||||||
if (data.length > 0) {
|
if (!files.length) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
|
||||||
|
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
|
||||||
|
// while ReplyBox filters and handles valid file attachments
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
if (text) {
|
||||||
|
view.dispatch(view.state.tr.insertText(text));
|
||||||
|
emitOnChange();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -852,6 +859,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .ProseMirror {
|
> .ProseMirror {
|
||||||
|
|||||||
+3
@@ -41,6 +41,9 @@ const getTemplateType = template => {
|
|||||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||||
}
|
}
|
||||||
|
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
|
||||||
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
|
||||||
|
}
|
||||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -692,20 +692,20 @@ export default {
|
|||||||
},
|
},
|
||||||
onPaste(e) {
|
onPaste(e) {
|
||||||
// Don't handle paste if compose new conversation modal is open
|
// Don't handle paste if compose new conversation modal is open
|
||||||
if (this.newConversationModalActive) {
|
if (this.newConversationModalActive) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = e.clipboardData.files;
|
const data = e.clipboardData.files;
|
||||||
if (!this.showRichContentEditor && data.length !== 0) {
|
if (!this.showRichContentEditor && data.length !== 0) {
|
||||||
this.$refs.messageInput?.$el?.blur();
|
this.$refs.messageInput?.$el?.blur();
|
||||||
}
|
}
|
||||||
if (!data.length || !data[0]) {
|
|
||||||
return;
|
// Filter valid files (non-zero size)
|
||||||
}
|
Array.from(e.clipboardData.files)
|
||||||
data.forEach(file => {
|
.filter(file => file.size > 0)
|
||||||
const { name, type, size } = file;
|
.forEach(file => {
|
||||||
this.onFileUpload({ name, type, size, file: file });
|
const { name, type, size } = file;
|
||||||
});
|
this.onFileUpload({ name, type, size, file });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
toggleUserMention(currentMentionState) {
|
toggleUserMention(currentMentionState) {
|
||||||
this.showUserMentions = currentMentionState;
|
this.showUserMentions = currentMentionState;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
|
export function useCallSession() {
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
const isJoining = ref(false);
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const durationTimer = new Timer(elapsed => {
|
||||||
|
callDuration.value = elapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hasActiveCall,
|
||||||
|
active => {
|
||||||
|
if (active) {
|
||||||
|
durationTimer.start();
|
||||||
|
} else {
|
||||||
|
durationTimer.stop();
|
||||||
|
callDuration.value = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
durationTimer.stop();
|
||||||
|
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endCall = async ({ conversationId, inboxId }) => {
|
||||||
|
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
durationTimer.stop();
|
||||||
|
callsStore.clearActiveCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
|
if (isJoining.value) return null;
|
||||||
|
|
||||||
|
isJoining.value = true;
|
||||||
|
try {
|
||||||
|
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const joinResponse = await VoiceAPI.joinConference({
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TwilioVoiceClient.joinClientCall({
|
||||||
|
to: joinResponse?.conference_sid,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
callsStore.setCallActive(callSid);
|
||||||
|
durationTimer.start();
|
||||||
|
|
||||||
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to join call:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isJoining.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectIncomingCall = callSid => {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissCall = callSid => {
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
formattedCallDuration,
|
||||||
|
joinCall,
|
||||||
|
endCall,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -140,6 +140,11 @@ export const FORMATTING = {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||||
},
|
},
|
||||||
|
'Context::Plain': {
|
||||||
|
marks: [],
|
||||||
|
nodes: [],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Editor menu options for Full Editor
|
// Editor menu options for Full Editor
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export default class Timer {
|
||||||
|
constructor(onTick = null) {
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = null;
|
||||||
|
this.onTick = onTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.elapsed += 1;
|
||||||
|
if (this.onTick) {
|
||||||
|
this.onTick(this.elapsed);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Timer from '../Timer';
|
||||||
|
|
||||||
|
describe('Timer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('initializes with elapsed 0 and no interval', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an onTick callback', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
expect(timer.onTick).toBe(onTick);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('starts the timer and increments elapsed every second', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTick callback with elapsed value', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(2);
|
||||||
|
|
||||||
|
expect(onTick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets elapsed to 0 when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears previous interval when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
const firstIntervalId = timer.intervalId;
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.intervalId).not.toBe(firstIntervalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('stops the timer and resets elapsed to 0', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(3);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents further increments after stopping', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
timer.stop();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stop when timer is not running', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(() => timer.stop()).not.toThrow();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import types from 'dashboard/store/mutation-types';
|
||||||
|
|
||||||
|
export const TERMINAL_STATUSES = [
|
||||||
|
'completed',
|
||||||
|
'busy',
|
||||||
|
'failed',
|
||||||
|
'no-answer',
|
||||||
|
'canceled',
|
||||||
|
'missed',
|
||||||
|
'ended',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isInbound = direction => direction === 'inbound';
|
||||||
|
|
||||||
|
const isVoiceCallMessage = message => {
|
||||||
|
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||||
|
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractCallData(message) {
|
||||||
|
const contentData = message?.content_attributes?.data || {};
|
||||||
|
return {
|
||||||
|
callSid: contentData.call_sid,
|
||||||
|
status: contentData.status,
|
||||||
|
callDirection: contentData.call_direction,
|
||||||
|
conversationId: message?.conversation_id,
|
||||||
|
senderId: message?.sender?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleVoiceCallCreated(message, currentUserId) {
|
||||||
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
|
const { callSid, callDirection, conversationId, senderId } =
|
||||||
|
extractCallData(message);
|
||||||
|
|
||||||
|
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||||
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
|
const { callSid, status, callDirection, conversationId, senderId } =
|
||||||
|
extractCallData(message);
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
|
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||||
|
|
||||||
|
const callInfo = { conversationId, callStatus: status };
|
||||||
|
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||||
|
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||||
|
|
||||||
|
const isNewCall =
|
||||||
|
status === 'ringing' &&
|
||||||
|
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||||
|
|
||||||
|
if (isNewCall) {
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"TYPES": {
|
"TYPES": {
|
||||||
"MEDIA": "Media",
|
"MEDIA": "Media",
|
||||||
"QUICK_REPLY": "Quick Reply",
|
"QUICK_REPLY": "Quick Reply",
|
||||||
|
"CALL_TO_ACTION": "Call to Action",
|
||||||
"TEXT": "Text"
|
"TEXT": "Text"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -275,6 +275,16 @@
|
|||||||
"SIDEBAR": {
|
"SIDEBAR": {
|
||||||
"CONTACT": "Contact",
|
"CONTACT": "Contact",
|
||||||
"COPILOT": "Copilot"
|
"COPILOT": "Copilot"
|
||||||
|
},
|
||||||
|
"VOICE_WIDGET": {
|
||||||
|
"INCOMING_CALL": "Incoming call",
|
||||||
|
"OUTGOING_CALL": "Outgoing call",
|
||||||
|
"CALL_IN_PROGRESS": "Call in progress",
|
||||||
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
|
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||||
|
"REJECT_CALL": "Reject",
|
||||||
|
"JOIN_CALL": "Join call",
|
||||||
|
"END_CALL": "End call"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EMAIL_TRANSCRIPT": {
|
"EMAIL_TRANSCRIPT": {
|
||||||
|
|||||||
@@ -808,6 +808,35 @@
|
|||||||
"LABEL": "Message",
|
"LABEL": "Message",
|
||||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||||
},
|
},
|
||||||
|
"BUTTON_TEXT": {
|
||||||
|
"LABEL": "Button text",
|
||||||
|
"PLACEHOLDER": "Please rate us"
|
||||||
|
},
|
||||||
|
"LANGUAGE": {
|
||||||
|
"LABEL": "Language",
|
||||||
|
"PLACEHOLDER": "Select template language"
|
||||||
|
},
|
||||||
|
"MESSAGE_PREVIEW": {
|
||||||
|
"LABEL": "Message preview",
|
||||||
|
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||||
|
},
|
||||||
|
"TEMPLATE_STATUS": {
|
||||||
|
"APPROVED": "Approved by WhatsApp",
|
||||||
|
"PENDING": "Pending WhatsApp approval",
|
||||||
|
"REJECTED": "Meta rejected the template",
|
||||||
|
"DEFAULT": "Needs WhatsApp approval",
|
||||||
|
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||||
|
},
|
||||||
|
"TEMPLATE_CREATION": {
|
||||||
|
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||||
|
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||||
|
},
|
||||||
|
"TEMPLATE_UPDATE_DIALOG": {
|
||||||
|
"TITLE": "Edit survey details",
|
||||||
|
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||||
|
"CONFIRM": "Create new template",
|
||||||
|
"CANCEL": "Go back"
|
||||||
|
},
|
||||||
"SURVEY_RULE": {
|
"SURVEY_RULE": {
|
||||||
"LABEL": "Survey rule",
|
"LABEL": "Survey rule",
|
||||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||||
@@ -819,6 +848,7 @@
|
|||||||
"SELECT_PLACEHOLDER": "select labels"
|
"SELECT_PLACEHOLDER": "select labels"
|
||||||
},
|
},
|
||||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||||
|
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { defineAsyncComponent, ref } from 'vue';
|
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||||
|
|
||||||
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||||
@@ -16,10 +16,15 @@ const CommandBar = defineAsyncComponent(
|
|||||||
() => import('./commands/commandbar.vue')
|
() => import('./commands/commandbar.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FloatingCallWidget = defineAsyncComponent(
|
||||||
|
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||||
|
);
|
||||||
|
|
||||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||||
|
|
||||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -30,6 +35,7 @@ export default {
|
|||||||
UpgradePage,
|
UpgradePage,
|
||||||
CopilotLauncher,
|
CopilotLauncher,
|
||||||
CopilotContainer,
|
CopilotContainer,
|
||||||
|
FloatingCallWidget,
|
||||||
MobileSidebarLauncher,
|
MobileSidebarLauncher,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -37,6 +43,7 @@ export default {
|
|||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
const { accountId } = useAccount();
|
const { accountId } = useAccount();
|
||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -44,6 +51,8 @@ export default {
|
|||||||
accountId,
|
accountId,
|
||||||
upgradePageRef,
|
upgradePageRef,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
|
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||||
|
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -151,6 +160,7 @@ export default {
|
|||||||
@toggle="toggleMobileSidebar"
|
@toggle="toggleMobileSidebar"
|
||||||
/>
|
/>
|
||||||
<CopilotContainer />
|
<CopilotContainer />
|
||||||
|
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||||
</template>
|
</template>
|
||||||
<AddAccountModal
|
<AddAccountModal
|
||||||
:show="showCreateAccountModal"
|
:show="showCreateAccountModal"
|
||||||
|
|||||||
@@ -150,8 +150,6 @@ const derivedAttributes = computed(() =>
|
|||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
:is-loading="uiFlags.isFetching"
|
:is-loading="uiFlags.isFetching"
|
||||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||||
:no-records-found="!attributes.length"
|
|
||||||
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
|
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<BaseSettingsHeader
|
<BaseSettingsHeader
|
||||||
@@ -177,7 +175,7 @@ const derivedAttributes = computed(() =>
|
|||||||
class="max-w-xl"
|
class="max-w-xl"
|
||||||
@tab-changed="onClickTabChange"
|
@tab-changed="onClickTabChange"
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-3">
|
<div v-if="derivedAttributes.length" class="grid gap-3">
|
||||||
<AttributeListItem
|
<AttributeListItem
|
||||||
v-for="attribute in derivedAttributes"
|
v-for="attribute in derivedAttributes"
|
||||||
:key="attribute.id"
|
:key="attribute.id"
|
||||||
@@ -187,6 +185,12 @@ const derivedAttributes = computed(() =>
|
|||||||
@delete="handleDeleteAttribute"
|
@delete="handleDeleteAttribute"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
|
||||||
|
>
|
||||||
|
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<AddAttribute
|
<AddAttribute
|
||||||
|
|||||||
+339
-11
@@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||||
|
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
||||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||||
|
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import Switch from 'next/switch/Switch.vue';
|
import Switch from 'next/switch/Switch.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||||
|
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
inbox: { type: Object, required: true },
|
inbox: { type: Object, required: true },
|
||||||
@@ -21,6 +28,10 @@ const { t } = useI18n();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const labels = useMapGetter('labels/getLabels');
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
|
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
|
||||||
|
props.inbox?.id
|
||||||
|
);
|
||||||
|
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
const selectedLabelValues = ref([]);
|
const selectedLabelValues = ref([]);
|
||||||
const currentLabel = ref('');
|
const currentLabel = ref('');
|
||||||
@@ -29,7 +40,19 @@ const state = reactive({
|
|||||||
csatSurveyEnabled: false,
|
csatSurveyEnabled: false,
|
||||||
displayType: 'emoji',
|
displayType: 'emoji',
|
||||||
message: '',
|
message: '',
|
||||||
|
templateButtonText: 'Please rate us',
|
||||||
surveyRuleOperator: 'contains',
|
surveyRuleOperator: 'contains',
|
||||||
|
templateLanguage: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateStatus = ref(null);
|
||||||
|
const templateLoading = ref(false);
|
||||||
|
const confirmDialog = ref(null);
|
||||||
|
|
||||||
|
const originalTemplateValues = ref({
|
||||||
|
message: '',
|
||||||
|
templateButtonText: '',
|
||||||
|
templateLanguage: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterTypes = [
|
const filterTypes = [
|
||||||
@@ -51,6 +74,59 @@ const labelOptions = computed(() =>
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const languageOptions = computed(() =>
|
||||||
|
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const messagePreviewData = computed(() => ({
|
||||||
|
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const shouldShowTemplateStatus = computed(
|
||||||
|
() => templateStatus.value && !templateLoading.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const templateApprovalStatus = computed(() => {
|
||||||
|
const statusMap = {
|
||||||
|
APPROVED: {
|
||||||
|
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
color: 'text-n-teal-11',
|
||||||
|
},
|
||||||
|
PENDING: {
|
||||||
|
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
|
||||||
|
icon: 'i-lucide-clock',
|
||||||
|
color: 'text-n-amber-11',
|
||||||
|
},
|
||||||
|
REJECTED: {
|
||||||
|
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
color: 'text-n-ruby-10',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle template not found case
|
||||||
|
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
|
||||||
|
return {
|
||||||
|
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
|
||||||
|
icon: 'i-lucide-alert-triangle',
|
||||||
|
color: 'text-n-ruby-10',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle existing template with status
|
||||||
|
if (templateStatus.value?.template_exists && templateStatus.value.status) {
|
||||||
|
return statusMap[templateStatus.value.status] || statusMap.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default case - no template exists
|
||||||
|
return {
|
||||||
|
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
|
||||||
|
icon: 'i-lucide-stamp',
|
||||||
|
color: 'text-n-slate-11',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const initializeState = () => {
|
const initializeState = () => {
|
||||||
if (!props.inbox) return;
|
if (!props.inbox) return;
|
||||||
|
|
||||||
@@ -63,21 +139,63 @@ const initializeState = () => {
|
|||||||
const {
|
const {
|
||||||
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
|
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
|
||||||
message = '',
|
message = '',
|
||||||
|
button_text: buttonText = 'Please rate us',
|
||||||
|
language = 'en',
|
||||||
survey_rules: surveyRules = {},
|
survey_rules: surveyRules = {},
|
||||||
} = csat_config;
|
} = csat_config;
|
||||||
|
|
||||||
state.displayType = displayType;
|
state.displayType = displayType;
|
||||||
state.message = message;
|
state.message = message;
|
||||||
|
state.templateButtonText = buttonText;
|
||||||
|
state.templateLanguage = language;
|
||||||
state.surveyRuleOperator = surveyRules.operator || 'contains';
|
state.surveyRuleOperator = surveyRules.operator || 'contains';
|
||||||
|
|
||||||
selectedLabelValues.value = Array.isArray(surveyRules.values)
|
selectedLabelValues.value = Array.isArray(surveyRules.values)
|
||||||
? [...surveyRules.values]
|
? [...surveyRules.values]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Store original template values for change detection
|
||||||
|
if (isWhatsAppChannel.value) {
|
||||||
|
originalTemplateValues.value = {
|
||||||
|
message: state.message,
|
||||||
|
templateButtonText: state.templateButtonText,
|
||||||
|
templateLanguage: state.templateLanguage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkTemplateStatus = async () => {
|
||||||
|
if (!isWhatsAppChannel.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
templateLoading.value = true;
|
||||||
|
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
|
||||||
|
inboxId: props.inbox.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle case where template doesn't exist
|
||||||
|
if (!response.template_exists && response.error === 'Template not found') {
|
||||||
|
templateStatus.value = {
|
||||||
|
template_exists: false,
|
||||||
|
error: 'TEMPLATE_NOT_FOUND',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
templateStatus.value = response;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
templateStatus.value = {
|
||||||
|
template_exists: false,
|
||||||
|
error: 'API_ERROR',
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
templateLoading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeState();
|
initializeState();
|
||||||
if (!labels.value?.length) store.dispatch('labels/get');
|
if (!labels.value?.length) store.dispatch('labels/get');
|
||||||
|
if (isWhatsAppChannel.value) checkTemplateStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.inbox, initializeState, { immediate: true });
|
watch(() => props.inbox, initializeState, { immediate: true });
|
||||||
@@ -105,6 +223,49 @@ const removeLabel = label => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if template-related fields have changed
|
||||||
|
const hasTemplateChanges = () => {
|
||||||
|
if (!isWhatsAppChannel.value) return false;
|
||||||
|
|
||||||
|
const original = originalTemplateValues.value;
|
||||||
|
return (
|
||||||
|
original.message !== state.message ||
|
||||||
|
original.templateButtonText !== state.templateButtonText ||
|
||||||
|
original.templateLanguage !== state.templateLanguage
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's an existing template
|
||||||
|
const hasExistingTemplate = () => {
|
||||||
|
const { template_exists, error } = templateStatus.value || {};
|
||||||
|
return template_exists && !error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if we should create a template
|
||||||
|
const shouldCreateTemplate = () => {
|
||||||
|
// Create template if no existing template
|
||||||
|
if (!hasExistingTemplate()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create template if there are changes to template fields
|
||||||
|
return hasTemplateChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build template config for saving
|
||||||
|
const buildTemplateConfig = () => {
|
||||||
|
if (!hasExistingTemplate()) return null;
|
||||||
|
|
||||||
|
const { template_name, template_id, template, status } =
|
||||||
|
templateStatus.value || {};
|
||||||
|
return {
|
||||||
|
name: template_name,
|
||||||
|
template_id,
|
||||||
|
language: template?.language || state.templateLanguage,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const updateInbox = async attributes => {
|
const updateInbox = async attributes => {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: props.inbox.id,
|
id: props.inbox.id,
|
||||||
@@ -115,31 +276,103 @@ const updateInbox = async attributes => {
|
|||||||
return store.dispatch('inboxes/updateInbox', payload);
|
return store.dispatch('inboxes/updateInbox', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const createTemplate = async () => {
|
||||||
|
if (!isWhatsAppChannel.value) return null;
|
||||||
|
|
||||||
|
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
||||||
|
inboxId: props.inbox.id,
|
||||||
|
template: {
|
||||||
|
message: state.message,
|
||||||
|
button_text: state.templateButtonText,
|
||||||
|
language: state.templateLanguage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
|
||||||
|
return response.template;
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSave = async () => {
|
||||||
try {
|
try {
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
|
let newTemplateData = null;
|
||||||
|
|
||||||
|
// For WhatsApp channels, create template first if needed
|
||||||
|
if (
|
||||||
|
isWhatsAppChannel.value &&
|
||||||
|
state.csatSurveyEnabled &&
|
||||||
|
shouldCreateTemplate()
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
newTemplateData = await createTemplate();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.error ||
|
||||||
|
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const csatConfig = {
|
const csatConfig = {
|
||||||
display_type: state.displayType,
|
display_type: state.displayType,
|
||||||
message: state.message,
|
message: state.message,
|
||||||
|
button_text: state.templateButtonText,
|
||||||
|
language: state.templateLanguage,
|
||||||
survey_rules: {
|
survey_rules: {
|
||||||
operator: state.surveyRuleOperator,
|
operator: state.surveyRuleOperator,
|
||||||
values: selectedLabelValues.value,
|
values: selectedLabelValues.value,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use new template data if created, otherwise preserve existing template information
|
||||||
|
if (newTemplateData) {
|
||||||
|
csatConfig.template = {
|
||||||
|
name: newTemplateData.name,
|
||||||
|
template_id: newTemplateData.template_id,
|
||||||
|
language: newTemplateData.language,
|
||||||
|
status: newTemplateData.status,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const templateConfig = buildTemplateConfig();
|
||||||
|
if (templateConfig) {
|
||||||
|
csatConfig.template = templateConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await updateInbox({
|
await updateInbox({
|
||||||
csat_survey_enabled: state.csatSurveyEnabled,
|
csat_survey_enabled: state.csatSurveyEnabled,
|
||||||
csat_config: csatConfig,
|
csat_config: csatConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
|
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
|
||||||
|
checkTemplateStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
|
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false;
|
isUpdating.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||||
|
if (
|
||||||
|
isWhatsAppChannel.value &&
|
||||||
|
state.csatSurveyEnabled &&
|
||||||
|
hasExistingTemplate() &&
|
||||||
|
hasTemplateChanges()
|
||||||
|
) {
|
||||||
|
confirmDialog.value?.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await performSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmTemplateUpdate = async () => {
|
||||||
|
// We will delete the template before creating the template
|
||||||
|
await performSave();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -155,7 +388,9 @@ const saveSettings = async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
|
<!-- Show display type only for non-WhatsApp channels -->
|
||||||
<WithLabel
|
<WithLabel
|
||||||
|
v-if="!isWhatsAppChannel"
|
||||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||||
name="display_type"
|
name="display_type"
|
||||||
>
|
>
|
||||||
@@ -165,14 +400,97 @@ const saveSettings = async () => {
|
|||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
|
||||||
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
|
<template v-if="isWhatsAppChannel">
|
||||||
<Editor
|
<div
|
||||||
v-model="state.message"
|
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
>
|
||||||
:max-length="200"
|
<div class="flex flex-col gap-3 basis-3/5">
|
||||||
class="w-full"
|
<WithLabel
|
||||||
/>
|
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||||
</WithLabel>
|
name="message"
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
v-model="state.message"
|
||||||
|
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||||
|
:max-length="200"
|
||||||
|
channel-type="Context::Plain"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
<Input
|
||||||
|
v-model="state.templateButtonText"
|
||||||
|
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||||
|
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WithLabel
|
||||||
|
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||||
|
name="language"
|
||||||
|
>
|
||||||
|
<ComboBox
|
||||||
|
v-model="state.templateLanguage"
|
||||||
|
:options="languageOptions"
|
||||||
|
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="shouldShowTemplateStatus"
|
||||||
|
class="flex gap-2 items-center mt-4"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="templateApprovalStatus.icon"
|
||||||
|
:class="templateApprovalStatus.color"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:class="templateApprovalStatus.color"
|
||||||
|
class="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ templateApprovalStatus.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||||
|
<Icon
|
||||||
|
v-tooltip.top-end="
|
||||||
|
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
|
||||||
|
"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
class="flex-shrink-0 mx-1 size-4"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<CSATTemplate
|
||||||
|
:message="messagePreviewData"
|
||||||
|
:button-text="state.templateButtonText"
|
||||||
|
class="pt-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Non-WhatsApp channels layout -->
|
||||||
|
<template v-else>
|
||||||
|
<WithLabel
|
||||||
|
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||||
|
name="message"
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
v-model="state.message"
|
||||||
|
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||||
|
:max-length="200"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
</template>
|
||||||
|
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||||
@@ -180,7 +498,7 @@ const saveSettings = async () => {
|
|||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<span
|
<span
|
||||||
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
|
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -217,7 +535,11 @@ const saveSettings = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
<p class="text-sm italic text-n-slate-11">
|
<p class="text-sm italic text-n-slate-11">
|
||||||
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
|
{{
|
||||||
|
isWhatsAppChannel
|
||||||
|
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||||
|
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<NextButton
|
<NextButton
|
||||||
@@ -229,5 +551,11 @@ const saveSettings = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionLayout>
|
</SectionLayout>
|
||||||
|
|
||||||
|
<!-- Template Update Confirmation Dialog -->
|
||||||
|
<ConfirmTemplateUpdateDialog
|
||||||
|
ref="confirmDialog"
|
||||||
|
@confirm="handleConfirmTemplateUpdate"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const handleDialogConfirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
dialogRef,
|
||||||
|
open: () => dialogRef.value?.open(),
|
||||||
|
close: () => dialogRef.value?.close(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
|
||||||
|
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
|
||||||
|
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
|
||||||
|
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
import messageReadActions from './actions/messageReadActions';
|
import messageReadActions from './actions/messageReadActions';
|
||||||
import messageTranslateActions from './actions/messageTranslateActions';
|
import messageTranslateActions from './actions/messageTranslateActions';
|
||||||
import * as Sentry from '@sentry/vue';
|
import * as Sentry from '@sentry/vue';
|
||||||
|
import {
|
||||||
|
handleVoiceCallCreated,
|
||||||
|
handleVoiceCallUpdated,
|
||||||
|
} from 'dashboard/helper/voice';
|
||||||
|
|
||||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||||
// This helper is used to check if the message has failed with an external error.
|
// This helper is used to check if the message has failed with an external error.
|
||||||
@@ -299,7 +303,7 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage({ commit }, message) {
|
addMessage({ commit, rootGetters }, message) {
|
||||||
commit(types.ADD_MESSAGE, message);
|
commit(types.ADD_MESSAGE, message);
|
||||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||||
@@ -308,10 +312,12 @@ const actions = {
|
|||||||
});
|
});
|
||||||
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
||||||
}
|
}
|
||||||
|
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMessage({ commit }, message) {
|
updateMessage({ commit, rootGetters }, message) {
|
||||||
commit(types.ADD_MESSAGE, message);
|
commit(types.ADD_MESSAGE, message);
|
||||||
|
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMessage: async function deleteLabels(
|
deleteMessage: async function deleteLabels(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [],
|
allConversations: [],
|
||||||
@@ -24,6 +25,10 @@ const state = {
|
|||||||
copilotAssistant: {},
|
copilotAssistant: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getConversationById = _state => conversationId => {
|
||||||
|
return _state.allConversations.find(c => c.id === conversationId);
|
||||||
|
};
|
||||||
|
|
||||||
// mutations
|
// mutations
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
||||||
@@ -270,6 +275,36 @@ export const mutations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||||
|
_state,
|
||||||
|
{ conversationId, callStatus }
|
||||||
|
) {
|
||||||
|
const chat = getConversationById(_state)(conversationId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
chat.additional_attributes = {
|
||||||
|
...chat.additional_attributes,
|
||||||
|
call_status: callStatus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||||
|
const chat = getConversationById(_state)(conversationId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const lastCall = (chat.messages || []).findLast(
|
||||||
|
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lastCall) return;
|
||||||
|
|
||||||
|
lastCall.content_attributes ??= {};
|
||||||
|
lastCall.content_attributes.data = {
|
||||||
|
...lastCall.content_attributes.data,
|
||||||
|
status: callStatus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||||
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
|
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { mutations } from '../index';
|
||||||
|
import types from '../../../mutation-types';
|
||||||
|
|
||||||
|
describe('#mutations', () => {
|
||||||
|
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||||
|
it('does nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [] };
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates call_status preserving existing additional_attributes', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||||
|
other_attr: 'value',
|
||||||
|
call_status: 'in-progress',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates additional_attributes if it does not exist', () => {
|
||||||
|
const state = { allConversations: [{ id: 1 }] };
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'completed',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||||
|
call_status: 'completed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||||
|
it('does nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [] };
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if no voice call message exists', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].messages[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
content_type: 'text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the last voice call message status', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { data: { status: 'ringing' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { data: { status: 'ringing' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data.status
|
||||||
|
).toBe('ringing');
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[1].content_attributes.data.status
|
||||||
|
).toBe('in-progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates content_attributes.data if it does not exist', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [{ id: 1, content_type: 'voice_call' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'completed',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data.status
|
||||||
|
).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing data in content_attributes.data', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: {
|
||||||
|
data: { call_sid: 'CA123', status: 'ringing' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data
|
||||||
|
).toEqual({
|
||||||
|
call_sid: 'CA123',
|
||||||
|
status: 'in-progress',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty messages array', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [{ id: 1, messages: [] }],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].messages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -83,6 +83,14 @@ export const getters = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
|
||||||
|
if (
|
||||||
|
template.name &&
|
||||||
|
template.name.startsWith('customer_satisfaction_survey')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||||
const hasUnsupportedComponents = template.components.some(
|
const hasUnsupportedComponents = template.components.some(
|
||||||
component =>
|
component =>
|
||||||
@@ -344,6 +352,14 @@ export const actions = {
|
|||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
createCSATTemplate: async (_, { inboxId, template }) => {
|
||||||
|
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
getCSATTemplateStatus: async (_, { inboxId }) => {
|
||||||
|
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export default {
|
|||||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||||
|
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||||
|
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||||
|
|
||||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
||||||
|
|
||||||
|
export const useCallsStore = defineStore('calls', {
|
||||||
|
state: () => ({
|
||||||
|
calls: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
activeCall: state => state.calls.find(call => call.isActive) || null,
|
||||||
|
hasActiveCall: state => state.calls.some(call => call.isActive),
|
||||||
|
incomingCalls: state => state.calls.filter(call => !call.isActive),
|
||||||
|
hasIncomingCall: state => state.calls.some(call => !call.isActive),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
handleCallStatusChanged({ callSid, status }) {
|
||||||
|
if (TERMINAL_STATUSES.includes(status)) {
|
||||||
|
this.removeCall(callSid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addCall(callData) {
|
||||||
|
if (!callData?.callSid) return;
|
||||||
|
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
this.calls.push({
|
||||||
|
...callData,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCall(callSid) {
|
||||||
|
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||||
|
if (callToRemove?.isActive) {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
}
|
||||||
|
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCallActive(callSid) {
|
||||||
|
this.calls = this.calls.map(call => ({
|
||||||
|
...call,
|
||||||
|
isActive: call.callSid === callSid,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActiveCall() {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
this.calls = this.calls.filter(call => !call.isActive);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissCall(callSid) {
|
||||||
|
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
|
|||||||
TEXT: 'text',
|
TEXT: 'text',
|
||||||
MEDIA: 'media',
|
MEDIA: 'media',
|
||||||
QUICK_REPLY: 'quick_reply',
|
QUICK_REPLY: 'quick_reply',
|
||||||
|
CALL_TO_ACTION: 'call_to_action',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# name :string
|
# name :string
|
||||||
# subscriptions :jsonb
|
# subscriptions :jsonb
|
||||||
# url :string
|
# url :text
|
||||||
# webhook_type :integer default("account_type")
|
# webhook_type :integer default("account_type")
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ class CsatSurveyService
|
|||||||
def perform
|
def perform
|
||||||
return unless should_send_csat_survey?
|
return unless should_send_csat_survey?
|
||||||
|
|
||||||
if within_messaging_window?
|
if whatsapp_channel? && template_available_and_approved?
|
||||||
|
send_whatsapp_template_survey
|
||||||
|
elsif within_messaging_window?
|
||||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||||
else
|
else
|
||||||
create_csat_not_sent_activity_message
|
create_csat_not_sent_activity_message
|
||||||
@@ -35,6 +37,64 @@ class CsatSurveyService
|
|||||||
conversation.can_reply?
|
conversation.can_reply?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def whatsapp_channel?
|
||||||
|
inbox.channel_type == 'Channel::Whatsapp'
|
||||||
|
end
|
||||||
|
|
||||||
|
def template_available_and_approved?
|
||||||
|
template_config = inbox.csat_config&.dig('template')
|
||||||
|
return false unless template_config
|
||||||
|
|
||||||
|
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
||||||
|
|
||||||
|
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
|
status_result[:success] && status_result[:template][:status] == 'APPROVED'
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error checking CSAT template status: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_whatsapp_template_survey
|
||||||
|
template_config = inbox.csat_config&.dig('template')
|
||||||
|
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
||||||
|
|
||||||
|
phone_number = conversation.contact_inbox.source_id
|
||||||
|
template_info = build_template_info(template_name, template_config)
|
||||||
|
message = build_csat_message
|
||||||
|
|
||||||
|
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
|
||||||
|
|
||||||
|
message.update!(source_id: message_id) if message_id.present?
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_info(template_name, template_config)
|
||||||
|
{
|
||||||
|
name: template_name,
|
||||||
|
lang_code: template_config['language'] || 'en',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'url',
|
||||||
|
index: '0',
|
||||||
|
parameters: [{ type: 'text', text: conversation.uuid }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_csat_message
|
||||||
|
conversation.messages.build(
|
||||||
|
account: conversation.account,
|
||||||
|
inbox: inbox,
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
|
||||||
|
content_type: :input_csat
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def create_csat_not_sent_activity_message
|
def create_csat_not_sent_activity_message
|
||||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||||
activity_message_params = {
|
activity_message_params = {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
|
|||||||
cr
|
cr
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def code_block(node)
|
||||||
|
out(node.string_content)
|
||||||
|
end
|
||||||
|
|
||||||
def softbreak(_node)
|
def softbreak(_node)
|
||||||
out("\n")
|
out("\n")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
|
|||||||
cr
|
cr
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def code_block(node)
|
||||||
|
out(node.string_content)
|
||||||
|
end
|
||||||
|
|
||||||
def softbreak(_node)
|
def softbreak(_node)
|
||||||
out("\n")
|
out("\n")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
|
|||||||
|
|
||||||
def build_content_variables(template)
|
def build_content_variables(template)
|
||||||
case template['template_type']
|
case template['template_type']
|
||||||
when 'text', 'quick_reply'
|
when 'text', 'quick_reply', 'call_to_action'
|
||||||
convert_text_template(template_params) # Text and quick reply templates use body variables
|
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
|
||||||
when 'media'
|
when 'media'
|
||||||
convert_media_template(template_params)
|
convert_media_template(template_params)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class Twilio::TemplateSyncService
|
|||||||
'media'
|
'media'
|
||||||
elsif template_types.include?('twilio/quick-reply')
|
elsif template_types.include?('twilio/quick-reply')
|
||||||
'quick_reply'
|
'quick_reply'
|
||||||
|
elsif template_types.include?('twilio/call-to-action')
|
||||||
|
'call_to_action'
|
||||||
elsif template_types.include?('twilio/catalog')
|
elsif template_types.include?('twilio/catalog')
|
||||||
'catalog'
|
'catalog'
|
||||||
else
|
else
|
||||||
@@ -107,6 +109,8 @@ class Twilio::TemplateSyncService
|
|||||||
template_types['twilio/media']['body']
|
template_types['twilio/media']['body']
|
||||||
elsif template_types['twilio/quick-reply']
|
elsif template_types['twilio/quick-reply']
|
||||||
template_types['twilio/quick-reply']['body']
|
template_types['twilio/quick-reply']['body']
|
||||||
|
elsif template_types['twilio/call-to-action']
|
||||||
|
template_types['twilio/call-to-action']['body']
|
||||||
elsif template_types['twilio/catalog']
|
elsif template_types['twilio/catalog']
|
||||||
template_types['twilio/catalog']['body']
|
template_types['twilio/catalog']['body']
|
||||||
else
|
else
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '4.8.0'
|
version: '4.9.1'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@
|
|||||||
- name: channel_voice
|
- name: channel_voice
|
||||||
display_name: Voice Channel
|
display_name: Voice Channel
|
||||||
enabled: false
|
enabled: false
|
||||||
chatwoot_internal: true
|
premium: true
|
||||||
- name: notion_integration
|
- name: notion_integration
|
||||||
display_name: Notion Integration
|
display_name: Notion Integration
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
# Alfred
|
# Alfred
|
||||||
# Add here as you use it for more features
|
# Add here as you use it for more features
|
||||||
# Used for Round Robin, Conversation Emails & Online Presence
|
# Used for Round Robin, Conversation Emails & Online Presence
|
||||||
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
|
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
|
||||||
|
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
|
||||||
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
|
||||||
Redis::Namespace.new('alfred', redis: redis, warning: true)
|
Redis::Namespace.new('alfred', redis: redis, warning: true)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ en:
|
|||||||
failed: Signup failed
|
failed: Signup failed
|
||||||
assignment_policy:
|
assignment_policy:
|
||||||
not_found: Assignment policy not found
|
not_found: Assignment policy not found
|
||||||
|
attachments:
|
||||||
|
invalid: Invalid attachment
|
||||||
saml:
|
saml:
|
||||||
feature_not_enabled: SAML feature not enabled for this account
|
feature_not_enabled: SAML feature not enabled for this account
|
||||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
change_column :webhooks, :url, :text
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
change_column :webhooks, :url, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
|
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
|
|||||||
create_table "webhooks", force: :cascade do |t|
|
create_table "webhooks", force: :cascade do |t|
|
||||||
t.integer "account_id"
|
t.integer "account_id"
|
||||||
t.integer "inbox_id"
|
t.integer "inbox_id"
|
||||||
t.string "url"
|
t.text "url"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "webhook_type", default: 0
|
t.integer "webhook_type", default: 0
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handoff_requested?
|
def handoff_requested?
|
||||||
@response['response'] == 'conversation_handoff'
|
@response['action'] == 'handoff'
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_action(action)
|
def process_action(action)
|
||||||
@@ -97,9 +97,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_handoff_message
|
def create_handoff_message
|
||||||
create_outgoing_message(
|
handoff_message = @response&.dig('response').presence ||
|
||||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
@assistant.config['handoff_message'].presence ||
|
||||||
)
|
I18n.t('conversations.captain.handoff')
|
||||||
|
create_outgoing_message(handoff_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_messages
|
def create_messages
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ class Captain::Assistant::AgentRunnerService
|
|||||||
|
|
||||||
def error_response(error_message)
|
def error_response(error_message)
|
||||||
{
|
{
|
||||||
'response' => 'conversation_handoff',
|
'response' => 'Transferring to another agent for further assistance.',
|
||||||
'reasoning' => "Error occurred: #{error_message}"
|
'reasoning' => "Error occurred: #{error_message}",
|
||||||
|
'action' => 'handoff'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -194,14 +194,25 @@ class Captain::Llm::SystemPromptsService
|
|||||||
- Do not share anything outside of the context provided.
|
- Do not share anything outside of the context provided.
|
||||||
- Add the reasoning why you arrived at the answer
|
- Add the reasoning why you arrived at the answer
|
||||||
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
||||||
#{config['instructions'] || ''}
|
#{config['instructions'].present? ? "\n [Custom Instructions]\n #{config['instructions']}\n" : ''}
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
reasoning: '',
|
"reasoning": "",
|
||||||
response: '',
|
"response": "",
|
||||||
|
"action": "continue" | "offer_handoff" | "handoff"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
|
The action field MUST be one of:
|
||||||
|
- "continue": Default. Use for normal conversation flow.
|
||||||
|
- "offer_handoff": Use when a handoff scenario is triggered (by custom instructions, inability to help, user frustration, or complex situations). Your response MUST ask the user if they would like to be connected to a support agent. Do NOT transfer yet - wait for their confirmation.
|
||||||
|
- "handoff": Use ONLY when:
|
||||||
|
• User has confirmed after you offered a handoff, OR
|
||||||
|
• User explicitly requests human support unprompted (in any language)
|
||||||
|
Never use handoff for scenarios where the bot decides a handoff is needed - use offer_handoff instead.
|
||||||
|
|
||||||
|
When action is "handoff", your response field MUST contain a message letting the user know they'll be connected to support.
|
||||||
|
This message MUST be in the same language as the user's message.
|
||||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||||
SYSTEM_PROMPT_MESSAGE
|
SYSTEM_PROMPT_MESSAGE
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
captain_integration
|
captain_integration
|
||||||
advanced_search_indexing
|
advanced_search_indexing
|
||||||
advanced_search
|
advanced_search
|
||||||
|
linear_integration
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# Additional features available starting with the Business plan
|
# Additional features available starting with the Business plan
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ module Enterprise::MessageTemplates::HookExecutionService
|
|||||||
return unless conversation.pending?
|
return unless conversation.pending?
|
||||||
|
|
||||||
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
|
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
|
||||||
|
handoff_message = inbox.captain_assistant&.config&.dig('handoff_message').presence ||
|
||||||
|
'Transferring to another agent for further assistance.'
|
||||||
conversation.messages.create!(
|
conversation.messages.create!(
|
||||||
message_type: :outgoing,
|
message_type: :outgoing,
|
||||||
account_id: conversation.account.id,
|
account_id: conversation.account.id,
|
||||||
inbox_id: conversation.inbox.id,
|
inbox_id: conversation.inbox.id,
|
||||||
content: 'Transferring to another agent for further assistance.'
|
content: handoff_message
|
||||||
)
|
)
|
||||||
conversation.bot_handoff!
|
conversation.bot_handoff!
|
||||||
send_out_of_office_message_after_handoff
|
send_out_of_office_message_after_handoff
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chatwoot/chatwoot",
|
"name": "@chatwoot/chatwoot",
|
||||||
"version": "4.8.0",
|
"version": "4.9.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"eslint": "eslint app/**/*.{js,vue}",
|
"eslint": "eslint app/**/*.{js,vue}",
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"@sindresorhus/slugify": "2.2.1",
|
"@sindresorhus/slugify": "2.2.1",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tanstack/vue-table": "^8.20.5",
|
"@tanstack/vue-table": "^8.20.5",
|
||||||
|
"@twilio/voice-sdk": "^2.12.4",
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
"@vue/compiler-sfc": "^3.5.8",
|
"@vue/compiler-sfc": "^3.5.8",
|
||||||
"@vuelidate/core": "^2.0.3",
|
"@vuelidate/core": "^2.0.3",
|
||||||
|
|||||||
Generated
+37
@@ -67,6 +67,9 @@ importers:
|
|||||||
'@tanstack/vue-table':
|
'@tanstack/vue-table':
|
||||||
specifier: ^8.20.5
|
specifier: ^8.20.5
|
||||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||||
|
'@twilio/voice-sdk':
|
||||||
|
specifier: ^2.12.4
|
||||||
|
version: 2.17.0
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||||
@@ -1283,9 +1286,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
'@twilio/voice-errors@1.7.0':
|
||||||
|
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
|
||||||
|
|
||||||
|
'@twilio/voice-sdk@2.17.0':
|
||||||
|
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/events@3.0.3':
|
||||||
|
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
|
||||||
|
|
||||||
'@types/flexsearch@0.7.6':
|
'@types/flexsearch@0.7.6':
|
||||||
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
|
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
|
||||||
|
|
||||||
@@ -2399,6 +2412,10 @@ packages:
|
|||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
events@3.3.0:
|
||||||
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
|
engines: {node: '>=0.8.x'}
|
||||||
|
|
||||||
expect-type@1.1.0:
|
expect-type@1.1.0:
|
||||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -3119,6 +3136,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
loglevel@1.9.2:
|
||||||
|
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
loupe@3.1.3:
|
loupe@3.1.3:
|
||||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||||
|
|
||||||
@@ -5750,8 +5771,20 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/once@2.0.0': {}
|
'@tootallnate/once@2.0.0': {}
|
||||||
|
|
||||||
|
'@twilio/voice-errors@1.7.0': {}
|
||||||
|
|
||||||
|
'@twilio/voice-sdk@2.17.0':
|
||||||
|
dependencies:
|
||||||
|
'@twilio/voice-errors': 1.7.0
|
||||||
|
'@types/events': 3.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
loglevel: 1.9.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/events@3.0.3': {}
|
||||||
|
|
||||||
'@types/flexsearch@0.7.6': {}
|
'@types/flexsearch@0.7.6': {}
|
||||||
|
|
||||||
'@types/fs-extra@9.0.13':
|
'@types/fs-extra@9.0.13':
|
||||||
@@ -7137,6 +7170,8 @@ snapshots:
|
|||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
|
events@3.3.0: {}
|
||||||
|
|
||||||
expect-type@1.1.0: {}
|
expect-type@1.1.0: {}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
@@ -7966,6 +8001,8 @@ snapshots:
|
|||||||
strip-ansi: 7.1.0
|
strip-ansi: 7.1.0
|
||||||
wrap-ansi: 9.0.0
|
wrap-ansi: 9.0.0
|
||||||
|
|
||||||
|
loglevel@1.9.2: {}
|
||||||
|
|
||||||
loupe@3.1.3: {}
|
loupe@3.1.3: {}
|
||||||
|
|
||||||
lower-case@2.0.2:
|
lower-case@2.0.2:
|
||||||
|
|||||||
@@ -141,21 +141,14 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'Saves file in the automation actions to send an attachments' do
|
it 'Saves file in the automation actions to send an attachments' do
|
||||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
expect(account.automation_rules.count).to eq(0)
|
expect(account.automation_rules.count).to eq(0)
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/upload/",
|
|
||||||
headers: administrator.create_new_auth_token,
|
|
||||||
params: { attachment: file }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
|
|
||||||
blob = response.parsed_body
|
|
||||||
|
|
||||||
expect(blob['blob_key']).to be_present
|
|
||||||
expect(blob['blob_id']).to be_present
|
|
||||||
|
|
||||||
params[:actions] = [
|
params[:actions] = [
|
||||||
{
|
{
|
||||||
'action_name': :send_message,
|
'action_name': :send_message,
|
||||||
@@ -163,7 +156,7 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'action_name': :send_attachment,
|
'action_name': :send_attachment,
|
||||||
'action_params': [blob['blob_id']]
|
'action_params': [blob.signed_id]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -177,29 +170,25 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'Saves files in the automation actions to send multiple attachments' do
|
it 'Saves files in the automation actions to send multiple attachments' do
|
||||||
file_1 = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
blob_1 = ActiveStorage::Blob.create_and_upload!(
|
||||||
file_2 = fixture_file_upload(Rails.root.join('spec/assets/sample.png'), 'image/png')
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
post "/api/v1/accounts/#{account.id}/upload/",
|
content_type: 'image/png'
|
||||||
headers: administrator.create_new_auth_token,
|
)
|
||||||
params: { attachment: file_1 }
|
blob_2 = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/sample.png').open,
|
||||||
blob_1 = response.parsed_body
|
filename: 'sample.png',
|
||||||
|
content_type: 'image/png'
|
||||||
post "/api/v1/accounts/#{account.id}/upload/",
|
)
|
||||||
headers: administrator.create_new_auth_token,
|
|
||||||
params: { attachment: file_2 }
|
|
||||||
|
|
||||||
blob_2 = response.parsed_body
|
|
||||||
|
|
||||||
params[:actions] = [
|
params[:actions] = [
|
||||||
{
|
{
|
||||||
'action_name': :send_attachment,
|
'action_name': :send_attachment,
|
||||||
'action_params': [blob_1['blob_id']]
|
'action_params': [blob_1.signed_id]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'action_name': :send_attachment,
|
'action_name': :send_attachment,
|
||||||
'action_params': [blob_2['blob_id']]
|
'action_params': [blob_2.signed_id]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -210,6 +199,46 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
automation_rule = account.automation_rules.first
|
automation_rule = account.automation_rules.first
|
||||||
expect(automation_rule.files.count).to eq(2)
|
expect(automation_rule.files.count).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns error for invalid attachment blob_id' do
|
||||||
|
params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': ['invalid_blob_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores the original blob_id in action_params after create' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': [blob.signed_id]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: params
|
||||||
|
|
||||||
|
automation_rule = account.automation_rules.first
|
||||||
|
attachment_action = automation_rule.actions.find { |a| a['action_name'] == 'send_attachment' }
|
||||||
|
expect(attachment_action['action_params'].first).to be_a(Integer)
|
||||||
|
expect(attachment_action['action_params'].first).to eq(automation_rule.files.first.blob_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -328,6 +357,68 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
|||||||
expect(body[:payload][:active]).to be(false)
|
expect(body[:payload][:active]).to be(false)
|
||||||
expect(automation_rule.reload.active).to be(false)
|
expect(automation_rule.reload.active).to be(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'allows update with existing blob_id' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
automation_rule.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
|
||||||
|
automation_rule.files.attach(blob)
|
||||||
|
|
||||||
|
update_params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': [blob.id]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: update_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error for invalid blob_id on update' do
|
||||||
|
update_params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': [999_999]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: update_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows adding new attachment on update with signed blob_id' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
update_params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': [blob.signed_id]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
patch "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: update_params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(automation_rule.reload.files.count).to eq(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -127,18 +127,11 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'Saves file in the macros actions to send an attachments' do
|
it 'Saves file in the macros actions to send an attachments' do
|
||||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
post "/api/v1/accounts/#{account.id}/upload/",
|
filename: 'avatar.png',
|
||||||
headers: administrator.create_new_auth_token,
|
content_type: 'image/png'
|
||||||
params: { attachment: file }
|
)
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
|
|
||||||
blob = response.parsed_body
|
|
||||||
|
|
||||||
expect(blob['blob_key']).to be_present
|
|
||||||
expect(blob['blob_id']).to be_present
|
|
||||||
|
|
||||||
params[:actions] = [
|
params[:actions] = [
|
||||||
{
|
{
|
||||||
@@ -147,7 +140,7 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'action_name': :send_attachment,
|
'action_name': :send_attachment,
|
||||||
'action_params': [blob['blob_id']]
|
'action_params': [blob.signed_id]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -159,6 +152,46 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
|||||||
expect(macro.files.presence).to be_truthy
|
expect(macro.files.presence).to be_truthy
|
||||||
expect(macro.files.count).to eq(1)
|
expect(macro.files.count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns error for invalid attachment blob_id' do
|
||||||
|
params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': ['invalid_blob_id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/macros",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: params
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'stores the original blob_id in action_params after create' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
params[:actions] = [
|
||||||
|
{
|
||||||
|
'action_name': :send_attachment,
|
||||||
|
'action_params': [blob.signed_id]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/macros",
|
||||||
|
headers: administrator.create_new_auth_token,
|
||||||
|
params: params
|
||||||
|
|
||||||
|
macro = account.macros.last
|
||||||
|
attachment_action = macro.actions.find { |a| a['action_name'] == 'send_attachment' }
|
||||||
|
expect(attachment_action['action_params'].first).to be_a(Integer)
|
||||||
|
expect(attachment_action['action_params'].first).to eq(macro.files.first.blob_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -202,6 +235,47 @@ RSpec.describe 'Api::V1::Accounts::MacrosController', type: :request do
|
|||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
expect(json_response['error']).to eq('You are not authorized to do this action')
|
expect(json_response['error']).to eq('You are not authorized to do this action')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'allows update with existing blob_id' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
macro.update!(actions: [{ 'action_name' => 'send_attachment', 'action_params' => [blob.id] }])
|
||||||
|
macro.files.attach(blob)
|
||||||
|
|
||||||
|
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||||
|
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.id] }] },
|
||||||
|
headers: administrator.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error for invalid blob_id on update' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||||
|
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [999_999] }] },
|
||||||
|
headers: administrator.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.attachments.invalid'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows adding new attachment on update with signed blob_id' do
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: Rails.root.join('spec/assets/avatar.png').open,
|
||||||
|
filename: 'avatar.png',
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
|
||||||
|
put "/api/v1/accounts/#{account.id}/macros/#{macro.id}",
|
||||||
|
params: { actions: [{ 'action_name': :send_attachment, 'action_params': [blob.signed_id] }] },
|
||||||
|
headers: administrator.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(macro.reload.files.count).to eq(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
|||||||
blob = response.parsed_body
|
blob = response.parsed_body
|
||||||
expect(blob['errors']).to be_nil
|
expect(blob['errors']).to be_nil
|
||||||
expect(blob['file_url']).to be_present
|
expect(blob['file_url']).to be_present
|
||||||
expect(blob['blob_key']).to be_present
|
|
||||||
expect(blob['blob_id']).to be_present
|
expect(blob['blob_id']).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
|||||||
blob = response.parsed_body
|
blob = response.parsed_body
|
||||||
expect(blob['error']).to be_nil
|
expect(blob['error']).to be_nil
|
||||||
expect(blob['file_url']).to be_present
|
expect(blob['file_url']).to be_present
|
||||||
expect(blob['blob_key']).to be_present
|
|
||||||
expect(blob['blob_id']).to be_present
|
expect(blob['blob_id']).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
closed_all_day: true,
|
closed_all_day: true,
|
||||||
open_all_day: false
|
open_all_day: false
|
||||||
)
|
)
|
||||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'action' => 'handoff' })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sends out of office message after handoff' do
|
it 'sends out of office message after handoff' do
|
||||||
@@ -275,7 +275,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
open_all_day: true,
|
open_all_day: true,
|
||||||
closed_all_day: false
|
closed_all_day: false
|
||||||
)
|
)
|
||||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'action' => 'handoff' })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not send out of office message after handoff' do
|
it 'does not send out of office message after handoff' do
|
||||||
@@ -321,7 +321,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
closed_all_day: true,
|
closed_all_day: true,
|
||||||
open_all_day: false
|
open_all_day: false
|
||||||
)
|
)
|
||||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'action' => 'handoff' })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not send out of office message' do
|
it 'does not send out of office message' do
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
|||||||
result = service.generate_response(message_history: message_history)
|
result = service.generate_response(message_history: message_history)
|
||||||
|
|
||||||
expect(result).to eq({
|
expect(result).to eq({
|
||||||
'response' => 'conversation_handoff',
|
'response' => 'Transferring to another agent for further assistance.',
|
||||||
'reasoning' => 'Error occurred: Test error'
|
'reasoning' => 'Error occurred: Test error',
|
||||||
|
'action' => 'handoff'
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -169,7 +170,8 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
|||||||
result = service.generate_response(message_history: message_history)
|
result = service.generate_response(message_history: message_history)
|
||||||
|
|
||||||
expect(result).to eq({
|
expect(result).to eq({
|
||||||
'response' => 'conversation_handoff',
|
'response' => 'Transferring to another agent for further assistance.',
|
||||||
|
'action' => 'handoff',
|
||||||
'reasoning' => 'Error occurred: Test error'
|
'reasoning' => 'Error occurred: Test error'
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ require 'rails_helper'
|
|||||||
describe CsatSurveyService do
|
describe CsatSurveyService do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
|
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
|
||||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
|
let(:contact) { create(:contact, account: account) }
|
||||||
|
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
|
||||||
|
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
|
||||||
let(:service) { described_class.new(conversation: conversation) }
|
let(:service) { described_class.new(conversation: conversation) }
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
@@ -87,5 +89,269 @@ describe CsatSurveyService do
|
|||||||
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when it is a WhatsApp channel' do
|
||||||
|
let(:whatsapp_channel) do
|
||||||
|
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||||
|
sync_templates: false, validate_provider_config: false)
|
||||||
|
end
|
||||||
|
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
|
||||||
|
let(:whatsapp_contact) { create(:contact, account: account) }
|
||||||
|
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
|
||||||
|
let(:whatsapp_conversation) do
|
||||||
|
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
|
||||||
|
end
|
||||||
|
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
|
||||||
|
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
|
||||||
|
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when template is available and approved' do
|
||||||
|
before do
|
||||||
|
setup_approved_template('customer_survey_template')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends WhatsApp template survey instead of regular survey' do
|
||||||
|
mock_successful_template_send('template_message_id_123')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(mock_provider_service).to have_received(:send_template).with(
|
||||||
|
'1234567890',
|
||||||
|
hash_including(
|
||||||
|
name: 'customer_survey_template',
|
||||||
|
lang_code: 'en',
|
||||||
|
parameters: array_including(
|
||||||
|
hash_including(
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'url',
|
||||||
|
index: '0',
|
||||||
|
parameters: array_including(
|
||||||
|
hash_including(type: 'text', text: whatsapp_conversation.uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
instance_of(Message)
|
||||||
|
)
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates message with returned message ID' do
|
||||||
|
mock_successful_template_send('template_message_id_123')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||||
|
expect(csat_message).to be_present
|
||||||
|
expect(csat_message.source_id).to eq('template_message_id_123')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'builds correct template info with default template name' do
|
||||||
|
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
|
||||||
|
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
|
||||||
|
allow(mock_provider_service).to receive(:get_template_status)
|
||||||
|
.with(expected_template_name)
|
||||||
|
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||||
|
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||||
|
message.save!
|
||||||
|
'msg_id'
|
||||||
|
end
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(mock_provider_service).to have_received(:send_template).with(
|
||||||
|
'1234567890',
|
||||||
|
hash_including(
|
||||||
|
name: expected_template_name,
|
||||||
|
lang_code: 'en'
|
||||||
|
),
|
||||||
|
anything
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'builds CSAT message with correct attributes' do
|
||||||
|
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||||
|
message.save!
|
||||||
|
'msg_id'
|
||||||
|
end
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||||
|
expect(csat_message.account).to eq(account)
|
||||||
|
expect(csat_message.inbox).to eq(whatsapp_inbox)
|
||||||
|
expect(csat_message.message_type).to eq('outgoing')
|
||||||
|
expect(csat_message.content).to eq('Please rate your experience')
|
||||||
|
expect(csat_message.content_type).to eq('input_csat')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses default message when not configured' do
|
||||||
|
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
|
||||||
|
mock_successful_template_send('msg_id')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||||
|
expect(csat_message.content).to eq('Please rate this conversation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when template is not available or not approved' do
|
||||||
|
it 'falls back to regular survey when template is pending' do
|
||||||
|
setup_template_with_status('pending_template', 'PENDING')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||||
|
expect(csat_template).to have_received(:perform)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to regular survey when template is rejected' do
|
||||||
|
setup_template_with_status('pending_template', 'REJECTED')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||||
|
expect(csat_template).to have_received(:perform)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to regular survey when template API call fails' do
|
||||||
|
allow(mock_provider_service).to receive(:get_template_status)
|
||||||
|
.with('pending_template')
|
||||||
|
.and_return({ success: false, error: 'Template not found' })
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||||
|
expect(csat_template).to have_received(:perform)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to regular survey when template status check raises error' do
|
||||||
|
allow(mock_provider_service).to receive(:get_template_status)
|
||||||
|
.and_raise(StandardError, 'API connection failed')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||||
|
expect(csat_template).to have_received(:perform)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no template is configured' do
|
||||||
|
it 'falls back to regular survey' do
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
|
||||||
|
expect(csat_template).to have_received(:perform)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when template sending fails' do
|
||||||
|
before do
|
||||||
|
setup_approved_template('working_template', {
|
||||||
|
'template' => { 'name' => 'working_template' },
|
||||||
|
'message' => 'Rate us'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles template sending errors gracefully' do
|
||||||
|
mock_template_send_failure('Template send failed')
|
||||||
|
|
||||||
|
expect { whatsapp_service.perform }.not_to raise_error
|
||||||
|
|
||||||
|
# Should still create the CSAT message even if sending fails
|
||||||
|
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||||
|
expect(csat_message).to be_present
|
||||||
|
expect(csat_message.source_id).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update message when send_template returns nil' do
|
||||||
|
mock_template_send_with_no_id
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
|
||||||
|
expect(csat_message).to be_present
|
||||||
|
expect(csat_message.source_id).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when outside messaging window' do
|
||||||
|
before do
|
||||||
|
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends template survey even when outside messaging window if template is approved' do
|
||||||
|
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
|
||||||
|
mock_successful_template_send('msg_id')
|
||||||
|
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(mock_provider_service).to have_received(:send_template)
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||||
|
# No activity message should be created when template is successfully sent
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates activity message when template is not available and outside window' do
|
||||||
|
whatsapp_service.perform
|
||||||
|
|
||||||
|
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
|
||||||
|
whatsapp_conversation,
|
||||||
|
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
|
||||||
|
)
|
||||||
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def setup_approved_template(template_name, config = nil)
|
||||||
|
template_config = config || {
|
||||||
|
'template' => {
|
||||||
|
'name' => template_name,
|
||||||
|
'language' => 'en'
|
||||||
|
},
|
||||||
|
'message' => 'Please rate your experience'
|
||||||
|
}
|
||||||
|
whatsapp_inbox.update(csat_config: template_config)
|
||||||
|
allow(mock_provider_service).to receive(:get_template_status)
|
||||||
|
.with(template_name)
|
||||||
|
.and_return({ success: true, template: { status: 'APPROVED' } })
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup_template_with_status(template_name, status)
|
||||||
|
whatsapp_inbox.update(csat_config: {
|
||||||
|
'template' => { 'name' => template_name }
|
||||||
|
})
|
||||||
|
allow(mock_provider_service).to receive(:get_template_status)
|
||||||
|
.with(template_name)
|
||||||
|
.and_return({ success: true, template: { status: status } })
|
||||||
|
end
|
||||||
|
|
||||||
|
def mock_successful_template_send(message_id)
|
||||||
|
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||||
|
message.save!
|
||||||
|
message_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mock_template_send_failure(error_message = 'Template send failed')
|
||||||
|
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||||
|
message.save!
|
||||||
|
raise StandardError, error_message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mock_template_send_with_no_id
|
||||||
|
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
|
||||||
|
message.save!
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
|||||||
expect(result.scan("\n").count).to eq(4)
|
expect(result.scan("\n").count).to eq(4)
|
||||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'renders code blocks as plain text' do
|
||||||
|
content = "```\ncode here\n```"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result.strip).to eq('code here')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'renders indented code blocks as plain text preserving exact content' do
|
||||||
|
content = ' indented code line'
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result).to eq('indented code line')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||||
|
content = " first line\n 🌐 second line\n"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result).to eq("first line\n🌐 second line")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when channel is Channel::Instagram' do
|
context 'when channel is Channel::Instagram' do
|
||||||
@@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
|||||||
expect(result.scan("\n").count).to eq(4)
|
expect(result.scan("\n").count).to eq(4)
|
||||||
expect(result).to include("Para 1\n\n\n\nPara 2")
|
expect(result).to include("Para 1\n\n\n\nPara 2")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'renders code blocks as plain text' do
|
||||||
|
content = "```\ncode here\n```"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result.strip).to eq('code here')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'renders indented code blocks as plain text preserving exact content' do
|
||||||
|
content = ' indented code line'
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result).to eq('indented code line')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||||
|
content = " first line\n 🌐 second line\n"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result).to eq("first line\n🌐 second line")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when channel is Channel::Line' do
|
context 'when channel is Channel::Line' do
|
||||||
@@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
|||||||
expect(result).to include('1. first step')
|
expect(result).to include('1. first step')
|
||||||
expect(result).to include('2. second step')
|
expect(result).to include('2. second step')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'renders code blocks as plain text' do
|
||||||
|
content = "```\ncode here\n```"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result.strip).to eq('code here')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles code blocks with emojis and special characters without stack overflow' do
|
||||||
|
content = " first line\n 🌐 second line\n"
|
||||||
|
result = described_class.new(content, channel_type).render
|
||||||
|
expect(result).to eq("first line\n🌐 second line")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when channel is Channel::TwilioSms' do
|
context 'when channel is Channel::TwilioSms' do
|
||||||
|
|||||||
@@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
|
let(:call_to_action_template) do
|
||||||
|
instance_double(
|
||||||
|
Twilio::REST::Content::V1::ContentInstance,
|
||||||
|
sid: 'HX444555666',
|
||||||
|
friendly_name: 'payment_reminder',
|
||||||
|
language: 'en',
|
||||||
|
date_created: Time.current,
|
||||||
|
date_updated: Time.current,
|
||||||
|
variables: {},
|
||||||
|
types: {
|
||||||
|
'twilio/call-to-action' => {
|
||||||
|
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||||
|
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||||
|
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||||
|
'actions' => [
|
||||||
|
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(twilio_channel).to receive(:send).and_call_original
|
allow(twilio_channel).to receive(:send).and_call_original
|
||||||
@@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do
|
|||||||
twilio_channel.reload
|
twilio_channel.reload
|
||||||
expect(twilio_channel.content_templates).to be_present
|
expect(twilio_channel.content_templates).to be_present
|
||||||
expect(twilio_channel.content_templates['templates']).to be_an(Array)
|
expect(twilio_channel.content_templates['templates']).to be_an(Array)
|
||||||
expect(twilio_channel.content_templates['templates'].size).to eq(4)
|
expect(twilio_channel.content_templates['templates'].size).to eq(5)
|
||||||
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
|
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'correctly formats call-to-action templates with variables' do
|
||||||
|
sync_service.call
|
||||||
|
|
||||||
|
twilio_channel.reload
|
||||||
|
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
|
||||||
|
t['friendly_name'] == 'payment_reminder'
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(call_to_action_data).to include(
|
||||||
|
'content_sid' => 'HX444555666',
|
||||||
|
'friendly_name' => 'payment_reminder',
|
||||||
|
'language' => 'en',
|
||||||
|
'status' => 'approved',
|
||||||
|
'template_type' => 'call_to_action',
|
||||||
|
'media_type' => nil,
|
||||||
|
'variables' => {},
|
||||||
|
'category' => 'utility'
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
|
||||||
|
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
|
||||||
|
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
|
||||||
|
expect(call_to_action_data['body']).to eq(expected_body)
|
||||||
|
expect(call_to_action_data['body']).to match(/{{date}}/)
|
||||||
|
end
|
||||||
|
|
||||||
it 'categorizes marketing templates correctly' do
|
it 'categorizes marketing templates correctly' do
|
||||||
marketing_template = instance_double(
|
marketing_template = instance_double(
|
||||||
Twilio::REST::Content::V1::ContentInstance,
|
Twilio::REST::Content::V1::ContentInstance,
|
||||||
|
|||||||
Reference in New Issue
Block a user