Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e28b2846e | ||
|
|
a7d14905de | ||
|
|
c08fa631a9 | ||
|
|
14b4c83dc6 | ||
|
|
df92fd12cb | ||
|
|
d84ae196d5 | ||
|
|
bdcc62f1b0 | ||
|
|
7acd239c70 | ||
|
|
9ca03c1af3 | ||
|
|
c218eff5ec | ||
|
|
4548bdcf7b | ||
|
|
7c60ad9e28 | ||
|
|
6b3f1114fd | ||
|
|
109b43aadb | ||
|
|
dfa77b88cf | ||
|
|
892d71eca5 | ||
|
|
beeb023e19 | ||
|
|
3ddab3ab26 | ||
|
|
e2dd2ccb42 | ||
|
|
9fab70aebf | ||
|
|
1a158317fb | ||
|
|
0736d69dec | ||
|
|
efe49f7da4 | ||
|
|
5aef9d2dd0 | ||
|
|
b98c614669 | ||
|
|
ba804e0f30 | ||
|
|
a44cb2c738 | ||
|
|
172ff87b5b | ||
|
|
55f6257313 | ||
|
|
76f129efaf | ||
|
|
3a1777d2fe | ||
|
|
2963ccbd47 | ||
|
|
7cec4ebaae | ||
|
|
6be95e79f8 | ||
|
|
2b85275e26 | ||
|
|
5b167b5b5b | ||
|
|
2a31ec7afd | ||
|
|
b220663785 | ||
|
|
40da358dc2 | ||
|
|
957a1b17c9 | ||
|
|
9dd13b9a2b | ||
|
|
223c04c748 | ||
|
|
7b6b2afdce | ||
|
|
2441487a76 | ||
|
|
418bd177f8 | ||
|
|
572f5b2709 | ||
|
|
15f25f019e | ||
|
|
280ca06e5b | ||
|
|
2bd1d88d50 | ||
|
|
1345f67966 | ||
|
|
0f914fa2ab | ||
|
|
59663dd558 |
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents'
|
||||
gem 'ai-agents', '>= 0.9.1'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.9.0)
|
||||
ai-agents (0.9.1)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -1024,7 +1024,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents
|
||||
ai-agents (>= 0.9.1)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.0
|
||||
4.11.1
|
||||
|
||||
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def message_params
|
||||
content_attributes = {
|
||||
in_reply_to_external_id: response.in_reply_to_external_id
|
||||
}
|
||||
content_attributes[:external_echo] = true if @outgoing_echo
|
||||
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: @message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
content: response.content,
|
||||
source_id: response.identifier,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: response.in_reply_to_external_id
|
||||
},
|
||||
content_attributes: content_attributes,
|
||||
sender: @outgoing_echo ? nil : @contact_inbox.contact
|
||||
}
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def meta
|
||||
result = conversation_finder.perform
|
||||
result = conversation_finder.perform_meta_only
|
||||
@conversations_count = result[:count]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
before_action :validate_captain_enabled, only: [:analyze]
|
||||
|
||||
def show
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def analyze
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
result = CsatTemplateUtilityAnalysisService.new(
|
||||
account: Current.account,
|
||||
inbox: @inbox,
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text],
|
||||
language: template_params[:language]
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_captain_enabled
|
||||
return if Current.account.feature_enabled?('captain_integration')
|
||||
|
||||
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
|
||||
@@ -11,6 +11,7 @@ class ConversationFinder
|
||||
'priority_desc' => %w[sort_on_priority desc],
|
||||
'waiting_since_asc' => %w[sort_on_waiting_since asc],
|
||||
'waiting_since_desc' => %w[sort_on_waiting_since desc],
|
||||
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
|
||||
|
||||
# To be removed in v3.5.0
|
||||
'latest' => %w[sort_on_last_activity_at desc],
|
||||
@@ -55,6 +56,22 @@ class ConversationFinder
|
||||
}
|
||||
end
|
||||
|
||||
def perform_meta_only
|
||||
set_up
|
||||
|
||||
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
|
||||
assigned_count = all_count - unassigned_count
|
||||
|
||||
{
|
||||
count: {
|
||||
mine_count: mine_count,
|
||||
assigned_count: assigned_count,
|
||||
unassigned_count: unassigned_count,
|
||||
all_count: all_count
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_up
|
||||
|
||||
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.open_timeout = 10
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
smtp.finish
|
||||
rescue Net::SMTPAuthenticationError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
|
||||
rescue OpenSSL::SSL::SSLError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
|
||||
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
end
|
||||
|
||||
def set_smtp_encryption(channel_data, smtp)
|
||||
if channel_data[:smtp_enable_ssl_tls]
|
||||
set_enable_tls(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
||||
elsif channel_data[:smtp_enable_starttls_auto]
|
||||
set_enable_starttls_auto(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_starttls_auto(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_starttls_auto)
|
||||
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
||||
return unless smtp.respond_to?(method)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_starttls_auto(context)
|
||||
else
|
||||
smtp.enable_starttls_auto
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_tls(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_tls)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_tls(context)
|
||||
else
|
||||
smtp.enable_tls
|
||||
end
|
||||
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
||||
context ? smtp.send(method, context) : smtp.send(method)
|
||||
end
|
||||
|
||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||
|
||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TELEGRAM: 'i-ri-telegram-fill',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
@@ -66,6 +67,7 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
telegram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode = '',
|
||||
country = '',
|
||||
city = '',
|
||||
socialTelegramUserName = '',
|
||||
socialProfiles = {},
|
||||
} = additionalAttributes || {};
|
||||
|
||||
const telegramUsername =
|
||||
socialProfiles?.telegram || socialTelegramUserName || '';
|
||||
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles,
|
||||
socialProfiles: {
|
||||
...socialProfiles,
|
||||
telegram: telegramUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +82,13 @@ const attributeIcon = computed(() => {
|
||||
sm
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
|
||||
<Button
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-17
@@ -158,21 +158,7 @@ const isAnyDropdownActive = computed(() => {
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -187,12 +173,12 @@ const handleDropdownUpdate = (type, value) => {
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
+10
-8
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
|
||||
);
|
||||
|
||||
const contactEmailsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
return props.contacts
|
||||
?.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle updates from TagInput and convert array back to string
|
||||
|
||||
+5
-23
@@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||
return {
|
||||
payload: keys.map(key => {
|
||||
const filterPayload = {
|
||||
attribute_key: key,
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
};
|
||||
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||
filterPayload.query_operator = 'or';
|
||||
}
|
||||
return filterPayload;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async ({ keys, query }) => {
|
||||
export const searchContacts = async query => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
if (!trimmed) return [];
|
||||
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.filter(
|
||||
undefined,
|
||||
'name',
|
||||
generateContactQuery({ keys, query })
|
||||
);
|
||||
} = await ContactAPI.search(trimmed);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
|
||||
+8
-96
@@ -336,70 +336,6 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContactQuery', () => {
|
||||
it('generates correct query structure for contact search', () => {
|
||||
const query = 'test@example.com';
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty query', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [''],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles mutliple keys', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
query_operator: 'or',
|
||||
},
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({
|
||||
keys: ['email', 'phone_number'],
|
||||
query: 'john',
|
||||
})
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
@@ -413,14 +349,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -432,16 +365,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -469,14 +393,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await helpers.searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -496,20 +417,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith('john');
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
@@ -536,7 +448,7 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ const props = defineProps({
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
@@ -172,7 +173,10 @@ const variant = computed(() => {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
const isBot =
|
||||
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
|
||||
props.senderType === SENDER_TYPES.AGENT_BOT ||
|
||||
(!props.sender && !props.additionalAttributes?.senderName);
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
}
|
||||
@@ -450,12 +454,13 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
// If no sender, check for Slack (or other integration) sender info
|
||||
if (!props.sender) {
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
};
|
||||
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
|
||||
if (senderName) {
|
||||
return { name: senderName, src: senderAvatarUrl ?? '' };
|
||||
}
|
||||
return { name: t('CONVERSATION.BOT'), src: '' };
|
||||
}
|
||||
|
||||
const { sender } = props;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
import RadioCard from './RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -46,7 +43,6 @@ import {
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -91,9 +85,9 @@ const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -161,12 +155,6 @@ const {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
@@ -384,18 +372,6 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
@@ -607,16 +583,13 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
@@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -977,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
|
||||
@@ -50,7 +50,6 @@ export default {
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:key="source.id"
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
|
||||
@@ -24,6 +24,10 @@ export default {
|
||||
type: [String, Date, Number],
|
||||
default: '',
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -74,6 +78,15 @@ export default {
|
||||
createdAtTimestamp() {
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
},
|
||||
conversationId() {
|
||||
// Reset display values and timer when the row is recycled to a different conversation.
|
||||
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
|
||||
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
clearTimeout(this.timer);
|
||||
this.createTimer();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isAutoRefreshEnabled) {
|
||||
@@ -111,7 +124,6 @@ export default {
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
|
||||
>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
if (this.isALineChannel || this.isATiktokChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
@@ -380,7 +380,11 @@ export default {
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
v-if="
|
||||
(isAWebWidgetInbox || isAPIInbox) &&
|
||||
!isOnPrivateNote &&
|
||||
!isEditorDisabled
|
||||
"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
|
||||
@@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
|
||||
value: 'priority_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
|
||||
value: 'priority_desc_created_at_asc',
|
||||
},
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
|
||||
value: 'waiting_since_asc',
|
||||
|
||||
@@ -100,6 +100,9 @@ export default {
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
:class="{
|
||||
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
|
||||
}"
|
||||
/>
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
@@ -50,10 +50,21 @@ const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
@@ -352,6 +363,7 @@ const deleteConversation = () => {
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -85,7 +85,12 @@ export default {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
|
||||
this.onCancel();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
const status = error?.response?.status;
|
||||
if (status === 402) {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
|
||||
} else {
|
||||
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export default {
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
|
||||
@@ -200,9 +200,13 @@ export default {
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
return this.isAWhatsAppChannel
|
||||
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
|
||||
}
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
|
||||
}
|
||||
return this.isPrivate
|
||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||
@@ -279,7 +283,8 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -426,7 +431,7 @@ export default {
|
||||
},
|
||||
isEditorDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
@@ -751,11 +756,13 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
// Instagram and TikTok do not support sending text and attachments in the same message.
|
||||
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
|
||||
// For TikTok, the API rejects messages that mix text and media.
|
||||
// To handle both cases, text and attachments are always sent as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
const isOnTiktok = this.isATiktokChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
@@ -1069,7 +1076,8 @@ export default {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
let caption =
|
||||
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -1091,11 +1099,13 @@ export default {
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
|
||||
// For WhatsApp, we only need a text message if there are no attachments.
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
((this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
this.message) ||
|
||||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
PRIORITY_DESC: 'priority_desc',
|
||||
WAITING_SINCE_ASC: 'waiting_since_asc',
|
||||
WAITING_SINCE_DESC: 'waiting_since_desc',
|
||||
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
|
||||
},
|
||||
ARTICLE_STATUS_TYPES: {
|
||||
DRAFT: 0,
|
||||
|
||||
@@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
CHANNEL_EMAIL: 'channel_email',
|
||||
CHANNEL_FACEBOOK: 'channel_facebook',
|
||||
CHANNEL_TWITTER: 'channel_twitter',
|
||||
CHANNEL_WEBSITE: 'channel_website',
|
||||
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
|
||||
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
|
||||
@@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
|
||||
@@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -458,6 +458,9 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
},
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
@@ -305,6 +306,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
|
||||
@@ -592,8 +592,10 @@
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
@@ -713,8 +715,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||
@@ -890,6 +892,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -901,7 +917,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
|
||||
"DONE": "Done"
|
||||
},
|
||||
"COUNT": "{n} webhook | {n} webhooks",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
|
||||
@@ -119,10 +119,7 @@ const debouncedSearch = debounce(async query => {
|
||||
}
|
||||
|
||||
try {
|
||||
const contacts = await searchContacts({
|
||||
keys: ['name', 'email', 'phone_number'],
|
||||
query,
|
||||
});
|
||||
const contacts = await searchContacts(query);
|
||||
|
||||
// Add selected contact to top if not already in results
|
||||
const allContacts = selectedContact.value
|
||||
|
||||
@@ -58,12 +58,14 @@ export default {
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
@@ -175,12 +177,14 @@ export default {
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
|
||||
@@ -81,10 +81,14 @@ export default {
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
|
||||
return {
|
||||
twitter: twitterScreenName,
|
||||
telegram: telegramUsername,
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
|
||||
@@ -182,6 +182,7 @@ onMounted(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeletePopup(bot)"
|
||||
/>
|
||||
|
||||
@@ -268,6 +268,7 @@ const confirmDeletion = () => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[agent.id]"
|
||||
@click="openDeletePopup(agent, index)"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
@@ -70,14 +70,6 @@ const automationActive = computed({
|
||||
:is-loading="loading"
|
||||
@click="$emit('edit', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
|
||||
:is-loading="loading"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
@click="$emit('delete', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
|
||||
icon="i-woot-clone"
|
||||
@@ -86,6 +78,15 @@ const automationActive = computed({
|
||||
:is-loading="loading"
|
||||
@click="$emit('clone', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
|
||||
:is-loading="loading"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', automation)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
|
||||
@@ -45,7 +45,10 @@ const records = computed(() =>
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['short_code', 'content']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'short_code', weight: 4 },
|
||||
'content',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters.getUIFlags.value);
|
||||
|
||||
@@ -232,6 +235,7 @@ const tableHeaders = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[cannedItem.id]"
|
||||
@click="openDeletePopup(cannedItem)"
|
||||
/>
|
||||
|
||||
+1
@@ -66,6 +66,7 @@ const getFormattedPermissions = role => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[customRole.id]"
|
||||
@click="emit('delete', customRole)"
|
||||
/>
|
||||
|
||||
@@ -116,17 +116,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full p-8 overflow-auto">
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3 p-8"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
@@ -140,7 +140,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
|
||||
@@ -62,14 +62,14 @@ const items = computed(() => {
|
||||
<div class="mx-auto flex flex-col gap-6 mb-8 max-w-7xl w-full !px-6">
|
||||
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh]"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
:global-config="globalConfig"
|
||||
:items="items"
|
||||
/>
|
||||
<div class="col-span-6 overflow-hidden">
|
||||
<div class="col-span-6 flex flex-col overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +170,7 @@ const openDelete = inbox => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="openDelete(inbox)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -53,6 +54,7 @@ export default {
|
||||
SettingsAccordion,
|
||||
WeeklyAvailability,
|
||||
SenderNameExamplePreview,
|
||||
LockToSingleConversationPreview,
|
||||
MicrosoftReauthorize,
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
@@ -214,6 +216,18 @@ export default {
|
||||
const { medium, channel_type: type } = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
},
|
||||
bannerMaxWidth() {
|
||||
const narrowTabs = [
|
||||
'collaborators',
|
||||
'configuration',
|
||||
'bot-configuration',
|
||||
];
|
||||
if (narrowTabs.includes(this.selectedTabKey)) return 'max-w-4xl';
|
||||
if (this.selectedTabKey === 'inbox-settings') {
|
||||
return this.isAWebWidgetInbox ? 'max-w-7xl' : 'max-w-4xl';
|
||||
}
|
||||
return 'max-w-7xl';
|
||||
},
|
||||
inboxName() {
|
||||
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
|
||||
return `${this.inbox.name} (${
|
||||
@@ -234,6 +248,9 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -524,6 +541,9 @@ export default {
|
||||
hideBusinessNameInput() {
|
||||
this.showBusinessNameInput = false;
|
||||
},
|
||||
toggleLockToSingleConversation(value) {
|
||||
this.locktoSingleConversation = value;
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
webhookUrl: {
|
||||
@@ -571,44 +591,57 @@ export default {
|
||||
v-if="microsoftUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<FacebookReauthorize
|
||||
v-if="facebookUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<GoogleReauthorize
|
||||
v-if="googleUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<InstagramReauthorize
|
||||
v-if="instagramUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<TiktokReauthorize
|
||||
v-if="tiktokUnauthorized"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<WhatsappReauthorize
|
||||
v-if="whatsappUnauthorized"
|
||||
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
|
||||
:inbox="inbox"
|
||||
class="mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
class="mx-6 mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
class="flex flex-col md:flex-row items-center lg:items-start justify-between gap-5 lg:gap-10 mx-6"
|
||||
>
|
||||
<div class="max-w-2xl flex-1 flex flex-col min-w-0">
|
||||
<div
|
||||
class="flex-1 flex flex-col min-w-0"
|
||||
:class="{
|
||||
'max-w-2xl': isAWebWidgetInbox,
|
||||
'max-w-4xl': !isAWebWidgetInbox,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-start mb-4">
|
||||
<label class="text-heading-3 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
|
||||
@@ -706,6 +739,21 @@ export default {
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
class="[&>div>div]:justify-end [&>div>div]:flex lg:[&>div:first-child]:h-12 [&>div:first-child]:h-16"
|
||||
>
|
||||
<template #extra>
|
||||
<LockToSingleConversationPreview
|
||||
:lock-to-single-conversation="locktoSingleConversation"
|
||||
@update="toggleLockToSingleConversation"
|
||||
/>
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="isAWebWidgetInbox || isAnEmailChannel"
|
||||
:label="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
|
||||
@@ -755,6 +803,7 @@ export default {
|
||||
<SenderNameExamplePreview
|
||||
:sender-name-type="senderNameType"
|
||||
:business-name="businessName"
|
||||
:is-website-channel="isAWebWidgetInbox"
|
||||
@update="toggleSenderNameType"
|
||||
/>
|
||||
</template>
|
||||
@@ -1048,19 +1097,6 @@ export default {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
v-model="locktoSingleConversation"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
@@ -1107,10 +1143,10 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-3xl">
|
||||
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-4xl">
|
||||
<CollaboratorsPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'configuration'">
|
||||
<div v-if="selectedTabKey === 'configuration'" class="mx-6 max-w-4xl">
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
|
||||
@@ -147,7 +147,9 @@ export default {
|
||||
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -175,7 +177,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
@@ -184,7 +186,7 @@ export default {
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
@@ -192,7 +194,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
@@ -200,7 +202,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@@ -209,7 +211,7 @@ export default {
|
||||
<woot-input
|
||||
v-model="domain"
|
||||
:class="{ error: v$.domain.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
|
||||
@blur="v$.domain.$touch"
|
||||
@@ -220,14 +222,14 @@ export default {
|
||||
:action="handleEncryptionChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
|
||||
:selected="openSSLVerifyMode"
|
||||
:options="openSSLVerifyModes"
|
||||
:action="handleSSLModeChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
|
||||
:selected="authMechanism"
|
||||
:options="authMechanisms"
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-6 max-w-3xl">
|
||||
<div class="mx-6 max-w-4xl">
|
||||
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
|
||||
<form v-else @submit.prevent="updateActiveAgentBot">
|
||||
<SettingsFieldSection
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
defineProps({
|
||||
lockToSingleConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
>
|
||||
<RadioCard
|
||||
id="disabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="!lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', false)"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
id="enabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+9
-2
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
@@ -13,6 +13,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isWebsiteChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
@@ -56,7 +60,10 @@ const toggleSenderNameType = key => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
class="flex flex-col items-start gap-4 mt-3 min-w-0"
|
||||
:class="
|
||||
isWebsiteChannel ? 'sm:flex-row md:flex-col xl:flex-row' : 'sm:flex-row'
|
||||
"
|
||||
>
|
||||
<RadioCard
|
||||
v-for="keyOption in senderNameKeyOptions"
|
||||
|
||||
+7
-7
@@ -149,7 +149,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isATwilioChannel" class="mx-6">
|
||||
<div v-if="isATwilioChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
|
||||
@@ -168,7 +168,7 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-6">
|
||||
<div v-else-if="isAVoiceChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
@@ -187,7 +187,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel" class="mx-6">
|
||||
<div v-else-if="isALineChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
|
||||
@@ -196,7 +196,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAWebWidgetInbox">
|
||||
<div class="mx-6">
|
||||
<div>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
|
||||
@@ -266,7 +266,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="isAPIInbox" class="mx-6">
|
||||
<div v-else-if="isAPIInbox">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
|
||||
@@ -297,7 +297,7 @@ export default {
|
||||
</div>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAnEmailChannel" class="mx-6">
|
||||
<div v-else-if="isAnEmailChannel">
|
||||
<div>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
|
||||
@@ -325,7 +325,7 @@ export default {
|
||||
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
|
||||
</div>
|
||||
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
|
||||
<div v-if="inbox.provider_config" class="mx-6">
|
||||
<div v-if="inbox.provider_config">
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
|
||||
+151
-1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -46,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ defineEmits(['edit', 'delete']);
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', app)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
@@ -133,6 +133,7 @@ const inboxName = hook => (hook.inbox ? hook.inbox.name : '');
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', hook)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
|
||||
+86
-48
@@ -1,60 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(webhook) {
|
||||
try {
|
||||
await this.$store.dispatch('webhooks/create', { webhook });
|
||||
useAlert(
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const createdWebhook = ref(null);
|
||||
|
||||
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
|
||||
|
||||
const onSubmit = async webhook => {
|
||||
try {
|
||||
const result = await store.dispatch('webhooks/create', { webhook });
|
||||
createdWebhook.value = result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
await copyTextToClipboard(createdWebhook.value.secret);
|
||||
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
<template v-if="createdWebhook">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
"
|
||||
/>
|
||||
<div class="px-8 pb-6">
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="createdWebhook.secret"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="handleCopySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex justify-end mt-4">
|
||||
<NextButton
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
|
||||
@click="props.onClose()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<woot-modal-header
|
||||
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="props.onClose()"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+39
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -57,10 +59,14 @@ export default {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasSecret() {
|
||||
return !!this.value.secret;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@@ -81,6 +87,10 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
async copySecret() {
|
||||
await copyTextToClipboard(this.value.secret);
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@@ -111,6 +121,35 @@ export default {
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="hasSecret" class="mb-4">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="
|
||||
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
|
||||
"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
|
||||
type="button"
|
||||
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
slate
|
||||
faded
|
||||
@click="secretVisible = !secretVisible"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="copySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label :class="{ error: v$.url.$error }" class="mb-2">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ const subscribedEvents = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', webhook, index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value);
|
||||
const filteredRecords = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['title', 'description']);
|
||||
return picoSearch(records.value, query, [
|
||||
{ name: 'title', weight: 4 },
|
||||
'description',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
|
||||
|
||||
@@ -174,6 +177,7 @@ onBeforeMount(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[label.id]"
|
||||
@click="openDeletePopup(label)"
|
||||
/>
|
||||
|
||||
@@ -96,6 +96,7 @@ const visibilityLabel = computed(() => {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +288,7 @@ export default {
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[sla.id]"
|
||||
@click="openDeletePopup(sla)"
|
||||
/>
|
||||
|
||||
@@ -88,19 +88,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<form
|
||||
class="flex flex-wrap mx-0 overflow-x-auto"
|
||||
@submit.prevent="addAgents"
|
||||
>
|
||||
<div class="w-full">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full h-full">
|
||||
<div v-if="v$.selectedAgents.$error">
|
||||
<p class="error-message pb-2">
|
||||
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<div class="h-full w-full p-6 col-span-6 overflow-y-auto">
|
||||
<PageHeader
|
||||
:header-title="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.TITLE')"
|
||||
:header-content="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.DESC')"
|
||||
|
||||
@@ -25,7 +25,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
|
||||
@@ -103,19 +103,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6">
|
||||
<form
|
||||
class="flex flex-wrap mx-0 overflow-x-auto"
|
||||
@submit.prevent="addAgents"
|
||||
>
|
||||
<div class="w-full">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full h-full">
|
||||
<div v-if="v$.selectedAgents.$error">
|
||||
<p class="error-message pb-2">
|
||||
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6">
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-y-auto">
|
||||
<PageHeader
|
||||
:header-title="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.TITLE')"
|
||||
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.DESC')"
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
|
||||
@@ -161,6 +161,7 @@ const confirmPlaceHolderText = computed(() =>
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[team.id]"
|
||||
@click="openDelete(team)"
|
||||
/>
|
||||
|
||||
@@ -457,11 +457,7 @@ const actions = {
|
||||
},
|
||||
|
||||
sendEmailTranscript: async (_, { conversationId, email }) => {
|
||||
try {
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
await ConversationApi.sendEmailTranscript({ conversationId, email });
|
||||
},
|
||||
|
||||
updateCustomAttributes: async (
|
||||
|
||||
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
|
||||
priority_desc: ['sortOnPriority', 'desc'],
|
||||
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
|
||||
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
|
||||
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
|
||||
};
|
||||
const sortAscending = (valueA, valueB) => valueA - valueB;
|
||||
const sortDescending = (valueA, valueB) => valueB - valueA;
|
||||
@@ -139,6 +140,14 @@ const sortConfig = {
|
||||
return getSortOrderFunction(sortDirection)(p1, p2);
|
||||
},
|
||||
|
||||
sortOnPriorityCreatedAt: (a, b) => {
|
||||
const DEFAULT_FOR_NULL = 0;
|
||||
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
|
||||
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
|
||||
if (p1 !== p2) return p2 - p1;
|
||||
return a.created_at - b.created_at;
|
||||
},
|
||||
|
||||
sortOnWaitingSince: (a, b, sortDirection) => {
|
||||
const sortFunc = getSortOrderFunction(sortDirection);
|
||||
if (!a.waiting_since || !b.waiting_since) {
|
||||
|
||||
@@ -360,6 +360,13 @@ export const actions = {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.analyzeCSATTemplateUtility(
|
||||
inboxId,
|
||||
template
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const actions = {
|
||||
} = response.data;
|
||||
commit(types.default.ADD_WEBHOOK, webhook);
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
|
||||
throw error;
|
||||
|
||||
@@ -46,9 +46,7 @@ export default {
|
||||
filteredActiveLabels() {
|
||||
if (!this.search) return this.accountLabels;
|
||||
|
||||
return picoSearch(this.accountLabels, this.search, ['title'], {
|
||||
threshold: 0.9,
|
||||
});
|
||||
return picoSearch(this.accountLabels, this.search, ['title']);
|
||||
},
|
||||
|
||||
noResult() {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('GoogleOAuthButton.vue', () => {
|
||||
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
|
||||
const params = googleAuthUrl.searchParams;
|
||||
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
|
||||
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
|
||||
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth');
|
||||
expect(params.get('client_id')).toBe('clientId');
|
||||
expect(params.get('redirect_uri')).toBe(
|
||||
'http://localhost:3000/test-callback'
|
||||
|
||||
@@ -6,8 +6,7 @@ export default {
|
||||
// Creating the URL manually because the devise-token-auth with
|
||||
// omniauth has a standing issue on redirecting the post request
|
||||
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
|
||||
const baseUrl =
|
||||
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
|
||||
const baseUrl = 'https://accounts.google.com/o/oauth2/auth';
|
||||
const clientId = window.chatwootConfig.googleOAuthClientId;
|
||||
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
|
||||
const responseType = 'code';
|
||||
|
||||
@@ -72,6 +72,10 @@ export default {
|
||||
return this.message.sender.available_name || this.message.sender.name;
|
||||
}
|
||||
|
||||
if (this.message.additional_attributes?.sender_name) {
|
||||
return this.message.additional_attributes.sender_name;
|
||||
}
|
||||
|
||||
if (this.useInboxAvatarForBot) {
|
||||
return this.channelConfig.websiteName;
|
||||
}
|
||||
@@ -87,9 +91,13 @@ export default {
|
||||
return displayImage;
|
||||
}
|
||||
|
||||
return this.message.sender
|
||||
? this.message.sender.avatar_url
|
||||
: displayImage;
|
||||
if (this.message.sender) {
|
||||
return this.message.sender.avatar_url;
|
||||
}
|
||||
|
||||
return (
|
||||
this.message.additional_attributes?.sender_avatar_url || displayImage
|
||||
);
|
||||
},
|
||||
hasRecordedResponse() {
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
return false if inbox.account.suspended?
|
||||
return false unless inbox.channel.imap_enabled
|
||||
return false if inbox.channel.reauthorization_required?
|
||||
return false if inbox.channel.in_backoff?
|
||||
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
return false if default_plan?(inbox.account)
|
||||
|
||||
@@ -6,26 +6,29 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def perform(channel, interval = 1)
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
|
||||
|
||||
with_lock(key, 5.minutes) do
|
||||
process_email_for_channel(channel, interval)
|
||||
end
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS => e
|
||||
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
|
||||
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
|
||||
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
fetch_emails_with_backoff(channel, interval)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_emails_with_backoff(channel, interval)
|
||||
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
|
||||
with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) }
|
||||
channel.clear_backoff!
|
||||
rescue Imap::AuthenticationError => e
|
||||
Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}"
|
||||
channel.apply_backoff!
|
||||
rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}"
|
||||
channel.apply_backoff!
|
||||
rescue LockAcquisitionError
|
||||
Rails.logger.error "Lock failed for #{channel.inbox.id}"
|
||||
end
|
||||
|
||||
def should_fetch_email?(channel)
|
||||
channel.imap_enabled? && !channel.reauthorization_required?
|
||||
channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff?
|
||||
end
|
||||
|
||||
def process_email_for_channel(channel, interval)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class WebhookJob < ApplicationJob
|
||||
queue_as :medium
|
||||
# There are 3 types of webhooks, account, inbox and agent_bot
|
||||
def perform(url, payload, webhook_type = :account_webhook)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type)
|
||||
def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
|
||||
Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
|
||||
account.webhooks.account_type.each do |webhook|
|
||||
next unless webhook.subscriptions.include?(payload[:event])
|
||||
|
||||
WebhookJob.perform_later(webhook.url, payload)
|
||||
WebhookJob.perform_later(webhook.url, payload, :account_webhook,
|
||||
secret: webhook.secret,
|
||||
delivery_id: SecureRandom.uuid)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
|
||||
return unless inbox.channel_type == 'Channel::Api'
|
||||
return if inbox.channel.webhook_url.blank?
|
||||
|
||||
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook)
|
||||
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
|
||||
delivery_id: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def deliver_webhook_payloads(payload, inbox)
|
||||
|
||||
@@ -41,6 +41,7 @@ class Account < ApplicationRecord
|
||||
'audio_transcriptions': { 'type': %w[boolean null] },
|
||||
'auto_resolve_label': { 'type': %w[string null] },
|
||||
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
|
||||
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
|
||||
'conversation_required_attributes': {
|
||||
'type': %w[array null],
|
||||
'items': { 'type': 'string' }
|
||||
@@ -90,6 +91,7 @@ class Account < ApplicationRecord
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
store_accessor :settings, :keep_pending_on_bot_failure
|
||||
store_accessor :settings, :captain_disable_auto_resolve
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
|
||||
@@ -72,6 +72,10 @@ class Channel::Email < ApplicationRecord
|
||||
imap_enabled && imap_address == 'imap.gmail.com'
|
||||
end
|
||||
|
||||
def backoff_log_identifier
|
||||
"Error for email channel - #{inbox.id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_forward_to_email
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include AssigneeActivityMessageHandler
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
@@ -104,27 +105,6 @@ module ActivityMessageHandler
|
||||
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
end
|
||||
|
||||
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
module AssigneeActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
if !user_name && Current.executed_by.present?
|
||||
user_name = case Current.executed_by
|
||||
when AssignmentPolicy
|
||||
I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name)
|
||||
when Inbox
|
||||
I18n.t('auto_assignment.default_policy_name')
|
||||
else
|
||||
I18n.t('automation.system_name')
|
||||
end
|
||||
end
|
||||
user_name
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
# Backoffable provides transient-error retry backoff for models that depend on external services.
|
||||
#
|
||||
# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!.
|
||||
# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that
|
||||
# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!.
|
||||
#
|
||||
# Call clear_backoff! after a successful operation to reset the counter.
|
||||
|
||||
module Backoffable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def backoff_log_identifier
|
||||
inbox_id = respond_to?(:inbox) && inbox&.id
|
||||
inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}"
|
||||
end
|
||||
|
||||
def backoff_retry_count
|
||||
::Redis::Alfred.get(backoff_retry_count_key).to_i
|
||||
end
|
||||
|
||||
def in_backoff?
|
||||
val = ::Redis::Alfred.get(backoff_retry_after_key)
|
||||
val.present? && Time.zone.at(val.to_f) > Time.current
|
||||
end
|
||||
|
||||
def apply_backoff!
|
||||
new_count = backoff_retry_count + 1
|
||||
max_interval, max_retries = backoff_limits
|
||||
|
||||
if new_count > max_retries
|
||||
exhaust_backoff(new_count)
|
||||
else
|
||||
schedule_backoff_retry(new_count, max_interval, max_retries)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_backoff!
|
||||
::Redis::Alfred.delete(backoff_retry_count_key)
|
||||
::Redis::Alfred.delete(backoff_retry_after_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backoff_limits
|
||||
max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i
|
||||
max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i
|
||||
[max_interval, (max_interval - 1) + max_count]
|
||||
end
|
||||
|
||||
def exhaust_backoff(new_count)
|
||||
Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization"
|
||||
clear_backoff!
|
||||
prompt_reauthorization!
|
||||
end
|
||||
|
||||
def schedule_backoff_retry(new_count, max_interval, max_retries)
|
||||
wait_minutes = [new_count, max_interval].min
|
||||
::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours)
|
||||
::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours)
|
||||
Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m"
|
||||
end
|
||||
|
||||
def backoff_retry_count_key
|
||||
format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
|
||||
end
|
||||
|
||||
def backoff_retry_after_key
|
||||
format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id)
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ module MessageFilterHelpers
|
||||
end
|
||||
|
||||
def notifiable?
|
||||
incoming? || outgoing?
|
||||
(incoming? || outgoing?) && !private?
|
||||
end
|
||||
|
||||
def conversation_transcriptable?
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
module Reauthorizable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Backoffable
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 2
|
||||
|
||||
# model attribute
|
||||
@@ -65,6 +67,7 @@ module Reauthorizable
|
||||
def reauthorized!
|
||||
::Redis::Alfred.delete(authorization_error_count_key)
|
||||
::Redis::Alfred.delete(reauthorization_required_key)
|
||||
clear_backoff!
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
end
|
||||
|
||||
@@ -14,6 +14,10 @@ module SortHandler
|
||||
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC"))
|
||||
end
|
||||
|
||||
def sort_on_priority_created_at(sort_direction = :desc)
|
||||
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
|
||||
end
|
||||
|
||||
def sort_on_waiting_since(sort_direction = :asc)
|
||||
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
|
||||
end
|
||||
|
||||
@@ -159,6 +159,7 @@ class Conversation < ApplicationRecord
|
||||
end
|
||||
|
||||
def bot_handoff!
|
||||
update(waiting_since: Time.current) if waiting_since.blank?
|
||||
open!
|
||||
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
|
||||
end
|
||||
|
||||
@@ -21,6 +21,9 @@ class Webhook < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
has_secure_token :secret
|
||||
encrypts :secret if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
||||
validate :validate_webhook_subscriptions
|
||||
|
||||
@@ -72,13 +72,17 @@ class AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def assign_conversation(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
conversation.update!(assignee: agent)
|
||||
Current.executed_by = nil
|
||||
|
||||
rate_limiter = build_rate_limiter(agent)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
|
||||
dispatch_assignment_event(conversation, agent)
|
||||
true
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
def dispatch_assignment_event(conversation, agent)
|
||||
|
||||
@@ -2,8 +2,6 @@ class AutoAssignment::RateLimiter
|
||||
pattr_initialize [:inbox!, :agent!]
|
||||
|
||||
def within_limit?
|
||||
return true unless enabled?
|
||||
|
||||
current_count < limit
|
||||
end
|
||||
|
||||
@@ -13,24 +11,18 @@ class AutoAssignment::RateLimiter
|
||||
end
|
||||
|
||||
def current_count
|
||||
return 0 unless enabled?
|
||||
|
||||
pattern = assignment_key_pattern
|
||||
Redis::Alfred.keys_count(pattern)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enabled?
|
||||
config.present? && limit.positive?
|
||||
end
|
||||
|
||||
def limit
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : 5
|
||||
end
|
||||
|
||||
def window
|
||||
config&.fair_distribution_window&.to_i || 24.hours.to_i
|
||||
config&.fair_distribution_window&.to_i || 5.minutes.to_i
|
||||
end
|
||||
|
||||
def config
|
||||
|
||||
@@ -110,6 +110,7 @@ class CsatTemplateManagementService
|
||||
|
||||
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||
status_result = template_service.get_template_status(content_sid)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
@@ -130,6 +131,7 @@ class CsatTemplateManagementService
|
||||
def get_whatsapp_template_status(template)
|
||||
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
class CsatTemplateUtilityAnalysisService
|
||||
include CsatTemplateUtilityRubric
|
||||
|
||||
pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }]
|
||||
|
||||
def perform
|
||||
baseline = rule_based_result
|
||||
return baseline if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
llm_result = llm_result_or_nil(baseline)
|
||||
llm_result || baseline
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def llm_result_or_nil(baseline)
|
||||
llm_output = Captain::CsatUtilityAnalysisService.new(
|
||||
account: account,
|
||||
message: message,
|
||||
button_text: button_text,
|
||||
language: language,
|
||||
baseline: baseline
|
||||
).perform
|
||||
|
||||
return nil if llm_output[:error]
|
||||
|
||||
normalize_llm_result(llm_output, baseline: baseline)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_llm_result(result, baseline:)
|
||||
classification = normalized_classification(result[:classification], baseline: baseline)
|
||||
optimized_message = result[:optimized_message].presence || baseline[:optimized_message]
|
||||
optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
{
|
||||
classification: classification,
|
||||
optimized_message: optimized_message
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_classification(value, baseline:)
|
||||
raw = value.to_s
|
||||
return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
raw
|
||||
end
|
||||
|
||||
def rule_based_result
|
||||
text = sanitized_message
|
||||
marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count)
|
||||
classification = classify(criteria: criteria, utility_hits_count: utility_hits_count)
|
||||
build_rule_payload(
|
||||
classification: classification
|
||||
)
|
||||
end
|
||||
|
||||
def build_rule_payload(payload)
|
||||
{
|
||||
classification: payload[:classification],
|
||||
optimized_message: optimized_message_for(payload[:classification])
|
||||
}
|
||||
end
|
||||
|
||||
def sanitized_message
|
||||
message.to_s.squish
|
||||
end
|
||||
|
||||
def classify(criteria:, utility_hits_count:)
|
||||
return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition]
|
||||
return 'LIKELY_MARKETING' unless criteria[:prohibited_content]
|
||||
return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2
|
||||
|
||||
'UNCLEAR'
|
||||
end
|
||||
|
||||
def optimized_message_for(classification)
|
||||
return sanitized_message if classification == 'LIKELY_UTILITY'
|
||||
|
||||
build_input_aware_utility_message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
# rubocop:disable Metrics/ModuleLength
|
||||
module CsatTemplateUtilityRubric
|
||||
LANGUAGE_FALLBACKS = {
|
||||
'en' => {
|
||||
support_request: 'support request',
|
||||
support_ticket: 'support ticket',
|
||||
support_conversation: 'support conversation',
|
||||
status_closed: 'closed',
|
||||
status_resolved: 'resolved',
|
||||
status_completed: 'completed',
|
||||
line_status: 'Your %<subject>s has been %<status>s.',
|
||||
line_help: 'If you still need help, simply reply to this message.',
|
||||
line_rate: 'To rate this support interaction, please use the button below.'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
MARKETING_PATTERNS = [
|
||||
/\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i,
|
||||
/\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i,
|
||||
/\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i,
|
||||
/\b(new\s*(plans?|products?|services?))\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTION_TRIGGER_PATTERNS = [
|
||||
/\b(closed|closing|resolved|completed)\b/i,
|
||||
/\b(ticket|request|case|conversation|support)\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTIONAL_CONTENT_PATTERNS = [
|
||||
/\b(ticket|request|case|conversation)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate|califica|calificar)\b/i
|
||||
].freeze
|
||||
|
||||
PROHIBITED_CONTENT_PATTERNS = [
|
||||
/\b(contest|sweepstake|lottery|quiz)\b/i,
|
||||
/\b(password|otp|pin|cvv|credit\s*card)\b/i,
|
||||
/\b(weapon|drugs|gambling)\b/i
|
||||
].freeze
|
||||
|
||||
STATUS_PATTERNS = {
|
||||
'closed' => /\b(closed|closing)\b/i,
|
||||
'resolved' => /\b(resolved|resolve[sd]?)\b/i,
|
||||
'completed' => /\b(completed|complete[sd]?)\b/i
|
||||
}.freeze
|
||||
|
||||
SUBJECT_PATTERNS = {
|
||||
'support ticket' => /\b(ticket)\b/i,
|
||||
'support conversation' => /\b(conversation|chat)\b/i,
|
||||
'support request' => /\b(request|case|support)\b/i
|
||||
}.freeze
|
||||
|
||||
UTILITY_PATTERNS = [
|
||||
/\b(support|ticket|request|conversation|case)\b/i,
|
||||
/\b(closed|resolved|completed)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message\b)/i,
|
||||
/\b(if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate\s+this\s+(support|interaction|conversation))\b/i
|
||||
].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_input_aware_utility_message
|
||||
text = translation_pack
|
||||
subject = detected_subject
|
||||
status = detected_status
|
||||
intro = extracted_intro_sentence
|
||||
|
||||
parts = []
|
||||
parts << intro if intro.present?
|
||||
parts << format(text[:line_status], subject: subject, status: status)
|
||||
parts << text[:line_help]
|
||||
parts << text[:line_rate]
|
||||
parts.join(' ')
|
||||
end
|
||||
|
||||
def detected_status
|
||||
matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
status_key = matched&.first || 'closed'
|
||||
translation_pack[:"status_#{status_key}"]
|
||||
end
|
||||
|
||||
def detected_subject
|
||||
matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
subject_key = matched&.first&.tr(' ', '_') || 'support_request'
|
||||
translation_pack[subject_key.to_sym]
|
||||
end
|
||||
|
||||
def extracted_intro_sentence
|
||||
first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s
|
||||
return nil if first_sentence.blank?
|
||||
return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) }
|
||||
return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i)
|
||||
|
||||
normalized = first_sentence.gsub(/\s+/, ' ').strip
|
||||
normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}."
|
||||
end
|
||||
|
||||
def translation_pack
|
||||
LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en'])
|
||||
end
|
||||
|
||||
def primary_language_code
|
||||
language.to_s.downcase.split(/[-_]/).first
|
||||
end
|
||||
|
||||
def evaluate_criteria(text:, marketing_hits_count:)
|
||||
{
|
||||
trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) },
|
||||
transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2,
|
||||
marketing_prohibition: marketing_hits_count.zero?,
|
||||
prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) },
|
||||
clarity_and_utility: clear_utility_intent?(text)
|
||||
}
|
||||
end
|
||||
|
||||
def clear_utility_intent?(text)
|
||||
has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i)
|
||||
has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) ||
|
||||
text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) ||
|
||||
text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i)
|
||||
has_support_context && has_actionable_next_step
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ModuleLength
|
||||
@@ -0,0 +1 @@
|
||||
class Imap::AuthenticationError < StandardError; end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user