Merge branch 'develop' into fix/cw_2914

This commit is contained in:
Vishnu Narayanan
2024-01-12 17:20:49 +05:30
committed by GitHub
45 changed files with 765 additions and 470 deletions
+1 -1
View File
@@ -553,7 +553,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.3.1)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -48,6 +48,10 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
@@ -118,7 +122,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
{
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
@@ -129,6 +133,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
@@ -60,13 +60,26 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_status
if params[:status].present?
# FIXME: move this logic into a service object
if pending_to_open_by_bot?
@conversation.bot_handoff!
elsif params[:status].present?
set_conversation_status
@status = @conversation.save!
else
@status = @conversation.toggle_status
end
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
assign_conversation if should_assign_conversation?
end
def pending_to_open_by_bot?
return false unless Current.user.is_a?(AgentBot)
@conversation.status == 'pending' && params[:status] == 'open'
end
def should_assign_conversation?
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def toggle_priority
@@ -14,7 +14,7 @@ module AccessTokenAuthHelper
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
@resource = @access_token.owner
Current.user = @resource if current_user.is_a?(User)
Current.user = @resource if [User, AgentBot].include?(@resource.class)
end
def validate_bot_access_token!
@@ -76,7 +76,11 @@
&.left {
.bubble {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50 rounded-r-lg rounded-l mr-auto break-words;
@apply rounded-r-lg rounded-l mr-auto break-words;
&:not(.is-unsupported) {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50
}
&.is-image {
@apply rounded-lg;
@@ -1,5 +1,9 @@
<template>
<mention-box :items="items" @mention-select="handleMentionClick">
<mention-box
v-if="items.length"
:items="items"
@mention-select="handleMentionClick"
>
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
@@ -29,8 +29,19 @@
:message-type="data.message_type"
:parent-has-attachments="hasAttachments"
/>
<div v-if="isUnsupported">
<template v-if="isAFacebookInbox && isInstagram">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM') }}
</template>
<template v-else-if="isAFacebookInbox">
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK') }}
</template>
<template v-else>
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</template>
</div>
<bubble-text
v-if="data.content"
v-else-if="data.content"
:message="message"
:is-email="isEmailContentType"
:display-quoted-button="displayQuotedButton"
@@ -176,6 +187,14 @@ export default {
type: Boolean,
default: false,
},
isAFacebookInbox: {
type: Boolean,
default: false,
},
isInstagram: {
type: Boolean,
default: false,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
@@ -219,6 +238,7 @@ export default {
this.hasAttachments ||
this.data.content ||
this.isEmailContentType ||
this.isUnsupported ||
this.isAnIntegrationMessage
);
},
@@ -407,6 +427,7 @@ export default {
return {
bubble: this.isBubble,
'is-private': this.data.private,
'is-unsupported': this.isUnsupported,
'is-image': this.hasMediaAttachment('image'),
'is-video': this.hasMediaAttachment('video'),
'is-text': this.hasText,
@@ -415,6 +436,9 @@ export default {
'is-email': this.isEmailContentType,
};
},
isUnsupported() {
return this.contentAttributes.is_unsupported ?? false;
},
isPending() {
return this.data.status === MESSAGE_STATUS.PROGRESS;
},
@@ -426,11 +450,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending);
},
errorMessage() {
const { meta } = this.data;
return meta ? meta.error : '';
return !(this.isFailed || this.isPending || this.isUnsupported);
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
@@ -532,6 +552,14 @@ export default {
> .bubble {
@apply min-w-[128px];
&.is-unsupported {
@apply text-xs max-w-[300px] border-dashed border border-slate-200 text-slate-600 dark:text-slate-200 bg-slate-50 dark:bg-slate-700 dark:border-slate-500;
.message-text--metadata .time {
@apply text-slate-400 dark:text-slate-300;
}
}
&.is-image,
&.is-video {
@apply p-0 overflow-hidden;
@@ -544,10 +572,12 @@ export default {
> video {
@apply rounded-lg;
}
> video {
@apply h-full w-full object-cover;
}
}
.video {
@apply h-[11.25rem];
}
@@ -562,9 +592,11 @@ export default {
.file--icon {
@apply text-woot-400 dark:text-woot-400;
}
.text-block-title {
@apply text-slate-700 dark:text-slate-700;
}
.download.button {
@apply text-woot-400 dark:text-woot-400;
}
@@ -573,6 +605,7 @@ export default {
&.is-private.is-text > .message-text__wrap .link {
@apply text-woot-600 dark:text-woot-200;
}
&.is-private.is-text > .message-text__wrap .prosemirror-mention-node {
@apply font-bold bg-none rounded-sm p-0 bg-yellow-100 dark:bg-yellow-700 text-slate-700 dark:text-slate-25 underline;
}
@@ -583,6 +616,7 @@ export default {
.message-text--metadata .time {
@apply text-violet-50 dark:text-violet-50;
}
&.is-private .message-text--metadata .time {
@apply text-slate-400 dark:text-slate-400;
}
@@ -32,6 +32,8 @@
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
@@ -54,6 +56,8 @@
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:is-web-widget-inbox="isAWebWidgetInbox"
:is-a-facebook-inbox="isAFacebookInbox"
:is-instagram-dm="isInstagramDM"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:in-reply-to="getInReplyToMessage(message)"
/>
@@ -283,6 +287,9 @@ export default {
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
@@ -1,6 +1,5 @@
<template>
<div
v-if="items.length"
ref="mentionsListContainer"
class="bg-white dark:bg-slate-800 rounded-md overflow-auto absolute w-full z-20 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-slate-100 dark:border-slate-700 mention--box"
>
@@ -41,6 +41,9 @@
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -1,5 +1,4 @@
import TimeMixin from '../time';
import { format } from 'date-fns';
describe('#messageStamp', () => {
it('returns correct value', () => {
@@ -11,10 +10,20 @@ describe('#messageStamp', () => {
});
describe('#messageTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers('modern');
const mockDate = new Date(2023, 4, 5);
jest.setSystemTime(mockDate);
});
afterEach(() => {
jest.useRealTimers();
});
it('should return the message date in the specified format if the message was sent in the current year', () => {
const currentEpochTime = Math.floor(new Date().getTime() / 1000);
expect(TimeMixin.methods.messageTimestamp(currentEpochTime)).toEqual(
format(new Date(currentEpochTime * 1000), 'MMM d, yyyy')
expect(TimeMixin.methods.messageTimestamp(1680777464)).toEqual(
'Apr 6, 2023'
);
});
it('should return the message date and time in a different format if the message was sent in a different year', () => {
@@ -50,6 +50,7 @@
:is-loading="uiFlags.isFetching"
:on-click-notification="openConversation"
:in-last-page="inLastPage"
@close="closeNotificationPanel"
/>
<div
v-if="records.length !== 0"
@@ -0,0 +1,103 @@
<template>
<div>
<woot-button
size="expanded"
color-scheme="secondary"
variant="link"
@click="onClickOpenNotification()"
>
<div
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
>
<div
v-if="!notificationItem.read_at"
class="w-2 h-2 rounded-full bg-woot-500"
/>
<div v-else class="w-2 flex" />
<div
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
>
<div class="flex justify-between">
<div class="items-center flex">
<span class="font-bold text-slate-800 dark:text-slate-100">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
>
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div v-if="hasNotificationAssignee">
<thumbnail
:src="notificationAssigneeThumbnail"
size="16px"
:username="notificationAssigneeName"
/>
</div>
</div>
<div class="w-full flex">
<span
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ notificationItem.push_message_title }}
</span>
</div>
<span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
>
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
computed: {
notificationAssignee() {
const { primary_actor: primaryActor } = this.notificationItem;
return primaryActor?.meta?.assignee;
},
hasNotificationAssignee() {
return !!this.notificationAssignee;
},
notificationAssigneeName() {
return this.notificationAssignee?.name || '';
},
notificationAssigneeThumbnail() {
return this.notificationAssignee?.thumbnail || '';
},
},
methods: {
onClickOpenNotification() {
this.$emit('open-notification', this.notificationItem);
},
},
};
</script>
@@ -1,70 +1,12 @@
<template>
<div class="flex-col py-2 px-2.5 overflow-auto h-full flex">
<woot-button
<notification-panel-item
v-for="notificationItem in notifications"
v-show="!isLoading"
:key="notificationItem.id"
size="expanded"
color-scheme="secondary"
variant="link"
@click="() => onClickNotification(notificationItem)"
>
<div
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
>
<div
v-if="!notificationItem.read_at"
class="w-2 h-2 rounded-full bg-woot-500"
/>
<div v-else class="w-2 flex" />
<div
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
>
<div class="flex justify-between">
<div class="items-center flex">
<span class="font-bold text-slate-800 dark:text-slate-100">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
>
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div>
<thumbnail
v-if="hasAssignee(notificationItem)"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px"
:username="notificationItem.primary_actor.meta.assignee.name"
/>
</div>
</div>
<div class="w-full flex">
<span
class="text-slate-700 dark:text-slate-200 font-normal overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ notificationItem.push_message_title }}
</span>
</div>
<span
class="mt-1 text-slate-500 dark:text-slate-400 text-xxs font-semibold flex"
>
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
:notification-item="notificationItem"
@open-notification="onClickNotification"
/>
<empty-state
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
@@ -94,18 +36,16 @@
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import timeMixin from '../../../../mixins/time';
import NotificationPanelItem from './NotificationPanelItem.vue';
export default {
components: {
Thumbnail,
NotificationPanelItem,
Spinner,
EmptyState,
},
mixins: [timeMixin],
props: {
notifications: {
type: Array,
@@ -139,9 +79,7 @@ export default {
name: 'notifications_index',
});
}
},
hasAssignee(notification) {
return notification.primary_actor.meta?.assignee;
this.$emit('close');
},
},
};
@@ -108,6 +108,7 @@ const sortConfig = {
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] = SORT_OPTIONS[sortKey] || [];
const [sortMethod, sortDirection] =
SORT_OPTIONS[sortKey] || SORT_OPTIONS.last_activity_at_desc;
return sortConfig[sortMethod](a, b, sortDirection);
};
@@ -0,0 +1,34 @@
export default [
{
created_at: 1702411932, // Dec 12, 2023 12:12:12
id: 1,
last_activity_at: 1704408443, // Jan 04, 2024 14:47:23
messages: [{ content: 'test1' }],
priority: 'medium',
waiting_since: 0, // not waiting
},
{
created_at: 1699819932, // Nov 12, 2023 12:12:12
id: 2,
last_activity_at: 1704485532, // Jan 05, 2024 12:12:12
messages: [{ content: 'test2' }],
priority: 'low',
waiting_since: 1683645800, // May 09 2023 15:23:20
},
{
created_at: 1641413532, // Jan 05, 2022 12:12:12
id: 3,
last_activity_at: 1704408567, // Jan 04, 2024 14:49:27
messages: [{ content: 'test3' }],
priority: 'low',
waiting_since: 0, // not waiting
},
{
created_at: 1641413531, // Jan 05, 2022 12:12:11
id: 4,
last_activity_at: 1704408566, // Jan 04, 2024 14:49:26
messages: [{ content: 'test4' }],
priority: 'high',
waiting_since: 1683645801, // May 09 2023 15:23:21
},
];
@@ -1,392 +1,130 @@
import commonHelpers from '../../../../helper/commons';
import getters from '../../conversations/getters';
/*
Order of conversations in the fixture is as follows:
- lastActivity: c0 < c3 < c2 < c1
- createdAt: c3 < c2 < c1 < c0
- priority: c1 < c2 < c0 < c3
- waitingSince: c1 > c3 > c0 < c2
*/
import conversations from './conversations.fixtures';
// loads .last() helper
commonHelpers();
describe('#getters', () => {
describe('#getAllConversations', () => {
it('order conversations based on last activity', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
};
it('returns conversations ordered by lastActivityAt in descending order if no sort order is available', () => {
const state = { allConversations: [...conversations] };
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on last activity with ascending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
chatSortFilter: 'latest_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on created at', () => {
it('returns conversations ordered by lastActivityAt in descending order if invalid sort order is available', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_last',
allConversations: [...conversations],
chatSortFilter: 'latest',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on created at with descending order', () => {
it('returns conversations ordered by lastActivityAt in descending order if chatStatusFilter = last_activity_at_desc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_first',
allConversations: [...conversations],
chatSortFilter: 'last_activity_at_desc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
conversations[1],
conversations[2],
conversations[3],
conversations[0],
]);
});
it('order conversations based on default order', () => {
it('returns conversations ordered by lastActivityAt in ascending order if chatStatusFilter = last_activity_at_asc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
allConversations: [...conversations],
chatSortFilter: 'last_activity_at_asc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on priority', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_first',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
conversations[0],
conversations[3],
conversations[2],
conversations[1],
]);
});
it('order conversations based on with descending order', () => {
it('returns conversations ordered by createdAt in descending order if chatStatusFilter = created_at_desc', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_last',
allConversations: [...conversations],
chatSortFilter: 'created_at_desc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
conversations[0],
conversations[1],
conversations[2],
conversations[3],
]);
});
it('order conversations based on waiting_since', () => {
it('returns conversations ordered by createdAt in ascending order if chatStatusFilter = created_at_asc', () => {
const state = {
allConversations: [
{
id: 3,
created_at: 1683645800,
waiting_since: 0,
},
{
id: 4,
created_at: 1683645799,
waiting_since: 0,
},
{
id: 1,
created_at: 1683645801,
waiting_since: 1683645802,
},
{
id: 2,
created_at: 1683645803,
waiting_since: 1683645800,
},
],
chatSortFilter: 'waiting_since_last',
allConversations: [...conversations],
chatSortFilter: 'created_at_asc',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
created_at: 1683645803,
waiting_since: 1683645800,
},
{
id: 1,
created_at: 1683645801,
waiting_since: 1683645802,
},
{
id: 4,
created_at: 1683645799,
waiting_since: 0,
},
{
id: 3,
created_at: 1683645800,
waiting_since: 0,
},
conversations[3],
conversations[2],
conversations[1],
conversations[0],
]);
});
it('returns conversations ordered by priority in descending order if chatStatusFilter = priority_desc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'priority_desc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[3],
conversations[0],
conversations[1],
conversations[2],
]);
});
it('returns conversations ordered by priority in ascending order if chatStatusFilter = priority_asc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'priority_asc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[1],
conversations[2],
conversations[0],
conversations[3],
]);
});
it('returns conversations ordered by longest waiting if chatStatusFilter = waiting_since_asc', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'waiting_since_asc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[1],
conversations[3],
conversations[2],
conversations[0],
]);
});
});
+3
View File
@@ -51,6 +51,9 @@ if (window.errorLoggingConfig) {
/safari-extension:/i,
],
integrations: [new Integrations.BrowserTracing()],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}
+3
View File
@@ -35,6 +35,9 @@ if (window.errorLoggingConfig) {
/safari-extension:/i,
],
integrations: [new Integrations.BrowserTracing()],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}
@@ -19,11 +19,7 @@
<reply-to-chip :reply-to="replyTo" />
</div>
<div class="flex gap-1">
<drag-wrapper
class="space-y-2"
direction="right"
@dragged="toggleReply"
>
<div class="space-y-2">
<AgentMessageBubble
v-if="shouldDisplayAgentMessage"
:content-type="contentType"
@@ -54,7 +50,7 @@
<file-bubble v-else :url="attachment.data_url" />
</div>
</div>
</drag-wrapper>
</div>
<div class="flex flex-col justify-end">
<message-reply-button
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
@@ -96,7 +92,6 @@ import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
import DragWrapper from 'widget/components/DragWrapper.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
@@ -109,7 +104,6 @@ export default {
FileBubble,
MessageReplyButton,
ReplyToChip,
DragWrapper,
},
mixins: [timeMixin, configMixin, messageMixin, darkModeMixin],
props: {
+1 -1
View File
@@ -1,6 +1,6 @@
class SendOnSlackJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 6
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
with_lock(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id) do
+2
View File
@@ -8,6 +8,8 @@ class ActionCableListener < BaseListener
end
def notification_deleted(event)
return if event.data[:notification].user.blank?
notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_DELETED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
+1 -1
View File
@@ -83,7 +83,7 @@ class ReplyMailbox < ApplicationMailbox
end
def validate_resource(resource)
raise "#{resource.class.name} not found" if resource.nil?
raise "Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
resource
end
+13 -1
View File
@@ -7,11 +7,19 @@ class SupportMailbox < ApplicationMailbox
:decorate_mail
def process
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
# to turn off spam conversation creation
return unless @account.active?
# prevent loop from chatwoot notification emails
return if notification_email_from_chatwoot?
# 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 seperately and mark the contact as invalid
# we are checking for @ since the returned value could be "\"\"" for some email clients
return unless original_sender_email.include?('@')
ActiveRecord::Base.transaction do
find_or_create_contact
find_or_create_conversation
@@ -56,6 +64,10 @@ class SupportMailbox < ApplicationMailbox
mail['In-Reply-To'].try(:value)
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
account_id: @account.id,
@@ -74,7 +86,7 @@ class SupportMailbox < ApplicationMailbox
end
def find_or_create_contact
@contact = @inbox.contacts.find_by(email: @processed_mail.original_sender&.downcase)
@contact = @inbox.contacts.find_by(email: original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
+5 -1
View File
@@ -101,7 +101,7 @@ class Message < ApplicationRecord
# [:external_error : Can specify if the message creation failed due to an error at external API
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id], coder: JSON
:translations, :in_reply_to_external_id, :is_unsupported], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
@@ -299,6 +299,10 @@ class Message < ApplicationRecord
end
def dispatch_update_event
# ref: https://github.com/rails/rails/issues/44500
# we want to skip the update event if the message is not updated
return if previous_changes.blank?
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
+1 -1
View File
@@ -21,7 +21,7 @@ class InboxPolicy < ApplicationPolicy
def show?
# FIXME: for agent bots, lets bring this validation to policies as well in future
return true if @user.blank?
return true if @user.is_a?(AgentBot)
Current.user.assigned_inboxes.include? record
end
@@ -100,6 +100,12 @@ class Telegram::IncomingMessageService
def attach_files
return unless file
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
if file_download_path.blank?
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
return
end
attachment_file = Down.download(
inbox.channel.get_telegram_file_path(file[:file_id])
)
@@ -107,11 +107,7 @@ class Twilio::IncomingMessageService
def attach_files
return if params[:MediaUrl0].blank?
attachment_file = Down.download(
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
attachment_file = download_attachment_file
attachment = @message.attachments.new(
account_id: @message.account_id,
@@ -126,4 +122,28 @@ class Twilio::IncomingMessageService
@message.save!
end
def download_attachment_file
download_with_auth
rescue Down::Error => e
handle_download_attachment_error(e)
end
def download_with_auth
Down.download(
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
end
# This is just a temporary workaround since some users have not yet enabled media protection. We will remove this in the future.
def handle_download_attachment_error(error)
logger.info "Error downloading attachment from Twilio: #{error.message}"
if error.message.include?('401 Unauthorized')
Down.download(params[:MediaUrl0])
else
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
end
end
+2 -2
View File
@@ -29,8 +29,8 @@ common: &default_settings
# independently. If `false`, all logging-related features are disabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_ENABLED', false) %>
forwarding:
# If `true`, the agent captures log records emitted by this application.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) %>
# If `true`, the agent captures log records emitted by this application
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) == "false" ? false : true %>
# Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 30000
metrics:
@@ -21,6 +21,9 @@ module Enterprise::Channelable
return if audited_changes.blank?
# skip audit log creation if the only change is whatsapp channel template update
return if messaging_template_updates?(audited_changes)
Enterprise::AuditLog.create(
auditable_id: auditable_id,
auditable_type: auditable_type,
@@ -30,5 +33,13 @@ module Enterprise::Channelable
audited_changes: audited_changes
)
end
def messaging_template_updates?(changes)
# if there is more than one key, return false
return false unless changes.keys.length == 1
# if the only key is message_templates_last_updated, return true
changes.key?('message_templates_last_updated')
end
end
end
@@ -14,7 +14,8 @@ class Enterprise::Billing::HandleStripeEventService
private
def process_subscription_updated
plan = find_plan(subscription['plan']['product'])
plan = find_plan(subscription['plan']['product']) if subscription['plan'].present?
# skipping self hosted plan events
return if plan.blank? || account.blank?
+2 -2
View File
@@ -4,7 +4,7 @@ class ChatGpt
end
def initialize(context_sections = '')
@model = 'gpt-4'
@model = 'gpt-4-1106-preview'
@messages = [system_message(context_sections)]
end
@@ -53,7 +53,7 @@ class ChatGpt
def request_gpt
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY')}" }
body = { model: @model, messages: @messages }.to_json
body = { model: @model, messages: @messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post("#{self.class.base_uri}/v1/chat/completions", headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
@@ -97,7 +97,7 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
post_message if message_content.present?
upload_file if message.attachments.any?
rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth,
Slack::Web::Api::Errors::ChannelNotFound => e
Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e
Rails.logger.error e
hook.prompt_reauthorization!
hook.disable
+3 -3
View File
@@ -12,10 +12,10 @@
# end
#
class Redis::LockManager
# Default lock timeout set to 2 seconds. This means that if the lock isn't released
# within 2 seconds, it will automatically expire.
# Default lock timeout set to 1 second. This means that if the lock isn't released
# within 1 second, it will automatically expire.
# This helps to avoid deadlocks in case the process holding the lock crashes or fails to release it.
LOCK_TIMEOUT = 2.seconds
LOCK_TIMEOUT = 1.second
# Attempts to acquire a lock for the given key.
#
+1 -1
View File
@@ -31,7 +31,7 @@
],
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "1.0.3",
"@chatwoot/prosemirror-schema": "1.0.5",
"@chatwoot/utils": "^0.0.21",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
@@ -323,6 +323,9 @@ RSpec.describe 'Conversations API', type: :request do
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_status' do
let(:conversation) { create(:conversation, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:pending_conversation) { create(:conversation, inbox: inbox, account: account, status: 'pending') }
let(:agent_bot) { create(:agent_bot, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@@ -424,6 +427,42 @@ RSpec.describe 'Conversations API', type: :request do
# expect(conversation.reload.status).to eq('pending')
# end
end
context 'when it is an authenticated bot' do
# this test will basically ensure that the status actually changes
# regardless of the value to be done
it 'returns authorized for arbritrary status' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update!(status: 'open')
expect(conversation.reload.status).to eq('open')
snoozed_until = (DateTime.now.utc + 2.days).to_i
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'snoozed', snoozed_until: snoozed_until },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('snoozed')
end
it 'triggers handoff event when moving from pending to open' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{pending_conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(pending_conversation.reload.status).to eq('open')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Events::Types::CONVERSATION_BOT_HANDOFF, kind_of(Time), conversation: pending_conversation, notifiable_assignee_change: false,
changed_attributes: anything, performed_by: anything)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_priority' do
+52
View File
@@ -97,4 +97,56 @@ RSpec.describe Inbox do
end
end
end
describe 'audit log with whatsapp channel' do
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:inbox) { channel.inbox }
before do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
.with(
headers: {
'Accept' => '*/*',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'User-Agent' => 'Ruby'
}
)
.to_return(status: 200, body: '', headers: {})
end
context 'when inbox is created' do
it 'has associated audit log created' do
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1)
end
end
context 'when inbox is updated' do
it 'has associated audit log created' do
inbox.update(name: 'Updated Inbox')
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
end
end
context 'when channel is updated' do
it 'has associated audit log created' do
previous_phone_number = inbox.channel.phone_number
new_phone_number = '1234567890'
inbox.channel.update(phone_number: new_phone_number)
# check if channel update creates an audit log against inbox
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
# Check for the specific phone_number update in the audit log
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update',
audited_changes: { 'phone_number' => [previous_phone_number, new_phone_number] }).count).to eq(1)
end
end
context 'when template sync runs' do
it 'has no associated audit log created' do
channel.sync_templates
# check if template sync does not create an audit log
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(0)
end
end
end
end
@@ -273,6 +273,33 @@ FactoryBot.define do
initialize_with { attributes }
end
factory :instagram_message_unsupported_event, class: Hash do
entry do
[
{
'id': 'instagram-message-unsupported-id-123',
'time': '2021-09-08T06:34:04+0000',
'messaging': [
{
'sender': {
'id': 'Sender-id-1'
},
'recipient': {
'id': 'chatwoot-app-user-id-1'
},
'timestamp': '2021-09-08T06:34:04+0000',
'message': {
'mid': 'unsupported-message-id-1',
'is_unsupported': true
}
}
]
}
]
end
initialize_with { attributes }
end
factory :messaging_seen_event, class: Hash do
entry do
[
+102
View File
@@ -0,0 +1,102 @@
X-Original-To: unique-id@reply.example.com
Received: from gate.forward.smtp.example.com (mxd [192.0.2.1]) by mx.example.net with ESMTP id JANE3UihQWCm3SPLwYMiwA for <unique-id@reply.example.com>; Mon, 01 Jan 2024 08:27:12.905 +0000 (UTC)
Return-Path: <>
X-Virus-Scanned: OK
Authentication-Results: smtp6.gate.example.com; iprev=pass policy.iprev="192.0.2.2"; spf=neutral smtp.mailfrom="" smtp.helo="backend.example.com"; dkim=none (message not signed) header.d=none
X-Suspicious-Flag: NO
X-Classification-ID: 9026a23e-a87f-11ee-b226-52540050e3e0-1-1
Received: from [192.0.2.2] ([192.0.2.2:52052] helo=backend.example.com)
by smtp6.gate.example.com (envelope-from <>)
(ecelerity 4.2.38.62370 r(:)) with ESMTPS (cipher=DHE-RSA-AES256-GCM-SHA384)
id 69/60-03303-06772956; Mon, 01 Jan 2024 03:27:12 -0500
Received: by backend.example.com (Postfix, from userid 5000)
id BE58147CF5; Mon, 1 Jan 2024 03:27:12 -0500 (EST)
X-Sieve: Pigeonhole Sieve 0.5.12 (f22f7ab3)
X-Sieve-Redirected-From: support@example.com
Delivered-To: support@example.com
Delivered-To: support@example.com
Received: from director.example.com ([192.0.2.3])
by backend.example.com with LMTP
id AB5kLWB3kmV0bgAAStNUoA
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
Received: from proxy.example.com ([192.0.2.3])
by director.example.com with LMTP
id 0BPqLGB3kmWXKQAAfY0hYg
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
Received: from smtp.example.com ([192.0.2.3])
(using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits))
by proxy.example.com with LMTPS
id yGPPLGB3kmXQNwAAyH2SIw
(envelope-from <>)
for <support@example.com>; Mon, 01 Jan 2024 03:27:12 -0500
X-Spam-Threshold: 95
X-Spam-Score: 0
X-Spam-Flag: NO
X-Virus-Scanned: OK
X-Orig-To: support@example.com
X-Originating-Ip: [192.0.2.4]
Received: from [192.0.2.4] ([192.0.2.4:47194] helo=smtp.example.com)
by smtp36.gate.example.com (envelope-from <>)
(ecelerity 4.2.38.62370 r(:)) with ESMTPS (cipher=DHE-RSA-AES256-GCM-SHA384)
id 57/13-02844-06772956; Mon, 01 Jan 2024 03:27:12 -0500
Received: by smtp5.relay.example.com (SMTP Server)
id 8D329A008A; Mon, 1 Jan 2024 03:27:12 -0500 (EST)
Date: Mon, 1 Jan 2024 03:27:12 -0500 (EST)
From: "" (Mail Delivery System)
Subject: Undelivered Mail Returned to Sender
To: support@example.com
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="AE732A0081.1704097632/smtp5.relay.example.com"
Message-Id: <20240101082712.8D329A008A@smtp5.relay.example.com>
This is a MIME-encapsulated message.
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Notification
Content-Type: text/plain; charset=us-ascii
This is the mail system at host smtp5.relay.example.com.
I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.
For further assistance, please send mail to postmaster.
If you do so, please include this problem report. You can
delete your own text from the attached returned message.
The mail system
<noreply@example-service.com>: host
mx.example-service.com.cust.a.hostedemail.com[198.51.100.4] said: 554 5.7.1
<noreply@example-service.com>: Recipient address rejected: user
noreply@example-service.com does not exist (in reply to RCPT TO command)
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Delivery report
Content-Type: message/delivery-status
Reporting-MTA: dns; smtp5.relay.example.com
X-SMTP-Server-Queue-ID: AE732A0081
X-SMTP-Server-Sender: rfc822; support@example.com
Arrival-Date: Mon, 1 Jan 2024 03:27:11 -0500 (EST)
Final-Recipient: rfc822; noreply@example-service.com
Original-Recipient: rfc822;noreply@example-service.com
Action: failed
Status: 5.7.1
Remote-MTA: dns; mx.example-service.com.cust.a.hostedemail.com
Diagnostic-Code: smtp; 554 5.7.1 <noreply@example-service.com>: Recipient
address rejected: user noreply@example-service.com does not exist
--AE732A0081.1704097632/smtp5.relay.example.com
Content-Description: Undelivered Message
Content-Type: message/rfc822
Return-Path: <support@example.com>
X-Milter-Dummy:
DKIM-Signature: v=1; a=rsa-sha256; c
@@ -28,6 +28,7 @@ describe Webhooks::InstagramEventsJob do
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:story_mention_echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
let!(:messaging_seen_event) { build(:messaging_seen_event).with_indifferent_access }
let!(:unsupported_message_event) { build(:instagram_message_unsupported_event).with_indifferent_access }
let(:fb_object) { double }
describe '#perform' do
@@ -45,6 +46,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'creates standby message in the instagram inbox' do
@@ -157,6 +159,22 @@ describe Webhooks::InstagramEventsJob do
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0]).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object.with_indifferent_access
)
instagram_webhook.perform_now(unsupported_message_event[:entry])
instagram_inbox.reload
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_profiles']['instagram']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
end
end
end
+15
View File
@@ -42,6 +42,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@@ -59,6 +64,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config and sentinel passwords will be empty' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
@@ -77,6 +87,11 @@ describe Redis::Config do
end
end
after do
# ensuring the redis config is unset and won't affect other tests
described_class.instance_variable_set(:@config, nil)
end
it 'checks for app redis config and redis password is replaced in sentinel config' do
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
+11
View File
@@ -16,6 +16,17 @@ RSpec.describe SupportMailbox do
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
@@ -215,6 +215,26 @@ describe Telegram::IncomingMessageService do
end
end
context 'when the API call to get the download path returns an error' do
it 'does not process the attachment' do
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return(nil)
params = {
'update_id' => 2_342_342_343_242,
'message' => {
'document' => {
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
'file_name' => 'Screenshot 2021-09-27 at 2.01.14 PM.png',
'mime_type' => 'application/png',
'file_size' => 536_392
}
}.merge(message_params)
}.with_indifferent_access
described_class.new(inbox: telegram_channel.inbox, params: params).perform
expect(telegram_channel.inbox.messages.first.attachments.count).to eq(0)
end
end
context 'when valid location message params' do
it 'creates appropriate conversations, message and contacts' do
params = {
@@ -169,5 +169,65 @@ describe Twilio::IncomingMessageService do
expect(twilio_sms_channel.inbox.conversations.count).to eq(1)
end
end
context 'when a message with an attachment is received' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: {})
end
let(:params_with_attachment) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'creates a new message with media in existing conversation' do
described_class.new(params: params_with_attachment).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
context 'when there is an error downloading the attachment' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_raise(Down::Error.new('Download error'))
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: {})
end
let(:params_with_attachment_error) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'retries downloading the attachment without a token after an error' do
expect do
described_class.new(params: params_with_attachment_error).perform
end.not_to raise_error
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
end
end
+4 -4
View File
@@ -3156,10 +3156,10 @@
"@braid/vue-formulate-i18n" "^1.16.0"
is-plain-object "^3.0.1"
"@chatwoot/prosemirror-schema@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.3.tgz#8599e517d42cb31fabf977554ade666eb6316ef0"
integrity sha512-BIVxV7c8x0vbWtWxGPk/VnBrtC1CV0TzZd+GPZC49irVcQQ2vAwgOYaZ/1qcFe9M3jv0kWAWOPqQAfbB5RBB7g==
"@chatwoot/prosemirror-schema@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.5.tgz#d6053692beae59d466ac0b04128fa157f59eb176"
integrity sha512-dOzkZ2K53PPbE9AQB0RHlVs+GIEyHHdXeeW44dNSEuULwH99PmTpzA2r45QX3uaVa2j7Mip76AQbJZGKbM2fxg==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"