Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b7240661f | ||
|
|
f2d20905c1 | ||
|
|
9cd08f4075 | ||
|
|
62f77d1091 | ||
|
|
6d38b917c9 | ||
|
|
7acd239c70 | ||
|
|
9ca03c1af3 | ||
|
|
c218eff5ec | ||
|
|
7c60ad9e28 | ||
|
|
6b3f1114fd | ||
|
|
109b43aadb | ||
|
|
3ddab3ab26 | ||
|
|
e2dd2ccb42 | ||
|
|
9fab70aebf | ||
|
|
efe49f7da4 | ||
|
|
5aef9d2dd0 | ||
|
|
b98c614669 | ||
|
|
ba804e0f30 | ||
|
|
a44cb2c738 | ||
|
|
172ff87b5b | ||
|
|
55f6257313 | ||
|
|
76f129efaf | ||
|
|
5f6cd48a53 | ||
|
|
af511b39c4 | ||
|
|
d4992ad4c6 | ||
|
|
d752c379b0 | ||
|
|
d0525660ab | ||
|
|
efc58eef9a | ||
|
|
96f1aa7205 | ||
|
|
ace5c90007 | ||
|
|
6e85dcbbce | ||
|
|
6a053e975e |
@@ -12,6 +12,7 @@ gem 'bootsnap', require: false
|
||||
gem 'acts-as-taggable-on'
|
||||
gem 'attr_extras'
|
||||
gem 'browser'
|
||||
gem 'counter_culture'
|
||||
gem 'hashie'
|
||||
gem 'jbuilder'
|
||||
gem 'kaminari'
|
||||
|
||||
@@ -192,6 +192,9 @@ GEM
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
counter_culture (3.12.1)
|
||||
activerecord (>= 4.2)
|
||||
activesupport (>= 4.2)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
@@ -1041,6 +1044,7 @@ DEPENDENCIES
|
||||
cld3 (~> 3.7)
|
||||
climate_control
|
||||
commonmarker
|
||||
counter_culture
|
||||
csv-safe
|
||||
database_cleaner
|
||||
datadog (~> 2.0)
|
||||
|
||||
@@ -2,6 +2,9 @@ class ConversationFinder
|
||||
attr_reader :current_user, :current_account, :params
|
||||
|
||||
DEFAULT_STATUS = 'open'.freeze
|
||||
# Filter params that affect conversation count
|
||||
# NOTE: When adding a new filter, add it here to prevent incorrect cache usage
|
||||
FILTER_PARAMS = %i[inbox_id team_id labels q source_id conversation_type].freeze
|
||||
SORT_OPTIONS = {
|
||||
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
|
||||
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
|
||||
@@ -11,6 +14,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],
|
||||
@@ -183,10 +187,23 @@ class ConversationFinder
|
||||
end
|
||||
|
||||
def set_count_for_all_conversations
|
||||
# Use cache only for admins with no filters other than status
|
||||
use_cache = current_account.feature_enabled?(:counter_cache_optimization) &&
|
||||
@is_admin &&
|
||||
FILTER_PARAMS.none? { |key| params[key] } &&
|
||||
params[:status] != 'all'
|
||||
|
||||
all_count = if use_cache
|
||||
status = params[:status].presence_in(Conversation.statuses.keys) || DEFAULT_STATUS
|
||||
current_account.public_send("#{status}_conversations_count")
|
||||
else
|
||||
@conversations.count
|
||||
end
|
||||
|
||||
[
|
||||
@conversations.assigned_to(current_user).count,
|
||||
@conversations.unassigned.count,
|
||||
@conversations.count
|
||||
all_count
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
+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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
+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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -246,6 +248,9 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -536,6 +541,9 @@ export default {
|
||||
hideBusinessNameInput() {
|
||||
this.showBusinessNameInput = false;
|
||||
},
|
||||
toggleLockToSingleConversation(value) {
|
||||
this.locktoSingleConversation = value;
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
webhookUrl: {
|
||||
@@ -731,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')"
|
||||
@@ -1074,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">
|
||||
|
||||
+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>
|
||||
+1
-1
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.authorization_error!
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class Internal::BackfillConversationCountersJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account)
|
||||
Internal::BackfillConversationCountersService.new(account: account).perform
|
||||
end
|
||||
end
|
||||
@@ -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)
|
||||
|
||||
@@ -144,6 +144,7 @@ class Account < ApplicationRecord
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_destroy :remove_account_sequences
|
||||
after_save :backfill_conversation_counters_if_needed
|
||||
|
||||
def agents
|
||||
users.where(account_users: { role: :agent })
|
||||
@@ -208,6 +209,13 @@ class Account < ApplicationRecord
|
||||
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
def backfill_conversation_counters_if_needed
|
||||
return unless saved_change_to_feature_flags?
|
||||
return unless feature_counter_cache_optimization?
|
||||
|
||||
Internal::BackfillConversationCountersJob.perform_later(self)
|
||||
end
|
||||
|
||||
def validate_limit_keys
|
||||
# method overridden in enterprise module
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -98,6 +98,8 @@ class Conversation < ApplicationRecord
|
||||
}
|
||||
|
||||
belongs_to :account
|
||||
counter_culture :account,
|
||||
column_name: proc { |model| "#{model.status}_conversations_count" }
|
||||
belongs_to :inbox
|
||||
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
|
||||
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
class Imap::AuthenticationError < StandardError; end
|
||||
@@ -107,7 +107,11 @@ class Imap::BaseFetchEmailService
|
||||
|
||||
def build_imap_client
|
||||
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
|
||||
imap.authenticate(authentication_type, channel.imap_login, imap_password)
|
||||
begin
|
||||
imap.authenticate(authentication_type, channel.imap_login, imap_password)
|
||||
rescue Net::IMAP::NoResponseError => e
|
||||
raise Imap::AuthenticationError, e.message
|
||||
end
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
class Internal::BackfillConversationCountersService
|
||||
# Backfills the conversation status counter cache columns on accounts
|
||||
# (open_conversations_count, resolved_conversations_count, pending_conversations_count, snoozed_conversations_count).
|
||||
#
|
||||
# Safe to re-run to correct any drift.
|
||||
#
|
||||
# Usage:
|
||||
# Internal::BackfillConversationCountersService.new.perform
|
||||
# Internal::BackfillConversationCountersService.new(account: Account.find(id)).perform
|
||||
#
|
||||
def initialize(account: nil)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def perform
|
||||
scope = @account ? Account.where(id: @account.id) : Account.all
|
||||
scope.find_each do |account|
|
||||
account.update(
|
||||
open_conversations_count: account.conversations.open.count,
|
||||
resolved_conversations_count: account.conversations.resolved.count,
|
||||
pending_conversations_count: account.conversations.pending.count,
|
||||
snoozed_conversations_count: account.conversations.snoozed.count
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -145,7 +145,12 @@ class Line::IncomingMessageService
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = @contact_inbox.conversations.first
|
||||
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
@conversation = if @inbox.lock_to_single_conversation
|
||||
@contact_inbox.conversations.last
|
||||
else
|
||||
@contact_inbox.conversations.where.not(status: :resolved).last
|
||||
end
|
||||
return if @conversation
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
|
||||
@@ -23,7 +23,12 @@ class Tiktok::MessageService
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
@conversation ||= if channel.inbox.lock_to_single_conversation
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
else
|
||||
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first
|
||||
end
|
||||
@conversation ||= create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
end
|
||||
|
||||
def create_message
|
||||
|
||||
@@ -27,7 +27,15 @@ module Tiktok::MessagingHelpers
|
||||
end
|
||||
|
||||
def find_conversation(channel, tt_conversation_id)
|
||||
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first
|
||||
contact_inbox = channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)
|
||||
return if contact_inbox.blank?
|
||||
|
||||
if channel.inbox.lock_to_single_conversation
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
else
|
||||
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first ||
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ json.name webhook.name
|
||||
json.url webhook.url
|
||||
json.account_id webhook.account_id
|
||||
json.subscriptions webhook.subscriptions
|
||||
json.secret webhook.secret
|
||||
if webhook.inbox
|
||||
json.inbox do
|
||||
json.id webhook.inbox.id
|
||||
|
||||
+5
-7
@@ -17,10 +17,10 @@
|
||||
display_name: Facebook Channel
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/fb
|
||||
- name: channel_twitter
|
||||
display_name: Twitter Channel
|
||||
enabled: true
|
||||
deprecated: true
|
||||
- name: counter_cache_optimization
|
||||
display_name: Counter Cache Optimization
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: ip_lookup
|
||||
display_name: IP Lookup
|
||||
enabled: false
|
||||
@@ -108,12 +108,10 @@
|
||||
- name: response_bot
|
||||
display_name: Response Bot
|
||||
enabled: false
|
||||
premium: true
|
||||
deprecated: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
help_url: https://chwt.app/hc/reply-to
|
||||
deprecated: true
|
||||
- name: insert_article_in_reply
|
||||
display_name: Insert Article in Reply
|
||||
@@ -149,7 +147,7 @@
|
||||
enabled: true
|
||||
- name: report_v4
|
||||
display_name: Report V4
|
||||
enabled: true
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: contact_chatwoot_support_team
|
||||
display_name: Contact Chatwoot Support Team
|
||||
|
||||
@@ -96,6 +96,19 @@
|
||||
locked: false
|
||||
# ------- End of Account Related Config ------- #
|
||||
|
||||
# ------- Transient Error Backoff Config ------- #
|
||||
- name: BACKOFF_MAX_INTERVAL_MINUTES
|
||||
display_title: 'Backoff Max Interval (minutes)'
|
||||
description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus'
|
||||
value: 5
|
||||
locked: false
|
||||
- name: BACKOFF_MAX_INTERVAL_COUNT
|
||||
display_title: 'Backoff Max Retry Count'
|
||||
description: 'Number of additional retries at the maximum interval before prompting reauthorization'
|
||||
value: 10
|
||||
locked: false
|
||||
# ------- End of Transient Error Backoff Config ------- #
|
||||
|
||||
# ------- Email Related Config ------- #
|
||||
- name: MAILER_INBOUND_EMAIL_DOMAIN
|
||||
display_title: 'Inbound Email Domain'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddStatusConversationCountersToAccounts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :accounts, :open_conversations_count, :integer, default: 0, null: false
|
||||
add_column :accounts, :resolved_conversations_count, :integer, default: 0, null: false
|
||||
add_column :accounts, :pending_conversations_count, :integer, default: 0, null: false
|
||||
add_column :accounts, :snoozed_conversations_count, :integer, default: 0, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddSecretToWebhooks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :webhooks, :secret, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class BackfillWebhookSecrets < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Webhook.find_each do |webhook|
|
||||
webhook.update!(secret: SecureRandom.urlsafe_base64(24))
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# no-op: removing the column in the previous migration handles cleanup
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# counter_cache_optimization reclaims the deprecated channel_twitter bit (position 4).
|
||||
# channel_twitter was enabled: true, so all existing accounts have bit 4 set to 1.
|
||||
# counter_cache_optimization is enabled: false, so we must clear bit 4 for all accounts.
|
||||
class DisableCounterCacheOptimizationForExistingAccounts < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
Account.feature_counter_cache_optimization.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:counter_cache_optimization)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
+6
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_125101) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -73,6 +73,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
|
||||
t.integer "status", default: 0
|
||||
t.jsonb "internal_attributes", default: {}, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
t.integer "open_conversations_count", default: 0, null: false
|
||||
t.integer "resolved_conversations_count", default: 0, null: false
|
||||
t.integer "pending_conversations_count", default: 0, null: false
|
||||
t.integer "snoozed_conversations_count", default: 0, null: false
|
||||
t.index ["status"], name: "index_accounts_on_status"
|
||||
end
|
||||
|
||||
@@ -1250,6 +1254,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
|
||||
t.integer "webhook_type", default: 0
|
||||
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"]
|
||||
t.string "name"
|
||||
t.string "secret"
|
||||
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
|
||||
end
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ module Concerns::Agentable
|
||||
state = context.context[:state] || {}
|
||||
conversation_data = state[:conversation] || {}
|
||||
contact_data = state[:contact] || {}
|
||||
campaign_data = state[:campaign] || {}
|
||||
enhanced_context = enhanced_context.merge(
|
||||
conversation: conversation_data,
|
||||
contact: contact_data
|
||||
contact: contact_data,
|
||||
campaign: campaign_data
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ module Concerns::Toolable
|
||||
add_base_headers(headers, state)
|
||||
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
|
||||
add_contact_headers(headers, state[:contact]) if state[:contact]
|
||||
add_contact_inbox_headers(headers, state[:contact_inbox])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -91,6 +92,11 @@ module Concerns::Toolable
|
||||
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
|
||||
end
|
||||
|
||||
def add_contact_inbox_headers(headers, contact_inbox)
|
||||
headers['X-Chatwoot-Contact-Inbox-Id'] = contact_inbox[:id].to_s if contact_inbox&.[](:id)
|
||||
headers['X-Chatwoot-Contact-Inbox-Verified'] = (contact_inbox&.[](:hmac_verified) || false).to_s
|
||||
end
|
||||
|
||||
def format_response(raw_response_body)
|
||||
return raw_response_body if response_template.blank?
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
audited associated_with: :account
|
||||
audited associated_with: :account, except: [:secret]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -125,15 +129,21 @@ class Captain::Assistant::AgentRunnerService
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
|
||||
if @conversation
|
||||
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
end
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
return unless @conversation.contact_inbox
|
||||
|
||||
state[:contact_inbox] =
|
||||
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
@@ -11,7 +11,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# List of the premium features in EE edition
|
||||
- disable_branding
|
||||
- audit_logs
|
||||
- response_bot
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
|
||||
@@ -5,7 +5,7 @@ class Captain::PromptRenderer
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
liquid_template.render(stringify_keys(context), registers: { file_system: snippet_file_system })
|
||||
end
|
||||
|
||||
private
|
||||
@@ -18,6 +18,13 @@ class Captain::PromptRenderer
|
||||
File.read(template_path)
|
||||
end
|
||||
|
||||
def snippet_file_system
|
||||
@snippet_file_system ||= Liquid::LocalFileSystem.new(
|
||||
Rails.root.join('enterprise/lib/captain/prompts/snippets'),
|
||||
'%s.liquid'
|
||||
)
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
|
||||
@@ -8,17 +8,21 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to primari
|
||||
|
||||
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
|
||||
|
||||
{% if conversation || contact -%}
|
||||
{% if conversation || contact || campaign.id -%}
|
||||
# Current Context
|
||||
|
||||
Here's the metadata we have about the current conversation and the contact associated with it:
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% render 'conversation', conversation: conversation %}
|
||||
{% endif -%}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% render 'contact', contact: contact %}
|
||||
{% endif -%}
|
||||
|
||||
{% if campaign.id -%}
|
||||
{% render 'campaign', campaign: campaign %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
|
||||
@@ -8,17 +8,21 @@ You are a specialized agent called "{{ title }}", your task is to handle the fol
|
||||
|
||||
If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
|
||||
|
||||
{% if conversation || contact %}
|
||||
{% if conversation || contact || campaign.id %}
|
||||
# Current Context
|
||||
|
||||
Here's the metadata we have about the current conversation and the contact associated with it:
|
||||
|
||||
{% if conversation -%}
|
||||
{% render 'conversation' %}
|
||||
{% render 'conversation', conversation: conversation %}
|
||||
{% endif -%}
|
||||
|
||||
{% if contact -%}
|
||||
{% render 'contact' %}
|
||||
{% render 'contact', contact: contact %}
|
||||
{% endif -%}
|
||||
|
||||
{% if campaign.id -%}
|
||||
{% render 'campaign', campaign: campaign %}
|
||||
{% endif -%}
|
||||
{% endif -%}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Campaign Context
|
||||
This conversation was initiated in response to a campaign message.
|
||||
- Campaign: {{ campaign.title }}
|
||||
- Type: {{ campaign.campaign_type }}
|
||||
{% if campaign.description -%}
|
||||
- Description: {{ campaign.description }}
|
||||
{% endif -%}
|
||||
- Original Message Sent: {{ campaign.message }}
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::ChatwootHub
|
||||
ENTERPRISE_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
|
||||
|
||||
def base_url
|
||||
return ENV.fetch('CHATWOOT_HUB_URL', ENTERPRISE_BASE_URL) if Rails.env.development?
|
||||
|
||||
ENTERPRISE_BASE_URL
|
||||
end
|
||||
end
|
||||
+32
-23
@@ -1,12 +1,30 @@
|
||||
# TODO: lets use HTTParty instead of RestClient
|
||||
class ChatwootHub
|
||||
BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com')
|
||||
PING_URL = "#{BASE_URL}/ping".freeze
|
||||
REGISTRATION_URL = "#{BASE_URL}/instances".freeze
|
||||
PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
|
||||
EVENTS_URL = "#{BASE_URL}/events".freeze
|
||||
BILLING_URL = "#{BASE_URL}/billing".freeze
|
||||
CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze
|
||||
DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
|
||||
|
||||
def self.base_url
|
||||
DEFAULT_BASE_URL
|
||||
end
|
||||
|
||||
def self.ping_url
|
||||
"#{base_url}/ping"
|
||||
end
|
||||
|
||||
def self.registration_url
|
||||
"#{base_url}/instances"
|
||||
end
|
||||
|
||||
def self.push_notification_url
|
||||
"#{base_url}/send_push"
|
||||
end
|
||||
|
||||
def self.events_url
|
||||
"#{base_url}/events"
|
||||
end
|
||||
|
||||
def self.billing_base_url
|
||||
"#{base_url}/billing"
|
||||
end
|
||||
|
||||
def self.installation_identifier
|
||||
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
|
||||
@@ -15,7 +33,7 @@ class ChatwootHub
|
||||
end
|
||||
|
||||
def self.billing_url
|
||||
"#{BILLING_URL}?installation_identifier=#{installation_identifier}"
|
||||
"#{billing_base_url}?installation_identifier=#{installation_identifier}"
|
||||
end
|
||||
|
||||
def self.pricing_plan
|
||||
@@ -68,7 +86,7 @@ class ChatwootHub
|
||||
begin
|
||||
info = instance_config
|
||||
info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY']
|
||||
response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
|
||||
response = RestClient.post(ping_url, info.to_json, { content_type: :json, accept: :json })
|
||||
parsed_response = JSON.parse(response)
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
@@ -80,7 +98,7 @@ class ChatwootHub
|
||||
|
||||
def self.register_instance(company_name, owner_name, owner_email)
|
||||
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
|
||||
RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(registration_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
@@ -89,32 +107,23 @@ class ChatwootHub
|
||||
|
||||
def self.send_push(fcm_options)
|
||||
info = { fcm_options: fcm_options }
|
||||
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def self.get_captain_settings(account)
|
||||
info = {
|
||||
installation_identifier: installation_identifier,
|
||||
chatwoot_account_id: account.id,
|
||||
account_name: account.name
|
||||
}
|
||||
HTTParty.post(CAPTAIN_ACCOUNTS_URL,
|
||||
body: info.to_json,
|
||||
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
|
||||
end
|
||||
|
||||
def self.emit_event(event_name, event_data)
|
||||
return if ENV['DISABLE_TELEMETRY']
|
||||
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(events_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub')
|
||||
|
||||
@@ -16,4 +16,10 @@ module ExceptionList
|
||||
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
|
||||
SocketError
|
||||
].freeze
|
||||
|
||||
IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [
|
||||
EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError,
|
||||
Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError,
|
||||
Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError
|
||||
]).freeze
|
||||
end
|
||||
|
||||
@@ -27,6 +27,10 @@ module Integrations::Slack::SlackMessageHelper
|
||||
end
|
||||
|
||||
def create_message
|
||||
resolved_sender, sender_name, sender_avatar_url = resolve_slack_sender
|
||||
slack_sender_attrs = {}
|
||||
slack_sender_attrs[:sender_name] = sender_name if sender_name
|
||||
slack_sender_attrs[:sender_avatar_url] = sender_avatar_url if sender_avatar_url
|
||||
@message = conversation.messages.build(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
@@ -34,7 +38,8 @@ module Integrations::Slack::SlackMessageHelper
|
||||
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: sender
|
||||
sender: resolved_sender,
|
||||
additional_attributes: slack_sender_attrs
|
||||
)
|
||||
process_attachments(params[:event][:files]) if attachments_present?
|
||||
@message.save!
|
||||
@@ -81,9 +86,22 @@ module Integrations::Slack::SlackMessageHelper
|
||||
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
|
||||
end
|
||||
|
||||
def sender
|
||||
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
|
||||
conversation.account.users.from_email(user_email)
|
||||
def resolve_slack_sender
|
||||
return [nil, nil, nil] unless params[:event][:user]
|
||||
|
||||
slack_user = slack_client.users_info(user: params[:event][:user])[:user]
|
||||
chatwoot_user = conversation.account.users.from_email(slack_user[:profile][:email])
|
||||
return [chatwoot_user, nil, nil] if chatwoot_user
|
||||
|
||||
sender_name = slack_user.dig(:profile, :display_name).presence ||
|
||||
slack_user[:real_name].presence ||
|
||||
slack_user[:name]
|
||||
sender_avatar_url = slack_user.dig(:profile, :image_192).presence
|
||||
[nil, sender_name, sender_avatar_url]
|
||||
rescue Slack::Web::Api::Errors::MissingScope
|
||||
raise
|
||||
rescue StandardError
|
||||
[nil, nil, nil]
|
||||
end
|
||||
|
||||
def private_note?
|
||||
|
||||
@@ -52,4 +52,8 @@ module Redis::RedisKeys
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
|
||||
## Transient Error Backoff
|
||||
BACKOFF_RETRY_COUNT = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_count'.freeze
|
||||
BACKOFF_RETRY_AFTER = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_after'.freeze
|
||||
end
|
||||
|
||||
+24
-6
@@ -1,14 +1,16 @@
|
||||
class Webhooks::Trigger
|
||||
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
|
||||
|
||||
def initialize(url, payload, webhook_type)
|
||||
def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
@url = url
|
||||
@payload = payload
|
||||
@webhook_type = webhook_type
|
||||
@secret = secret
|
||||
@delivery_id = delivery_id
|
||||
end
|
||||
|
||||
def self.execute(url, payload, webhook_type)
|
||||
new(url, payload, webhook_type).execute
|
||||
def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute
|
||||
end
|
||||
|
||||
def execute
|
||||
@@ -21,15 +23,27 @@ class Webhooks::Trigger
|
||||
private
|
||||
|
||||
def perform_request
|
||||
body = @payload.to_json
|
||||
RestClient::Request.execute(
|
||||
method: :post,
|
||||
url: @url,
|
||||
payload: @payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
payload: body,
|
||||
headers: request_headers(body),
|
||||
timeout: webhook_timeout
|
||||
)
|
||||
end
|
||||
|
||||
def request_headers(body)
|
||||
headers = { content_type: :json, accept: :json }
|
||||
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
|
||||
if @secret.present?
|
||||
ts = Time.now.to_i.to_s
|
||||
headers['X-Chatwoot-Timestamp'] = ts
|
||||
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
|
||||
end
|
||||
headers
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||
return unless message
|
||||
@@ -72,7 +86,11 @@ class Webhooks::Trigger
|
||||
def message
|
||||
return if message_id.blank?
|
||||
|
||||
@message ||= Message.find_by(id: message_id)
|
||||
if defined?(@message)
|
||||
@message
|
||||
else
|
||||
@message = Message.find_by(id: message_id)
|
||||
end
|
||||
end
|
||||
|
||||
def message_id
|
||||
|
||||
+3
-3
@@ -35,7 +35,7 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.6",
|
||||
"@chatwoot/utils": "^0.0.51",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@rails/actioncable": "6.1.3",
|
||||
"@rails/ujs": "^7.1.400",
|
||||
"@scmmishra/pico-search": "0.5.4",
|
||||
"@scmmishra/pico-search": "0.6.0",
|
||||
"@sentry/vue": "^8.55.0",
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -95,6 +95,7 @@
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"virtua": "^0.48.6",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
@@ -103,7 +104,6 @@
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-router": "~4.4.5",
|
||||
"vue-upload-component": "^3.1.17",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||
"vue3-click-away": "^1.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "~4.1.0",
|
||||
|
||||
Generated
+37
-39
@@ -26,8 +26,8 @@ importers:
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.51
|
||||
version: 0.0.51
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -59,8 +59,8 @@ importers:
|
||||
specifier: ^7.1.400
|
||||
version: 7.1.400
|
||||
'@scmmishra/pico-search':
|
||||
specifier: 0.5.4
|
||||
version: 0.5.4
|
||||
specifier: 0.6.0
|
||||
version: 0.6.0
|
||||
'@sentry/vue':
|
||||
specifier: ^8.55.0
|
||||
version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2))
|
||||
@@ -205,6 +205,9 @@ importers:
|
||||
videojs-wavesurfer:
|
||||
specifier: 3.8.0
|
||||
version: 3.8.0
|
||||
virtua:
|
||||
specifier: ^0.48.6
|
||||
version: 0.48.6(vue@3.5.12(typescript@5.6.2))
|
||||
vue:
|
||||
specifier: ^3.5.12
|
||||
version: 3.5.12(typescript@5.6.2)
|
||||
@@ -229,9 +232,6 @@ importers:
|
||||
vue-upload-component:
|
||||
specifier: ^3.1.17
|
||||
version: 3.1.17
|
||||
vue-virtual-scroller:
|
||||
specifier: ^2.0.0-beta.8
|
||||
version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
|
||||
vue3-click-away:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
@@ -457,8 +457,8 @@ packages:
|
||||
'@chatwoot/prosemirror-schema@1.3.6':
|
||||
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
|
||||
|
||||
'@chatwoot/utils@0.0.51':
|
||||
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -1240,8 +1240,8 @@ packages:
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
'@scmmishra/pico-search@0.5.4':
|
||||
resolution: {integrity: sha512-JdV8KumQ+pE5tqgQ71xUT9biE/qV//tx3NCqTLkW9Z4tsjKGN0B6kVowmtaZBAtErqir9XiMxsKXRTMF/MpUww==}
|
||||
'@scmmishra/pico-search@0.6.0':
|
||||
resolution: {integrity: sha512-1zC2cAwPWuv38VEh0It90fdUWkvX75OwBUjgTj+d5LTltARnf3ydbpcN2Ucl0aATBMmaNqPMcVvT25IOCAqCEA==}
|
||||
|
||||
'@sentry-internal/browser-utils@8.55.0':
|
||||
resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==}
|
||||
@@ -3305,9 +3305,6 @@ packages:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mitt@2.1.0:
|
||||
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -4511,6 +4508,26 @@ packages:
|
||||
videojs-wavesurfer@3.8.0:
|
||||
resolution: {integrity: sha512-qHucCBiEW+4dZ0Zp1k4R1elprUOV+QDw87UDA9QRXtO7GK/MrSdoe/TMFxP9SLnJCiX9xnYdf4OQgrmvJ9UVVw==}
|
||||
|
||||
virtua@0.48.6:
|
||||
resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
solid-js: '>=1.0'
|
||||
svelte: '>=5.0'
|
||||
vue: '>=3.2'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
vite-node@2.0.1:
|
||||
resolution: {integrity: sha512-nVd6kyhPAql0s+xIVJzuF+RSRH8ZimNrm6U8ZvTA4MXv8CHI17TFaQwRaFiK75YX6XeFqZD4IoAaAfi9OR1XvQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -4625,11 +4642,6 @@ packages:
|
||||
vue-letter@0.2.1:
|
||||
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
@@ -4643,11 +4655,6 @@ packages:
|
||||
vue-upload-component@3.1.17:
|
||||
resolution: {integrity: sha512-1orTC5apoFzBz4ku2HAydpviaAOck+ABc83rGypIK/Bgl+TqhtoWsQOhXqbb7vDv7pKlvRVWwml9PM224HyhkA==}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue3-click-away@1.2.4:
|
||||
resolution: {integrity: sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==}
|
||||
|
||||
@@ -5010,7 +5017,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.51':
|
||||
'@chatwoot/utils@0.0.52':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
@@ -5788,7 +5795,7 @@ snapshots:
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@scmmishra/pico-search@0.5.4': {}
|
||||
'@scmmishra/pico-search@0.6.0': {}
|
||||
|
||||
'@sentry-internal/browser-utils@8.55.0':
|
||||
dependencies:
|
||||
@@ -8226,8 +8233,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
mitt@2.1.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
@@ -9574,6 +9579,10 @@ snapshots:
|
||||
video.js: 7.18.1
|
||||
wavesurfer.js: 7.8.6
|
||||
|
||||
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
|
||||
optionalDependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -9692,10 +9701,6 @@ snapshots:
|
||||
dependencies:
|
||||
lettersanitizer: 1.0.6
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
@@ -9707,13 +9712,6 @@ snapshots:
|
||||
|
||||
vue-upload-component@3.1.17: {}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
mitt: 2.1.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
|
||||
vue3-click-away@1.2.4: {}
|
||||
|
||||
vue@3.5.12(typescript@5.6.2):
|
||||
|
||||
@@ -32,23 +32,25 @@ RSpec.describe 'TikTok Authorization API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
travel_to Time.zone.parse('2025-01-01 00:00:00 UTC') do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
helper = Class.new do
|
||||
include Tiktok::IntegrationHelper
|
||||
end.new
|
||||
|
||||
expected_state = helper.generate_tiktok_token(account.id)
|
||||
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
|
||||
|
||||
expect(response.parsed_body['url']).to eq(expected_url)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
helper = Class.new do
|
||||
include Tiktok::IntegrationHelper
|
||||
end.new
|
||||
|
||||
expected_state = helper.generate_tiktok_token(account.id)
|
||||
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
|
||||
|
||||
expect(response.parsed_body['url']).to eq(expected_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,7 +58,7 @@ RSpec.describe Captain::PromptRenderer do
|
||||
it 'loads and parses liquid template' do
|
||||
liquid_template_double = instance_double(Liquid::Template)
|
||||
allow(Liquid::Template).to receive(:parse).with(template_content).and_return(liquid_template_double)
|
||||
allow(liquid_template_double).to receive(:render).with(hash_including('name', 'balance')).and_return('rendered')
|
||||
allow(liquid_template_double).to receive(:render).with(hash_including('name', 'balance'), anything).and_return('rendered')
|
||||
|
||||
result = described_class.render(template_name, context)
|
||||
|
||||
@@ -67,6 +67,36 @@ RSpec.describe Captain::PromptRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'snippet rendering' do
|
||||
let(:snippets_dir) { Rails.root.join('enterprise/lib/captain/prompts/snippets') }
|
||||
let(:snippet_path) { snippets_dir.join('greeting.liquid') }
|
||||
|
||||
before do
|
||||
allow(File).to receive(:exist?).and_call_original
|
||||
allow(File).to receive(:read).and_call_original
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(true)
|
||||
# Create a controlled snippet to decouple from real snippet content
|
||||
allow(File).to receive(:exist?).with(snippet_path.to_s).and_return(true)
|
||||
allow(File).to receive(:read).with(snippet_path.to_s).and_return('Hello {{ name }}')
|
||||
end
|
||||
|
||||
it 'resolves render tags from the snippets directory' do
|
||||
allow(File).to receive(:read).with(template_path).and_return("{% render 'greeting', name: name %}")
|
||||
|
||||
result = described_class.render(template_name, { name: 'World' })
|
||||
|
||||
expect(result).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'outputs a liquid error for missing snippets' do
|
||||
allow(File).to receive(:read).with(template_path).and_return("{% render 'nonexistent' %}")
|
||||
|
||||
result = described_class.render(template_name, {})
|
||||
|
||||
expect(result).to include('Liquid error')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.load_template' do
|
||||
it 'reads template file from correct path' do
|
||||
described_class.send(:load_template, template_name)
|
||||
|
||||
@@ -249,6 +249,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact_inbox: {
|
||||
id: conversation.contact_inbox.id,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
@@ -272,6 +276,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
||||
'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s,
|
||||
'X-Chatwoot-Contact-Inbox-Id' => conversation.contact_inbox.id.to_s,
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
||||
'X-Chatwoot-Contact-Id' => contact.id.to_s,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
})
|
||||
@@ -282,6 +288,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
})
|
||||
end
|
||||
@@ -296,6 +303,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
}
|
||||
)
|
||||
@@ -316,6 +324,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
.with(headers: {
|
||||
'Authorization' => 'Bearer test_token',
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
|
||||
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
@@ -336,13 +345,18 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact_inbox: {
|
||||
id: conversation.contact_inbox.id,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
}
|
||||
})
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Conversation-Id' => conversation.id.to_s
|
||||
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
@@ -351,6 +365,32 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
end
|
||||
|
||||
it 'defaults contact inbox verified header to false when contact inbox is missing' do
|
||||
tool_context_without_contact_inbox = Struct.new(:state).new({
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email
|
||||
}
|
||||
})
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_without_contact_inbox)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
|
||||
end
|
||||
|
||||
it 'includes contact phone when present' do
|
||||
contact.update!(phone_number: '+1234567890')
|
||||
tool_context_with_state.state[:contact][:phone_number] = '+1234567890'
|
||||
@@ -366,6 +406,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' })
|
||||
end
|
||||
|
||||
it 'includes unverified contact inbox status explicitly as false' do
|
||||
conversation.contact_inbox.update!(hmac_verified: false)
|
||||
tool_context_with_state.state[:contact_inbox][:hmac_verified] = false
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_with_state)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ChatwootHub do
|
||||
describe '.base_url' do
|
||||
it 'uses the static hub url outside development for enterprise edition' do
|
||||
with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
|
||||
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
|
||||
|
||||
expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses CHATWOOT_HUB_URL in development for enterprise edition' do
|
||||
with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
|
||||
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
|
||||
|
||||
expect(described_class.base_url).to eq('https://custom.example.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -341,6 +341,10 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact_inbox: {
|
||||
id: conversation.contact_inbox.id,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
@@ -376,6 +380,13 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email)
|
||||
end
|
||||
|
||||
it 'includes contact inbox verification metadata when present' do
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Id']).to eq(conversation.contact_inbox.id.to_s)
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq(conversation.contact_inbox.hmac_verified.to_s)
|
||||
end
|
||||
|
||||
it 'handles missing conversation gracefully' do
|
||||
state[:conversation] = nil
|
||||
|
||||
@@ -396,11 +407,21 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'handles missing contact inbox gracefully' do
|
||||
state[:contact_inbox] = nil
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Id']).to be_nil
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
|
||||
end
|
||||
|
||||
it 'handles empty state' do
|
||||
headers = tool.build_metadata_headers({})
|
||||
|
||||
expect(headers).to be_a(Hash)
|
||||
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
|
||||
end
|
||||
|
||||
it 'omits contact email header when email is blank' do
|
||||
@@ -418,6 +439,22 @@ RSpec.describe Captain::CustomTool, type: :model do
|
||||
|
||||
expect(headers).not_to have_key('X-Chatwoot-Contact-Phone')
|
||||
end
|
||||
|
||||
it 'includes contact inbox verified header when false' do
|
||||
state[:contact_inbox][:hmac_verified] = false
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
|
||||
end
|
||||
|
||||
it 'defaults contact inbox verified header to false when value is nil' do
|
||||
state[:contact_inbox][:hmac_verified] = nil
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_tool_metadata' do
|
||||
|
||||
@@ -97,7 +97,8 @@ RSpec.describe Concerns::Agentable do
|
||||
expected_context = {
|
||||
base_key: 'base_value',
|
||||
conversation: { id: 123 },
|
||||
contact: { name: 'John' }
|
||||
contact: { name: 'John' },
|
||||
campaign: {}
|
||||
}
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
@@ -108,6 +109,26 @@ RSpec.describe Concerns::Agentable do
|
||||
dummy_instance.agent_instructions(context_double)
|
||||
end
|
||||
|
||||
it 'merges campaign data from context state' do
|
||||
context_double = instance_double(Agents::RunContext,
|
||||
context: {
|
||||
state: {
|
||||
conversation: { id: 123 },
|
||||
contact: { name: 'John' },
|
||||
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'dummy_class',
|
||||
hash_including(
|
||||
campaign: { id: 10, title: 'Summer Sale', message: 'Check it out' }
|
||||
)
|
||||
)
|
||||
|
||||
dummy_instance.agent_instructions(context_double)
|
||||
end
|
||||
|
||||
it 'handles context without state' do
|
||||
context_double = instance_double(Agents::RunContext, context: {})
|
||||
|
||||
@@ -116,7 +137,8 @@ RSpec.describe Concerns::Agentable do
|
||||
hash_including(
|
||||
base_key: 'base_value',
|
||||
conversation: {},
|
||||
contact: {}
|
||||
contact: {},
|
||||
campaign: {}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -384,6 +384,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(state[:channel_type]).to eq(inbox.channel_type)
|
||||
end
|
||||
|
||||
it 'includes contact inbox attributes when conversation is present' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state[:contact_inbox]).to include(
|
||||
id: conversation.contact_inbox.id,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes contact attributes when contact is present' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
@@ -394,6 +403,34 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not include campaign when conversation has no campaign' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state).not_to have_key(:campaign)
|
||||
end
|
||||
|
||||
context 'when conversation has a campaign' do
|
||||
let(:campaign) { create(:campaign, account: account, title: 'Summer Sale', message: 'Check out our deals!', description: 'Seasonal promo') }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, campaign: campaign) }
|
||||
|
||||
it 'includes campaign attributes in state' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state[:campaign]).to include(
|
||||
id: campaign.id,
|
||||
title: 'Summer Sale',
|
||||
message: 'Check out our deals!',
|
||||
description: 'Seasonal promo'
|
||||
)
|
||||
end
|
||||
|
||||
it 'only includes attributes defined in CAMPAIGN_STATE_ATTRIBUTES' do
|
||||
state = service.send(:build_state)
|
||||
|
||||
expect(state[:campaign].keys).to match_array(described_class::CAMPAIGN_STATE_ATTRIBUTES)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is nil' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
|
||||
|
||||
@@ -407,6 +444,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
)
|
||||
expect(state).not_to have_key(:conversation)
|
||||
expect(state).not_to have_key(:contact)
|
||||
expect(state).not_to have_key(:campaign)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -477,5 +515,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
:id, :name, :email, :phone_number, :identifier, :contact_type
|
||||
)
|
||||
end
|
||||
|
||||
it 'defines campaign state attributes' do
|
||||
expect(described_class::CAMPAIGN_STATE_ATTRIBUTES).to include(
|
||||
:id, :title, :message, :campaign_type, :description
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do
|
||||
end
|
||||
|
||||
it 'executes perform' do
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
perform_enqueued_jobs { job }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is in backoff' do
|
||||
it 'does not fetch emails' do
|
||||
allow(imap_email_channel).to receive(:in_backoff?).and_return(true)
|
||||
expect(Imap::FetchEmailService).not_to receive(:new)
|
||||
described_class.perform_now(imap_email_channel)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the channel is regular imap' do
|
||||
it 'calls the imap fetch service' do
|
||||
fetch_service = double
|
||||
@@ -56,6 +64,17 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
described_class.perform_now(imap_email_channel, 4)
|
||||
expect(fetch_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'clears backoff after successful fetch' do
|
||||
fetch_service = double
|
||||
allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service)
|
||||
allow(fetch_service).to receive(:perform).and_return([])
|
||||
allow(imap_email_channel).to receive(:clear_backoff!)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
|
||||
expect(imap_email_channel).to have_received(:clear_backoff!)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the channel is Microsoft' do
|
||||
@@ -69,6 +88,37 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authentication error is raised' do
|
||||
it 'calls authorization_error! on the channel' do
|
||||
allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError)
|
||||
allow(imap_email_channel).to receive(:authorization_error!)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
|
||||
expect(imap_email_channel).to have_received(:authorization_error!)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a transient IMAP error is raised' do
|
||||
it 'calls apply_backoff! on the channel' do
|
||||
allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError)
|
||||
allow(imap_email_channel).to receive(:apply_backoff!)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
|
||||
expect(imap_email_channel).to have_received(:apply_backoff!)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock acquisition fails' do
|
||||
it 'does not raise an error' do
|
||||
lock_manager = instance_double(Redis::LockManager, lock: false)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
|
||||
expect { described_class.perform_now(imap_email_channel) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when IMAP OAuth errors out' do
|
||||
it 'marks the connection as requiring authorization' do
|
||||
error_response = double
|
||||
|
||||
@@ -16,7 +16,7 @@ RSpec.describe WebhookJob do
|
||||
end
|
||||
|
||||
it 'executes perform with default webhook type' do
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
perform_enqueued_jobs { job }
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ RSpec.describe WebhookJob do
|
||||
let(:webhook_type) { :api_inbox_webhook }
|
||||
|
||||
it 'executes perform with inbox webhook type' do
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
|
||||
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
|
||||
perform_enqueued_jobs { job }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ChatwootHub do
|
||||
describe '.base_url' do
|
||||
it 'uses the static hub url' do
|
||||
expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com')
|
||||
expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'generates installation identifier' do
|
||||
installation_identifier = described_class.installation_identifier
|
||||
expect(installation_identifier).not_to be_nil
|
||||
@@ -12,7 +19,7 @@ describe ChatwootHub do
|
||||
version = '1.1.1'
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL, described_class.instance_config
|
||||
expect(RestClient).to have_received(:post).with(described_class.ping_url, described_class.instance_config
|
||||
.merge(described_class.instance_metrics).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
@@ -21,7 +28,7 @@ describe ChatwootHub do
|
||||
with_modified_env DISABLE_TELEMETRY: 'true' do
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.ping_url,
|
||||
described_class.instance_config.to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
@@ -41,7 +48,7 @@ describe ChatwootHub do
|
||||
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.register_instance(company_name, owner_name, owner_email)
|
||||
expect(RestClient).to have_received(:post).with(described_class::REGISTRATION_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.registration_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
@@ -54,7 +61,7 @@ describe ChatwootHub do
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).to have_received(:post).with(described_class::EVENTS_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.events_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
@@ -64,29 +71,9 @@ describe ChatwootHub do
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).not_to have_received(:post)
|
||||
.with(described_class::EVENTS_URL,
|
||||
.with(described_class.events_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fetching captain settings' do
|
||||
it 'returns the captain settings' do
|
||||
account = create(:account)
|
||||
stub_request(:post, ChatwootHub::CAPTAIN_ACCOUNTS_URL).with(
|
||||
body: { installation_identifier: described_class.installation_identifier, chatwoot_account_id: account.id, account_name: account.name }
|
||||
).to_return(
|
||||
body: { account_email: 'test@test.com', account_id: '123', access_token: '123', assistant_id: '123' }.to_json
|
||||
)
|
||||
|
||||
expect(described_class.get_captain_settings(account).body).to eq(
|
||||
{
|
||||
account_email: 'test@test.com',
|
||||
account_id: '123',
|
||||
access_token: '123',
|
||||
assistant_id: '123'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,7 +69,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
2.times.each { builder.perform }
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
@@ -79,7 +79,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
@@ -89,7 +89,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(private_message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('pRivate: A private note message')
|
||||
@@ -130,7 +130,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
messages_count = conversation.messages.count
|
||||
message_with_attachments[:event][:files] = nil
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
@@ -139,7 +139,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
@@ -152,7 +152,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
message_with_attachments[:event][:text] = 'Attached File!'
|
||||
builder = described_class.new(message_with_attachments)
|
||||
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
builder.perform
|
||||
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
@@ -165,13 +165,113 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
|
||||
|
||||
builder = described_class.new(video_attachment_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resolving slack sender' do
|
||||
let(:builder) { described_class.new(message_params) }
|
||||
|
||||
before do
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
end
|
||||
|
||||
context 'when slack user email matches a chatwoot agent' do
|
||||
before do
|
||||
create(:user, account: conversation.account, email: 'agent@example.com')
|
||||
slack_response = {
|
||||
user: {
|
||||
profile: { email: 'agent@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' },
|
||||
real_name: 'Muhsin K',
|
||||
name: 'muhsink'
|
||||
}
|
||||
}
|
||||
allow(slack_client).to receive(:users_info)
|
||||
.with(user: message_params[:event][:user])
|
||||
.and_return(slack_response)
|
||||
end
|
||||
|
||||
it 'sets the matched agent as message sender' do
|
||||
builder.perform
|
||||
expect(conversation.messages.last.sender).to eq(conversation.account.users.from_email('agent@example.com'))
|
||||
end
|
||||
|
||||
it 'does not store sender_name in additional_attributes' do
|
||||
builder.perform
|
||||
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when slack user email does not match any chatwoot agent' do
|
||||
before do
|
||||
slack_response = {
|
||||
user: {
|
||||
profile: { email: 'unknown@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' },
|
||||
real_name: 'Muhsin K',
|
||||
name: 'muhsink'
|
||||
}
|
||||
}
|
||||
allow(slack_client).to receive(:users_info)
|
||||
.with(user: message_params[:event][:user])
|
||||
.and_return(slack_response)
|
||||
end
|
||||
|
||||
it 'saves sender_name from slack display_name in additional_attributes' do
|
||||
builder.perform
|
||||
expect(conversation.messages.last.sender).to be_nil
|
||||
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K')
|
||||
end
|
||||
|
||||
it 'saves sender_avatar_url from slack profile image in additional_attributes' do
|
||||
builder.perform
|
||||
expect(conversation.messages.last.additional_attributes['sender_avatar_url'])
|
||||
.to eq('https://avatars.slack-edge.com/avatar.png')
|
||||
end
|
||||
|
||||
it 'falls back to real_name when display_name is blank' do
|
||||
allow(slack_client).to receive(:users_info).and_return({
|
||||
user: {
|
||||
profile: { email: 'unknown@example.com', display_name: '',
|
||||
image_192: nil }, real_name: 'Muhsin K', name: 'muhsink'
|
||||
}
|
||||
})
|
||||
builder.perform
|
||||
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K')
|
||||
end
|
||||
|
||||
it 'falls back to slack username when display_name and real_name are both blank' do
|
||||
allow(slack_client).to receive(:users_info).and_return({
|
||||
user: {
|
||||
profile: { email: 'unknown@example.com', display_name: '',
|
||||
image_192: nil }, real_name: '', name: 'muhsink'
|
||||
}
|
||||
})
|
||||
builder.perform
|
||||
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('muhsink')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the slack API call raises an error' do
|
||||
before do
|
||||
allow(slack_client).to receive(:users_info).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'creates the message with nil sender' do
|
||||
expect { builder.perform }.not_to raise_error
|
||||
expect(conversation.messages.last.sender).to be_nil
|
||||
end
|
||||
|
||||
it 'does not store sender info in additional_attributes' do
|
||||
builder.perform
|
||||
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name')
|
||||
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_avatar_url')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link shared' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
|
||||
@@ -168,6 +168,71 @@ describe Webhooks::Trigger do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'request headers' do
|
||||
let(:payload) { { event: 'message_created' } }
|
||||
let(:body) { payload.to_json }
|
||||
|
||||
context 'without secret or delivery_id' do
|
||||
it 'sends only content-type and accept headers' do
|
||||
expect(RestClient::Request).to receive(:execute).with(
|
||||
hash_including(headers: { content_type: :json, accept: :json })
|
||||
)
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with delivery_id' do
|
||||
it 'adds X-Chatwoot-Delivery header' do
|
||||
expect(RestClient::Request).to receive(:execute) do |args|
|
||||
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid')
|
||||
expect(args[:headers]).not_to have_key('X-Chatwoot-Signature')
|
||||
expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp')
|
||||
end
|
||||
trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with secret' do
|
||||
let(:secret) { 'test-secret' }
|
||||
|
||||
it 'adds X-Chatwoot-Timestamp header' do
|
||||
expect(RestClient::Request).to receive(:execute) do |args|
|
||||
expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/)
|
||||
end
|
||||
trigger.execute(url, payload, webhook_type, secret: secret)
|
||||
end
|
||||
|
||||
it 'adds X-Chatwoot-Signature header with correct HMAC' do
|
||||
expect(RestClient::Request).to receive(:execute) do |args|
|
||||
ts = args[:headers]['X-Chatwoot-Timestamp']
|
||||
expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}"
|
||||
expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig)
|
||||
end
|
||||
trigger.execute(url, payload, webhook_type, secret: secret)
|
||||
end
|
||||
|
||||
it 'signs timestamp.body not just body' do
|
||||
expect(RestClient::Request).to receive(:execute) do |args|
|
||||
args[:headers]['X-Chatwoot-Timestamp']
|
||||
wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
|
||||
expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig)
|
||||
end
|
||||
trigger.execute(url, payload, webhook_type, secret: secret)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with both secret and delivery_id' do
|
||||
it 'includes all three security headers' do
|
||||
expect(RestClient::Request).to receive(:execute) do |args|
|
||||
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123')
|
||||
expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present
|
||||
expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=')
|
||||
end
|
||||
trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not update message status if webhook fails for other events' do
|
||||
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured and event is subscribed' do
|
||||
it 'triggers the webhook event' do
|
||||
webhook = create(:webhook, inbox: inbox, account: account)
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(message_created_event)
|
||||
end
|
||||
end
|
||||
@@ -54,8 +57,10 @@ describe WebhookListener do
|
||||
conversation: api_conversation
|
||||
)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
|
||||
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
||||
:api_inbox_webhook).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
|
||||
:api_inbox_webhook, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(api_event)
|
||||
end
|
||||
|
||||
@@ -90,7 +95,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured' do
|
||||
it 'triggers webhook' do
|
||||
webhook = create(:webhook, inbox: inbox, account: account)
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_created(conversation_created_event)
|
||||
end
|
||||
end
|
||||
@@ -101,9 +109,11 @@ describe WebhookListener do
|
||||
api_inbox = channel_api.inbox
|
||||
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
|
||||
api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation)
|
||||
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url,
|
||||
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
||||
:api_inbox_webhook).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url,
|
||||
api_conversation.webhook_data.merge(event: 'conversation_created'),
|
||||
:api_inbox_webhook, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_created(api_event)
|
||||
end
|
||||
|
||||
@@ -156,7 +166,9 @@ describe WebhookListener do
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
),
|
||||
:account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.conversation_updated(conversation_updated_event)
|
||||
@@ -177,7 +189,10 @@ describe WebhookListener do
|
||||
context 'when webhook is configured' do
|
||||
it 'triggers webhook' do
|
||||
webhook = create(:webhook, account: account)
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.contact_created(contact_event)
|
||||
end
|
||||
end
|
||||
@@ -213,7 +228,9 @@ describe WebhookListener do
|
||||
contact.webhook_data.merge(
|
||||
event: 'contact_updated',
|
||||
changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }]
|
||||
)
|
||||
),
|
||||
:account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.contact_updated(contact_updated_event)
|
||||
end
|
||||
@@ -235,7 +252,10 @@ describe WebhookListener do
|
||||
it 'triggers webhook' do
|
||||
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
|
||||
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.inbox_created(inbox_created_event)
|
||||
end
|
||||
end
|
||||
@@ -272,7 +292,9 @@ describe WebhookListener do
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url,
|
||||
inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data)
|
||||
inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data),
|
||||
:account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.inbox_updated(inbox_updated_event)
|
||||
@@ -302,7 +324,10 @@ describe WebhookListener do
|
||||
is_private: false
|
||||
}
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, payload, :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_typing_on(typing_event)
|
||||
end
|
||||
end
|
||||
@@ -321,7 +346,10 @@ describe WebhookListener do
|
||||
is_private: false
|
||||
}
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url, payload, :api_inbox_webhook,
|
||||
delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_typing_on(api_event)
|
||||
end
|
||||
end
|
||||
@@ -349,7 +377,10 @@ describe WebhookListener do
|
||||
is_private: false
|
||||
}
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
webhook.url, payload, :account_webhook,
|
||||
secret: webhook.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_typing_off(typing_event)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
require 'rails_helper'
|
||||
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
|
||||
require Rails.root.join 'spec/models/concerns/backoffable_shared.rb'
|
||||
|
||||
RSpec.describe Channel::Email do
|
||||
let(:channel) { create(:channel_email) }
|
||||
|
||||
describe 'concerns' do
|
||||
it_behaves_like 'reauthorizable'
|
||||
it_behaves_like 'backoffable'
|
||||
|
||||
context 'when prompt_reauthorization!' do
|
||||
it 'calls channel notifier mail for email' do
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user