Merge branch 'develop' into CW-2773

This commit is contained in:
Sojan Jose
2024-01-05 15:39:03 -08:00
committed by GitHub
17 changed files with 293 additions and 369 deletions
@@ -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!
@@ -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', () => {
@@ -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],
]);
});
});
@@ -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: {
@@ -0,0 +1,10 @@
class Notification::ReopenSnoozedNotificationsJob < ApplicationJob
queue_as :low
def perform
# rubocop:disable Rails/SkipsModelValidations
Notification.where(snoozed_until: 3.days.ago..Time.current)
.update_all(snoozed_until: nil, updated_at: Time.current, last_activity_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
end
+3
View File
@@ -11,6 +11,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to reopen snoozed conversations
Conversations::ReopenSnoozedConversationsJob.perform_later
# Job to reopen snoozed notifications
Notification::ReopenSnoozedNotificationsJob.perform_later
# Job to auto-resolve conversations
Account::ConversationsResolutionSchedulerJob.perform_later
+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
@@ -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
@@ -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
[
@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Notification::ReopenSnoozedNotificationsJob do
let!(:snoozed_till_5_minutes_ago) { create(:notification, snoozed_until: 5.minutes.ago) }
let!(:snoozed_till_tomorrow) { create(:notification, snoozed_until: 1.day.from_now) }
let!(:snoozed_indefinitely) { create(:notification) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'reopens snoozed notifications whose snooze until has passed' do
described_class.perform_now
expect(snoozed_till_5_minutes_ago.reload.snoozed_until).to be_nil
expect(snoozed_till_tomorrow.reload.snoozed_until.to_date).to eq 1.day.from_now.to_date
expect(snoozed_indefinitely.reload.snoozed_until).to be_nil
end
end
end
@@ -26,6 +26,11 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
it 'triggers Notification::ReopenSnoozedNotificationsJob' do
expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Account::ConversationsResolutionSchedulerJob' do
expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once
described_class.perform_now
@@ -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