Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-08-05 16:22:18 +05:30
committed by GitHub
33 changed files with 576 additions and 273 deletions
@@ -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>
@@ -19,14 +19,14 @@ export default {
</script>
<template>
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<fluent-icon
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
class="ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
:icon="computedInboxClass"
size="12"
/>
{{ inbox.name }}
<span class="truncate">
{{ inbox.name }}
</span>
</div>
</template>
@@ -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 });
},
@@ -289,17 +299,21 @@ export default {
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
>
<div class="flex justify-between conversation-card--meta">
<InboxName v-if="showInboxName" :inbox="inbox" />
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
<div class="flex items-center conversation-card--meta min-w-0">
<InboxName
v-if="showInboxName"
:inbox="inbox"
class="flex-1 min-w-0 mx-2"
/>
<div class="flex items-center gap-2 flex-shrink-0">
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" />
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
@@ -359,6 +373,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 +383,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>
+8 -3
View File
@@ -17,7 +17,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_created(event)
return if performed_by_automation?(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -34,7 +34,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_opened(event)
return if performed_by_automation?(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -87,8 +87,13 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
end
def ignore_message_created_event?(event)
message = event.data[:message]
performed_by_automation?(event) || message.activity?
performed_by_automation?(event) || message.activity? || message.auto_reply_email?
end
end
+19 -1
View File
@@ -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
+1
View File
@@ -84,6 +84,7 @@ class Imap::ImapMailbox
additional_attributes: {
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
@@ -8,13 +8,6 @@ module IncomingEmailValidityHelper
# 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?
true
end
@@ -24,13 +17,4 @@ module IncomingEmailValidityHelper
true
end
def auto_reply_email?
if @processed_mail.auto_reply?
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
true
else
false
end
end
end
+1
View File
@@ -70,6 +70,7 @@ class SupportMailbox < ApplicationMailbox
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
+6
View File
@@ -195,6 +195,12 @@ class Message < ApplicationRecord
true
end
def auto_reply_email?
return false unless incoming_email? || inbox.email?
content_attributes.dig(:email, :auto_reply) == true
end
def valid_first_reply?
return false unless human_response? && !private?
return false if conversation.first_reply_created_at.present?
+2 -1
View File
@@ -103,7 +103,8 @@ class MailPresenter < SimpleDelegator
references: references,
subject: subject,
text_content: text_content,
to: to
to: to,
auto_reply: auto_reply?
}
end
@@ -4,6 +4,7 @@ class MessageTemplates::HookExecutionService
def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
trigger_templates
end
@@ -88,12 +88,16 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: formatted_phone_number,
name: contact_name,
phone_number: phone_number,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number
end
def additional_attributes
if twilio_channel.sms?
{
@@ -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
@@ -48,6 +48,13 @@ describe AutomationRuleListener do
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if conversation has auto_reply in additional_attributes' do
conversation.additional_attributes = { 'auto_reply' => true }
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
end
end
@@ -165,6 +172,18 @@ describe AutomationRuleListener do
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if message is auto reply email' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
email_conversation = create(:conversation, inbox: email_inbox, account: account)
email_message = create(:message, conversation: email_conversation, account: account, content_attributes: { email: { auto_reply: true } })
email_event = Events::Base.new('message_created', Time.zone.now, { message: email_message })
allow(condition_match).to receive(:present?).and_return(true)
listener.message_created(email_event)
expect(AutomationRules::ActionService).not_to have_received(:new)
end
it 'does not call AutomationRules::ActionService if conditions do not match based on content' do
message.update!(processed_message_content: 'hi', content: "hi\n\nhello")
allow(condition_match).to receive(:present?).and_return(false)
+81 -1
View File
@@ -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
+4 -1
View File
@@ -111,7 +111,8 @@ RSpec.describe Imap::ImapMailbox do
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml') }
it 'does not create a new conversation' do
expect { class_instance.process(auto_reply_mail.mail, channel) }.not_to change(Conversation, :count)
expect { class_instance.process(auto_reply_mail.mail, channel) }.to change(Conversation, :count)
expect(Conversation.last.additional_attributes['auto_reply']).to be true
end
end
@@ -120,6 +121,8 @@ RSpec.describe Imap::ImapMailbox do
it 'processes the bounced email' do
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
expect(Message.last.content_attributes['email']['auto_reply']).to be true
expect(Conversation.last.additional_attributes['auto_reply']).to be true
end
end
+2 -1
View File
@@ -12,7 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to]
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
end
context 'with reply uuid present' do
+1 -1
View File
@@ -56,7 +56,7 @@ RSpec.describe SupportMailbox do
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to]
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+65
View File
@@ -548,4 +548,69 @@ RSpec.describe Message do
expect(presenter).to have_received(:outgoing_content)
end
end
describe '#auto_reply_email?' do
context 'when message is not an incoming email and inbox is not email' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
it 'returns false' do
expect(message.auto_reply_email?).to be false
end
end
context 'when message is an incoming email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
context 'when inbox is email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
end
end
+8 -1
View File
@@ -49,13 +49,15 @@ RSpec.describe MailPresenter do
:references,
:subject,
:text_content,
:to
:to,
:auto_reply
])
expect(data[:content_type]).to include('multipart/alternative')
expect(data[:date].to_s).to eq('2020-04-20T04:20:20-04:00')
expect(data[:message_id]).to eq(mail.message_id)
expect(data[:multipart]).to be(true)
expect(data[:subject]).to eq(decorated_mail.subject)
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'give email from in downcased format' do
@@ -136,6 +138,11 @@ RSpec.describe MailPresenter do
expect(decorated_auto_reply_mail.auto_reply?).to be true
expect(decorated_auto_reply_with_auto_submitted_mail.auto_reply?).to be true
end
it 'includes auto_reply status in serialized_data' do
expect(decorated_auto_reply_mail.serialized_data[:auto_reply]).to be true
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
end
end
end
end
@@ -111,6 +111,35 @@ describe MessageTemplates::HookExecutionService do
end
end
context 'when message is an auto reply email' do
it 'does not call any template hooks' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
message = create(:message, conversation: conversation, content_type: :incoming_email)
message.content_attributes = { email: { auto_reply: true } }
message.save!
greeting_service = double
email_collect_service = double
out_of_office_service = double
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
described_class.new(message: message).perform
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when it is after working hours' do
it 'calls ::MessageTemplates::Template::OutOfOffice' do
contact = create(:contact)
@@ -282,5 +282,51 @@ describe Twilio::IncomingMessageService do
expect(message.attachments.first.file_type).to eq('location')
end
end
context 'when ProfileName is provided for WhatsApp' do
it 'uses ProfileName as contact name' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello with profile name',
ProfileName: 'John Doe'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('John Doe')
end
it 'falls back to formatted phone number when ProfileName is blank' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello without profile name',
ProfileName: ''
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'uses formatted phone number when ProfileName is not provided' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Regular SMS message'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
end
end
end
@@ -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