fix: reopen pending human conversations
Build and publish Docker images / Build and publish images (push) Successful in 2m23s
Build and publish Docker images / Build and publish images (push) Successful in 2m23s
This commit is contained in:
@@ -1404,6 +1404,7 @@ func widgetConversationPayload(conversation model.Conversation) gin.H {
|
||||
"inbox_id": conversation.InboxID,
|
||||
"contact_last_seen_at": conversation.ContactLastSeenAt,
|
||||
"status": conversation.Status,
|
||||
"ai_takeover_active": conversation.AssigneeAgentBotID != nil && conversation.Status == string(model.ConversationStatusPending),
|
||||
"custom_attributes": conversation.CustomAttributes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,18 @@ func (s *WidgetService) createIncomingMessage(ctx context.Context, conversation
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Pending without an active Captain bot is a human-waiting conversation.
|
||||
// Reopen it when the customer replies so it returns to the normal inbox
|
||||
// and does not look like an agent is typing forever in the widget.
|
||||
if !reopen && !conversation.Muted && conversation.Status == string(model.ConversationStatusPending) &&
|
||||
conversation.AssigneeAgentBotID == nil && captainJob == nil {
|
||||
reopen = true
|
||||
if err := tx.Model(&model.Conversation{}).
|
||||
Where("id = ? AND account_id = ?", conversation.ID, conversation.AccountID).
|
||||
Updates(map[string]any{"status": string(model.ConversationStatusOpen), "snoozed_until": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.realtime != nil && event != nil {
|
||||
var committedConversation model.Conversation
|
||||
if err := tx.Where("id = ? AND account_id = ?", conversation.ID, conversation.AccountID).First(&committedConversation).Error; err != nil {
|
||||
|
||||
@@ -354,6 +354,20 @@ func TestWidgetService_PublicCreateMessageUpdatesConversationBeforeDispatch(t *t
|
||||
assert.Equal(t, wantTimestamp, *eventConversation.LastActivityAt)
|
||||
}
|
||||
|
||||
func TestWidgetService_PublicCreateMessageReopensPendingHumanConversation(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
_, _, _, _, conversation, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusPending)
|
||||
|
||||
message, reopened, _, err := svc.PublicCreateMessage(context.Background(), "public-api", "visitor-source", displayID, PublicMessageRequest{Content: "reply to human"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, message.CreatedAt.Unix(), *reopened.LastActivityAt)
|
||||
assert.Equal(t, string(model.ConversationStatusOpen), reopened.Status)
|
||||
|
||||
var persisted model.Conversation
|
||||
require.NoError(t, db.First(&persisted, conversation.ID).Error)
|
||||
assert.Equal(t, string(model.ConversationStatusOpen), persisted.Status)
|
||||
}
|
||||
|
||||
func TestWidgetService_PublicCreateMessageReturnsDispatchError(t *testing.T) {
|
||||
db, svc := setupWidgetServiceTest(t)
|
||||
_, _, _, _, _, displayID, _ := seedPublicMessageTest(t, db, model.ConversationStatusOpen)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ConversationWrap from './ConversationWrap.vue';
|
||||
|
||||
const mountConversation = ({
|
||||
status = 'pending',
|
||||
aiTakeoverActive = false,
|
||||
isAgentTyping = false,
|
||||
messageType = 0,
|
||||
} = {}) =>
|
||||
shallowMount(ConversationWrap, {
|
||||
props: { groupedMessages: [] },
|
||||
global: {
|
||||
plugins: [
|
||||
createStore({
|
||||
getters: {
|
||||
'appConfig/darkMode': () => 'light',
|
||||
'conversation/getEarliestMessage': () => ({ id: 1 }),
|
||||
'conversation/getLastMessage': () => ({
|
||||
message_type: messageType,
|
||||
}),
|
||||
'conversation/getAllMessagesLoaded': () => true,
|
||||
'conversation/getIsFetchingList': () => false,
|
||||
'conversation/getConversationSize': () => 1,
|
||||
'conversation/getIsAgentTyping': () => isAgentTyping,
|
||||
'conversationAttributes/getConversationParams': () => ({
|
||||
id: 1,
|
||||
status,
|
||||
aiTakeoverActive,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('ConversationWrap', () => {
|
||||
it('does not show typing for an ordinary pending conversation', () => {
|
||||
const wrapper = mountConversation();
|
||||
|
||||
expect(wrapper.vm.showStatusIndicator).toBe(false);
|
||||
});
|
||||
|
||||
it('shows typing while an AI takeover is waiting for a reply', () => {
|
||||
const wrapper = mountConversation({ aiTakeoverActive: true });
|
||||
|
||||
expect(wrapper.vm.showStatusIndicator).toBe(true);
|
||||
});
|
||||
|
||||
it('shows typing when an explicit agent typing event is active', () => {
|
||||
const wrapper = mountConversation({
|
||||
status: 'open',
|
||||
isAgentTyping: true,
|
||||
});
|
||||
|
||||
expect(wrapper.vm.showStatusIndicator).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -45,14 +45,10 @@ export default {
|
||||
return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
|
||||
},
|
||||
showStatusIndicator() {
|
||||
const { status } = this.conversationAttributes;
|
||||
const isConversationInPendingStatus = status === 'pending';
|
||||
const { aiTakeoverActive } = this.conversationAttributes;
|
||||
const isLastMessageIncoming =
|
||||
this.lastMessage.message_type === MESSAGE_TYPE.INCOMING;
|
||||
return (
|
||||
this.isAgentTyping ||
|
||||
(isConversationInPendingStatus && isLastMessageIncoming)
|
||||
);
|
||||
return this.isAgentTyping || (aiTakeoverActive && isLastMessageIncoming);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversationAPI } from '../../api/conversation';
|
||||
const state = {
|
||||
id: '',
|
||||
status: '',
|
||||
aiTakeoverActive: false,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -37,16 +38,21 @@ export const mutations = {
|
||||
[SET_CONVERSATION_ATTRIBUTES]($state, data) {
|
||||
$state.id = data.id;
|
||||
$state.status = data.status;
|
||||
$state.aiTakeoverActive = data.ai_takeover_active === true;
|
||||
},
|
||||
[UPDATE_CONVERSATION_ATTRIBUTES]($state, data) {
|
||||
if (data.id === $state.id) {
|
||||
$state.id = data.id;
|
||||
$state.status = data.status;
|
||||
if (Object.prototype.hasOwnProperty.call(data, 'ai_takeover_active')) {
|
||||
$state.aiTakeoverActive = data.ai_takeover_active === true;
|
||||
}
|
||||
}
|
||||
},
|
||||
[CLEAR_CONVERSATION_ATTRIBUTES]($state) {
|
||||
$state.id = '';
|
||||
$state.status = '';
|
||||
$state.aiTakeoverActive = false;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+27
-8
@@ -3,42 +3,61 @@ import { mutations } from '../../conversationAttributes';
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONVERSATION_ATTRIBUTES', () => {
|
||||
it('set status of the conversation', () => {
|
||||
const state = { id: '', status: '' };
|
||||
const state = { id: '', status: '', aiTakeoverActive: false };
|
||||
mutations.SET_CONVERSATION_ATTRIBUTES(state, {
|
||||
id: 1,
|
||||
status: 'open',
|
||||
ai_takeover_active: true,
|
||||
});
|
||||
expect(state).toEqual({
|
||||
id: 1,
|
||||
status: 'open',
|
||||
aiTakeoverActive: true,
|
||||
});
|
||||
expect(state).toEqual({ id: 1, status: 'open' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_CONVERSATION_ATTRIBUTES', () => {
|
||||
it('update status if it is same conversation', () => {
|
||||
const state = { id: 1, status: 'pending' };
|
||||
const state = { id: 1, status: 'pending', aiTakeoverActive: false };
|
||||
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
|
||||
id: 1,
|
||||
status: 'open',
|
||||
ai_takeover_active: true,
|
||||
});
|
||||
expect(state).toEqual({
|
||||
id: 1,
|
||||
status: 'open',
|
||||
aiTakeoverActive: true,
|
||||
});
|
||||
expect(state).toEqual({ id: 1, status: 'open' });
|
||||
});
|
||||
it('doesnot update status if it is not the same conversation', () => {
|
||||
const state = { id: 1, status: 'pending' };
|
||||
const state = { id: 1, status: 'pending', aiTakeoverActive: false };
|
||||
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
|
||||
id: 2,
|
||||
status: 'open',
|
||||
ai_takeover_active: true,
|
||||
});
|
||||
expect(state).toEqual({
|
||||
id: 1,
|
||||
status: 'pending',
|
||||
aiTakeoverActive: false,
|
||||
});
|
||||
expect(state).toEqual({ id: 1, status: 'pending' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CLEAR_CONVERSATION_ATTRIBUTES', () => {
|
||||
it('clear status if it is same conversation', () => {
|
||||
const state = { id: 1, status: 'open' };
|
||||
const state = { id: 1, status: 'open', aiTakeoverActive: true };
|
||||
mutations.CLEAR_CONVERSATION_ATTRIBUTES(state, {
|
||||
id: 1,
|
||||
status: 'open',
|
||||
});
|
||||
expect(state).toEqual({ id: '', status: '' });
|
||||
expect(state).toEqual({
|
||||
id: '',
|
||||
status: '',
|
||||
aiTakeoverActive: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user