Merge branch 'feat/scenario-agents' of github.com:chatwoot/chatwoot into feat/scenario-agents
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
3.4.1
|
||||
3.4.2
|
||||
|
||||
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
|
||||
});
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
|
||||
{
|
||||
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
|
||||
icon: isUnread.value ? 'mail' : 'mail-unread',
|
||||
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
|
||||
},
|
||||
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
|
||||
]);
|
||||
|
||||
const messageClasses = computed(() => ({
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
return {
|
||||
accountId,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return { conversationMeta: {} };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
getAccount: 'accounts/getAccount',
|
||||
}),
|
||||
bannerMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
|
||||
},
|
||||
actionButtonMessage() {
|
||||
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
|
||||
},
|
||||
shouldShowBanner() {
|
||||
if (!this.isOnChatwootCloud) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTrialAccount()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isLimitExceeded();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isOnChatwootCloud) {
|
||||
this.fetchLimits();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchLimits() {
|
||||
this.$store.dispatch('accounts/limits');
|
||||
},
|
||||
routeToBilling() {
|
||||
this.$router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: this.accountId },
|
||||
});
|
||||
},
|
||||
isTrialAccount() {
|
||||
// check if account is less than 15 days old
|
||||
const account = this.getAccount(this.accountId);
|
||||
if (!account) return false;
|
||||
|
||||
const createdAt = new Date(account.created_at);
|
||||
|
||||
const diffDays = differenceInDays(new Date(), createdAt);
|
||||
|
||||
return diffDays <= 15;
|
||||
},
|
||||
isLimitExceeded() {
|
||||
const account = this.getAccount(this.accountId);
|
||||
if (!account) return false;
|
||||
|
||||
const { limits } = account;
|
||||
if (!limits) return false;
|
||||
|
||||
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
|
||||
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
|
||||
},
|
||||
testLimit({ allowed, consumed }) {
|
||||
return consumed > allowed;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Banner
|
||||
v-if="shouldShowBanner"
|
||||
color-scheme="alert"
|
||||
:banner-message="bannerMessage"
|
||||
:action-button-label="actionButtonMessage"
|
||||
has-action-button
|
||||
@primary-action="routeToBilling"
|
||||
/>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -18,6 +19,7 @@ defineProps({
|
||||
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { isEnterprise } = useConfig();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
@@ -82,6 +84,9 @@ const setAssistant = async assistant => {
|
||||
};
|
||||
|
||||
const shouldShowCopilotPanel = computed(() => {
|
||||
if (!isEnterprise) {
|
||||
return false;
|
||||
}
|
||||
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
@@ -113,7 +118,9 @@ const sendMessage = async message => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
if (isEnterprise) {
|
||||
store.dispatch('captainAssistants/get');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowedContextMenuOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'contextMenuToggle',
|
||||
@@ -151,11 +155,9 @@ export default {
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
conversationPath() {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
@@ -166,18 +168,26 @@ export default {
|
||||
conversationType: this.conversationType,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
const path = this.conversationPath;
|
||||
if (!path) return;
|
||||
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (this.isActiveChat) return;
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
@@ -359,6 +369,8 @@ export default {
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@@ -367,6 +379,7 @@ export default {
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const MENU = {
|
||||
MARK_AS_READ: 'mark-as-read',
|
||||
MARK_AS_UNREAD: 'mark-as-unread',
|
||||
PRIORITY: 'priority',
|
||||
STATUS: 'status',
|
||||
SNOOZE: 'snooze',
|
||||
AGENT: 'agent',
|
||||
TEAM: 'team',
|
||||
LABEL: 'label',
|
||||
DELETE: 'delete',
|
||||
OPEN_NEW_TAB: 'open-new-tab',
|
||||
COPY_LINK: 'copy-link',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -37,6 +53,14 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conversationUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowedOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'updateConversation',
|
||||
@@ -47,6 +71,7 @@ export default {
|
||||
'assignTeam',
|
||||
'assignLabel',
|
||||
'deleteConversation',
|
||||
'close',
|
||||
],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
@@ -56,6 +81,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -63,7 +89,7 @@ export default {
|
||||
},
|
||||
unreadOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
|
||||
icon: 'mail',
|
||||
icon: 'mail-unread',
|
||||
},
|
||||
statusMenuConfig: [
|
||||
{
|
||||
@@ -88,7 +114,7 @@ export default {
|
||||
icon: 'snooze',
|
||||
},
|
||||
priorityConfig: {
|
||||
key: 'priority',
|
||||
key: MENU.PRIORITY,
|
||||
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
||||
icon: 'warning',
|
||||
options: [
|
||||
@@ -115,25 +141,35 @@ export default {
|
||||
].filter(item => item.key !== this.priority),
|
||||
},
|
||||
labelMenuConfig: {
|
||||
key: 'label',
|
||||
key: MENU.LABEL,
|
||||
icon: 'tag',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
||||
},
|
||||
agentMenuConfig: {
|
||||
key: 'agent',
|
||||
key: MENU.AGENT,
|
||||
icon: 'person-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
||||
},
|
||||
teamMenuConfig: {
|
||||
key: 'team',
|
||||
key: MENU.TEAM,
|
||||
icon: 'people-team-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
||||
},
|
||||
deleteOption: {
|
||||
key: 'delete',
|
||||
key: MENU.DELETE,
|
||||
icon: 'delete',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
|
||||
},
|
||||
openInNewTabOption: {
|
||||
key: MENU.OPEN_NEW_TAB,
|
||||
icon: 'open',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
|
||||
},
|
||||
copyLinkOption: {
|
||||
key: MENU.COPY_LINK,
|
||||
icon: 'copy',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -180,6 +216,10 @@ export default {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
},
|
||||
methods: {
|
||||
isAllowed(keys) {
|
||||
if (!this.allowedOptions.length) return true;
|
||||
return keys.some(key => this.allowedOptions.includes(key));
|
||||
},
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.$emit('updateConversation', status, snoozedUntil);
|
||||
},
|
||||
@@ -194,6 +234,24 @@ export default {
|
||||
deleteConversation() {
|
||||
this.$emit('deleteConversation', this.chatId);
|
||||
},
|
||||
openInNewTab() {
|
||||
if (!this.conversationUrl) return;
|
||||
|
||||
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
this.$emit('close');
|
||||
},
|
||||
async copyConversationLink() {
|
||||
if (!this.conversationUrl) return;
|
||||
try {
|
||||
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
|
||||
await copyTextToClipboard(url);
|
||||
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
|
||||
this.$emit('close');
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
},
|
||||
show(key) {
|
||||
// If the conversation status is same as the action, then don't display the option
|
||||
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
||||
@@ -217,83 +275,114 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
|
||||
<MenuItem
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click.stop="$emit('markAsUnread')"
|
||||
/>
|
||||
<MenuItem
|
||||
v-else
|
||||
:option="readOption"
|
||||
variant="icon"
|
||||
@click.stop="$emit('markAsRead')"
|
||||
/>
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<div
|
||||
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
|
||||
>
|
||||
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
|
||||
<MenuItem
|
||||
v-if="show(option.key)"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
@click.stop="$emit('markAsUnread')"
|
||||
/>
|
||||
<MenuItem
|
||||
v-else
|
||||
:option="readOption"
|
||||
variant="icon"
|
||||
@click.stop="$emit('markAsRead')"
|
||||
/>
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
</template>
|
||||
<MenuItem
|
||||
v-if="showSnooze"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click.stop="snoozeConversation()"
|
||||
/>
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
<MenuItemWithSubmenu :option="priorityConfig">
|
||||
<MenuItem
|
||||
v-for="(option, i) in priorityConfig.options"
|
||||
:key="i"
|
||||
:option="option"
|
||||
@click.stop="assignPriority(option.key)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click.stop="$emit('assignLabel', label)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
:option="agentMenuConfig"
|
||||
:sub-menu-available="!!assignableAgents.length"
|
||||
>
|
||||
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||
<template v-else>
|
||||
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<MenuItem
|
||||
v-for="agent in assignableAgents"
|
||||
:key="agent.id"
|
||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||
variant="agent"
|
||||
@click.stop="$emit('assignAgent', agent)"
|
||||
v-if="show(option.key) && isAllowed([MENU.STATUS])"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
/>
|
||||
</template>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
:option="teamMenuConfig"
|
||||
:sub-menu-available="!!teams.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
:option="generateMenuLabelConfig(team, 'team')"
|
||||
@click.stop="$emit('assignTeam', team)"
|
||||
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click.stop="snoozeConversation()"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<template v-if="isAdmin">
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
</template>
|
||||
<template
|
||||
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
|
||||
>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.PRIORITY])"
|
||||
:option="priorityConfig"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="(option, i) in priorityConfig.options"
|
||||
:key="i"
|
||||
:option="option"
|
||||
@click.stop="assignPriority(option.key)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.LABEL])"
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click.stop="$emit('assignLabel', label)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.AGENT])"
|
||||
:option="agentMenuConfig"
|
||||
:sub-menu-available="!!assignableAgents.length"
|
||||
>
|
||||
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||
<template v-else>
|
||||
<MenuItem
|
||||
v-for="agent in assignableAgents"
|
||||
:key="agent.id"
|
||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||
variant="agent"
|
||||
@click.stop="$emit('assignAgent', agent)"
|
||||
/>
|
||||
</template>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.TEAM])"
|
||||
:option="teamMenuConfig"
|
||||
:sub-menu-available="!!teams.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
:option="generateMenuLabelConfig(team, 'team')"
|
||||
@click.stop="$emit('assignTeam', team)"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
</template>
|
||||
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
|
||||
<MenuItem
|
||||
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
|
||||
:option="openInNewTabOption"
|
||||
variant="icon"
|
||||
@click.stop="openInNewTab"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="isAllowed([MENU.COPY_LINK])"
|
||||
:option="copyLinkOption"
|
||||
variant="icon"
|
||||
@click.stop="copyConversationLink"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
<MenuItem
|
||||
:option="deleteOption"
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
props: {
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,12 +26,12 @@ export default {
|
||||
class="label-pill flex-shrink-0"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:name="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
:status="option.status === 'online' ? option.status : null"
|
||||
:size="20"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<p class="menu-label truncate min-w-0 flex-1">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const captainEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||
@@ -33,7 +35,9 @@ export function useCaptain() {
|
||||
});
|
||||
|
||||
const fetchLimits = () => {
|
||||
store.dispatch('accounts/limits');
|
||||
if (isEnterprise) {
|
||||
store.dispatch('accounts/limits');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -144,6 +144,9 @@
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
"OPEN_IN_NEW_TAB": "Open in new tab",
|
||||
"COPY_LINK": "Copy conversation link",
|
||||
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
|
||||
"API": {
|
||||
"AGENT_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
|
||||
|
||||
@@ -60,6 +60,8 @@ export default {
|
||||
:chat="conversation"
|
||||
:hide-inbox-name="false"
|
||||
hide-thumbnail
|
||||
enable-context-menu
|
||||
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
|
||||
class="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +246,7 @@ onMounted(() => {
|
||||
:key="notificationItem.id"
|
||||
:inbox-item="notificationItem"
|
||||
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
|
||||
class="inbox-card rounded-lg hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
:class="
|
||||
currentConversationId === notificationItem.primaryActor?.id
|
||||
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import MenuItem from './MenuItem.vue';
|
||||
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
defineProps({
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['close', 'selectAction'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onMenuItemClick(key) {
|
||||
this.$emit('selectAction', key);
|
||||
this.handleClose();
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'selectAction']);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onMenuItemClick = key => {
|
||||
emit('selectAction', key);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,12 +32,14 @@ export default {
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
|
||||
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:option="item"
|
||||
variant="icon"
|
||||
class="!w-48"
|
||||
@click.stop="onMenuItemClick(item.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -22,6 +23,7 @@ const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { accountId, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
@@ -100,7 +102,11 @@ const routeToBilling = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => fetchLimits());
|
||||
onMounted(() => {
|
||||
if (isEnterprise) {
|
||||
fetchLimits();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ shouldShowUpgradePage });
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
include BillingHelper
|
||||
|
||||
def perform
|
||||
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
|
||||
@@ -11,6 +12,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
private
|
||||
|
||||
def should_fetch_emails?(inbox)
|
||||
inbox.channel.imap_enabled && !inbox.account.suspended?
|
||||
return false if inbox.account.suspended?
|
||||
return false unless inbox.channel.imap_enabled
|
||||
return false if inbox.channel.reauthorization_required?
|
||||
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
return false if default_plan?(inbox.account)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,10 +37,11 @@ class HookListener < BaseListener
|
||||
private
|
||||
|
||||
def execute_hooks(event, message)
|
||||
message.account.hooks.each do |hook|
|
||||
message.account.hooks.find_each do |hook|
|
||||
# In case of dialogflow, we would have a hook for each inbox.
|
||||
# Which means we will execute the same hook multiple times if the below filter isn't there
|
||||
next if hook.inbox.present? && hook.inbox != message.inbox
|
||||
next unless supported_hook_event?(hook, event.name)
|
||||
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
end
|
||||
@@ -48,7 +49,24 @@ class HookListener < BaseListener
|
||||
|
||||
def execute_account_hooks(event, account, event_data = {})
|
||||
account.hooks.account_hooks.find_each do |hook|
|
||||
next unless supported_hook_event?(hook, event.name)
|
||||
|
||||
HookJob.perform_later(hook, event.name, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
def supported_hook_event?(hook, event_name)
|
||||
return false if hook.disabled?
|
||||
|
||||
supported_events_map = {
|
||||
'slack' => ['message.created'],
|
||||
'dialogflow' => ['message.created', 'message.updated'],
|
||||
'google_translate' => ['message.created'],
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
}
|
||||
|
||||
return false unless supported_events_map.key?(hook.app_id)
|
||||
|
||||
supported_events_map[hook.app_id].include?(event_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,16 +4,17 @@ module IncomingEmailValidityHelper
|
||||
def incoming_email_from_valid_email?
|
||||
return false unless valid_external_email_for_active_account?
|
||||
|
||||
# Return if email doesn't have a valid sender
|
||||
# This can happen in cases like bounce emails for invalid contact email address
|
||||
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
|
||||
|
||||
# Process bounced emails, as regular emails
|
||||
return true if @processed_mail.bounced?
|
||||
|
||||
# we skip processing auto reply emails like delivery status notifications
|
||||
# out of office replies, etc.
|
||||
return false if auto_reply_email?
|
||||
|
||||
# return if email doesn't have a valid sender
|
||||
# This can happen in cases like bounce emails for invalid contact email address
|
||||
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
|
||||
# The returned value could be "\"\"" for some email clients
|
||||
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -157,6 +157,10 @@ class MailPresenter < SimpleDelegator
|
||||
auto_submitted? || x_auto_reply?
|
||||
end
|
||||
|
||||
def bounced?
|
||||
@mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
|
||||
end
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
# notification emails are send via mailer sender email address. so it should match
|
||||
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
|
||||
@@ -156,11 +156,18 @@ class Whatsapp::IncomingMessageBaseService
|
||||
phones = contact[:phones]
|
||||
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
|
||||
|
||||
name_info = contact['name'] || {}
|
||||
contact_meta = {
|
||||
firstName: name_info['first_name'],
|
||||
lastName: name_info['last_name']
|
||||
}.compact
|
||||
|
||||
phones.each do |phone|
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type(message_type),
|
||||
fallback_title: phone[:phone].to_s
|
||||
fallback_title: phone[:phone].to_s,
|
||||
meta: contact_meta
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Description: Install and manage a Chatwoot installation.
|
||||
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
|
||||
# Script Version: 3.4.1
|
||||
# Script Version: 3.4.2
|
||||
# Run this script as root
|
||||
|
||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||
@@ -990,7 +990,7 @@ EOF
|
||||
# Check if CW_VERSION is 4.0 or above
|
||||
if [[ "$(printf '%s\n' "$CW_VERSION" "4.0" | sort -V | head -n 1)" == "4.0" ]]; then
|
||||
echo "Chatwoot v4.0 and above requires pgvector support in PostgreSQL."
|
||||
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [Y/n]: " user_input
|
||||
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [y/N]: " user_input
|
||||
user_input=${user_input:-Y}
|
||||
if [[ "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||
echo "Proceeding with the upgrade..."
|
||||
@@ -1005,6 +1005,7 @@ EOF
|
||||
upgrade_redis
|
||||
upgrade_node
|
||||
get_pnpm
|
||||
|
||||
sudo -i -u chatwoot << EOF
|
||||
|
||||
# Navigate to the Chatwoot directory
|
||||
@@ -1016,9 +1017,9 @@ EOF
|
||||
|
||||
# Ensure the ruby version is upto date
|
||||
# Parse the latest ruby version
|
||||
latest_ruby_version="$(cat '.ruby-version')"
|
||||
rvm install "ruby-$latest_ruby_version"
|
||||
rvm use "$latest_ruby_version" --default
|
||||
latest_ruby_version="\$(cat '.ruby-version')"
|
||||
rvm install "ruby-\$latest_ruby_version"
|
||||
rvm use "\$latest_ruby_version" --default
|
||||
|
||||
# Update dependencies
|
||||
bundle
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
context 'when chatwoot_cloud is enabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
|
||||
let(:imap_email_channel) { create(:channel_email, imap_enabled: true, account: account) }
|
||||
let(:premium_imap_channel) { create(:channel_email, imap_enabled: true, account: premium_account) }
|
||||
|
||||
before do
|
||||
premium_account.custom_attributes['plan_name'] = 'Startups'
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create!(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create!(value: [{ 'name' => 'Hacker' }])
|
||||
end
|
||||
|
||||
it 'skips inboxes with default plan' do
|
||||
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel)
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'processes inboxes with premium plan' do
|
||||
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(premium_imap_channel)
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
Delivered-To: robert.smith@gmail.com
|
||||
Return-Path: <>
|
||||
Subject: Delivery Status Notification (Failure)
|
||||
From: Mail Delivery Subsystem <mailer-daemon@googlemail.com>
|
||||
To: robert.smith@gmail.com
|
||||
Content-Type: multipart/report; boundary="00000000000093475906390e1e9b"; report-type=delivery-status
|
||||
Auto-Submitted: auto-replied
|
||||
Message-ID: <686707c9.050a0220.302e7d.0cb2.GMR@mx.google.com>
|
||||
Date: Thu, 03 Jul 2025 15:44:25 -0700 (PDT)
|
||||
X-Failed-Recipients: alex.jones@fictionalcorp.com
|
||||
|
||||
--00000000000093475906390e1e9b
|
||||
Content-Type: multipart/related; boundary="000000000000936d8406390e1ec7"
|
||||
|
||||
--000000000000936d8406390e1ec7
|
||||
Content-Type: multipart/alternative; boundary="000000000000936d9006390e1ec8"
|
||||
|
||||
--000000000000936d9006390e1ec8
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
|
||||
** Address not found **
|
||||
|
||||
Your message wasn't delivered to alex.jones@fictionalcorp.com because the address co=
|
||||
uldn't be found or is unable to receive email.
|
||||
|
||||
Learn more here: https://support.google.com/mail/?p=3DNoSuchUser
|
||||
|
||||
The response was:
|
||||
|
||||
550 5.1.1 The email account that you tried to reach does not exist. Please =
|
||||
try double-checking the recipient's email address for typos or unnecessary =
|
||||
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
|
||||
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
|
||||
|
||||
--000000000000936d9006390e1ec8
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {
|
||||
font-family:Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table cellpadding=3D"0" cellspacing=3D"0" class=3D"email-wrapper" style=3D=
|
||||
"padding-top:32px;background-color:#ffffff;"><tbody>
|
||||
<tr><td>
|
||||
<table cellpadding=3D0 cellspacing=3D0><tbody>
|
||||
<tr><td style=3D"max-width:560px;padding:24px 24px 32px;background-color:#f=
|
||||
afafa;border:1px solid #e0e0e0;border-radius:2px">
|
||||
<img style=3D"padding:0 24px 16px 0;float:left" width=3D72 height=3D72 alt=
|
||||
=3D"Error Icon" src=3D"cid:icon.png">
|
||||
<table style=3D"min-width:272px;padding-top:8px"><tbody>
|
||||
<tr><td><h2 style=3D"font-size:20px;color:#212121;font-weight:bold;margin:0=
|
||||
">
|
||||
Address not found
|
||||
</h2></td></tr>
|
||||
<tr><td style=3D"padding-top:20px;color:#757575;font-size:16px;font-weight:=
|
||||
normal;text-align:left">
|
||||
Your message wasn't delivered to <a style=3D'color:#212121;text-decoration:=
|
||||
none'><b>alex.jones@fictionalcorp.com</b></a> because the address couldn't be found =
|
||||
or is unable to receive email.
|
||||
</td></tr>
|
||||
<tr><td style=3D"padding-top:24px;color:#4285F4;font-size:14px;font-weight:=
|
||||
bold;text-align:left">
|
||||
<a style=3D"text-decoration:none" href=3D"https://support.google.com/mail/?=
|
||||
p=3DNoSuchUser">LEARN MORE</a>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
<tr style=3D"border:none;background-color:#fff;font-size:12.8px;width:90%">
|
||||
<td align=3D"left" style=3D"padding:48px 10px">
|
||||
The response was:<br/>
|
||||
<p style=3D"font-family:monospace">
|
||||
550 5.1.1 The email account that you tried to reach does not exist. Please =
|
||||
try double-checking the recipient's email address for typos or unnecessary =
|
||||
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
|
||||
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--000000000000936d9006390e1ec8--
|
||||
--000000000000936d8406390e1ec7
|
||||
Content-Type: image/png; name="icon.png"
|
||||
Content-Disposition: attachment; filename="icon.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <icon.png>
|
||||
|
||||
--000000000000936d8406390e1ec7--
|
||||
--00000000000093475906390e1e9b
|
||||
Content-Type: message/delivery-status
|
||||
|
||||
--00000000000093475906390e1e9b
|
||||
Content-Type: message/rfc822
|
||||
|
||||
Date: Thu, 03 Jul 2025 15:44:23 -0700
|
||||
From: Robert Smith <robert.smith@gmail.com>
|
||||
Reply-To: robert.smith@gmail.com
|
||||
To: alex.jones@fictionalcorp.com
|
||||
Message-ID: <conversation/93775311-9416-4428-964b-862001f8a8b2/messages/27855337@gmail.com>
|
||||
In-Reply-To: <account/1/conversation/93775311-9416-4428-964b-862001f8a8b2@gmail.com>
|
||||
Subject: Just checking in
|
||||
Mime-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<p>Hey, just checking in. Let me know if you got my earlier message.</p>
|
||||
|
||||
--00000000000093475906390e1e9b--
|
||||
@@ -3,6 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:suspended_account) { create(:account, status: 'suspended') }
|
||||
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
|
||||
|
||||
let(:imap_email_channel) do
|
||||
create(:channel_email, imap_enabled: true, account: account)
|
||||
@@ -16,6 +17,19 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
create(:channel_email, imap_enabled: false, account: account)
|
||||
end
|
||||
|
||||
let(:reauth_required_channel) do
|
||||
create(:channel_email, imap_enabled: true, account: account)
|
||||
end
|
||||
|
||||
let(:premium_imap_channel) do
|
||||
create(:channel_email, imap_enabled: true, account: premium_account)
|
||||
end
|
||||
|
||||
before do
|
||||
reauth_required_channel.prompt_reauthorization!
|
||||
premium_account.custom_attributes['plan_name'] = 'Startups'
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('scheduled_jobs')
|
||||
@@ -44,5 +58,11 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'skips channels requiring reauthorization' do
|
||||
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(reauth_required_channel)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,8 @@ describe HookListener do
|
||||
account: account, inbox: inbox, conversation: conversation)
|
||||
end
|
||||
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
|
||||
let(:contact_event) { Events::Base.new('contact.updated', Time.zone.now, contact: conversation.contact) }
|
||||
let(:conversation_event) { Events::Base.new('conversation.created', Time.zone.now, conversation: conversation) }
|
||||
|
||||
describe '#message_created' do
|
||||
let(:event_name) { 'message.created' }
|
||||
@@ -42,10 +44,88 @@ describe HookListener do
|
||||
|
||||
context 'when hook is configured' do
|
||||
it 'triggers hook job' do
|
||||
hook = create(:integrations_hook, account: account)
|
||||
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
|
||||
listener.message_updated(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'hook job enqueuing behavior' do
|
||||
let(:event_name) { 'message.created' }
|
||||
|
||||
context 'when app_id is not in the allowed list' do
|
||||
it 'does not enqueue the job' do
|
||||
create(:integrations_hook, account: account, app_id: 'unsupported_app')
|
||||
expect(HookJob).not_to receive(:perform_later)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hook is enabled and app_id is supported' do
|
||||
it 'enqueues the job for slack' do
|
||||
hook = create(:integrations_hook, account: account)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
|
||||
it 'enqueues the job for dialogflow' do
|
||||
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
|
||||
it 'enqueues the job for google_translate' do
|
||||
hook = create(:integrations_hook, :google_translate, account: account)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with disabled hook' do
|
||||
it 'does not enqueue job for disabled hooks' do
|
||||
create(:integrations_hook, account: account, status: 'disabled', app_id: 'slack')
|
||||
expect(HookJob).not_to receive(:perform_later)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unsupported app_id and event combination' do
|
||||
it 'does not enqueue job for unsupported app_id' do
|
||||
create(:integrations_hook, account: account, app_id: 'unsupported_app')
|
||||
expect(HookJob).not_to receive(:perform_later)
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with leadsquared hook' do
|
||||
let(:hook) { create(:integrations_hook, :leadsquared, account: account) }
|
||||
|
||||
before do
|
||||
account.enable_features(:crm_integration)
|
||||
end
|
||||
|
||||
it 'enqueues the job for conversation.created' do
|
||||
expect(HookJob)
|
||||
.to receive(:perform_later)
|
||||
.with(hook, 'conversation.created', { conversation: conversation })
|
||||
|
||||
listener.conversation_created(conversation_event)
|
||||
end
|
||||
|
||||
it 'enqueues the job for contact.updated' do
|
||||
expect(HookJob)
|
||||
.to receive(:perform_later)
|
||||
.with(hook, 'contact.updated', { contact: conversation.contact })
|
||||
|
||||
listener.contact_updated(contact_event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,6 +115,14 @@ RSpec.describe Imap::ImapMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email is bounced' do
|
||||
let!(:bounced_mail) { create_inbound_email_from_fixture('bounced_gmail.eml') }
|
||||
|
||||
it 'processes the bounced email' do
|
||||
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a reply for existing email conversation' do
|
||||
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
|
||||
let(:reply_mail) do
|
||||
|
||||
@@ -267,19 +267,16 @@ describe Whatsapp::IncomingMessageService do
|
||||
] }] }.with_indifferent_access
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(Contact.all.first.name).to eq('Kedar')
|
||||
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
|
||||
# Two messages are tested deliberately to ensure multiple contact attachments work.
|
||||
m1 = whatsapp_channel.inbox.messages.first
|
||||
contact_attachments = m1.attachments.first
|
||||
expect(m1.content).to eq('Apple Inc.')
|
||||
expect(contact_attachments.fallback_title).to eq('+911800')
|
||||
expect(m1.attachments.first.fallback_title).to eq('+911800')
|
||||
expect(m1.attachments.first.meta).to eq({})
|
||||
|
||||
m2 = whatsapp_channel.inbox.messages.last
|
||||
contact_attachments = m2.attachments.first
|
||||
expect(m2.content).to eq('Chatwoot')
|
||||
expect(contact_attachments.fallback_title).to eq('+1 (415) 341-8386')
|
||||
expect(m2.attachments.first.meta).to eq({ 'firstName' => 'Chatwoot' })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user