Merge remote-tracking branch 'origin/develop' into perf/conversation_count
This commit is contained in:
@@ -14,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],
|
||||
|
||||
+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;
|
||||
|
||||
@@ -955,10 +955,8 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
:overscan="10"
|
||||
>
|
||||
<ConversationItem
|
||||
:key="item.id"
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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')
|
||||
@@ -427,7 +431,7 @@ export default {
|
||||
},
|
||||
isEditorDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
!this.currentChat.can_reply
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-3
@@ -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,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
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_03_125200) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1254,6 +1254,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_03_125200) 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 }}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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))
|
||||
@@ -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==}
|
||||
@@ -5795,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
shared_examples_for 'backoffable' do
|
||||
let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2)
|
||||
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3)
|
||||
# max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff!
|
||||
end
|
||||
|
||||
it 'starts with no backoff' do
|
||||
expect(obj.in_backoff?).to be false
|
||||
expect(obj.backoff_retry_count).to eq 0
|
||||
end
|
||||
|
||||
it 'ramps backoff on each failure' do
|
||||
obj.apply_backoff!
|
||||
expect(obj.backoff_retry_count).to eq 1
|
||||
expect(obj.in_backoff?).to be true
|
||||
end
|
||||
|
||||
it 'caps wait time at max interval' do
|
||||
4.times { obj.apply_backoff! }
|
||||
expect(obj.backoff_retry_count).to eq 4
|
||||
expect(obj.in_backoff?).to be true
|
||||
end
|
||||
|
||||
it 'exhausts backoff and calls prompt_reauthorization! after max retries' do
|
||||
allow(obj).to receive(:prompt_reauthorization!)
|
||||
5.times { obj.apply_backoff! }
|
||||
expect(obj).to have_received(:prompt_reauthorization!)
|
||||
expect(obj.backoff_retry_count).to eq 0
|
||||
expect(obj.in_backoff?).to be false
|
||||
end
|
||||
|
||||
it 'clear_backoff! resets retry count and backoff window' do
|
||||
obj.apply_backoff!
|
||||
obj.clear_backoff!
|
||||
expect(obj.in_backoff?).to be false
|
||||
expect(obj.backoff_retry_count).to eq 0
|
||||
end
|
||||
end
|
||||
@@ -8,4 +8,20 @@ RSpec.describe Webhook do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'secret token' do
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
it 'auto-generates a secret on create' do
|
||||
webhook = create(:webhook, account: account)
|
||||
expect(webhook.secret).to be_present
|
||||
end
|
||||
|
||||
it 'does not regenerate the secret on update' do
|
||||
webhook = create(:webhook, account: account)
|
||||
original_secret = webhook.secret
|
||||
webhook.update!(url: "#{webhook.url}?updated=1")
|
||||
expect(webhook.reload.secret).to eq(original_secret)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user