Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e7985b0f |
@@ -29,6 +29,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
|
||||
Rails.logger.error e
|
||||
@inbox.channel.authorization_error!
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[FacebookMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -14,6 +14,8 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[InstagramMessageBuilder] Dropped message for inbox #{@inbox.id}: #{e.message}")
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
user = Current.user || @resource
|
||||
mb = Messages::MessageBuilder.new(user, @conversation, params)
|
||||
@message = mb.perform
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
@@ -27,11 +29,14 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
|
||||
def retry
|
||||
return if message.blank?
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, @conversation if @conversation.message_creation_locked?
|
||||
|
||||
service = Messages::StatusUpdateService.new(message, 'sent')
|
||||
service.perform
|
||||
message.update!(content_attributes: {})
|
||||
::SendReplyJob.perform_later(message.id)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -9,6 +9,9 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
def twitter_events
|
||||
twitter_consumer.consume
|
||||
head :ok
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping Twitter webhook because message creation is locked (#{e.message})")
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
head :ok
|
||||
|
||||
@@ -3,6 +3,7 @@ module RequestExceptionHandler
|
||||
|
||||
included do
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from CustomExceptions::ConversationMessageCreationLocked, with: :render_error_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
// Viewer's UTC offset in hours, matching the reports API convention so the
|
||||
// backend can anchor calendar ranges to the viewer's day.
|
||||
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
class CaptainAssistant extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
@@ -25,18 +21,6 @@ class CaptainAssistant extends ApiClient {
|
||||
message_history: messageHistory,
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
@@ -182,8 +182,7 @@ const handleCreateAssistant = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="headerActions" />
|
||||
<div class="flex gap-2">
|
||||
<slot name="search" />
|
||||
<div
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
|
||||
const DISMISS_STORE = 'captain_overview_coverage_banner';
|
||||
|
||||
const accountId = computed(() => route.params.accountId);
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
|
||||
const dismissed = ref(
|
||||
LocalStorage.getFlag(DISMISS_STORE, accountId.value, assistantId.value)
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
// is the quickest lever to lift auto-resolution, so nudge the team to act.
|
||||
const COVERAGE_THRESHOLD = 85;
|
||||
const PENDING_THRESHOLD = 100;
|
||||
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
|
||||
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
|
||||
dismissed.value = true;
|
||||
};
|
||||
|
||||
const goToPending = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_responses_pending',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
|
||||
count: knowledge.pending,
|
||||
coverage: knowledge.coverage,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToPending"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
|
||||
@click="dismiss"
|
||||
>
|
||||
<span class="i-lucide-x size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
// Selected day-range, used only for the chart's start-axis label.
|
||||
range: { type: String, default: '30' },
|
||||
});
|
||||
|
||||
// Daily assistant credit usage (sample data) rendered as a lightweight bar
|
||||
// chart, so we avoid pulling in a chart library for placeholder visuals.
|
||||
const creditUsage = {
|
||||
total: '48,210',
|
||||
trend: '+18.2%',
|
||||
daily: [
|
||||
120, 145, 132, 160, 175, 158, 190, 210, 195, 230, 220, 245, 260, 240, 275,
|
||||
290, 270, 310, 295, 330, 350, 325, 360, 380, 355, 400, 420, 395, 440, 465,
|
||||
],
|
||||
};
|
||||
|
||||
// Bar heights as a percentage of the peak day, with a small floor so even the
|
||||
// lowest day stays visible.
|
||||
const bars = computed(() => {
|
||||
const max = Math.max(...creditUsage.daily);
|
||||
return creditUsage.daily.map((value, index) => ({
|
||||
key: index,
|
||||
value,
|
||||
height: Math.max(6, Math.round((value / max) * 100)),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ creditUsage.total }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums text-n-slate-11">
|
||||
{{ creditUsage.trend }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="rounded-full size-2.5 bg-n-brand" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border rounded-xl bg-n-solid-1 border-n-weak">
|
||||
<div class="flex items-end gap-1 h-40">
|
||||
<div
|
||||
v-for="bar in bars"
|
||||
:key="bar.key"
|
||||
v-tooltip="`${bar.value} ${$t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`"
|
||||
class="flex-1 rounded-t transition-colors bg-n-brand/70 hover:bg-n-brand"
|
||||
:style="{ height: `${bar.height}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_START', { count: range }) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_END') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const dismissed = ref(false);
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const inboxes = useMapGetter('captainInboxes/getRecords');
|
||||
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
|
||||
|
||||
// Only surface once we know the assistant has no connected inboxes.
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
!uiFlags.value.fetchingList &&
|
||||
inboxes.value.length === 0
|
||||
);
|
||||
|
||||
watch(
|
||||
assistantId,
|
||||
id => {
|
||||
dismissed.value = false;
|
||||
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const goToInboxes = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToInboxes"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
|
||||
@click="dismissed = true"
|
||||
>
|
||||
<span class="i-lucide-x size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
|
||||
|
||||
const linkTo = routeName => ({
|
||||
name: routeName,
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
},
|
||||
});
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
key: 'approved',
|
||||
value: props.knowledge.approved,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
value: props.knowledge.documents,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
|
||||
to: linkTo('captain_assistants_documents_index'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
|
||||
</span>
|
||||
<span class="text-sm tabular-nums text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-brand"
|
||||
:style="{ width: `${approvedPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<RouterLink
|
||||
v-for="stat in stats"
|
||||
:key="stat.key"
|
||||
:to="stat.to"
|
||||
class="flex flex-col gap-1 group/stat"
|
||||
>
|
||||
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ stat.value }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
|
||||
>
|
||||
{{ stat.label }}
|
||||
<span
|
||||
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
trend: { type: String, default: '' },
|
||||
hint: { type: String, default: '' },
|
||||
// null = neutral, true = good direction, false = bad direction
|
||||
trendGood: { type: Boolean, default: null },
|
||||
});
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (props.trendGood === null) return 'text-n-slate-11';
|
||||
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
|
||||
<span
|
||||
v-if="hint"
|
||||
v-tooltip="hint"
|
||||
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<span
|
||||
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<span class="text-sm font-medium tabular-nums" :class="trendClass">
|
||||
{{ trend }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, RouterLink } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const assistantParams = computed(() => ({
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
}));
|
||||
|
||||
const links = computed(() => [
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
|
||||
icon: 'i-lucide-book-open',
|
||||
href: getHelpUrlForFeature('captain'),
|
||||
},
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
|
||||
icon: 'i-lucide-flask-conical',
|
||||
to: {
|
||||
name: 'captain_assistants_playground_index',
|
||||
params: assistantParams.value,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'billing',
|
||||
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
|
||||
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
to: {
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<component
|
||||
:is="link.href ? 'a' : RouterLink"
|
||||
v-for="link in links"
|
||||
:key="link.key"
|
||||
:href="link.href"
|
||||
:to="link.to"
|
||||
:target="link.href ? '_blank' : undefined"
|
||||
:rel="link.href ? 'noopener noreferrer' : undefined"
|
||||
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
|
||||
>
|
||||
<span
|
||||
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<span :class="link.icon" class="size-4" />
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ link.title }}
|
||||
</span>
|
||||
<span class="text-xs truncate text-n-slate-11">
|
||||
{{ link.description }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
:class="
|
||||
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
|
||||
"
|
||||
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
|
||||
/>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const modelValue = defineModel({ type: String, default: '30' });
|
||||
|
||||
const { t } = useI18n();
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const DAY_RANGES = ['7', '30', '90'];
|
||||
|
||||
const decorate = item => ({
|
||||
...item,
|
||||
action: 'select',
|
||||
isSelected: item.value === modelValue.value,
|
||||
});
|
||||
|
||||
const menuSections = computed(() => {
|
||||
const dayItems = DAY_RANGES.map(value =>
|
||||
decorate({
|
||||
value,
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
|
||||
})
|
||||
);
|
||||
const monthItems = [
|
||||
decorate({
|
||||
value: 'this_month',
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
|
||||
}),
|
||||
decorate({
|
||||
value: 'last_month',
|
||||
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
|
||||
}),
|
||||
];
|
||||
return [{ items: dayItems }, { items: monthItems }];
|
||||
});
|
||||
|
||||
const menuItems = computed(() =>
|
||||
menuSections.value.flatMap(section => section.items)
|
||||
);
|
||||
|
||||
const selectedLabel = computed(
|
||||
() => menuItems.value.find(item => item.isSelected)?.label || ''
|
||||
);
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
modelValue.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
trailing-icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:label="selectedLabel"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
:menu-sections="menuSections"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Flagged responses broken down by report reason, mapped from
|
||||
// captain_message_reports.report_reason (sample data).
|
||||
const flagReasons = [
|
||||
{
|
||||
key: 'incorrect',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCORRECT'),
|
||||
count: 11,
|
||||
},
|
||||
{
|
||||
key: 'incomplete',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCOMPLETE'),
|
||||
count: 7,
|
||||
},
|
||||
{
|
||||
key: 'outdated',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OUTDATED'),
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
key: 'inappropriate',
|
||||
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INAPPROPRIATE'),
|
||||
count: 2,
|
||||
},
|
||||
{ key: 'other', label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OTHER'), count: 3 },
|
||||
];
|
||||
|
||||
const total = computed(() =>
|
||||
flagReasons.reduce((sum, reason) => sum + reason.count, 0)
|
||||
);
|
||||
|
||||
// Flagged responses as a share of all Captain replies (sample data).
|
||||
const rate = '1.3%';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TITLE') }}
|
||||
</span>
|
||||
<span class="text-sm tabular-nums text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TOTAL', { count: total, rate }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="reason in flagReasons"
|
||||
:key="reason.key"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="w-28 text-xs truncate text-n-slate-11 shrink-0">
|
||||
{{ reason.label }}
|
||||
</span>
|
||||
<div class="flex-1 h-2 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<div
|
||||
class="h-full rounded-full bg-n-amber-9"
|
||||
:style="{ width: `${Math.round((reason.count / total) * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="w-6 text-xs font-medium text-right tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ reason.count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
const props = defineProps({
|
||||
range: {
|
||||
type: String,
|
||||
default: '30',
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
|
||||
// Markdown summary generated by the model from the assistant's stats (served by
|
||||
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
|
||||
// **bold** so we can highlight them.
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getSummary({
|
||||
assistantId: assistantId.value,
|
||||
range: props.range,
|
||||
});
|
||||
welcomeMarkdown.value = data.message ?? '';
|
||||
} catch {
|
||||
welcomeMarkdown.value = '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
|
||||
|
||||
// Split a line on **bold** runs so we can render emphasised numbers as
|
||||
// brand-highlighted spans with Tailwind (instead of styling raw HTML).
|
||||
const parseSegments = text => {
|
||||
const parts = [];
|
||||
const regex = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match = regex.exec(text);
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ text: match[1], bold: true });
|
||||
lastIndex = regex.lastIndex;
|
||||
match = regex.exec(text);
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
return parts.map((part, index) => ({ ...part, key: index }));
|
||||
};
|
||||
|
||||
// Break the markdown into paragraphs on blank lines so newline breaks survive
|
||||
// (a single <p> would collapse them), each parsed into highlightable segments.
|
||||
const paragraphs = computed(() =>
|
||||
welcomeMarkdown.value
|
||||
.split(/\n{2,}/)
|
||||
.map(block => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block, index) => ({ key: index, segments: parseSegments(block) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-10">
|
||||
<span class="i-lucide-sparkles size-3.5" />
|
||||
<span class="text-xs">
|
||||
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex flex-col gap-5"
|
||||
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.key"
|
||||
class="text-lg leading-relaxed text-n-slate-12"
|
||||
>
|
||||
<template v-for="segment in paragraph.segments" :key="segment.key">
|
||||
<span
|
||||
v-if="segment.bold"
|
||||
class="font-bold tabular-nums text-n-brand"
|
||||
>{{ segment.text }}</span
|
||||
>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -440,14 +440,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
activeOn: ['captain_assistants_create_index'],
|
||||
children: [
|
||||
{
|
||||
name: 'Overview',
|
||||
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
|
||||
activeOn: ['captain_assistants_overview_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_overview_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'FAQs',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
|
||||
@@ -125,6 +125,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMessageCreationLocked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'toggleInsertArticle',
|
||||
@@ -250,7 +254,16 @@ export default {
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
enableInsertArticleInReply() {
|
||||
return this.portalSlug;
|
||||
return !this.isMessageCreationLocked && this.portalSlug;
|
||||
},
|
||||
showQuotedReplyButton() {
|
||||
return this.showQuotedReplyToggle && !this.isMessageCreationLocked;
|
||||
},
|
||||
showWhatsAppTemplateButton() {
|
||||
return this.enableWhatsAppTemplates && !this.isMessageCreationLocked;
|
||||
},
|
||||
showContentTemplateButton() {
|
||||
return this.enableContentTemplates && !this.isMessageCreationLocked;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
@@ -340,7 +353,7 @@ export default {
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showQuotedReplyToggle"
|
||||
v-if="showQuotedReplyButton"
|
||||
v-tooltip.top-end="quotedReplyToggleTooltip"
|
||||
icon="i-ph-quotes"
|
||||
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
|
||||
@@ -350,7 +363,7 @@ export default {
|
||||
@click="$emit('toggleQuotedReply')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-if="showWhatsAppTemplateButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
@@ -359,7 +372,7 @@ export default {
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-if="showContentTemplateButton"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue';
|
||||
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
|
||||
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
showCopilotEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -16,6 +16,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isMessageCreationLocked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -41,6 +45,8 @@ const clearEditorSelection = () => {
|
||||
};
|
||||
|
||||
const onSend = () => {
|
||||
if (props.isMessageCreationLocked) return;
|
||||
|
||||
emit('send', copilotEditorContent.value);
|
||||
copilotEditorContent.value = '';
|
||||
};
|
||||
@@ -58,7 +64,9 @@ const onSend = () => {
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
v-if="
|
||||
showCopilotEditor && !isGeneratingContent && !isMessageCreationLocked
|
||||
"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
|
||||
@@ -193,6 +193,19 @@ export default {
|
||||
}
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
messageCreationLockBannerMessage() {
|
||||
if (!this.currentChat?.message_creation_locked) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.currentChat.message_creation_lock_reason === 'message_limit') {
|
||||
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT', {
|
||||
limit: this.currentChat.message_limit,
|
||||
});
|
||||
}
|
||||
|
||||
return this.$t('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
@@ -435,6 +448,8 @@ export default {
|
||||
},
|
||||
async handleMessageRetry(message) {
|
||||
if (!message) return;
|
||||
if (this.currentChat.message_creation_locked) return;
|
||||
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
@@ -455,7 +470,13 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="currentChat.message_creation_locked"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="messageCreationLockBannerMessage"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -201,6 +201,9 @@ export default {
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
);
|
||||
},
|
||||
isMessageCreationLocked() {
|
||||
return !!this.currentChat?.message_creation_locked;
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -209,6 +212,9 @@ export default {
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isEditorDisabled) {
|
||||
if (this.isMessageCreationLocked) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
|
||||
}
|
||||
@@ -326,7 +332,11 @@ export default {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
showAudioRecorderEditor() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
return (
|
||||
!this.isMessageCreationLocked &&
|
||||
this.showAudioRecorder &&
|
||||
this.isRecordingAudio
|
||||
);
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -439,6 +449,10 @@ export default {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
isEditorDisabled() {
|
||||
if (this.isMessageCreationLocked) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(this.isAWhatsAppChannel || this.isAPIInbox) &&
|
||||
!this.isOnPrivateNote &&
|
||||
@@ -804,6 +818,8 @@ export default {
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(
|
||||
@@ -888,6 +904,8 @@ export default {
|
||||
editorMessage = '',
|
||||
copilotAcceptedMessage = ''
|
||||
) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
@@ -907,6 +925,8 @@ export default {
|
||||
}
|
||||
},
|
||||
async onSendWhatsAppReply(messagePayload) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
@@ -914,6 +934,8 @@ export default {
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
@@ -921,6 +943,8 @@ export default {
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
@@ -943,6 +967,8 @@ export default {
|
||||
this.onFocus();
|
||||
},
|
||||
executeCopilotAction(action, data) {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
this.copilot.execute(action, data);
|
||||
},
|
||||
clearMessage() {
|
||||
@@ -1235,6 +1261,8 @@ export default {
|
||||
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
if (this.isMessageCreationLocked) return;
|
||||
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
this.message = acceptedMessage;
|
||||
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||
@@ -1315,6 +1343,7 @@ export default {
|
||||
v-if="copilot.isActive.value && !showAudioRecorderEditor"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:is-message-creation-locked="isMessageCreationLocked"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@@ -1395,7 +1424,9 @@ export default {
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="copilot.isActive.value"
|
||||
key="copilot-bottom-panel"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
:is-generating-content="
|
||||
copilot.isButtonDisabled.value || isMessageCreationLocked
|
||||
"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
@@ -1412,6 +1443,7 @@ export default {
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:is-editor-disabled="isEditorDisabled"
|
||||
:is-message-creation-locked="isMessageCreationLocked"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import CopilotEditorSection from '../CopilotEditorSection.vue';
|
||||
import MessagesView from '../MessagesView.vue';
|
||||
import ReplyBox from '../ReplyBox.vue';
|
||||
import ReplyBottomPanel from '../../WootWriter/ReplyBottomPanel.vue';
|
||||
|
||||
const mountCopilotEditorSection = props =>
|
||||
shallowMount(CopilotEditorSection, {
|
||||
props: {
|
||||
showCopilotEditor: true,
|
||||
isGeneratingContent: false,
|
||||
generatedContent: 'Suggested reply',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Transition: false,
|
||||
CopilotEditor: {
|
||||
template:
|
||||
'<button data-testid="copilot-editor" @click="$emit(\'send\')" />',
|
||||
},
|
||||
CaptainLoader: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('conversation message creation lock UI', () => {
|
||||
describe('MessagesView', () => {
|
||||
it('returns the message-limit banner copy with the configured limit', () => {
|
||||
const $t = vi.fn((key, params) => `${key}:${params.limit}`);
|
||||
const message =
|
||||
MessagesView.computed.messageCreationLockBannerMessage.call({
|
||||
currentChat: {
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
message_limit: 10000,
|
||||
},
|
||||
$t,
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT:10000'
|
||||
);
|
||||
expect($t).toHaveBeenCalledWith(
|
||||
'CONVERSATION.MESSAGE_CREATION_LOCK.MESSAGE_LIMIT',
|
||||
{ limit: 10000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the manual lock banner copy', () => {
|
||||
const $t = vi.fn(key => key);
|
||||
const message =
|
||||
MessagesView.computed.messageCreationLockBannerMessage.call({
|
||||
currentChat: {
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'manual',
|
||||
},
|
||||
$t,
|
||||
});
|
||||
|
||||
expect(message).toBe('CONVERSATION.MESSAGE_CREATION_LOCK.MANUAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplyBox', () => {
|
||||
it('disables the editor when message creation is locked', () => {
|
||||
const isDisabled = ReplyBox.computed.isEditorDisabled.call({
|
||||
isMessageCreationLocked: true,
|
||||
isAWhatsAppChannel: false,
|
||||
isAPIInbox: false,
|
||||
isOnPrivateNote: false,
|
||||
currentChat: { can_reply: true },
|
||||
});
|
||||
|
||||
expect(isDisabled).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the locked placeholder when message creation is locked', () => {
|
||||
const placeholder = ReplyBox.computed.messagePlaceHolder.call({
|
||||
isEditorDisabled: true,
|
||||
isMessageCreationLocked: true,
|
||||
$t: key => key,
|
||||
});
|
||||
|
||||
expect(placeholder).toBe('CONVERSATION.FOOTER.MESSAGE_CREATION_LOCKED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReplyBottomPanel', () => {
|
||||
it('keeps template actions available when only the editor is disabled', () => {
|
||||
const showWhatsAppTemplateButton =
|
||||
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
|
||||
enableWhatsAppTemplates: true,
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
const showContentTemplateButton =
|
||||
ReplyBottomPanel.computed.showContentTemplateButton.call({
|
||||
enableContentTemplates: true,
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
|
||||
expect(showWhatsAppTemplateButton).toBe(true);
|
||||
expect(showContentTemplateButton).toBe(true);
|
||||
});
|
||||
|
||||
it('hides template actions when message creation is locked', () => {
|
||||
const showWhatsAppTemplateButton =
|
||||
ReplyBottomPanel.computed.showWhatsAppTemplateButton.call({
|
||||
enableWhatsAppTemplates: true,
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
const showContentTemplateButton =
|
||||
ReplyBottomPanel.computed.showContentTemplateButton.call({
|
||||
enableContentTemplates: true,
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
|
||||
expect(showWhatsAppTemplateButton).toBe(false);
|
||||
expect(showContentTemplateButton).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CopilotEditorSection', () => {
|
||||
it('keeps the follow-up editor available when message creation is unlocked', async () => {
|
||||
const wrapper = mountCopilotEditorSection({
|
||||
isMessageCreationLocked: false,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the follow-up editor when message creation is locked', async () => {
|
||||
const wrapper = mountCopilotEditorSection({
|
||||
isMessageCreationLocked: true,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="copilot-editor"]').exists()).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ const FEATURE_HELP_URLS = {
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain: 'https://chwt.app/captain-docs',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"MESSAGE_CREATION_LOCK": {
|
||||
"MESSAGE_LIMIT": "This conversation has reached the {limit} message limit. We will drop all messages after this limit.",
|
||||
"MANUAL": "This conversation is locked. We will drop all new messages until it is unlocked."
|
||||
},
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -213,6 +217,7 @@
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_CREATION_LOCKED": "Message creation is locked for this conversation",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
|
||||
@@ -392,93 +392,6 @@
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"OVERVIEW": {
|
||||
"HEADER": "Overview",
|
||||
"WELCOME": {
|
||||
"LABEL": "Captain summary",
|
||||
"LOADING": "Generating summary…"
|
||||
},
|
||||
"INBOX_BANNER": {
|
||||
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
|
||||
"ACTION": "Connect inbox",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"COVERAGE_BANNER": {
|
||||
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review FAQs",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"RANGES": {
|
||||
"LAST_DAYS": "Last {count} days",
|
||||
"THIS_MONTH": "This month",
|
||||
"LAST_MONTH": "Last month"
|
||||
},
|
||||
"METRICS": {
|
||||
"HANDLED": {
|
||||
"LABEL": "Conversations handled",
|
||||
"HINT": "Distinct conversations this assistant replied in."
|
||||
},
|
||||
"AUTO_RESOLUTION": {
|
||||
"LABEL": "Auto-resolution rate",
|
||||
"HINT": "Share of handled conversations closed without a human reply."
|
||||
},
|
||||
"HANDOFF": {
|
||||
"LABEL": "Handoff rate",
|
||||
"HINT": "Share of handled conversations escalated to a human agent."
|
||||
},
|
||||
"HOURS_SAVED": {
|
||||
"LABEL": "Hours saved",
|
||||
"HINT": "Estimate: Captain replies times the team's average response time. Directional, not measured labor."
|
||||
},
|
||||
"REOPEN": {
|
||||
"LABEL": "Reopen rate",
|
||||
"HINT": "Auto-resolved conversations that were reopened afterwards."
|
||||
},
|
||||
"DEPTH": {
|
||||
"LABEL": "Messages / conversation",
|
||||
"HINT": "Average replies the assistant sends per conversation."
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE": {
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
"APPROVED": "Approved FAQs",
|
||||
"PENDING": "Pending FAQs",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"FLAGGED": {
|
||||
"TITLE": "Response quality",
|
||||
"TOTAL": "{count} flagged · {rate}"
|
||||
},
|
||||
"CREDITS": {
|
||||
"TITLE": "Credit usage",
|
||||
"UNIT": "credits",
|
||||
"LEGEND": "Daily credits used",
|
||||
"AXIS_START": "{count}d ago",
|
||||
"AXIS_END": "Today"
|
||||
},
|
||||
"LINKS": {
|
||||
"DOCS": {
|
||||
"TITLE": "Captain docs",
|
||||
"DESCRIPTION": "Guides and how-tos for Captain"
|
||||
},
|
||||
"PLAYGROUND": {
|
||||
"TITLE": "Playground",
|
||||
"DESCRIPTION": "Test this assistant's replies"
|
||||
},
|
||||
"BILLING": {
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage credits and plan"
|
||||
}
|
||||
},
|
||||
"FLAG_REASONS": {
|
||||
"INCORRECT": "Incorrect info",
|
||||
"INCOMPLETE": "Incomplete",
|
||||
"OUTDATED": "Outdated",
|
||||
"INAPPROPRIATE": "Inappropriate",
|
||||
"OTHER": "Other"
|
||||
}
|
||||
},
|
||||
"ASSISTANT_SWITCHER": {
|
||||
"ASSISTANTS": "Assistants",
|
||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||
|
||||
@@ -324,7 +324,6 @@
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"CAPTAIN": "Captain",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_OVERVIEW": "Overview",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
|
||||
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
|
||||
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
|
||||
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
|
||||
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
|
||||
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
|
||||
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const stats = ref(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
});
|
||||
stats.value = data;
|
||||
} catch {
|
||||
stats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
const resolveTrendGood = (trendValue, direction) => {
|
||||
if (direction === 'neutral' || trendValue === 0) return null;
|
||||
return direction === 'up' ? trendValue > 0 : trendValue < 0;
|
||||
};
|
||||
|
||||
// Trend units mirror the backend pack mode: a relative percent change ('%') for
|
||||
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
|
||||
// :point, and a plain number for :absolute counts like conversation depth.
|
||||
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
return {
|
||||
value: formatValue(data.current),
|
||||
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
|
||||
trendGood: resolveTrendGood(data.trend, direction),
|
||||
};
|
||||
};
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'handled',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
|
||||
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
|
||||
},
|
||||
{
|
||||
key: 'autoResolution',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
|
||||
...metricFor('hours_saved', v => `${v}h`, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
|
||||
...metricFor(
|
||||
'conversation_depth',
|
||||
v => v.toFixed(1),
|
||||
'neutral',
|
||||
'absolute'
|
||||
),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
|
||||
:is-empty="false"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
>
|
||||
<template #headerActions>
|
||||
<RangeSelector v-model="selectedRange" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
>
|
||||
<MetricCard
|
||||
v-for="metric in metrics"
|
||||
:key="metric.key"
|
||||
:label="metric.label"
|
||||
:value="metric.value"
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -6,7 +6,6 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
|
||||
import AssistantEmptyStateIndex from './assistants/Index.vue';
|
||||
|
||||
import AssistantOverviewIndex from './assistants/overview/Index.vue';
|
||||
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
|
||||
@@ -37,12 +36,6 @@ const metaV2 = {
|
||||
};
|
||||
|
||||
const assistantRoutes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
|
||||
component: AssistantOverviewIndex,
|
||||
name: 'captain_assistants_overview_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
||||
component: ResponsesIndex,
|
||||
|
||||
@@ -53,7 +53,6 @@ const routeToLastActiveAssistant = () => {
|
||||
|
||||
const { navigationPath } = route.params;
|
||||
const isAValidRoute = [
|
||||
'captain_assistants_overview_index', // Overview page
|
||||
'captain_assistants_responses_index', // Faq page
|
||||
'captain_assistants_documents_index', // Document page
|
||||
'captain_assistants_scenarios_index', // Scenario page
|
||||
|
||||
@@ -289,12 +289,14 @@ const actions = {
|
||||
|
||||
sendMessageWithData: async ({ commit }, pendingMessage) => {
|
||||
const { conversation_id: conversationId, id } = pendingMessage;
|
||||
const shouldRetryMessage =
|
||||
hasMessageFailedWithExternalError(pendingMessage);
|
||||
try {
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
status: MESSAGE_STATUS.PROGRESS,
|
||||
});
|
||||
const response = hasMessageFailedWithExternalError(pendingMessage)
|
||||
const response = shouldRetryMessage
|
||||
? await MessageApi.retry(conversationId, id)
|
||||
: await MessageApi.create(pendingMessage);
|
||||
commit(types.ADD_MESSAGE, {
|
||||
@@ -309,6 +311,31 @@ const actions = {
|
||||
const errorMessage = error.response
|
||||
? error.response.data.error
|
||||
: undefined;
|
||||
const errorResponse = error.response?.data || {};
|
||||
if (errorResponse.message_creation_locked) {
|
||||
if (shouldRetryMessage) {
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
...(pendingMessage.meta || {}),
|
||||
error: errorResponse.error,
|
||||
},
|
||||
status: MESSAGE_STATUS.FAILED,
|
||||
});
|
||||
} else {
|
||||
commit(types.DELETE_MESSAGE, pendingMessage);
|
||||
}
|
||||
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
|
||||
conversationId,
|
||||
message_limit: errorResponse.message_limit,
|
||||
message_limit_reached: errorResponse.message_limit_reached,
|
||||
message_creation_locked: errorResponse.message_creation_locked,
|
||||
message_creation_lock_reason:
|
||||
errorResponse.message_creation_lock_reason,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
commit(types.ADD_MESSAGE, {
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
@@ -322,6 +349,12 @@ const actions = {
|
||||
|
||||
addMessage({ commit, rootGetters }, message) {
|
||||
commit(types.ADD_MESSAGE, message);
|
||||
if (message.conversation) {
|
||||
commit(types.SET_CONVERSATION_MESSAGE_CREATION_LOCK, {
|
||||
conversationId: message.conversation_id,
|
||||
...message.conversation,
|
||||
});
|
||||
}
|
||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||
conversationId: message.conversation_id,
|
||||
|
||||
@@ -29,6 +29,24 @@ const getConversationById = _state => conversationId => {
|
||||
return _state.allConversations.find(c => c.id === conversationId);
|
||||
};
|
||||
|
||||
const MESSAGE_CREATION_LOCK_FIELDS = [
|
||||
'message_limit',
|
||||
'message_limit_reached',
|
||||
'message_creation_locked',
|
||||
'message_creation_lock_reason',
|
||||
];
|
||||
|
||||
const updateConversationMessageCreationLock = (
|
||||
conversation,
|
||||
lockState = {}
|
||||
) => {
|
||||
MESSAGE_CREATION_LOCK_FIELDS.forEach(field => {
|
||||
if (Object.prototype.hasOwnProperty.call(lockState, field)) {
|
||||
conversation[field] = lockState[field];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// mutations
|
||||
export const mutations = {
|
||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
||||
@@ -205,6 +223,20 @@ export const mutations = {
|
||||
});
|
||||
},
|
||||
|
||||
[types.DELETE_MESSAGE]({ allConversations }, message) {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const [chat] = getSelectedChatConversation({
|
||||
allConversations,
|
||||
selectedChatId: conversationId,
|
||||
});
|
||||
if (!chat) return;
|
||||
|
||||
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
||||
if (pendingMessageIndex !== -1) {
|
||||
chat.messages.splice(pendingMessageIndex, 1);
|
||||
}
|
||||
},
|
||||
|
||||
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const [chat] = getSelectedChatConversation({
|
||||
@@ -216,17 +248,29 @@ export const mutations = {
|
||||
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
||||
if (pendingMessageIndex !== -1) {
|
||||
chat.messages[pendingMessageIndex] = message;
|
||||
updateConversationMessageCreationLock(chat, message.conversation);
|
||||
} else {
|
||||
chat.messages.push(message);
|
||||
chat.timestamp = message.created_at;
|
||||
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
|
||||
chat.unread_count = unreadCount;
|
||||
updateConversationMessageCreationLock(chat, message.conversation);
|
||||
if (selectedChatId === conversationId) {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](
|
||||
_state,
|
||||
{ conversationId, ...lockState }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
updateConversationMessageCreationLock(chat, lockState);
|
||||
}
|
||||
},
|
||||
|
||||
[types.ADD_CONVERSATION](_state, conversation) {
|
||||
const exists = _state.allConversations.some(c => c.id === conversation.id);
|
||||
if (!exists) {
|
||||
|
||||
@@ -288,6 +288,138 @@ describe('#actions', () => {
|
||||
actions.addMessage({ commit }, message);
|
||||
expect(commit.mock.calls).toEqual([[types.ADD_MESSAGE, message]]);
|
||||
});
|
||||
|
||||
it('syncs message creation lock metadata from message payload', () => {
|
||||
const message = {
|
||||
id: 1,
|
||||
message_type: 1,
|
||||
conversation_id: 1,
|
||||
conversation: {
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
};
|
||||
actions.addMessage({ commit }, message);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.ADD_MESSAGE, message],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
...message.conversation,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessageWithData', () => {
|
||||
it('removes optimistic message and updates lock metadata when creation is locked', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const pendingMessage = {
|
||||
id: 'temp-1',
|
||||
conversation_id: 1,
|
||||
message: 'Locked message',
|
||||
};
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'This conversation has reached the 10000 message limit.',
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
},
|
||||
};
|
||||
axios.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
status: 'progress',
|
||||
},
|
||||
],
|
||||
[types.DELETE_MESSAGE, pendingMessage],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an existing failed message when retry is locked', async () => {
|
||||
const localCommit = vi.fn();
|
||||
const pendingMessage = {
|
||||
id: 42,
|
||||
conversation_id: 1,
|
||||
message: 'Retry message',
|
||||
status: 'failed',
|
||||
content_attributes: {
|
||||
external_error: 'Provider rejected the message',
|
||||
},
|
||||
};
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
error: 'This conversation has reached the 10000 message limit.',
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
},
|
||||
};
|
||||
axios.post.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
actions.sendMessageWithData({ commit: localCommit }, pendingMessage)
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(localCommit.mock.calls).toEqual([
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
status: 'progress',
|
||||
},
|
||||
],
|
||||
[
|
||||
types.ADD_MESSAGE,
|
||||
{
|
||||
...pendingMessage,
|
||||
meta: {
|
||||
error: error.response.data.error,
|
||||
},
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
[
|
||||
types.SET_CONVERSATION_MESSAGE_CREATION_LOCK,
|
||||
{
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#markMessagesRead', () => {
|
||||
|
||||
@@ -109,6 +109,46 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONVERSATION_MESSAGE_CREATION_LOCK', () => {
|
||||
it('sets message creation lock metadata', () => {
|
||||
const state = { allConversations: [{ id: 1, messages: [] }] };
|
||||
mutations[types.SET_CONVERSATION_MESSAGE_CREATION_LOCK](state, {
|
||||
conversationId: 1,
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0]).toMatchObject({
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#DELETE_MESSAGE', () => {
|
||||
it('removes a pending message from the conversation', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [{ id: 'temp-1', echo_id: 'temp-1' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mutations[types.DELETE_MESSAGE](state, {
|
||||
conversation_id: 1,
|
||||
id: 'temp-1',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ADD_MESSAGE', () => {
|
||||
it('does not add message to the store if conversation does not exist', () => {
|
||||
const state = { allConversations: [] };
|
||||
@@ -208,6 +248,32 @@ describe('#mutations', () => {
|
||||
]);
|
||||
expect(emitter.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates conversation lock metadata from message payload', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, messages: [] }],
|
||||
selectedChatId: 1,
|
||||
};
|
||||
|
||||
mutations[types.ADD_MESSAGE](state, {
|
||||
conversation_id: 1,
|
||||
content: 'Test message',
|
||||
created_at: 1602256198,
|
||||
conversation: {
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.allConversations[0]).toMatchObject({
|
||||
message_limit: 10000,
|
||||
message_limit_reached: true,
|
||||
message_creation_locked: true,
|
||||
message_creation_lock_reason: 'message_limit',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#CHANGE_CONVERSATION_STATUS', () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
SET_CONVERSATION_MESSAGE_CREATION_LOCK:
|
||||
'SET_CONVERSATION_MESSAGE_CREATION_LOCK',
|
||||
|
||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
|
||||
|
||||
@@ -5,4 +5,8 @@ class ApplicationJob < ActiveJob::Base
|
||||
job.instance_variable_get(:@serialized_arguments)
|
||||
} because of ActiveJob::DeserializationError (#{error.message})")
|
||||
end
|
||||
|
||||
discard_on CustomExceptions::ConversationMessageCreationLocked do |job, error|
|
||||
Rails.logger.info("Skipping #{job.class} because message creation is locked (#{error.message})")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
rescue Timeout::Error
|
||||
mark_email_as_failed(inbound_mail.message_id)
|
||||
Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{inbound_mail.message_id}"
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(inbound_mail, e)
|
||||
rescue StandardError => e
|
||||
mark_email_as_failed(inbound_mail.message_id)
|
||||
Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}"
|
||||
@@ -79,4 +81,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
|
||||
def email_processing_timeout
|
||||
GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
|
||||
end
|
||||
|
||||
def log_message_creation_locked(inbound_mail, error)
|
||||
Rails.logger.info "[IMAP] Dropped email #{inbound_mail.message_id}: #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,12 +16,12 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
persist_reporting_event(reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -39,11 +39,11 @@ class ReportingEventListener < BaseListener
|
||||
user_id: message.sender_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: last_non_human_activity(conversation),
|
||||
event_end_time: message.created_at,
|
||||
**actor_attributes(actor_from_event(event) || message.sender)
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
|
||||
persist_reporting_event(reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -65,10 +65,10 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: waiting_since,
|
||||
event_end_time: message.created_at,
|
||||
**actor_attributes(actor_from_event(event) || message.sender)
|
||||
event_end_time: message.created_at
|
||||
)
|
||||
persist_reporting_event(reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
@@ -92,10 +92,10 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
persist_reporting_event(reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
@@ -116,53 +116,43 @@ class ReportingEventListener < BaseListener
|
||||
name: 'conversation_resolved'
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
create_conversation_opened_event(
|
||||
conversation,
|
||||
conversation_opened_event_attributes(conversation, last_resolved_event, event_end_time),
|
||||
actor_from_event(event)
|
||||
)
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
business_hours_value = 0
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation_opened_event_attributes(conversation, last_resolved_event, event_end_time)
|
||||
return first_conversation_opened_event_attributes(conversation, event_end_time) if last_resolved_event.blank?
|
||||
|
||||
{
|
||||
value: event_end_time.to_i - last_resolved_event.event_end_time.to_i,
|
||||
value_in_business_hours: business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time),
|
||||
event_start_time: last_resolved_event.event_end_time,
|
||||
event_end_time: event_end_time
|
||||
}
|
||||
end
|
||||
|
||||
def first_conversation_opened_event_attributes(conversation, event_end_time)
|
||||
{
|
||||
value: 0,
|
||||
value_in_business_hours: 0,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time
|
||||
}
|
||||
end
|
||||
|
||||
def create_conversation_opened_event(conversation, event_attributes, actor)
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
value_in_business_hours: business_hours_value,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
**event_attributes,
|
||||
**actor_attributes(actor)
|
||||
event_start_time: start_time,
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
persist_reporting_event(reporting_event, rollup: false)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
ReportingEvent.create!(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
@@ -170,10 +160,8 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
event_end_time: event.timestamp
|
||||
)
|
||||
persist_reporting_event(reporting_event, rollup: false)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
@@ -183,29 +171,8 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
persist_reporting_event(bot_resolved_event)
|
||||
end
|
||||
|
||||
def persist_reporting_event(reporting_event, rollup: true)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event) if rollup
|
||||
update_captain_conversation_fact(reporting_event)
|
||||
end
|
||||
|
||||
def update_captain_conversation_fact(reporting_event)
|
||||
return unless defined?(Captain::ConversationFactUpdater)
|
||||
|
||||
Captain::ConversationFactUpdater.record_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def actor_from_event(event)
|
||||
event.data[:performed_by]
|
||||
end
|
||||
|
||||
def actor_attributes(actor)
|
||||
return {} if actor.blank? || actor.id.blank?
|
||||
|
||||
{ actor_type: actor.class.name, actor_id: actor.id }
|
||||
bot_resolved_event.save!
|
||||
safe_rollup(bot_resolved_event)
|
||||
end
|
||||
|
||||
def safe_rollup(reporting_event)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class ApplicationMailbox < ActionMailbox::Base
|
||||
include MailboxHelper
|
||||
|
||||
rescue_from CustomExceptions::ConversationMessageCreationLocked do |error|
|
||||
Rails.logger.info("Skipping inbound email because message creation is locked (#{error.message})")
|
||||
end
|
||||
|
||||
# Last part is the regex for the UUID
|
||||
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
|
||||
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
#
|
||||
|
||||
class Conversation < ApplicationRecord
|
||||
MESSAGE_CREATION_LOCK_KEY = 'message_creation_lock'.freeze
|
||||
MESSAGE_CREATION_LOCK_REASON_MANUAL = 'manual'.freeze
|
||||
MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT = 'message_limit'.freeze
|
||||
|
||||
include Labelable
|
||||
include LlmFormattable
|
||||
include AssignmentHandler
|
||||
@@ -133,6 +137,58 @@ class Conversation < ApplicationRecord
|
||||
Conversations::MessageWindowService.new(self).can_reply?
|
||||
end
|
||||
|
||||
def message_limit
|
||||
Limits.conversation_message_limit
|
||||
end
|
||||
|
||||
def message_limit_reached?
|
||||
messages.where(account_id: account_id).reorder(nil).limit(message_limit).count >= message_limit
|
||||
end
|
||||
|
||||
def manual_message_creation_locked?
|
||||
message_creation_lock_data['locked'] == true
|
||||
end
|
||||
|
||||
def message_creation_locked?
|
||||
manual_message_creation_locked? || message_limit_reached?
|
||||
end
|
||||
|
||||
def message_creation_lock_reason
|
||||
message_creation_lock_reason_for(message_limit_reached?, manual_message_creation_locked?)
|
||||
end
|
||||
|
||||
def message_creation_lock_state
|
||||
limit_reached = message_limit_reached?
|
||||
manually_locked = manual_message_creation_locked?
|
||||
|
||||
{
|
||||
message_limit: message_limit,
|
||||
message_limit_reached: limit_reached,
|
||||
message_creation_locked: manually_locked || limit_reached,
|
||||
message_creation_lock_reason: message_creation_lock_reason_for(limit_reached, manually_locked)
|
||||
}
|
||||
end
|
||||
|
||||
def message_creation_lock_data
|
||||
additional_attributes&.fetch(MESSAGE_CREATION_LOCK_KEY, {}) || {}
|
||||
end
|
||||
|
||||
def lock_message_creation!(reason: nil)
|
||||
lock_data = {
|
||||
'locked' => true,
|
||||
'reason' => reason.presence || MESSAGE_CREATION_LOCK_REASON_MANUAL,
|
||||
'locked_at' => Time.current.iso8601
|
||||
}
|
||||
|
||||
updated_additional_attributes = (additional_attributes || {}).merge(MESSAGE_CREATION_LOCK_KEY => lock_data)
|
||||
update!(additional_attributes: updated_additional_attributes)
|
||||
end
|
||||
|
||||
def unlock_message_creation!
|
||||
updated_additional_attributes = (additional_attributes || {}).except(MESSAGE_CREATION_LOCK_KEY)
|
||||
update!(additional_attributes: updated_additional_attributes)
|
||||
end
|
||||
|
||||
def language
|
||||
additional_attributes&.dig('conversation_language')
|
||||
end
|
||||
@@ -243,6 +299,11 @@ class Conversation < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def message_creation_lock_reason_for(limit_reached, manually_locked)
|
||||
return MESSAGE_CREATION_LOCK_REASON_MESSAGE_LIMIT if limit_reached
|
||||
return MESSAGE_CREATION_LOCK_REASON_MANUAL if manually_locked
|
||||
end
|
||||
|
||||
def execute_after_update_commit_callbacks
|
||||
handle_resolved_status_change
|
||||
notify_status_change
|
||||
@@ -315,7 +376,10 @@ class Conversation < ApplicationRecord
|
||||
def allowed_keys?
|
||||
(
|
||||
previous_changes.keys.intersect?(list_of_keys) ||
|
||||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
|
||||
(
|
||||
previous_changes['additional_attributes'].present? &&
|
||||
previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language message_creation_lock])
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -343,7 +407,7 @@ class Conversation < ApplicationRecord
|
||||
def dispatcher_dispatch(event_name, changed_attributes = nil)
|
||||
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
|
||||
changed_attributes: changed_attributes,
|
||||
performed_by: Current.executed_by || Current.user)
|
||||
performed_by: Current.executed_by)
|
||||
end
|
||||
|
||||
def set_unread_count_deletion_data
|
||||
|
||||
@@ -45,5 +45,3 @@ class CsatSurveyResponse < ApplicationRecord
|
||||
# filter by rating value
|
||||
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
|
||||
end
|
||||
|
||||
CsatSurveyResponse.include_mod_with('CsatSurveyResponse')
|
||||
|
||||
+10
-1
@@ -34,7 +34,7 @@
|
||||
# index_messages_on_conversation_id (conversation_id)
|
||||
# index_messages_on_created_at (created_at)
|
||||
# index_messages_on_inbox_id (inbox_id)
|
||||
# index_messages_on_sender_and_created (sender_type,sender_id,created_at)
|
||||
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
|
||||
# index_messages_on_source_id (source_id)
|
||||
#
|
||||
|
||||
@@ -63,6 +63,7 @@ class Message < ApplicationRecord
|
||||
}.to_json.freeze
|
||||
|
||||
before_validation :ensure_content_type
|
||||
before_validation :ensure_message_creation_unlocked, on: :create
|
||||
before_validation :prevent_message_flooding
|
||||
before_save :ensure_processed_message_content
|
||||
before_save :ensure_in_reply_to
|
||||
@@ -160,6 +161,7 @@ class Message < ApplicationRecord
|
||||
assignee_id: conversation.assignee_id,
|
||||
unread_count: conversation.unread_incoming_messages.count,
|
||||
last_activity_at: conversation.last_activity_at.to_i,
|
||||
**conversation.message_creation_lock_state,
|
||||
contact_inbox: { source_id: conversation.contact_inbox.source_id }
|
||||
}
|
||||
end
|
||||
@@ -285,6 +287,13 @@ class Message < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_message_creation_unlocked
|
||||
return if conversation.blank?
|
||||
|
||||
locked_conversation = Conversation.lock.find(conversation.id)
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, locked_conversation if locked_conversation.message_creation_locked?
|
||||
end
|
||||
|
||||
def prevent_message_flooding
|
||||
# Added this to cover the validation specs in messages
|
||||
# We can revisit and see if we can remove this later
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Table name: reporting_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actor_type :string
|
||||
# event_end_time :datetime
|
||||
# event_start_time :datetime
|
||||
# name :string
|
||||
@@ -12,22 +11,20 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# actor_id :bigint
|
||||
# conversation_id :integer
|
||||
# inbox_id :integer
|
||||
# user_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_reporting_events_on_account_actor_name_created (account_id,actor_type,actor_id,name,created_at)
|
||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
#
|
||||
|
||||
class ReportingEvent < ApplicationRecord
|
||||
@@ -39,7 +36,6 @@ class ReportingEvent < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :inbox, optional: true
|
||||
belongs_to :conversation, optional: true
|
||||
belongs_to :actor, polymorphic: true, optional: true
|
||||
|
||||
# Scopes for filtering
|
||||
scope :filter_by_date_range, lambda { |range|
|
||||
|
||||
@@ -17,7 +17,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
first_reply_created_at: first_reply_created_at,
|
||||
priority: priority,
|
||||
waiting_since: waiting_since.to_i,
|
||||
**push_timestamps
|
||||
**push_timestamps.merge(message_creation_lock_data)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -49,6 +49,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||
}
|
||||
end
|
||||
|
||||
def message_creation_lock_data
|
||||
message_creation_lock_state
|
||||
end
|
||||
|
||||
def push_timestamps
|
||||
{
|
||||
agent_last_seen_at: agent_last_seen_at.to_i,
|
||||
|
||||
@@ -12,6 +12,8 @@ class AutomationRules::ActionService < ActionService
|
||||
action = action.with_indifferent_access
|
||||
begin
|
||||
send(action[:action_name], action[:action_params])
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping automation action #{action[:action_name]} because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
end
|
||||
|
||||
@@ -3,7 +3,16 @@ class CsatSurveyService
|
||||
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
return drop_locked_csat_survey if conversation.message_creation_locked?
|
||||
|
||||
send_csat_survey
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, :contact, to: :conversation
|
||||
|
||||
def send_csat_survey
|
||||
if whatsapp_channel? && template_available_and_approved?
|
||||
send_whatsapp_template_survey
|
||||
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
|
||||
@@ -15,10 +24,6 @@ class CsatSurveyService
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, :contact, to: :conversation
|
||||
|
||||
def should_send_csat_survey?
|
||||
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
|
||||
end
|
||||
@@ -115,6 +120,8 @@ class CsatSurveyService
|
||||
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
|
||||
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(e)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
@@ -164,6 +171,8 @@ class CsatSurveyService
|
||||
)
|
||||
|
||||
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
log_message_creation_locked(e)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
@@ -178,4 +187,12 @@ class CsatSurveyService
|
||||
}
|
||||
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
|
||||
end
|
||||
|
||||
def log_message_creation_locked(error)
|
||||
Rails.logger.info("Skipping CSAT survey for conversation #{conversation.id} because message creation is locked (#{error.message})")
|
||||
end
|
||||
|
||||
def drop_locked_csat_survey
|
||||
log_message_creation_locked(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,8 @@ class Macros::ExecutionService < ActionService
|
||||
action = action.with_indifferent_access
|
||||
begin
|
||||
send(action[:action_name], action[:action_params])
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping macro action #{action[:action_name]} because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
end
|
||||
|
||||
@@ -9,6 +9,8 @@ class MessageTemplates::Template::AutoResolve
|
||||
else
|
||||
create_auto_resolve_not_sent_activity_message
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping auto-resolve template because message creation is locked (#{e.message})")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,6 +5,8 @@ class MessageTemplates::Template::CsatSurvey
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(csat_survey_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping CSAT survey template because message creation is locked (#{e.message})")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -6,6 +6,9 @@ class MessageTemplates::Template::EmailCollect
|
||||
conversation.messages.create!(ways_to_reach_you_message_params)
|
||||
conversation.messages.create!(email_input_box_template_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping email collect template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -5,6 +5,9 @@ class MessageTemplates::Template::Greeting
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(greeting_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping greeting template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -13,6 +13,9 @@ class MessageTemplates::Template::OutOfOffice
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
end
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping out-of-office template because message creation is locked (#{e.message})")
|
||||
true
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
|
||||
@@ -42,6 +42,11 @@ json.additional_attributes conversation.additional_attributes
|
||||
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
|
||||
json.can_reply conversation.can_reply?
|
||||
message_creation_lock_state = conversation.message_creation_lock_state
|
||||
json.message_limit message_creation_lock_state[:message_limit]
|
||||
json.message_limit_reached message_creation_lock_state[:message_limit_reached]
|
||||
json.message_creation_locked message_creation_lock_state[:message_creation_locked]
|
||||
json.message_creation_lock_reason message_creation_lock_state[:message_creation_lock_reason]
|
||||
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
|
||||
json.custom_attributes conversation.custom_attributes
|
||||
json.inbox_id conversation.inbox_id
|
||||
|
||||
@@ -66,8 +66,6 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :stats
|
||||
get :summary
|
||||
end
|
||||
collection do
|
||||
get :tools
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
class AddSenderCreatedIndexToMessages < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
# Adds created_at to the (sender_type, sender_id) index so per-assistant
|
||||
# windowed lookups (Captain Overview stats) can range-scan the time slice
|
||||
# instead of reading every lifetime row and filtering at the heap. The new
|
||||
# index is a left-prefix superset of the old one, so the old one is dropped
|
||||
# to keep write amplification on `messages` neutral.
|
||||
def up
|
||||
add_index :messages, [:sender_type, :sender_id, :created_at],
|
||||
name: 'index_messages_on_sender_and_created', algorithm: :concurrently, if_not_exists: true
|
||||
remove_index :messages, name: 'index_messages_on_sender_type_and_sender_id',
|
||||
algorithm: :concurrently, if_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
add_index :messages, [:sender_type, :sender_id],
|
||||
name: 'index_messages_on_sender_type_and_sender_id', algorithm: :concurrently, if_not_exists: true
|
||||
remove_index :messages, name: 'index_messages_on_sender_and_created',
|
||||
algorithm: :concurrently, if_exists: true
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class AddActorToReportingEvents < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :reporting_events, :actor_type, :string
|
||||
add_column :reporting_events, :actor_id, :bigint
|
||||
|
||||
add_index :reporting_events,
|
||||
[:account_id, :actor_type, :actor_id, :name, :created_at],
|
||||
name: 'idx_reporting_events_on_account_actor_name_created',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
class CreateCaptainConversationFacts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_captain_conversation_facts_table
|
||||
add_captain_conversation_facts_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_captain_conversation_facts_table
|
||||
create_table :captain_conversation_facts do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :conversation_id, null: false
|
||||
t.bigint :assistant_id, null: false
|
||||
t.bigint :inbox_id, null: false
|
||||
t.datetime :first_captain_message_at
|
||||
t.datetime :last_captain_message_at
|
||||
t.datetime :captain_resolved_at
|
||||
t.datetime :captain_handed_off_at
|
||||
t.datetime :first_human_reply_after_captain_at
|
||||
t.datetime :reopened_after_captain_resolution_at
|
||||
t.bigint :csat_response_id
|
||||
t.integer :csat_rating
|
||||
t.datetime :csat_submitted_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def add_captain_conversation_facts_indexes
|
||||
add_index :captain_conversation_facts, :account_id
|
||||
add_index :captain_conversation_facts, :conversation_id, unique: true
|
||||
add_index :captain_conversation_facts, [:account_id, :assistant_id, :first_captain_message_at],
|
||||
name: 'idx_captain_facts_on_account_assistant_first_message'
|
||||
add_index :captain_conversation_facts, [:account_id, :captain_resolved_at],
|
||||
name: 'idx_captain_facts_on_account_resolved_at'
|
||||
add_index :captain_conversation_facts, [:account_id, :captain_handed_off_at],
|
||||
name: 'idx_captain_facts_on_account_handed_off_at'
|
||||
add_index :captain_conversation_facts, [:account_id, :csat_submitted_at],
|
||||
name: 'idx_captain_facts_on_account_csat_submitted_at'
|
||||
end
|
||||
end
|
||||
+2
-29
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -350,30 +350,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
|
||||
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
|
||||
end
|
||||
|
||||
create_table "captain_conversation_facts", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.datetime "first_captain_message_at"
|
||||
t.datetime "last_captain_message_at"
|
||||
t.datetime "captain_resolved_at"
|
||||
t.datetime "captain_handed_off_at"
|
||||
t.datetime "first_human_reply_after_captain_at"
|
||||
t.datetime "reopened_after_captain_resolution_at"
|
||||
t.bigint "csat_response_id"
|
||||
t.integer "csat_rating"
|
||||
t.datetime "csat_submitted_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "assistant_id", "first_captain_message_at"], name: "idx_captain_facts_on_account_assistant_first_message"
|
||||
t.index ["account_id", "captain_handed_off_at"], name: "idx_captain_facts_on_account_handed_off_at"
|
||||
t.index ["account_id", "captain_resolved_at"], name: "idx_captain_facts_on_account_resolved_at"
|
||||
t.index ["account_id", "csat_submitted_at"], name: "idx_captain_facts_on_account_csat_submitted_at"
|
||||
t.index ["account_id"], name: "index_captain_conversation_facts_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_captain_conversation_facts_on_conversation_id", unique: true
|
||||
end
|
||||
|
||||
create_table "captain_custom_tools", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "slug", null: false
|
||||
@@ -1058,7 +1034,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
|
||||
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
|
||||
t.index ["created_at"], name: "index_messages_on_created_at"
|
||||
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
|
||||
t.index ["sender_type", "sender_id", "created_at"], name: "index_messages_on_sender_and_created"
|
||||
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
|
||||
t.index ["source_id"], name: "index_messages_on_source_id"
|
||||
end
|
||||
|
||||
@@ -1191,9 +1167,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
|
||||
t.float "value_in_business_hours"
|
||||
t.datetime "event_start_time", precision: nil
|
||||
t.datetime "event_end_time", precision: nil
|
||||
t.string "actor_type"
|
||||
t.bigint "actor_id"
|
||||
t.index ["account_id", "actor_type", "actor_id", "name", "created_at"], name: "idx_reporting_events_on_account_actor_name_created"
|
||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
# Computes per-assistant overview metrics for the Captain Overview page.
|
||||
# Each metric is returned for the current window and the previous equal-length
|
||||
# window, plus a derived trend.
|
||||
#
|
||||
# Queries are batched to cut round trips: the message-derived counts (handled,
|
||||
# public replies, depth) and the average reply time are each computed for both
|
||||
# windows in a single scan via conditional FILTER aggregation.
|
||||
class Captain::AssistantStatsBuilder
|
||||
RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
|
||||
HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
|
||||
DEFAULT_RANGE = '30'.freeze
|
||||
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
|
||||
|
||||
attr_reader :assistant, :account, :range
|
||||
|
||||
# `range` is either a day count ('7', '30', '90') or a named period
|
||||
# ('this_month', 'last_month'). The previous window mirrors the current one:
|
||||
# the preceding N days for day ranges, or the preceding month for month ranges.
|
||||
# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
|
||||
# it), so month/day boundaries anchor to the viewer's day rather than UTC.
|
||||
def initialize(assistant, range = DEFAULT_RANGE, timezone_offset = nil)
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset.to_f] || Time.zone
|
||||
end
|
||||
|
||||
def metrics
|
||||
messages = message_window_metrics
|
||||
reply_times = avg_reply_times
|
||||
current = window_metrics(current_range, messages[:current], reply_times[:current])
|
||||
previous = window_metrics(previous_range, messages[:previous], reply_times[:previous])
|
||||
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Human-readable description of the period the metrics cover, for grounding the
|
||||
# LLM summary in real dates.
|
||||
def period
|
||||
{ label: period_label, starts_on: current_range.first.to_date, ends_on: current_range.last.to_date }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_metrics(current, previous)
|
||||
{
|
||||
conversations_handled: pack(current[:handled], previous[:handled], :percent),
|
||||
auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
|
||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||
knowledge: knowledge
|
||||
}
|
||||
end
|
||||
|
||||
def period_label
|
||||
{ 'this_month' => 'this month', 'last_month' => 'last month' }[range.to_s] || "the last #{range.to_i} days"
|
||||
end
|
||||
|
||||
def current_range
|
||||
resolved_ranges[:current]
|
||||
end
|
||||
|
||||
def previous_range
|
||||
resolved_ranges[:previous]
|
||||
end
|
||||
|
||||
def resolved_ranges
|
||||
@resolved_ranges ||= case range.to_s
|
||||
when 'this_month' then this_month_ranges
|
||||
when 'last_month' then last_month_ranges
|
||||
else day_ranges
|
||||
end
|
||||
end
|
||||
|
||||
# Current time anchored to the viewer's timezone, so calendar boundaries land on
|
||||
# the viewer's day instead of UTC's.
|
||||
def now
|
||||
@now ||= Time.current.in_time_zone(@timezone)
|
||||
end
|
||||
|
||||
def this_month_ranges
|
||||
start = now.beginning_of_month
|
||||
elapsed = now - start
|
||||
previous_start = start - 1.month
|
||||
# Clamp to the previous month's end so a longer current month can't pull the
|
||||
# comparison window into the current month and double-count its rows.
|
||||
previous_end = [previous_start + elapsed, previous_start.end_of_month].min
|
||||
{ current: start..now, previous: previous_start..previous_end }
|
||||
end
|
||||
|
||||
def last_month_ranges
|
||||
start = (now - 1.month).beginning_of_month
|
||||
previous_start = start - 1.month
|
||||
{ current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
|
||||
end
|
||||
|
||||
def day_ranges
|
||||
days = range.to_i
|
||||
{ current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
|
||||
end
|
||||
|
||||
# Combines the per-window message counts and reply time with the reporting-event metrics for one window.
|
||||
def window_metrics(range, message_counts, avg_reply)
|
||||
handled = message_counts[:handled]
|
||||
public_count = message_counts[:public_count]
|
||||
depth_conversations = message_counts[:depth_conversations]
|
||||
resolution = resolution_counts(range)
|
||||
|
||||
{
|
||||
handled: handled,
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * avg_reply / 3600.0).round,
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
|
||||
# One scan over the assistant's messages computes handled, public-reply count,
|
||||
# and depth-conversation count for both windows via conditional aggregation.
|
||||
def message_window_metrics
|
||||
public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
|
||||
cur = window_clause(current_range)
|
||||
prev = window_clause(previous_range)
|
||||
|
||||
row = handled_scope(full_span).reorder(nil).pick(
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
|
||||
)
|
||||
|
||||
{
|
||||
current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
|
||||
previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
|
||||
}
|
||||
end
|
||||
|
||||
# Average reply time (seconds) for both windows in one scan.
|
||||
def avg_reply_times
|
||||
row = account.reporting_events.where(name: 'reply_time', created_at: full_span).reorder(nil).pick(
|
||||
Arel.sql("AVG(value) FILTER (WHERE #{window_clause(current_range)})"),
|
||||
Arel.sql("AVG(value) FILTER (WHERE #{window_clause(previous_range)})")
|
||||
)
|
||||
{ current: row[0].to_f, previous: row[1].to_f }
|
||||
end
|
||||
|
||||
# Resolved and handed-off conversation counts for one window, in a single scan
|
||||
# of the handled set's reporting events.
|
||||
def resolution_counts(range)
|
||||
row = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
|
||||
created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
.reorder(nil)
|
||||
.pick(
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(RESOLVED_EVENT_NAMES)}))"),
|
||||
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
|
||||
)
|
||||
{ resolved: row[0], handoff: row[1] }
|
||||
end
|
||||
|
||||
# Conversations the assistant participated in (authored any message).
|
||||
def handled_scope(range)
|
||||
account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
|
||||
end
|
||||
|
||||
# Span covering both windows so a single scan can split them with FILTER.
|
||||
def full_span
|
||||
[current_range.first, previous_range.first].min..current_range.last
|
||||
end
|
||||
|
||||
def window_clause(range)
|
||||
"created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
|
||||
end
|
||||
|
||||
def quote(value)
|
||||
account.class.connection.quote(value)
|
||||
end
|
||||
|
||||
def quoted(values)
|
||||
values.map { |value| quote(value) }.join(', ')
|
||||
end
|
||||
|
||||
# Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
|
||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
# event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
|
||||
# Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
|
||||
# in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
|
||||
# start time instead would misfire: the inference event is dispatched just after the generic
|
||||
# conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
|
||||
reopened = account.reporting_events
|
||||
.where(name: 'conversation_opened')
|
||||
.where('reporting_events.value > 0')
|
||||
.joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def knowledge
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
return 0 if denominator.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
|
||||
def pack(current, previous, mode)
|
||||
{ current: current, previous: previous, trend: trend(current, previous, mode) }
|
||||
end
|
||||
|
||||
def trend(current, previous, mode)
|
||||
case mode
|
||||
when :percent
|
||||
previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
|
||||
else # :point and :absolute are both current - previous
|
||||
(current - previous).round(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -43,43 +43,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def summary
|
||||
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
|
||||
|
||||
if result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_content
|
||||
else
|
||||
render json: { message: result[:message] }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cached_or_generated_summary(builder)
|
||||
cache_key = summary_cache_key(builder.range)
|
||||
cached = Rails.cache.read(cache_key)
|
||||
return cached if cached
|
||||
|
||||
result = Captain::OverviewSummaryService.new(
|
||||
account: Current.account,
|
||||
assistant: @assistant,
|
||||
first_name: Current.user.name.to_s.split.first,
|
||||
stats: builder.metrics,
|
||||
period: builder.period
|
||||
).perform
|
||||
# Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
|
||||
Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
|
||||
result
|
||||
end
|
||||
|
||||
def summary_cache_key(range)
|
||||
"captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
@assistant = account_assistants.find(params[:id])
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
before_action :set_conversation, only: :initiate
|
||||
before_action :ensure_message_creation_unlocked, only: :initiate
|
||||
before_action :ensure_calling_enabled, only: :initiate
|
||||
before_action :ensure_sdp_offer, only: :initiate
|
||||
before_action :ensure_contact_phone, only: :initiate
|
||||
@@ -84,6 +85,12 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
def ensure_message_creation_unlocked
|
||||
return unless @conversation.message_creation_locked?
|
||||
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, @conversation
|
||||
end
|
||||
|
||||
def ensure_recording_present
|
||||
return if params[:recording].present?
|
||||
|
||||
|
||||
@@ -73,8 +73,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
ActiveRecord::Base.transaction do
|
||||
create_messages
|
||||
create_messages_and_increment_usage
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages_and_increment_usage
|
||||
ActiveRecord::Base.transaction do
|
||||
if create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
@@ -181,6 +186,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][ResponseBuilderJob] Dropped message for conversation #{@conversation.display_id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
|
||||
@@ -100,26 +100,30 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
create_message_or_drop(conversation, 'private note') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
create_message_or_drop(conversation, 'resolution message') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -127,13 +131,23 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
create_message_or_drop(conversation, 'handoff message') do
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_message_or_drop(conversation, message_type)
|
||||
yield
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info(
|
||||
"[CAPTAIN][InboxPendingConversationsResolutionJob] Dropped #{message_type} for conversation #{conversation.display_id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,7 +35,6 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
has_many :conversation_facts, class_name: 'Captain::ConversationFact', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_conversation_facts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# captain_handed_off_at :datetime
|
||||
# captain_resolved_at :datetime
|
||||
# csat_rating :integer
|
||||
# csat_submitted_at :datetime
|
||||
# first_captain_message_at :datetime
|
||||
# first_human_reply_after_captain_at :datetime
|
||||
# last_captain_message_at :datetime
|
||||
# reopened_after_captain_resolution_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# csat_response_id :bigint
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_captain_facts_on_account_assistant_first_message (account_id,assistant_id,first_captain_message_at)
|
||||
# idx_captain_facts_on_account_csat_submitted_at (account_id,csat_submitted_at)
|
||||
# idx_captain_facts_on_account_handed_off_at (account_id,captain_handed_off_at)
|
||||
# idx_captain_facts_on_account_resolved_at (account_id,captain_resolved_at)
|
||||
# index_captain_conversation_facts_on_account_id (account_id)
|
||||
# index_captain_conversation_facts_on_conversation_id (conversation_id) UNIQUE
|
||||
#
|
||||
class Captain::ConversationFact < ApplicationRecord
|
||||
self.table_name = 'captain_conversation_facts'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :inbox
|
||||
belongs_to :csat_response, class_name: 'CsatSurveyResponse', optional: true
|
||||
|
||||
validates :conversation_id, uniqueness: true
|
||||
end
|
||||
@@ -13,7 +13,6 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_conversation_facts, dependent: :destroy_async, class_name: 'Captain::ConversationFact'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -7,7 +7,6 @@ module Enterprise::Concerns::Conversation
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_one :captain_conversation_fact, class_name: 'Captain::ConversationFact', dependent: :destroy_async
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module Enterprise::CsatSurveyResponse
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :update_captain_conversation_fact, on: [:create, :update]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_captain_conversation_fact
|
||||
Captain::ConversationFactUpdater.record_csat_response(self)
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,6 @@ module Enterprise::Message
|
||||
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
|
||||
|
||||
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
|
||||
|
||||
after_create_commit :update_captain_conversation_fact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,10 +15,6 @@ module Enterprise::Message
|
||||
|
||||
private
|
||||
|
||||
def update_captain_conversation_fact
|
||||
Captain::ConversationFactUpdater.record_message(self)
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
@@ -11,10 +11,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def summary?
|
||||
true
|
||||
end
|
||||
|
||||
def tools?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
class Captain::ConversationFactUpdater
|
||||
RESOLVED_EVENT_NAMES = %w[
|
||||
conversation_resolved
|
||||
conversation_bot_resolved
|
||||
conversation_captain_inference_resolved
|
||||
].freeze
|
||||
|
||||
HANDOFF_EVENT_NAMES = %w[
|
||||
conversation_bot_handoff
|
||||
conversation_captain_inference_handoff
|
||||
].freeze
|
||||
|
||||
class << self
|
||||
def record_message(message)
|
||||
return unless message.outgoing?
|
||||
return if message.private?
|
||||
|
||||
if captain_message?(message)
|
||||
record_captain_message(message)
|
||||
elsif human_message?(message)
|
||||
record_human_message(message)
|
||||
end
|
||||
end
|
||||
|
||||
def record_reporting_event(reporting_event)
|
||||
if RESOLVED_EVENT_NAMES.include?(reporting_event.name)
|
||||
record_captain_resolution(reporting_event)
|
||||
elsif HANDOFF_EVENT_NAMES.include?(reporting_event.name)
|
||||
record_captain_handoff(reporting_event)
|
||||
elsif reporting_event.name == 'conversation_opened'
|
||||
record_reopen_after_captain_resolution(reporting_event)
|
||||
end
|
||||
end
|
||||
|
||||
def record_csat_response(csat_response)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: csat_response.conversation_id)
|
||||
return if fact.blank?
|
||||
|
||||
fact.assign_attributes(
|
||||
csat_response_id: csat_response.id,
|
||||
csat_rating: csat_response.rating,
|
||||
csat_submitted_at: csat_response.created_at
|
||||
)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_message?(message)
|
||||
message.sender_type == 'Captain::Assistant'
|
||||
end
|
||||
|
||||
def human_message?(message)
|
||||
message.sender_type == 'User'
|
||||
end
|
||||
|
||||
def record_captain_message(message)
|
||||
fact = find_or_create_fact!(
|
||||
conversation_id: message.conversation_id,
|
||||
account_id: message.account_id,
|
||||
inbox_id: message.inbox_id,
|
||||
assistant_id: message.sender_id
|
||||
)
|
||||
fact.first_captain_message_at ||= message.created_at
|
||||
fact.last_captain_message_at = latest_time(fact.last_captain_message_at, message.created_at)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_human_message(message)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: message.conversation_id)
|
||||
return if fact.blank?
|
||||
return if fact.first_captain_message_at.blank?
|
||||
return if fact.first_human_reply_after_captain_at.present?
|
||||
return if message.created_at <= fact.first_captain_message_at
|
||||
|
||||
fact.update!(first_human_reply_after_captain_at: message.created_at)
|
||||
end
|
||||
|
||||
def record_captain_resolution(reporting_event)
|
||||
return unless captain_actor?(reporting_event)
|
||||
|
||||
fact = find_or_create_fact_from_event!(reporting_event)
|
||||
fact.captain_resolved_at = earliest_time(fact.captain_resolved_at, reporting_event.event_end_time)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_captain_handoff(reporting_event)
|
||||
return unless captain_actor?(reporting_event)
|
||||
|
||||
fact = find_or_create_fact_from_event!(reporting_event)
|
||||
fact.captain_handed_off_at = earliest_time(fact.captain_handed_off_at, reporting_event.event_end_time)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_reopen_after_captain_resolution(reporting_event)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: reporting_event.conversation_id)
|
||||
return if fact.blank?
|
||||
return if fact.captain_resolved_at.blank?
|
||||
return if fact.reopened_after_captain_resolution_at.present?
|
||||
return if reporting_event.event_end_time < fact.captain_resolved_at
|
||||
|
||||
fact.update!(reopened_after_captain_resolution_at: reporting_event.event_end_time)
|
||||
end
|
||||
|
||||
def captain_actor?(reporting_event)
|
||||
reporting_event.actor_type == 'Captain::Assistant' && reporting_event.actor_id.present?
|
||||
end
|
||||
|
||||
def find_or_create_fact_from_event!(reporting_event)
|
||||
find_or_create_fact!(
|
||||
conversation_id: reporting_event.conversation_id,
|
||||
account_id: reporting_event.account_id,
|
||||
inbox_id: reporting_event.inbox_id,
|
||||
assistant_id: reporting_event.actor_id
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_fact!(conversation_id:, account_id:, inbox_id:, assistant_id:)
|
||||
Captain::ConversationFact.create_or_find_by!(conversation_id: conversation_id) do |fact|
|
||||
fact.account_id = account_id
|
||||
fact.inbox_id = inbox_id
|
||||
fact.assistant_id = assistant_id
|
||||
end
|
||||
end
|
||||
|
||||
def earliest_time(current_time, candidate_time)
|
||||
[current_time, candidate_time].compact.min
|
||||
end
|
||||
|
||||
def latest_time(current_time, candidate_time)
|
||||
[current_time, candidate_time].compact.max
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -57,14 +57,20 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
return unless conversation.pending?
|
||||
|
||||
Rails.logger.info("Captain limit exceeded, performing handoff mid-conversation for conversation: #{conversation.id}")
|
||||
create_handoff_message
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account.id,
|
||||
inbox_id: conversation.inbox.id,
|
||||
content: 'Transferring to another agent for further assistance.'
|
||||
)
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CaptainHandoff] Dropped handoff message for conversation #{conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
def send_out_of_office_message_after_handoff
|
||||
|
||||
@@ -7,8 +7,6 @@ json.event_end_time reporting_event.event_end_time
|
||||
json.account_id reporting_event.account_id
|
||||
json.inbox_id reporting_event.inbox_id
|
||||
json.user_id reporting_event.user_id
|
||||
json.actor_type reporting_event.actor_type
|
||||
json.actor_id reporting_event.actor_id
|
||||
json.conversation_id reporting_event.conversation_id
|
||||
json.created_at reporting_event.created_at
|
||||
json.updated_at reporting_event.updated_at
|
||||
|
||||
@@ -9,7 +9,7 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
|
||||
create_private_note(conversation, note)
|
||||
return 'Message creation is locked for this conversation' unless create_private_note(conversation, note)
|
||||
|
||||
'Private note added successfully'
|
||||
end
|
||||
@@ -25,6 +25,9 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
content: note,
|
||||
private: true
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CAPTAIN][AddPrivateNoteTool] Dropped private note for conversation #{conversation.display_id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def permissions
|
||||
|
||||
@@ -25,14 +25,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
def trigger_handoff(conversation, reason)
|
||||
# post the reason as a private note
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
create_private_note(conversation, reason)
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
@@ -49,6 +42,19 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def create_private_note(conversation, reason)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: reason
|
||||
)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[CAPTAIN][HandoffTool] Dropped handoff note for conversation #{conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
# This tool could be enhanced to:
|
||||
# 1. Accept team_id parameter for routing to specific teams
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Generates the LLM welcome summary for the Captain Overview page from the
|
||||
# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
|
||||
# captain_overview_summary.liquid prompt and returns markdown.
|
||||
class Captain::OverviewSummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :assistant!, :first_name!, :stats!, :period!]
|
||||
|
||||
def perform
|
||||
api_response = make_api_call(
|
||||
feature: 'editor',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: 'Write the summary.' }
|
||||
]
|
||||
)
|
||||
|
||||
return api_response if api_response[:error]
|
||||
|
||||
{ message: api_response[:message] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_prompt
|
||||
Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
stat_variables.merge(period_variables)
|
||||
end
|
||||
|
||||
def stat_variables
|
||||
{
|
||||
'first_name' => first_name.to_s,
|
||||
'assistant_name' => assistant.name.to_s,
|
||||
'conversations_handled' => current(:conversations_handled),
|
||||
'hours_saved' => current(:hours_saved),
|
||||
'auto_resolution_rate' => current(:auto_resolution_rate),
|
||||
'auto_resolution_trend' => trend(:auto_resolution_rate),
|
||||
'handoff_rate' => current(:handoff_rate),
|
||||
'handoff_trend' => trend(:handoff_rate),
|
||||
'reopen_rate' => current(:reopen_rate),
|
||||
'reopen_trend' => trend(:reopen_rate),
|
||||
'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s,
|
||||
'knowledge_approved' => stats.dig(:knowledge, :approved).to_s,
|
||||
'knowledge_documents' => stats.dig(:knowledge, :documents).to_s
|
||||
}
|
||||
end
|
||||
|
||||
def period_variables
|
||||
{
|
||||
'today' => formatted_date(Time.zone.today),
|
||||
'period_label' => period[:label].to_s,
|
||||
'period_start' => formatted_date(period[:starts_on]),
|
||||
'period_end' => formatted_date(period[:ends_on])
|
||||
}
|
||||
end
|
||||
|
||||
def formatted_date(date)
|
||||
date.strftime('%B %-d, %Y')
|
||||
end
|
||||
|
||||
def current(key)
|
||||
stats.dig(key, :current).to_s
|
||||
end
|
||||
|
||||
def trend(key)
|
||||
stats.dig(key, :trend).to_s
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain_overview_summary'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
|
||||
# The overview summary is an internal analytics readout, not a customer-facing
|
||||
# response, so it should not consume or be blocked by the captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomExceptions::ConversationMessageCreationLocked < CustomExceptions::Base
|
||||
ERROR_CODE = 'conversation_message_creation_locked'
|
||||
|
||||
def message
|
||||
if lock_state[:message_creation_lock_reason] == 'message_limit'
|
||||
"This conversation has reached the #{lock_state[:message_limit]} message limit. We will drop all messages after this limit."
|
||||
else
|
||||
'This conversation is locked. We will drop all new messages until it is unlocked.'
|
||||
end
|
||||
end
|
||||
|
||||
def http_status
|
||||
:unprocessable_entity
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
error: message,
|
||||
message: message,
|
||||
error_code: ERROR_CODE,
|
||||
**lock_state
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_state
|
||||
@lock_state ||= @data.message_creation_lock_state
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,8 @@ class Integrations::BotProcessorService
|
||||
return unless should_run_processor?(message)
|
||||
|
||||
process_content(message)
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("Skipping bot processor response because message creation is locked (#{e.message})")
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: (hook&.account || agent_bot&.account)).capture_exception
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ class Integrations::Dyte::ProcessorService
|
||||
pattr_initialize [:account!, :conversation!]
|
||||
|
||||
def create_a_meeting(agent)
|
||||
raise CustomExceptions::ConversationMessageCreationLocked, conversation if conversation.message_creation_locked?
|
||||
|
||||
return missing_realtimekit_credentials_response if realtimekit_credentials_missing?
|
||||
|
||||
title = I18n.t('integration_apps.dyte.meeting_name', agent_name: agent.available_name)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
|
||||
|
||||
Voice and format:
|
||||
- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
|
||||
- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
|
||||
- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
|
||||
- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
|
||||
- Output plain markdown only: no headings, lists, preamble, or sign-off. Do not use em dashes.
|
||||
- Never state an exact figure. This summary is cached and the live numbers keep moving, so a precise value would quickly look wrong. Round every number down to a clean approximation and soften it with words like "around", "roughly", "about", "nearly", "just over", or "upwards of". For example, render **1,248** as "upwards of **1,200**", **63.2%** as "around **60%**", and **612** hours as "roughly **600** hours". For a small count, use a loose phrase like "a handful" instead of the exact number.
|
||||
- Wrap the approximate figure in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases or the softening word.
|
||||
|
||||
Timing: today is {{ today }}. These stats cover {{ period_label }} ({{ period_start }} to {{ period_end }}). You may lightly reference the month, the season, or how far into the period things stand when it genuinely fits, but never invent events or facts.
|
||||
|
||||
The stats for this period:
|
||||
<stats>
|
||||
- Conversations handled: {{ conversations_handled }}. Distinct conversations {{ assistant_name }} replied in at least once. Raw volume and adoption, not a measure of quality.
|
||||
- Hours saved: {{ hours_saved }} hours. A rough, directional estimate of agent time saved. A feel-good figure, not exact measured labor.
|
||||
- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} points vs previous period). Of the conversations it handled, the share {{ assistant_name }} resolved on its own with no human reply. The core performance signal; higher is better.
|
||||
- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} points vs previous period). Of the conversations it handled, the share it escalated to a human agent. The inverse of deflection; lower is better.
|
||||
- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} points vs previous period). Of the conversations it auto-resolved, the share later reopened. A quality signal; lower is better, and a high value means it closed conversations the customer was not actually done with.
|
||||
- Knowledge base: {{ knowledge_approved }} approved FAQ answers, {{ knowledge_documents }} documents, {{ knowledge_coverage }}% coverage (the share of FAQ answers the team has approved). This is setup the team controls, not something {{ assistant_name }} earned. It is a leading indicator: low coverage tends to cause low auto-resolution.
|
||||
</stats>
|
||||
|
||||
Only the auto-resolution, handoff, and reopen rates reflect how {{ assistant_name }} actually performed, and they are the only things worth crediting it for. Conversations handled and hours saved are context. The knowledge base is an input, never a win to praise.
|
||||
|
||||
How to judge the numbers (rough bands, do not quote them in the summary):
|
||||
- Auto-resolution rate: below 30% is low and early-stage, 30 to 50% is decent, above 50% is genuinely strong.
|
||||
- Handoff rate: above 60% is high, 30 to 60% is moderate, below 30% is strong.
|
||||
- Reopen-after-resolve rate: below 5% is healthy, 5 to 15% is worth watching, above 15% is a real problem.
|
||||
- Knowledge coverage: only worth mentioning when below 85% (below 60% is seriously thin), as a likely cause of weak auto-resolution. At 85% or above it is just the healthy baseline, so do not mention or praise it.
|
||||
- When the conversation volume is small (roughly under 30), rates are noisy, so describe them tentatively and do not over-interpret a perfect or terrible looking percentage.
|
||||
|
||||
Writing the summary:
|
||||
- Cold start: if conversations handled is 0, there is no performance to report. Skip the auto-resolution, handoff, reopen, and hours-saved figures entirely. Instead note the knowledge base and say {{ assistant_name }} is set up and ready to start handling support (or ready to start once some knowledge is added, if the base is empty). Ignore the rest of these points in this case.
|
||||
- Be honest and proportionate. Do not call a result impressive, strong, excellent, solid, flawless, or perfect unless it clears the "strong" band above. State a low or middling number plainly or as room to grow, never dressed up. A modest summary is fine and often correct.
|
||||
- Lead with the genuinely strong results if there are any. If nothing clears the strong band, open plainly with the volume of work handled, without overselling it.
|
||||
- Mention a trend only when it is meaningful, and judge it against the bands rather than the direction alone (a rate that rose but is still in the low band is not yet a win).
|
||||
- Surface at most one proactive concern when a stat warrants it (a high handoff rate, a low auto-resolution rate, a rising reopen rate, or thin coverage). Skip it entirely when everything looks healthy. Keep it a calm observation about the data, not an alarm.
|
||||
@@ -7,6 +7,9 @@ module Integrations::Slack::SlackMessageHelper
|
||||
rescue Slack::Web::Api::Errors::MissingScope => e
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
disable_and_reauthorize
|
||||
rescue CustomExceptions::ConversationMessageCreationLocked => e
|
||||
Rails.logger.info("[SlackMessageHelper] Dropped message for conversation #{conversation.display_id}: #{e.message}")
|
||||
success_response
|
||||
end
|
||||
|
||||
def handle_conversation
|
||||
|
||||
@@ -14,4 +14,11 @@ module Limits
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
end
|
||||
|
||||
def self.conversation_message_limit
|
||||
limit = Integer(ENV.fetch('CONVERSATION_MESSAGE_LIMIT', '10000'), 10)
|
||||
raise ArgumentError, 'CONVERSATION_MESSAGE_LIMIT must be greater than 0' unless limit.positive?
|
||||
|
||||
limit
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
USAGE = <<~USAGE
|
||||
Usage:
|
||||
bundle exec rails runner script/conversation_message_lock.rb ACCOUNT_ID CONVERSATION_DISPLAY_ID lock "reason"
|
||||
bundle exec rails runner script/conversation_message_lock.rb ACCOUNT_ID CONVERSATION_DISPLAY_ID unlock
|
||||
USAGE
|
||||
|
||||
account_id, conversation_display_id, action, reason = ARGV
|
||||
|
||||
abort USAGE if account_id.blank? || conversation_display_id.blank? || action.blank?
|
||||
|
||||
conversation = Account.find(account_id).conversations.find_by!(display_id: conversation_display_id)
|
||||
|
||||
case action
|
||||
when 'lock'
|
||||
conversation.lock_message_creation!(reason: reason)
|
||||
puts "Locked message creation for conversation #{conversation.display_id}"
|
||||
when 'unlock'
|
||||
conversation.unlock_message_creation!
|
||||
puts "Unlocked message creation for conversation #{conversation.display_id}"
|
||||
else
|
||||
abort USAGE
|
||||
end
|
||||
@@ -297,6 +297,23 @@ describe Messages::Facebook::MessageBuilder do
|
||||
expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id)
|
||||
expect(Conversation.count).to eq(inital_count + 1)
|
||||
end
|
||||
|
||||
it 'drops the message without reporting an exception when the conversation message limit is reached' do
|
||||
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
|
||||
contact_id: contact.id, contact_inbox_id: contact_inbox.id,
|
||||
status: :open)
|
||||
create(:message, conversation: existing_conversation, account: facebook_channel.inbox.account, inbox: facebook_channel.inbox)
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
expect { mocked_message_builder }.not_to(change { existing_conversation.messages.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is enabled' do
|
||||
|
||||
@@ -101,6 +101,28 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
||||
)
|
||||
end
|
||||
|
||||
it 'drops the message without reporting an exception when the conversation message limit is reached' do
|
||||
messaging = dm_params[:entry][0]['messaging'][0]
|
||||
sender_id = messaging['sender']['id']
|
||||
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
|
||||
contact_inbox = contact.contact_inboxes.find_by!(inbox: instagram_messenger_inbox)
|
||||
conversation = create(
|
||||
:conversation,
|
||||
account_id: account.id,
|
||||
inbox_id: instagram_messenger_inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
|
||||
)
|
||||
create(:message, conversation: conversation, account: account, inbox: instagram_messenger_inbox)
|
||||
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
expect { described_class.new(messaging, instagram_messenger_inbox).perform }.not_to(change { conversation.messages.count })
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates message with for reply with story id' do
|
||||
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||
sender_id = messaging['sender']['id']
|
||||
|
||||
@@ -82,6 +82,28 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(conversation.messages.last.attachments.first.file_type).to eq('image')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
|
||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||
params: { content: 'test-message', private: true },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when api inbox' do
|
||||
let(:api_channel) { create(:channel_api, account: account) }
|
||||
let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
|
||||
@@ -277,6 +299,23 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
||||
expect(message.reload.status).to eq('sent')
|
||||
expect(message.reload.content_attributes['external_error']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when retry is locked' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
post "/api/v1/accounts/#{account.id}/conversations/#{message.conversation.display_id}/messages/#{message.id}/retry",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(message.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the message id is invalid' do
|
||||
|
||||
@@ -60,6 +60,27 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
expect(conversation.display_id).to eq(response_body['conversation_id'])
|
||||
expect(last_message.id).to eq(response_body['id'])
|
||||
end
|
||||
|
||||
it 'returns lock metadata without creating an external meeting when message creation is locked' do
|
||||
create(:message, conversation: conversation, account: account, inbox: conversation.inbox)
|
||||
expect(Dyte).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post create_a_meeting_api_v1_account_integrations_dyte_url(account),
|
||||
params: { conversation_id: conversation.display_id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
response_body = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response_body).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is errored' do
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Webhooks API', type: :request do
|
||||
describe 'POST /webhooks/twitter' do
|
||||
it 'drops message creation locks without reporting an exception' do
|
||||
conversation = create(:conversation)
|
||||
conversation.lock_message_creation!
|
||||
consumer = instance_double(Webhooks::Twitter)
|
||||
|
||||
allow(Webhooks::Twitter).to receive(:new).and_return(consumer)
|
||||
allow(consumer).to receive(:consume).and_raise(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
post '/webhooks/twitter', params: { direct_message_events: [] }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -130,6 +130,30 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
||||
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked', :skip_before do
|
||||
create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||
message_params = { content: 'hello world', timestamp: Time.current }
|
||||
message_count_before_request = conversation.reload.messages.count
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post api_v1_widget_messages_url,
|
||||
params: { website_token: web_widget.website_token, message: message_params },
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
as: :json
|
||||
end
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(message_count_before_request)
|
||||
end
|
||||
|
||||
it 'creates message in conversation with a valid reply to' do
|
||||
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
|
||||
post api_v1_widget_messages_url,
|
||||
|
||||
@@ -53,6 +53,27 @@ RSpec.describe 'Public Inbox Contact Conversation Messages API', type: :request
|
||||
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
|
||||
end
|
||||
|
||||
it 'returns structured lock metadata when message creation is locked' do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation)
|
||||
message_count_before_request = conversation.reload.messages.count
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
|
||||
params: { content: 'hello' }
|
||||
end
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_limit' => 1,
|
||||
'message_limit_reached' => true,
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'message_limit'
|
||||
)
|
||||
expect(conversation.reload.messages.count).to eq(message_count_before_request)
|
||||
end
|
||||
|
||||
it 'creates attachment message in conversation' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
post "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations/#{conversation.display_id}/messages",
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AssistantStatsBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
|
||||
|
||||
describe '#metrics' do
|
||||
# Two conversations handled in the current 30-day window, one in the previous.
|
||||
let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
[current_convo_a, current_convo_b].each do |conversation|
|
||||
create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
|
||||
end
|
||||
create(:message, account: account, inbox: inbox, conversation: previous_convo,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
|
||||
end
|
||||
|
||||
it 'returns every metric for the current and previous window' do
|
||||
metrics = described_class.new(assistant, '30').metrics
|
||||
|
||||
expect(metrics.keys).to contain_exactly(
|
||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
|
||||
)
|
||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||
end
|
||||
|
||||
it 'counts distinct handled conversations per window and the percent trend' do
|
||||
handled = described_class.new(assistant, '30').metrics[:conversations_handled]
|
||||
|
||||
expect(handled[:current]).to eq(2)
|
||||
expect(handled[:previous]).to eq(1)
|
||||
expect(handled[:trend]).to eq(100.0)
|
||||
end
|
||||
|
||||
it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
|
||||
create(:reporting_event, account: account, conversation: current_convo_a,
|
||||
name: 'conversation_captain_inference_resolved')
|
||||
create(:reporting_event, account: account, conversation: current_convo_b,
|
||||
name: 'conversation_captain_inference_handoff')
|
||||
|
||||
metrics = described_class.new(assistant, '30').metrics
|
||||
|
||||
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
|
||||
expect(metrics[:handoff_rate][:current]).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'excludes resolution events that fall outside the current window' do
|
||||
create(:reporting_event, account: account, conversation: current_convo_a,
|
||||
name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
|
||||
|
||||
metrics = described_class.new(assistant, '30').metrics
|
||||
|
||||
expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'computes conversation depth as public replies per handled conversation' do
|
||||
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
|
||||
|
||||
# 2 public outgoing replies across 2 distinct conversations in the current window.
|
||||
expect(depth[:current]).to eq(1.0)
|
||||
end
|
||||
|
||||
it 'ignores private notes and incoming messages when counting public replies' do
|
||||
create(:message, account: account, inbox: inbox, conversation: current_convo_a,
|
||||
sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
|
||||
|
||||
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
|
||||
|
||||
expect(depth[:current]).to eq(1.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'range handling' do
|
||||
it 'accepts the allowed day and named ranges' do
|
||||
%w[7 30 90 this_month last_month].each do |allowed|
|
||||
expect(described_class.new(assistant, allowed).range).to eq(allowed)
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to the default range for values outside the allowed set' do
|
||||
expect(described_class.new(assistant, '365000').range).to eq('30')
|
||||
expect(described_class.new(assistant, 'bogus').range).to eq('30')
|
||||
expect(described_class.new(assistant, nil).range).to eq('30')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metrics reopen_rate' do
|
||||
# A conversation the assistant handled (messaged) inside the current 30-day window.
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
|
||||
end
|
||||
|
||||
it 'counts a reopen that happened after the captain resolve' do
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
|
||||
it 'ignores a human resolve/reopen that happened before the captain resolve' do
|
||||
# Earlier resolve/reopen cycle, then Captain resolves later in the same window.
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
|
||||
# Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
|
||||
# only holds the inference event, which is dispatched a moment after the generic conversation_resolved
|
||||
# that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved',
|
||||
event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
|
||||
it 'counts both inference and time-based bot resolves in the denominator' do
|
||||
# conversation: inference-resolved and reopened
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
# other: time-based bot-resolved, never reopened
|
||||
other = create(:conversation, account: account, inbox: inbox)
|
||||
create(:message, account: account, inbox: inbox, conversation: other,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: other,
|
||||
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'derives the cohort from handled conversations, not current inbox membership' do
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
# The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
|
||||
CaptainInbox.where(captain_assistant: assistant).delete_all
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'timezone anchoring' do
|
||||
# 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
|
||||
it 'anchors the this_month window to the supplied offset, not UTC' do
|
||||
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
|
||||
utc = described_class.new(assistant, 'this_month').period
|
||||
la = described_class.new(assistant, 'this_month', -7).period
|
||||
|
||||
expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
|
||||
expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
|
||||
expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
|
||||
end
|
||||
end
|
||||
|
||||
it 'defaults to UTC when no offset is given' do
|
||||
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
|
||||
expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metrics knowledge' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||
create_list(:captain_document, 2, assistant: assistant, account: account)
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
|
||||
it 'reports zero coverage when there are no responses' do
|
||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#period' do
|
||||
it 'labels a day range and exposes its bounds' do
|
||||
period = described_class.new(assistant, '30').period
|
||||
|
||||
expect(period[:label]).to eq('the last 30 days')
|
||||
expect(period[:starts_on]).to eq(30.days.ago.to_date)
|
||||
expect(period[:ends_on]).to eq(Time.zone.today)
|
||||
end
|
||||
|
||||
it 'labels the this_month range' do
|
||||
expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
|
||||
end
|
||||
|
||||
it 'labels the last_month range' do
|
||||
expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -252,48 +252,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
|
||||
let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
|
||||
let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
|
||||
|
||||
def get_summary(user)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
|
||||
params: { range: '30' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
before do
|
||||
# Test env uses a null store; swap in a real store so caching behaviour is observable.
|
||||
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
|
||||
allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
|
||||
end
|
||||
|
||||
it 'caches the summary per viewer so one user never receives another user\'s greeting' do
|
||||
allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
|
||||
|
||||
get_summary(alice)
|
||||
get_summary(alice) # served from Alice's cache, no regeneration
|
||||
get_summary(bob) # distinct cache key, regenerated for Bob
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Captain::OverviewSummaryService).to have_received(:new).twice
|
||||
end
|
||||
|
||||
it 'does not cache failures so a transient error is retried' do
|
||||
allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
|
||||
|
||||
get_summary(alice)
|
||||
get_summary(alice)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(json_response[:error]).to eq('LLM unavailable')
|
||||
expect(Captain::OverviewSummaryService).to have_received(:new).twice
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) do
|
||||
|
||||
@@ -104,6 +104,24 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
|
||||
end
|
||||
|
||||
it 'returns the lock response without calling Meta when the conversation is message-creation locked' do
|
||||
initiate_conversation.lock_message_creation!(reason: 'manual')
|
||||
expect(provider_service).not_to receive(:initiate_call)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
end.not_to change(Call, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body).to include(
|
||||
'error_code' => 'conversation_message_creation_locked',
|
||||
'message_creation_locked' => true,
|
||||
'message_creation_lock_reason' => 'manual'
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
|
||||
|
||||
@@ -19,6 +19,11 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when captain_tasks is disabled' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
end
|
||||
|
||||
it 'resolves pending conversations inactive for over 1 hour' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@ RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, note: 'This is a test note')
|
||||
end
|
||||
|
||||
it 'returns a locked response when the private note is dropped by the message lock' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, note: 'This is a private note')
|
||||
expect(result).to eq('Message creation is locked for this conversation')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank note content' do
|
||||
|
||||
@@ -86,6 +86,22 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, reason: reason)
|
||||
end
|
||||
|
||||
it 'hands off even when the private note is dropped by the message lock' do
|
||||
conversation.update!(status: :pending)
|
||||
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '1' do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox)
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to eq('Conversation handed off to human support team (Reason: Customer needs specialized support)')
|
||||
end.not_to change(Message, :count)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'without reason provided' do
|
||||
|
||||
@@ -99,6 +99,20 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'performs handoff when the handoff message is dropped by the conversation message limit' do
|
||||
create(:message, conversation: conversation, message_type: :outgoing, account: account)
|
||||
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '2' do
|
||||
expect do
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
end.to change { conversation.messages.count }.by(1)
|
||||
end
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::ActivityMessageJob do
|
||||
describe '#perform' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message_params) do
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: 'Conversation activity'
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops locked activity messages without raising' do
|
||||
conversation.lock_message_creation!
|
||||
|
||||
expect do
|
||||
described_class.perform_now(conversation, message_params)
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::BotProcessorService do
|
||||
describe '#perform' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message) { create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox) }
|
||||
let(:service) { described_class.new(event_name: 'message.created', hook: nil, event_data: { message: message }) }
|
||||
|
||||
it 'drops locked bot responses without reporting an exception' do
|
||||
allow(service).to receive(:should_run_processor?).and_return(true)
|
||||
allow(service).to receive(:process_content).and_raise(CustomExceptions::ConversationMessageCreationLocked.new(conversation))
|
||||
expect(ChatwootExceptionTracker).not_to receive(:new)
|
||||
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -96,6 +96,18 @@ describe Integrations::Slack::IncomingMessageBuilder do
|
||||
expect(conversation.messages.last.private).to be(true)
|
||||
end
|
||||
|
||||
it 'drops message creation when the conversation is locked' do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox)
|
||||
|
||||
with_modified_env CONVERSATION_MESSAGE_LIMIT: '1' do
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
|
||||
|
||||
expect(builder.perform).to eq({ status: 'success' })
|
||||
expect(conversation.reload.messages.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:type] = 'invalid_event_type'
|
||||
|
||||
@@ -104,6 +104,53 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message creation lock' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message_params) { { conversation: conversation, account: conversation.account, inbox: conversation.inbox } }
|
||||
|
||||
it 'detects when the message limit has been reached' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': '2' do
|
||||
create(:message, **message_params)
|
||||
|
||||
expect(conversation.reload.message_limit).to eq(2)
|
||||
expect(conversation.message_limit_reached?).to be false
|
||||
|
||||
create(:message, **message_params)
|
||||
|
||||
expect(conversation.reload.message_limit_reached?).to be true
|
||||
expect(conversation.message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_lock_reason).to eq('message_limit')
|
||||
end
|
||||
end
|
||||
|
||||
it 'stores and clears manual lock metadata' do
|
||||
freeze_time do
|
||||
conversation.lock_message_creation!(reason: 'maintenance')
|
||||
|
||||
lock_data = conversation.reload.additional_attributes['message_creation_lock']
|
||||
expect(lock_data).to include(
|
||||
'locked' => true,
|
||||
'reason' => 'maintenance',
|
||||
'locked_at' => Time.current.iso8601
|
||||
)
|
||||
expect(conversation.manual_message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_locked?).to be true
|
||||
expect(conversation.message_creation_lock_reason).to eq('manual')
|
||||
|
||||
conversation.unlock_message_creation!
|
||||
|
||||
expect(conversation.reload.additional_attributes).not_to have_key('message_creation_lock')
|
||||
expect(conversation.message_creation_locked?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
it 'fails loudly when the message limit config is invalid' do
|
||||
with_modified_env 'CONVERSATION_MESSAGE_LIMIT': 'invalid' do
|
||||
expect { conversation.message_limit }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.after_update' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:old_assignee) do
|
||||
@@ -620,6 +667,10 @@ RSpec.describe Conversation do
|
||||
contact_inbox: conversation.contact_inbox,
|
||||
timestamp: conversation.last_activity_at.to_i,
|
||||
can_reply: true,
|
||||
message_limit: conversation.message_limit,
|
||||
message_limit_reached: conversation.message_limit_reached?,
|
||||
message_creation_locked: conversation.message_creation_locked?,
|
||||
message_creation_lock_reason: conversation.message_creation_lock_reason,
|
||||
channel: 'Channel::WebWidget',
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
custom_attributes: conversation.custom_attributes,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user