Compare commits

..
Author SHA1 Message Date
Pranav 5171c81384 Add elastic search to the query 2024-11-08 14:21:59 -08:00
39 changed files with 419 additions and 2110 deletions
+3 -3
View File
@@ -175,15 +175,15 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'opensearch-ruby'
gem 'searchkick'
### Gems required only in specific deployment environments ###
##############################################################
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end
group :development do
+8 -9
View File
@@ -397,13 +397,6 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
judoscale-ruby (1.8.2)
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
base64
kaminari (1.2.2)
@@ -539,6 +532,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -710,6 +706,9 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.4.0)
activemodel (>= 6.1)
hashie
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -898,8 +897,6 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
judoscale-rails
judoscale-sidekiq
jwt
kaminari
koala
@@ -919,6 +916,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -944,6 +942,7 @@ DEPENDENCIES
rubocop-rspec
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1,53 +1,22 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
before_action :check_admin_authorization?
before_action :fetch_hook
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
end
private
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def permitted_params
params.permit(:method, :route, body: {})
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
end
end
+2 -2
View File
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
}
@@ -1,95 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
conversationLabels: {
type: Array,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const WIDTH_CONFIG = Object.freeze({
DEFAULT_WIDTH: 80,
CHAR_WIDTH: {
SHORT: 8, // For labels <= 5 chars
LONG: 6, // For labels > 5 chars
},
BASE_WIDTH: 12, // dot + gap
THRESHOLD: 5, // character length threshold
});
const containerRef = ref(null);
const maxLabels = ref(1);
const activeLabels = computed(() => {
const labelSet = new Set(props.conversationLabels);
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
});
const calculateLabelWidth = ({ title = '' }) => {
const charWidth =
title.length > WIDTH_CONFIG.THRESHOLD
? WIDTH_CONFIG.CHAR_WIDTH.LONG
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
};
const getAverageWidth = labels => {
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
const totalWidth = labels.reduce(
(sum, label) => sum + calculateLabelWidth(label),
0
);
return totalWidth / labels.length;
};
const visibleLabels = computed(() =>
activeLabels.value?.slice(0, maxLabels.value)
);
const updateVisibleLabels = () => {
if (!containerRef.value) return;
const containerWidth = containerRef.value.offsetWidth;
const avgWidth = getAverageWidth(activeLabels.value);
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
};
</script>
<template>
<div
ref="containerRef"
v-resize="updateVisibleLabels"
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
>
<template v-for="(label, index) in visibleLabels" :key="label.id">
<div
class="flex items-center gap-1.5 min-w-0"
:class="[
index !== visibleLabels.length - 1
? 'flex-shrink-0 text-ellipsis'
: 'flex-shrink',
]"
>
<div
:style="{ backgroundColor: label.color }"
class="size-1.5 rounded-full flex-shrink-0"
/>
<span
class="text-sm text-n-slate-10 whitespace-nowrap"
:class="{ truncate: index === visibleLabels.length - 1 }"
>
{{ label.title }}
</span>
</div>
</template>
</div>
</template>
@@ -1,59 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
</script>
<template>
<div class="flex items-end w-full gap-2 pb-1">
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
{{ lastNonActivityMessageContent }}
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
</script>
<template>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
{{ lastNonActivityMessageContent }}
</p>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
<div
class="grid items-center gap-2.5 h-7"
:class="
hasSlaThreshold
? 'grid-cols-[auto_auto_1fr_20px]'
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
:conversation-labels="conversation.labels"
:account-labels="accountLabels"
/>
</div>
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
</div>
</div>
</template>
@@ -1,207 +0,0 @@
<script setup>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
priority: {
type: String,
default: '',
},
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template>
@@ -1,477 +0,0 @@
<script setup>
import { computed } from 'vue';
import ConversationCard from './ConversationCard.vue';
// Base conversation object
const conversationWithoutMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phone_number: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
last_activity_at: 1712127410,
created_at: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 38,
messages: [
{
id: 3597,
content: 'Sivin set the priority to low',
accountId: 2,
inboxId: 7,
conversationId: 38,
messageType: 2,
createdAt: 1730885168,
updatedAt: '2024-11-06T09:26:08.565Z',
private: false,
status: 'sent',
source_id: null,
contentType: 'text',
contentAttributes: {},
senderType: null,
senderId: null,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent: 'Sivin set the priority to low',
sentiment: {},
conversation: {
assigneeId: 1,
unreadCount: 0,
lastActivityAt: 1730885168,
contactInbox: {
sourceId: 'candice@chatwoot.com',
},
},
},
],
accountId: 2,
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
additionalAttributes: {
mail_subject: 'Test email',
},
agentLastSeenAt: 0,
assigneeLastSeenAt: 0,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [],
status: 'open',
createdAt: 1730836533,
timestamp: 1730885168,
firstReplyCreatedAt: 1730836533,
unreadCount: 0,
lastNonActivityMessage: {
id: 3591,
content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
account_id: 2,
inbox_id: 7,
conversation_id: 38,
message_type: 1,
created_at: 1730836533,
updated_at: '2024-11-05T19:55:37.158Z',
private: false,
status: 'sent',
source_id:
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: ['test@gmail.com'],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885168,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885168,
priority: 'low',
waitingSince: 0,
slaPolicyId: null,
slaEvents: [],
};
const conversationWithMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 37,
messages: [
{
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
accountId: 2,
inboxId: 7,
conversationId: 37,
messageType: 1,
createdAt: 1730885428,
updatedAt: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
sourceId:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
contentType: 'text',
contentAttributes: {
ccEmails: [],
bccEmails: [],
toEmails: [],
},
sender_type: 'User',
senderId: 1,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
availableName: 'Sivin',
avatarUrl: '',
type: 'user',
availabilityStatus: 'online',
thumbnail: '',
},
},
],
accountId: 2,
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
additionalAttributes: {
mail_subject: 'we',
},
agentLastSeenAt: 1730885428,
assigneeLastSeenAt: 1730885428,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [
'billing',
'delivery',
'lead',
'premium-customer',
'software',
'ops-handover',
],
muted: false,
snoozedUntil: null,
status: 'open',
createdAt: 1722487645,
timestamp: 1730885428,
firstReplyCreatedAt: 1722487645,
unreadCount: 0,
lastNonActivityMessage: {
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
account_id: 2,
inbox_id: 7,
conversation_id: 37,
message_type: 1,
created_at: 1730885428,
updated_at: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
source_id:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 2,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'willy@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885428,
priority: 'urgent',
waitingSince: 1730885428,
slaPolicyId: 3,
appliedSla: {
id: 4,
sla_id: 3,
sla_status: 'active_with_misses',
created_at: 1712127410,
updated_at: 1712127545,
sla_description:
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
sla_name: 'Premium SLA',
sla_first_response_time_threshold: 120,
sla_next_response_time_threshold: 180,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 360,
},
slaEvents: [
{
id: 8,
event_type: 'frt',
meta: {},
updated_at: 1712127545,
created_at: 1712127545,
},
{
id: 9,
event_type: 'rt',
meta: {},
updated_at: 1712127790,
created_at: 1712127790,
},
],
};
const contactForConversationWithoutMeta = computed(() => ({
availabilityStatus: null,
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
customAttributes: {},
last_activity_at: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const contactForConversationWithMeta = computed(() => ({
availabilityStatus: null,
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
customAttributes: {},
lastActivityAt: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const webWidgetInbox = computed(() => ({
phone_number: '+918585858585',
channel_type: 'Channel::WebWidget',
}));
const accountLabels = computed(() => [
{
id: 1,
title: 'billing',
description: 'Label is used for tagging billing related conversations',
color: '#28AD21',
show_on_sidebar: true,
},
{
id: 3,
title: 'delivery',
description: null,
color: '#A2FDD5',
show_on_sidebar: true,
},
{
id: 6,
title: 'lead',
description: null,
color: '#F161C8',
show_on_sidebar: true,
},
{
id: 4,
title: 'ops-handover',
description: null,
color: '#A53326',
show_on_sidebar: true,
},
{
id: 5,
title: 'premium-customer',
description: null,
color: '#6FD4EF',
show_on_sidebar: true,
},
{
id: 2,
title: 'software',
description: null,
color: '#8F6EF2',
show_on_sidebar: true,
},
]);
</script>
<template>
<Story
title="Components/ConversationCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Conversation without meta">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="conversationWithoutMeta"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
priority: 'medium',
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation without meta (Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="{
...conversationWithoutMeta,
unreadCount: 2,
priority: 'high',
}"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
unreadCount: 2,
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,102 +0,0 @@
<script setup>
import { computed } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardMessagePreview from './CardMessagePreview.vue';
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
contact: {
type: Object,
required: true,
},
stateInbox: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
const currentContactStatus = computed(
() => currentContact.value?.availabilityStatus
);
const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const lastActivityAt = computed(() => {
const timestamp = props.conversation?.timestamp;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
});
</script>
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
>
<Avatar
:name="currentContactName"
:src="currentContactThumbnail"
:size="24"
:status="currentContactStatus"
rounded-full
/>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
</h4>
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.top-start="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<CardMessagePreview
v-if="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-else
:conversation="conversation"
:account-labels="accountLabels"
/>
</div>
</div>
</template>
@@ -1,90 +0,0 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
chat: props.conversation,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.conversation, updateSlaStatus);
</script>
<template>
<div class="flex items-center min-w-fit gap-0.5 h-6">
<div class="flex items-center justify-center size-4">
<svg
width="10"
height="13"
viewBox="0 0 10 13"
fill="none"
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
/>
</svg>
</div>
<span
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
</span>
</div>
</template>
@@ -35,7 +35,7 @@ const emit = defineEmits([
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => props.article.title,
@@ -1,9 +1,6 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { computed, ref, watch } from 'vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -30,114 +27,34 @@ const props = defineProps({
status: {
type: String,
default: null,
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
iconName: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
},
});
const emit = defineEmits(['upload']);
const { t } = useI18n();
const isImageValid = ref(true);
const AVATAR_COLORS = {
dark: [
['#4B143D', '#FF8DCC'],
['#3F220D', '#FFA366'],
['#2A2A2A', '#ADB1B8'],
['#023B37', '#0BD8B6'],
['#27264D', '#A19EFF'],
['#1D2E62', '#9EB1FF'],
],
light: [
['#FBDCEF', '#C2298A'],
['#FFE0BB', '#99543A'],
['#E8E8E8', '#60646C'],
['#CCF3EA', '#008573'],
['#EBEBFE', '#4747C2'],
['#E1E9FF', '#3A5BC7'],
],
default: { bg: '#E8E8E8', text: '#60646C' },
};
const STATUS_CLASSES = {
online: 'bg-n-teal-10',
busy: 'bg-n-amber-10',
offline: 'bg-n-slate-10',
};
const showDefaultAvatar = computed(() => !props.src && !props.name);
function invalidateCurrentImage() {
isImageValid.value = false;
}
const initials = computed(() => {
if (!props.name) return '';
const words = removeEmoji(props.name).split(/\s+/);
return words.length === 1
? words[0].charAt(0).toUpperCase()
: words
.slice(0, 2)
.map(word => word.charAt(0))
.join('')
.toUpperCase();
const splitNames = props.name.split(' ');
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
return firstName[0] + lastName[0];
}
const firstName = splitNames[0];
return firstName[0];
});
const getColorsByNameLength = computed(() => {
if (!props.name) return AVATAR_COLORS.default;
const index = props.name.length % AVATAR_COLORS.light.length;
return {
bg: AVATAR_COLORS.light[index][0],
darkBg: AVATAR_COLORS.dark[index][0],
text: AVATAR_COLORS.light[index][1],
darkText: AVATAR_COLORS.dark[index][1],
};
});
const containerStyles = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
const avatarStyles = computed(() => ({
...containerStyles.value,
backgroundColor:
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
? getColorsByNameLength.value.bg
: undefined,
color:
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
? getColorsByNameLength.value.text
: undefined,
'--dark-bg': getColorsByNameLength.value.darkBg,
'--dark-text': getColorsByNameLength.value.darkText,
}));
const badgeStyles = computed(() => {
const badgeSize = Math.max(props.size * 0.35, 8); // 35% of avatar size, minimum 8px
return {
width: `${badgeSize}px`,
height: `${badgeSize}px`,
top: `${props.size - badgeSize / 1.1}px`,
left: `${props.size - badgeSize / 1.1}px`,
};
});
const iconStyles = computed(() => ({
fontSize: `${props.size / 1.6}px`,
}));
const initialsStyles = computed(() => ({
fontSize: `${props.size / 1.8}px`,
}));
const invalidateCurrentImage = () => {
isImageValid.value = false;
};
watch(
() => props.src,
() => {
@@ -147,62 +64,48 @@ watch(
</script>
<template>
<span class="relative inline-flex" :style="containerStyles">
<!-- Status Badge -->
<slot name="badge" :size="size">
<span
class="relative inline"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<slot name="badge" :size>
<div
v-if="status"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
class="rounded-full w-2.5 h-2.5 absolute z-20"
:style="{
top: `${size - 10}px`,
left: `${size - 10}px`,
}"
:class="{
'bg-n-teal-10': status === 'online',
'bg-n-amber-10': status === 'busy',
'bg-n-slate-10': status === 'offline',
}"
/>
</slot>
<!-- Avatar Container -->
<span
role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
:class="[
roundedFull ? 'rounded-full' : 'rounded-xl',
{
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid),
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
},
]"
:style="avatarStyles"
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
:class="{
'rounded-full': roundedFull,
'rounded-xl': !roundedFull,
}"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<!-- Avatar Content -->
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<template v-else>
<!-- Custom Icon -->
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
<!-- Initials -->
<span
v-else-if="!showDefaultAvatar"
:style="initialsStyles"
class="select-none"
>
{{ initials }}
</span>
<!-- Fallback Icon if no name or image -->
<Icon
v-else
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
icon="i-lucide-user"
:style="iconStyles"
/>
</template>
<!-- Upload Overlay -->
<span v-else>
{{ initials }}
</span>
<div
v-if="allowUpload"
role="button"
@@ -164,25 +164,9 @@ const menuItems = computed(() => {
},
{
name: 'Captain',
icon: 'i-woot-captain',
icon: 'i-lucide-bot',
label: t('SIDEBAR.CAPTAIN'),
children: [
{
name: 'Documents',
label: 'Documents',
to: accountScopedRoute('captain', { page: 'documents' }),
},
{
name: 'Responses',
label: 'Responses',
to: accountScopedRoute('captain', { page: 'responses' }),
},
{
name: 'Playground',
label: 'Playground',
to: accountScopedRoute('captain', { page: 'playground' }),
},
],
to: accountScopedRoute('captain'),
},
{
name: 'Contacts',
@@ -122,9 +122,7 @@ const toggleTrigger = () => {
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:label="child.label"
:icon="child.icon"
:children="child.children"
v-bind="child"
:is-expanded="isExpanded"
:active-child="activeChild"
/>
@@ -22,7 +22,7 @@ const primaryMenuItems = accountId => [
key: 'captain',
label: 'CAPTAIN',
featureFlag: FEATURE_FLAGS.CAPTAIN,
toState: frontendURL(`accounts/${accountId}/captain/documents`),
toState: frontendURL(`accounts/${accountId}/captain`),
toStateName: 'captain',
},
{
@@ -1,107 +1,75 @@
<script setup>
import { nextTick, watch, computed } from 'vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { computed, onMounted, ref, watch } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { makeRouter, setupApp } from '@chatwoot/captain';
const props = defineProps({
page: {
type: String,
required: true,
},
});
import integrations from '../../api/integrations';
import Spinner from 'shared/components/Spinner.vue';
const isLoading = ref(true);
const captainURL = ref('');
const hasError = ref(false);
const loadCaptainFrame = async integration => {
if (!integration || !integration.enabled) {
return;
}
try {
isLoading.value = true;
const { data } = await integrations.fetchCaptainURL();
captainURL.value = data.sso_url;
} catch (error) {
hasError.value = true;
} finally {
isLoading.value = false;
}
};
const getters = useStoreGetters();
const routeMap = {
documents: '/app/accounts/[account_id]/documents/',
playground: '/app/accounts/[account_id]/playground/',
responses: '/app/accounts/[account_id]/responses/',
};
const resolvedRoute = computed(() => routeMap[props.page]);
let router = null;
watch(
() => props.page,
() => {
if (router) {
router.push({ name: resolvedRoute.value });
}
},
{ immediate: true }
);
const buildApp = () => {
router = makeRouter();
setupApp('#captain', {
router,
fetchFn: async (source, options) => {
const parsedSource = new URL(source);
let path = parsedSource.pathname;
if (path === `/api/sessions/profile`) {
path = '/sessions/profile';
} else {
path = path.replace(/^\/api\/accounts\/\d+/, '');
}
// include search params
path = `${path}${parsedSource.search}`;
const response = await IntegrationsAPI.requestCaptain({
method: options.method ?? 'GET',
route: path,
body: options.body ? JSON.parse(options.body) : null,
});
return {
json: () => {
return response.data;
},
ok: response.status >= 200 && response.status < 300,
status: response.status,
headers: response.headers,
};
},
});
router.push({ name: resolvedRoute.value });
};
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
watch(
() => captainIntegration.value,
(newValue, prevValue) => {
if (!prevValue && newValue) {
nextTick(() => buildApp());
}
},
{ immediate: true }
onMounted(() => loadCaptainFrame(captainIntegration.value));
watch(captainIntegration, updatedIntegration =>
loadCaptainFrame(updatedIntegration)
);
</script>
<template>
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
class="flex-1 overflow-auto flex gap-8 flex-col font-inter text-slate-900 dark:text-slate-500"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
<div class="flex-1 flex items-center justify-center">
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div
v-else-if="isLoading"
class="flex-1 flex items-center justify-center"
>
<Spinner color-scheme="primary" />
<span>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.LOADING_CONSOLE') }}</span>
</div>
<div v-else-if="!isLoading && hasError">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.FAILED_TO_LOAD_CONSOLE') }}
</div>
<iframe
v-else-if="!isLoading && captainURL"
:src="captainURL"
class="w-full min-h-[800px] h-full"
/>
</div>
</div>
<div v-else id="captain" class="w-full" />
</template>
<style>
@import '@chatwoot/captain/dist/style.css';
</style>
@@ -21,14 +21,13 @@ export default {
component: AppContainer,
children: [
{
path: frontendURL('accounts/:accountId/captain/:page'),
path: frontendURL('accounts/:accountId/captain'),
name: 'captain',
component: Captain,
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
props: true,
},
...inboxRoutes,
...conversation.routes,
@@ -6,6 +6,16 @@ import { GROUP_BY_FILTER } from '../constants';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
INCOMING_MESSAGES: 'incoming_messages_count',
OUTGOING_MESSAGES: 'outgoing_messages_count',
FIRST_RESPONSE_TIME: 'avg_first_response_time',
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
REPLY_TIME: 'reply_time',
};
const GROUP_BY_OPTIONS = {
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
WEEK: [
@@ -62,22 +72,6 @@ export default {
filterItemsList() {
return this.$store.getters[this.getterKey] || [];
},
isAgentType() {
return this.type === 'agent';
},
reportKeys() {
return {
CONVERSATIONS: 'conversations_count',
...(!this.isAgentType && {
INCOMING_MESSAGES: 'incoming_messages_count',
}),
OUTGOING_MESSAGES: 'outgoing_messages_count',
FIRST_RESPONSE_TIME: 'avg_first_response_time',
RESOLUTION_TIME: 'avg_resolution_time',
RESOLUTION_COUNT: 'resolutions_count',
REPLY_TIME: 'reply_time',
};
},
},
mounted() {
this.$store.dispatch(this.actionKey);
@@ -98,11 +92,19 @@ export default {
}
},
fetchChartData() {
Object.keys(this.reportKeys).forEach(async key => {
[
'CONVERSATIONS',
'INCOMING_MESSAGES',
'OUTGOING_MESSAGES',
'FIRST_RESPONSE_TIME',
'RESOLUTION_TIME',
'RESOLUTION_COUNT',
'REPLY_TIME',
].forEach(async key => {
try {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.reportKeys[key],
metric: REPORTS_KEYS[key],
from,
to,
type: this.type,
@@ -218,10 +220,6 @@ export default {
@group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/>
<ReportContainer
v-if="filterItemsList.length"
:group-by="groupBy"
:report-keys="reportKeys"
/>
<ReportContainer v-if="filterItemsList.length" :group-by="groupBy" />
</div>
</template>
-2
View File
@@ -2,7 +2,6 @@ import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import i18nMessages from 'dashboard/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
const i18n = createI18n({
@@ -14,5 +13,4 @@ const i18n = createI18n({
export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(store);
app.use(i18n);
app.directive('resize', vResizeObserver);
});
@@ -0,0 +1,5 @@
class Migration::Search::MessageReindexJob < ApplicationJob
queue :searchkick
end
+4
View File
@@ -38,6 +38,10 @@
#
class Message < ApplicationRecord
searchkick batch_size: 10_000 if SearchConfig.enabled?
scope :search_import, -> { includes(:inbox, :conversation, :sender) }
include MessageFilterHelpers
include Liquidable
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
+1 -1
View File
@@ -77,7 +77,7 @@ class MailPresenter < SimpleDelegator
mail.attachments.map do |attachment|
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(attachment.body.to_s),
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
filename: attachment.filename,
content_type: attachment.content_type
)
{ original: attachment, blob: blob }
@@ -4,7 +4,7 @@
class Line::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:inbox!, :params!]
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/iphone/sticker.png'.freeze
def perform
# probably test events
+16 -5
View File
@@ -34,11 +34,22 @@ class SearchService
end
def filter_messages
@messages = current_account.messages.where(inbox_id: accessable_inbox_ids)
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC')
.limit(10)
@messages = if SearchConfig.enabled?
Message.search(
search_query, where: {
inbox_id: accessable_inbox_ids,
account_id: current_account.id
}, order: { created_at: :desc }, limit: 20
)
else
current_account
.messages
.where(inbox_id: accessable_inbox_ids)
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC')
.limit(10)
end
end
def filter_contacts
-6
View File
@@ -27,12 +27,6 @@ if ENV.fetch('SENTRY_DSN', false).present?
require 'sentry-sidekiq'
end
# heroku autoscaling
if ENV.fetch('JUDOSCALE_URL', false).present?
require 'judoscale-rails'
require 'judoscale-sidekiq'
end
module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
-6
View File
@@ -1,6 +0,0 @@
require 'rack-timeout'
# Reduce noise by filtering state=ready and state=completed which are logged at INFO level
Rails.application.config.after_initialize do
Rack::Timeout::Logger.level = Logger::ERROR
end
+32
View File
@@ -0,0 +1,32 @@
class SearchConfig
class << self
def enabled?
opensearch_url.present?
end
def setup!
return unless enabled?
Searchkick.client_options = client_options
end
def client_options
{
url: opensearch_url,
transport_options: {
request: { timeout: timeout }
}
}
end
private
def opensearch_url
ENV.fetch('OPENSEARCH_URL', nil)
end
def timeout
ENV.fetch('OPENSEARCH_TIMEOUT', 10).to_i
end
end
end
-1
View File
@@ -58,7 +58,6 @@ test:
staging:
<<: *default_settings
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Staging)
monitor_mode: <%= ENV.fetch('NEW_RELIC_STAGING_MONITORING_ENABLED', false) %>
production:
<<: *default_settings
+1 -1
View File
@@ -219,7 +219,7 @@ Rails.application.routes.draw do
resources :apps, only: [:index, :show]
resource :captain, controller: 'captain', only: [] do
collection do
post :proxy
get :sso_url
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do
+16 -16
View File
@@ -8,21 +8,21 @@ internal_check_new_versions_job:
class: 'Internal::CheckNewVersionsJob'
queue: scheduled_jobs
# executed At every 5th minute..
trigger_scheduled_items_job:
cron: '*/5 * * * *'
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
# # executed At every 5th minute..
# trigger_scheduled_items_job:
# cron: '*/5 * * * *'
# class: 'TriggerScheduledItemsJob'
# queue: scheduled_jobs
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# # executed At every minute..
# trigger_imap_email_inboxes_job:
# cron: '*/1 * * * *'
# class: 'Inboxes::FetchImapEmailInboxesJob'
# queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleContactInboxesJob'
queue: scheduled_jobs
# # executed daily at 2230 UTC
# # which is our lowest traffic time
# remove_stale_contact_inboxes_job.rb:
# cron: '30 22 * * *'
# class: 'Internal::RemoveStaleContactInboxesJob'
# queue: scheduled_jobs
+1
View File
@@ -23,6 +23,7 @@
- action_mailbox_routing
- low
- scheduled_jobs
- searchkick
- async_database_migration
- active_storage_analysis
- active_storage_purge
+4 -5
View File
@@ -31,7 +31,6 @@
],
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/captain": "0.0.3-alpha.4",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.1.1-next",
"@chatwoot/utils": "^0.0.25",
@@ -84,7 +83,7 @@
"video.js": "7.18.1",
"videojs-record": "4.5.0",
"videojs-wavesurfer": "3.8.0",
"vue": "^3.5.12",
"vue": "^3.5.8",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
@@ -102,10 +101,10 @@
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@iconify-json/ri": "^1.2.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.11",
"@iconify-json/ri": "^1.2.3",
"@iconify-json/logos": "^1.2.0",
"@iconify-json/lucide": "^1.2.10",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "2.0.1",
"@vue/test-utils": "^2.4.6",
+134 -484
View File
File diff suppressed because it is too large Load Diff
@@ -1,79 +1,65 @@
require 'rails_helper'
RSpec.describe 'Captain Integrations API', type: :request do
let!(:account) { create(:account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:hook) do
create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
assistant_id: '1',
account_id: '1'
})
end
let(:captain_api_url) { 'https://captain.example.com/' }
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
before do
InstallationConfig.where(name: 'CAPTAIN_API_URL').first_or_create(value: captain_api_url)
end
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/proxy' do
describe 'GET /api/v1/accounts/{account.id}/integrations/captain/sso_url' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
as: :json
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'when valid request method and route' do
let(:route) { 'some_route' }
let(:method) { 'get' }
it 'return unauthorized if agent' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: agent.create_new_auth_token,
as: :json
it 'proxies the request to Captain API' do
stub_request(:get, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
.with(headers: {
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json'
})
.to_return(status: 200, body: 'Success', headers: {})
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: method, route: route },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to eq('Success')
end
expect(response).to have_http_status(:unauthorized)
end
context 'when HTTP method is invalid' do
it 'returns unprocessable entity' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'invalid', route: 'some_route', body: { some: 'data' } },
headers: agent.create_new_auth_token,
as: :json
it 'returns 404 if hook is not available' do
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:internal_server_error)
end
expect(response).to have_http_status(:not_found)
end
context 'when the hook is not found' do
before { hook.destroy }
it 'returns sso url if hook is available' do
InstallationConfig.where(name: 'CAPTAIN_APP_URL').first_or_create(value: 'https://app.chatwoot.com')
it 'returns not found' do
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
headers: agent.create_new_auth_token,
as: :json
hook = create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
account_id: '1',
assistant_id: '1',
inbox_ids: '1'
})
expect(response).to have_http_status(:not_found)
end
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
params: {},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = response.parsed_body
params_string = "token=#{URI.encode_www_form_component(hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(hook['settings']['account_id'])}"
sso_url = "https://app.chatwoot.com/sso?#{params_string}"
expect(data['sso_url']).to eq(sso_url)
end
end
end
-43
View File
@@ -1,43 +0,0 @@
From: test@gmail.com
Date: Thu, 4 May 2023 10:35:52 +0530
Message-ID: <6215d536e0484_10bc6191402183@tejaswinis-MacBook-Pro.local.mail>
Subject: multiple attachments
To: test@outlook.com
Content-Type: multipart/mixed; boundary="0000000000002488f405fad721cc"
--0000000000002488f405fad721cc
Content-Type: multipart/alternative; boundary="0000000000002488f205fad721ca"
--0000000000002488f205fad721ca
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
Hi people!
We are excited to inform you that we have recently released some new
features and several updates to our platform. These features and updates
are designed to enhance your experience and make your trading journey
seamless and efficient.
> Okay noted
--0000000000002488f205fad721ca--
--0000000000002488f405fad721cc
Content-Type: image/png; name=""
Content-Disposition: attachment; filename=""
Content-Transfer-Encoding: base64
Content-ID: <f_lh8nwk8l3>
X-Attachment-Id: f_lh8nwk8l3
--0000000000002488f405fad721cc
Content-Type: image/png; name=""
Content-Disposition: attachment; filename=""
Content-Transfer-Encoding: base64
Content-ID: <f_lh8nwk8l2>
X-Attachment-Id: f_lh8nwk8l2
--0000000000002488f405fad721cc--
-16
View File
@@ -43,22 +43,6 @@ RSpec.describe Imap::ImapMailbox do
end
end
context 'when the email has attachments with no filename' do
let(:inbound_mail) { create_inbound_email_from_fixture('attachments_without_filename.eml') }
it 'creates a conversation and a message with properly named attachments' do
expect do
class_instance.process(inbound_mail.mail, channel)
end.to change(Conversation, :count).by(1)
last_message = conversation.messages.last
expect(last_message.attachments.count).to be 2
filenames = last_message.attachments.map(&:file).map { |file| file.blob.filename.to_s }
expect(filenames.all? { |filename| filename.present? && filename.start_with?('attachment_') }).to be true
end
end
context 'when the email has 15 or more attachments' do
let(:inbound_mail) { create_inbound_email_from_fixture('multiple_attachments.eml') }
@@ -178,7 +178,7 @@ describe Line::IncomingMessageService do
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
expect(line_channel.inbox.conversations).not_to eq(0)
expect(Contact.all.first.name).to eq('LINE Test')
expect(line_channel.inbox.messages.first.content).to eq('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/android/sticker.png)')
expect(line_channel.inbox.messages.first.content).to eq('![sticker-52002738](https://stickershop.line-scdn.net/stickershop/v1/sticker/52002738/iphone/sticker.png)')
end
end
-7
View File
@@ -117,13 +117,6 @@ const tailwindConfig = {
width: 2,
height: 12,
},
captain: {
body: `<path d="M150.485 213.282C150.485 200.856 160.559 190.782 172.985 190.782C185.411 190.782 195.485 200.856 195.485 213.282V265.282C195.485 277.709 185.411 287.782 172.985 287.782C160.559 287.782 150.485 277.709 150.485 265.282V213.282Z" fill="currentColor"/>
<path d="M222.485 213.282C222.485 200.856 232.559 190.782 244.985 190.782C257.411 190.782 267.485 200.856 267.485 213.282V265.282C267.485 277.709 257.411 287.782 244.985 287.782C232.559 287.782 222.485 277.709 222.485 265.282V213.282Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M412.222 109.961C317.808 96.6217 240.845 96.0953 144.309 109.902C119.908 113.392 103.762 115.751 91.4521 119.354C80.0374 122.694 73.5457 126.678 68.1762 132.687C57.0576 145.13 55.592 159.204 54.0765 208.287C52.587 256.526 55.5372 299.759 61.1249 348.403C64.1025 374.324 66.1515 391.817 69.4229 405.117C72.526 417.732 76.2792 424.515 81.4954 429.708C86.7533 434.942 93.4917 438.633 105.859 441.629C118.94 444.797 136.104 446.713 161.613 449.5C244.114 458.514 305.869 458.469 388.677 449.548C414.495 446.767 431.939 444.849 445.216 441.702C457.83 438.712 464.612 435.047 469.797 429.962C474.873 424.985 478.752 418.118 482.116 404.874C485.626 391.056 488.014 372.772 491.47 345.913C497.636 297.99 502.076 255.903 502.248 209.798C502.433 160.503 501.426 146.477 490.181 133.468C484.75 127.185 478.148 123.053 466.473 119.612C453.865 115.897 437.283 113.502 412.222 109.961ZM138.414 68.5711C238.977 54.1882 319.888 54.7514 418.047 68.6199L419.483 68.8227C442.724 72.1054 462.359 74.8786 478.244 79.5601C495.387 84.6124 509.724 92.2821 521.706 106.145C544.308 132.295 544.161 163.321 543.965 204.542C543.956 206.327 543.948 208.131 543.941 209.954C543.758 258.703 539.048 302.844 532.821 351.247L532.656 352.528C529.407 377.787 526.729 398.602 522.522 415.166C518.098 432.584 511.485 447.517 498.968 459.792C486.56 471.959 471.897 478.282 454.819 482.33C438.691 486.153 418.624 488.314 394.436 490.919L393.136 491.059C307.385 500.297 242.618 500.349 157.091 491.004L155.772 490.86C131.921 488.255 112.062 486.086 96.056 482.209C79.0408 478.087 64.4759 471.637 52.1005 459.316C39.6835 446.955 33.1618 432.265 28.94 415.102C24.9582 398.915 22.6435 378.759 19.8561 354.488L19.7052 353.174C13.9746 303.287 10.8315 257.908 12.4035 206.997C12.4606 205.15 12.5151 203.323 12.5691 201.516C13.7911 160.603 14.7077 129.914 37.1055 104.847C48.989 91.5477 63.035 84.1731 79.7563 79.2794C95.2643 74.7408 114.386 72.0068 137.018 68.7707C137.482 68.7044 137.948 68.6379 138.414 68.5711Z" fill="currentColor"/>`,
width: 556,
height: 556,
},
},
},
...getIconCollections(['lucide', 'logos', 'ri']),