Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cdd2bc14c | ||
|
|
8c6b70670c | ||
|
|
ddb0535a93 | ||
|
|
42cbf7d3b9 | ||
|
|
887897ea98 | ||
|
|
8aee518149 | ||
|
|
5733b822e3 | ||
|
|
1e52d23d7a | ||
|
|
fbb3479263 | ||
|
|
166a41c31c | ||
|
|
e65e18e9c5 |
@@ -2,6 +2,14 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -14,15 +22,23 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
bulk_create_agents(emails)
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
clear_onboarding_step
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
|
||||
phone_number = phone_number.to_s
|
||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
|
||||
@@ -26,13 +26,20 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range, signal }) {
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
// The credit_usage stat from the overview stats endpoint:
|
||||
// { current, previous, trend, daily: [{ date, value }] }
|
||||
usage: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const daily = computed(() => props.usage?.daily ?? []);
|
||||
|
||||
// Long ranges (90 days) get too many daily bars to read, so past this many
|
||||
// days the series is bucketed into weeks.
|
||||
const WEEKLY_THRESHOLD = 35;
|
||||
|
||||
const isWeekly = computed(() => daily.value.length > WEEKLY_THRESHOLD);
|
||||
|
||||
// One bucket per day, or per 7 consecutive days on long ranges (the last week
|
||||
// may be partial and always includes today). A bucket is unrecorded (null)
|
||||
// only when every day in it predates credit tracking.
|
||||
const buckets = computed(() => {
|
||||
if (!isWeekly.value) {
|
||||
return daily.value.map(day => ({
|
||||
start: day.date,
|
||||
end: day.date,
|
||||
value: day.value,
|
||||
}));
|
||||
}
|
||||
|
||||
const weeks = [];
|
||||
for (let i = 0; i < daily.value.length; i += 7) {
|
||||
const chunk = daily.value.slice(i, i + 7);
|
||||
const recorded = chunk.filter(day => day.value !== null);
|
||||
weeks.push({
|
||||
start: chunk[0].date,
|
||||
end: chunk[chunk.length - 1].date,
|
||||
value: recorded.length
|
||||
? +recorded.reduce((sum, day) => sum + day.value, 0).toFixed(2)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
});
|
||||
|
||||
// Bar heights as a percentage of the peak bucket, with a small floor so even
|
||||
// the lowest one stays visible. Unrecorded buckets render as muted
|
||||
// placeholder bars.
|
||||
const bars = computed(() => {
|
||||
const max = Math.max(...buckets.value.map(bucket => bucket.value ?? 0), 0);
|
||||
return buckets.value.map(bucket => ({
|
||||
...bucket,
|
||||
key: bucket.start,
|
||||
recorded: bucket.value !== null,
|
||||
height:
|
||||
max === 0 || bucket.value === null
|
||||
? 6
|
||||
: Math.max(6, Math.round((bucket.value / max) * 100)),
|
||||
}));
|
||||
});
|
||||
|
||||
// Bucket dates arrive as date-only strings ('2026-07-17'), which new Date()
|
||||
// would parse as midnight UTC and shift a day back for viewers west of UTC.
|
||||
// Parse the parts as a local date so the label matches the server-side bucket.
|
||||
const formatDate = date => {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const barTooltip = bar => {
|
||||
const period =
|
||||
bar.start === bar.end
|
||||
? formatDate(bar.start)
|
||||
: `${formatDate(bar.start)} – ${formatDate(bar.end)}`;
|
||||
const usage = bar.recorded
|
||||
? `${bar.value} ${t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`
|
||||
: t('CAPTAIN.OVERVIEW.CREDITS.NO_DATA');
|
||||
return `${period} · ${usage}`;
|
||||
};
|
||||
|
||||
const total = computed(() =>
|
||||
props.usage ? props.usage.current.toLocaleString() : '—'
|
||||
);
|
||||
|
||||
const trend = computed(() => {
|
||||
if (!props.usage?.trend) return '';
|
||||
const sign = props.usage.trend > 0 ? '+' : '';
|
||||
return `${sign}${props.usage.trend}%`;
|
||||
});
|
||||
|
||||
const axisStart = computed(() =>
|
||||
daily.value.length ? formatDate(daily.value[0].date) : ''
|
||||
);
|
||||
const axisEnd = computed(() =>
|
||||
daily.value.length ? formatDate(daily.value[daily.value.length - 1].date) : ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
|
||||
</h3>
|
||||
<span
|
||||
v-tooltip="$t('CAPTAIN.OVERVIEW.CREDITS.DISCLAIMER')"
|
||||
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ total }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="trend"
|
||||
class="text-sm font-medium tabular-nums text-n-slate-11"
|
||||
>
|
||||
{{ 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">
|
||||
{{
|
||||
isWeekly
|
||||
? $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND_WEEKLY')
|
||||
: $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-end flex-1">
|
||||
<div class="flex items-end flex-1 gap-1 min-h-24">
|
||||
<div
|
||||
v-for="bar in bars"
|
||||
:key="bar.key"
|
||||
v-tooltip="barTooltip(bar)"
|
||||
class="flex-1 rounded-t transition-colors"
|
||||
:class="
|
||||
bar.recorded
|
||||
? 'bg-n-brand hover:bg-n-brand/70'
|
||||
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
|
||||
"
|
||||
:style="{ height: `${bar.height}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-xs text-n-slate-10">{{ axisStart }}</span>
|
||||
<span class="text-xs text-n-slate-10">{{ axisEnd }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+2
-2
@@ -57,13 +57,13 @@ const stats = computed(() => [
|
||||
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 mt-4 overflow-hidden rounded-full bg-n-alpha-2">
|
||||
<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 mt-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<RouterLink
|
||||
v-for="stat in stats"
|
||||
:key="stat.key"
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ const onActivate = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-5 bg-n-solid-1"
|
||||
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
|
||||
:class="
|
||||
clickable
|
||||
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
|
||||
@@ -43,7 +43,7 @@ const onActivate = () => {
|
||||
<span
|
||||
v-if="hint"
|
||||
v-tooltip="hint"
|
||||
class="cursor-help i-lucide-info size-3.5 text-n-slate-9"
|
||||
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loading" class="flex items-end justify-between gap-2">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,7 +53,11 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -9,7 +11,6 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -460,14 +460,6 @@
|
||||
"PENDING": "Pending FAQs",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"CREDITS": {
|
||||
"TITLE": "Credit usage",
|
||||
"UNIT": "credits",
|
||||
"LEGEND": "Daily credits used",
|
||||
"LEGEND_WEEKLY": "Weekly credits used",
|
||||
"NO_DATA": "No data recorded",
|
||||
"DISCLAIMER": "Shows credits used by this assistant only. Your account's total credit usage also includes other assistants and features."
|
||||
},
|
||||
"LINKS": {
|
||||
"DOCS": {
|
||||
"TITLE": "Captain docs",
|
||||
|
||||
@@ -13,7 +13,6 @@ import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overvi
|
||||
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
|
||||
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
|
||||
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
|
||||
import CreditUsageCard from 'dashboard/components-next/captain/pageComponents/overview/CreditUsageCard.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';
|
||||
@@ -27,25 +26,28 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const stats = ref(null);
|
||||
const isFetching = ref(false);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let fetchToken = 0;
|
||||
let abortController = null;
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
|
||||
const fetchStats = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
stats.value = null;
|
||||
isFetching.value = true;
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
|
||||
const requestStats = () =>
|
||||
CaptainAssistant.getStats({
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
@@ -53,25 +55,54 @@ const fetchStats = async () => {
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestStats());
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === fetchToken && !signal.aborted)
|
||||
({ data } = await requestStats());
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== fetchToken || signal.aborted) return;
|
||||
stats.value = data;
|
||||
isFetching.value = false;
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
};
|
||||
|
||||
onUnmounted(() => abortController?.abort());
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { 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.
|
||||
@@ -91,7 +122,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = stats.value?.[statKey];
|
||||
const data = metricStats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -185,9 +216,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
|
||||
<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"
|
||||
@@ -200,16 +231,15 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetching"
|
||||
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
<CreditUsageCard :usage="stats?.credit_usage" />
|
||||
</div>
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<BaseTableCell class="max-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:name="bot.name || ''"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
class="flex-shrink-0"
|
||||
|
||||
@@ -68,6 +68,10 @@ const isAgentBot = computed(
|
||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const selectedItemName = computed(() =>
|
||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
||||
);
|
||||
|
||||
const selectedThumbnail = computed(
|
||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||
);
|
||||
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
|
||||
<h4
|
||||
v-else
|
||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
:title="selectedItem.name"
|
||||
:title="selectedItemName"
|
||||
>
|
||||
{{ selectedItem.name }}
|
||||
{{ selectedItemName }}
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||
:src="selectedThumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItem.name"
|
||||
:name="selectedItemName"
|
||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||
:size="24"
|
||||
hide-offline-status
|
||||
|
||||
@@ -53,7 +53,9 @@ export default {
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
return this.options.filter(option => {
|
||||
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||
return (option.name || '')
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
noResult() {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -57,6 +62,9 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -90,19 +98,35 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -144,6 +168,7 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||
if should_send_template_message
|
||||
send_template_message
|
||||
else
|
||||
send_session_message
|
||||
end
|
||||
return send_template_message if template_params.present?
|
||||
return send_session_message if message.conversation.can_reply?
|
||||
|
||||
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
|
||||
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
|
||||
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
|
||||
# raw digits first and then a normalized fallback, accepting only a candidate whose
|
||||
# phone_number_id matches.
|
||||
class Whatsapp::WebhookChannelFinderService
|
||||
def initialize(display_phone_number:, phone_number_id:)
|
||||
@display_phone_number = display_phone_number
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
return if digits.blank?
|
||||
|
||||
candidates = [
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
|
||||
channel_by_normalized_number
|
||||
]
|
||||
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def digits
|
||||
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
|
||||
end
|
||||
|
||||
def channel_by_normalized_number
|
||||
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
|
||||
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
|
||||
return unless normalizer
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
|
||||
end
|
||||
end
|
||||
@@ -154,6 +154,7 @@ en:
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
||||
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
|
||||
+2
-1
@@ -66,7 +66,8 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :stats
|
||||
get :metrics
|
||||
get :faq_stats
|
||||
get :summary
|
||||
get :drilldown
|
||||
end
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# Computes per-assistant credit consumption (from agent_sessions) for the
|
||||
# Captain Overview page: window totals for the trend plus a per-day series for
|
||||
# the usage chart.
|
||||
#
|
||||
# Days are bucketed on the viewer's calendar day, so a completed day's sum never
|
||||
# changes. Those sums are cached per (assistant, timezone, date) with a long TTL
|
||||
# and only the still-accruing current day is computed live on every request.
|
||||
class Captain::AssistantCreditUsageBuilder
|
||||
CACHE_TTL = 30.days
|
||||
|
||||
# Credit tracking on agent_sessions began on this date. Earlier days are still
|
||||
# returned in `daily` but with a nil value ("no data recorded") and are
|
||||
# excluded from the queried span and the window totals.
|
||||
DATA_EPOCH = Date.new(2026, 7, 17)
|
||||
|
||||
def initialize(assistant, window)
|
||||
@assistant = assistant
|
||||
@window = window
|
||||
end
|
||||
|
||||
# => { current:, previous:, daily: [{ date:, value: }, ...] }
|
||||
# `daily` covers the current window only; `previous` is the total for the
|
||||
# preceding equal-length day span, used for the trend. A nil daily value means
|
||||
# the day predates credit tracking.
|
||||
def build
|
||||
sums = daily_sums(recorded_dates)
|
||||
|
||||
{
|
||||
current: total(sums, current_dates),
|
||||
previous: total(sums, previous_dates),
|
||||
daily: current_dates.map { |date| { date: date, value: sums[date] } }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :assistant, :window
|
||||
|
||||
# Credit usage is bucketed on calendar days (unlike the rolling timestamp
|
||||
# windows of the other metrics): a day-count range means exactly N calendar
|
||||
# days ending today in the viewer's timezone, and month ranges follow the
|
||||
# window's calendar boundaries.
|
||||
def current_dates
|
||||
@current_dates ||= if day_range?
|
||||
(today - (window.range.to_i - 1))..today
|
||||
else
|
||||
to_dates(window.current)
|
||||
end
|
||||
end
|
||||
|
||||
# Day ranges derive the comparison span from the current dates (the N days
|
||||
# right before them), since current_dates redefines the window as N calendar
|
||||
# days ending today. Named month ranges follow window.previous so the trend
|
||||
# compares the same periods as every other overview metric.
|
||||
def previous_dates
|
||||
if day_range?
|
||||
(current_dates.first - current_dates.count)..(current_dates.first - 1)
|
||||
else
|
||||
to_dates(window.previous)
|
||||
end
|
||||
end
|
||||
|
||||
def day_range?
|
||||
window.range.match?(/\A\d+\z/)
|
||||
end
|
||||
|
||||
def to_dates(range)
|
||||
range.first.in_time_zone(timezone).to_date..range.last.in_time_zone(timezone).to_date
|
||||
end
|
||||
|
||||
# Pre-epoch days are never queried or cached; they fall out of the sums hash
|
||||
# and surface as nil daily values / zero contribution to totals. While the
|
||||
# previous span is fully pre-epoch this leaves `previous` at 0, so the
|
||||
# frontend hides the trend.
|
||||
def recorded_dates
|
||||
(previous_dates.to_a + current_dates.to_a).select { |date| date >= DATA_EPOCH }
|
||||
end
|
||||
|
||||
def daily_sums(dates)
|
||||
cacheable = dates.select { |date| date < today }
|
||||
cached = Rails.cache.read_multi(*cacheable.map { |date| cache_key(date) })
|
||||
|
||||
missing = dates.reject { |date| cached.key?(cache_key(date)) }
|
||||
live = live_sums(missing)
|
||||
missing.each do |date|
|
||||
Rails.cache.write(cache_key(date), live[date], expires_in: CACHE_TTL) if date < today
|
||||
end
|
||||
|
||||
dates.index_with { |date| cached.fetch(cache_key(date)) { live[date] } }
|
||||
end
|
||||
|
||||
# One grouped scan covering the span of the requested dates. Zero-filled via
|
||||
# groupdate so cache entries exist even for days without sessions.
|
||||
def live_sums(dates)
|
||||
return {} if dates.empty?
|
||||
|
||||
span = dates.min.in_time_zone(timezone).beginning_of_day..dates.max.in_time_zone(timezone).end_of_day
|
||||
Captain::AgentSession
|
||||
.where(assistant_id: assistant.id, created_at: span)
|
||||
.group_by_day(:created_at, time_zone: timezone, range: span)
|
||||
.sum(:credits_consumed)
|
||||
.to_h { |day, value| [day.to_date, (value || 0).round(2)] }
|
||||
end
|
||||
|
||||
def total(sums, dates)
|
||||
dates.sum { |date| sums[date].to_f }.round(2)
|
||||
end
|
||||
|
||||
def today
|
||||
@today ||= Time.current.in_time_zone(timezone).to_date
|
||||
end
|
||||
|
||||
def timezone
|
||||
window.timezone
|
||||
end
|
||||
|
||||
def cache_key(date)
|
||||
"captain_credit_usage/#{assistant.id}/#{timezone_name}/#{date}"
|
||||
end
|
||||
|
||||
def timezone_name
|
||||
@timezone_name ||= timezone.is_a?(String) ? timezone : timezone.name
|
||||
end
|
||||
end
|
||||
@@ -37,6 +37,23 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def faq_stats
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window
|
||||
@@ -56,9 +73,7 @@ class Captain::AssistantStatsBuilder
|
||||
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),
|
||||
credit_usage: credit_usage,
|
||||
knowledge: knowledge
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -74,7 +89,7 @@ class Captain::AssistantStatsBuilder
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||
reopen: reopen_rate(range),
|
||||
reopen: reopen_rate(range, resolution[:resolved]),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
@@ -159,7 +174,9 @@ class Captain::AssistantStatsBuilder
|
||||
# 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)
|
||||
def reopen_rate(range, resolved_count)
|
||||
return 0 if resolved_count.zero?
|
||||
|
||||
resolved_scope = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
@@ -179,31 +196,7 @@ class Captain::AssistantStatsBuilder
|
||||
'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
|
||||
|
||||
# Credits consumed by the assistant's agent sessions: window totals packed like
|
||||
# the other trend metrics, plus the per-day series for the usage chart.
|
||||
def credit_usage
|
||||
usage = Captain::AssistantCreditUsageBuilder.new(assistant, window).build
|
||||
pack(usage[:current], usage[:previous], :percent).merge(daily: usage[:daily])
|
||||
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
|
||||
}
|
||||
rate(reopened, resolved_count)
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
|
||||
@@ -14,7 +14,7 @@ class Captain::AssistantStatsWindow
|
||||
DEFAULT_RANGE = '30'.freeze
|
||||
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
|
||||
|
||||
attr_reader :range, :timezone
|
||||
attr_reader :range
|
||||
|
||||
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
|
||||
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -42,10 +42,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def stats
|
||||
def metrics
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
|
||||
end
|
||||
|
||||
def summary
|
||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||
result = cached_or_generated_summary(window, summary_stats)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
|
||||
@@ -7,16 +7,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
||||
|
||||
def perform(conversation, assistant)
|
||||
def perform(conversation, assistant, trigger_message_id = nil)
|
||||
@conversation = conversation
|
||||
@inbox = conversation.inbox
|
||||
@assistant = assistant
|
||||
@trigger_message_id = trigger_message_id if captain_v2_enabled?
|
||||
|
||||
return unless conversation_pending?
|
||||
|
||||
Current.executed_by = @assistant
|
||||
|
||||
if captain_v2_enabled?
|
||||
return unless trigger_message_current?
|
||||
|
||||
generate_response_with_v2
|
||||
else
|
||||
generate_and_process_response
|
||||
@@ -45,11 +48,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation)
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation,
|
||||
trigger_message_id: @trigger_message_id
|
||||
)
|
||||
message_history = Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
@response = runner_service.generate_response(message_history: message_history)
|
||||
@run_result = runner_service.last_run_result
|
||||
|
||||
return if runner_service.response_discarded?
|
||||
return unless trigger_message_current?
|
||||
|
||||
process_response
|
||||
end
|
||||
|
||||
@@ -77,16 +87,24 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
message = create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
||||
process_standard_response
|
||||
end
|
||||
end
|
||||
|
||||
def process_standard_response
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
next unless trigger_message_current?
|
||||
|
||||
message = create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
return unless message
|
||||
|
||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -117,6 +135,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def process_v2_handoff
|
||||
return unless trigger_message_current?
|
||||
|
||||
# HandoffTool already ran bot_handoff! + OOO inside the agent loop. Preserve
|
||||
# waiting_since so this message doesn't clear the timestamp it left in place.
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
@@ -152,7 +172,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
@response ||= {}
|
||||
@response['action_source'] ||= 'error'
|
||||
@response['action_reason'] ||= error_action_reason(error)
|
||||
process_v1_handoff if conversation_pending?
|
||||
process_v1_handoff if conversation_pending? && (!captain_v2_enabled? || trigger_message_current?)
|
||||
true
|
||||
end
|
||||
|
||||
@@ -181,4 +201,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
|
||||
status == 'pending' || status == Conversation.statuses[:pending]
|
||||
end
|
||||
|
||||
def trigger_message_current?
|
||||
return true if @trigger_message_id.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
@conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id == @trigger_message_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,11 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def stats?
|
||||
def metrics?
|
||||
true
|
||||
end
|
||||
|
||||
def faq_stats?
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
attr_reader :last_run_result
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil, trigger_message_id: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
@trigger_message_id = trigger_message_id
|
||||
@handoff_tool_called = false
|
||||
end
|
||||
|
||||
@@ -31,6 +32,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
def response_discarded?
|
||||
@response_discarded == true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_context(message_history)
|
||||
@@ -151,10 +156,9 @@ class Captain::Assistant::AgentRunnerService
|
||||
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
|
||||
end
|
||||
|
||||
if ChatwootApp.otel_enabled?
|
||||
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||
write_credits_used_metadata(context_wrapper)
|
||||
end
|
||||
runner.on_run_complete do |_agent_name, _result, context_wrapper|
|
||||
@response_discarded = trigger_message_stale?
|
||||
write_run_metadata(context_wrapper) if ChatwootApp.otel_enabled?
|
||||
end
|
||||
runner
|
||||
end
|
||||
@@ -169,11 +173,22 @@ class Captain::Assistant::AgentRunnerService
|
||||
@handoff_tool_called = true
|
||||
end
|
||||
|
||||
def write_credits_used_metadata(context_wrapper)
|
||||
def write_run_metadata(context_wrapper)
|
||||
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
|
||||
return unless root_span
|
||||
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), @handoff_tool_called ? 'false' : 'true')
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'discarded'), response_discarded?.to_s)
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), (!@handoff_tool_called && !response_discarded?).to_s)
|
||||
end
|
||||
|
||||
def trigger_message_stale?
|
||||
return false if @trigger_message_id.blank? || @conversation.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
@conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id != @trigger_message_id
|
||||
end
|
||||
|
||||
def runner
|
||||
|
||||
@@ -13,7 +13,8 @@ class Captain::Assistant::InstrumentationAttributeProvider
|
||||
|
||||
def generation_attributes(_context_wrapper, _chat, message)
|
||||
{
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message),
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'discarded') => @service.send(:trigger_message_stale?).to_s
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ module Captain::Assistant::RunnerStateHelper
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
state[:trigger_message_id] = @trigger_message_id if @trigger_message_id.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
|
||||
@@ -31,6 +31,7 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
|
||||
def schedule_captain_response
|
||||
job_args = [conversation, conversation.inbox.captain_assistant]
|
||||
job_args << message.id if conversation.account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
if message.attachments.blank?
|
||||
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
|
||||
|
||||
@@ -5,6 +5,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
return 'Handoff skipped because a newer customer message arrived' unless trigger_message_current?(tool_context.state, conversation)
|
||||
|
||||
# Log the handoff with reason
|
||||
log_tool_usage('tool_handoff', {
|
||||
@@ -23,6 +24,17 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
private
|
||||
|
||||
def trigger_message_current?(state, conversation)
|
||||
trigger_message_id = state&.dig(:trigger_message_id)
|
||||
return true if trigger_message_id.blank?
|
||||
|
||||
latest_incoming_message_id = Conversation.uncached do
|
||||
conversation.messages.incoming.maximum(:id)
|
||||
end
|
||||
|
||||
latest_incoming_message_id == trigger_message_id
|
||||
end
|
||||
|
||||
def trigger_handoff(tool_context, conversation, reason)
|
||||
# post the reason as a private note
|
||||
note = conversation.messages.create!(
|
||||
|
||||
+1
-4
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -86,9 +86,6 @@
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
|
||||
Generated
+6
-22
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
specifier: 1.3.23
|
||||
version: 1.3.23
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
@@ -180,15 +180,6 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -461,8 +452,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
||||
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
@@ -4001,9 +3992,6 @@ packages:
|
||||
prosemirror-tables@1.5.0:
|
||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
@@ -5136,7 +5124,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -9035,7 +9023,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.10.0
|
||||
prosemirror-transform: 1.12.0
|
||||
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
@@ -9051,10 +9039,6 @@ snapshots:
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
@@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
it 'deletes the linear integration' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
@@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an administrator' do
|
||||
it 'deletes the shopify integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
@@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized and keeps the integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to(change { account.hooks.count })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
|
||||
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
|
||||
expect(metrics.keys).to contain_exactly(
|
||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||
:hours_saved, :reopen_rate, :conversation_depth, :credit_usage, :knowledge
|
||||
:hours_saved, :reopen_rate, :conversation_depth
|
||||
)
|
||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||
end
|
||||
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metrics knowledge' do
|
||||
describe '#faq_stats' 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)
|
||||
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
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]
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
end
|
||||
|
||||
@@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
|
||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
||||
competing_agent_created = false
|
||||
|
||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
||||
unless competing_agent_created
|
||||
create(:user, account: account, role: :agent)
|
||||
competing_agent_created = true
|
||||
end
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
expect(User.from_email(params[:email])).to be_nil
|
||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
|
||||
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
|
||||
let(:assistant_model) { Llm::Models.default_model_for('assistant') }
|
||||
let(:trigger_message) { conversation.messages.find_by!(content: 'Hello') }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
@@ -23,6 +24,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(nil)
|
||||
allow(mock_agent_runner_service).to receive(:response_discarded?).and_return(false)
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
|
||||
@@ -358,14 +360,28 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
it 'uses Captain::Assistant::AgentRunnerService' do
|
||||
expect(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
conversation: conversation
|
||||
conversation: conversation,
|
||||
trigger_message_id: trigger_message.id
|
||||
)
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
described_class.perform_now(conversation, assistant, trigger_message.id)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
|
||||
end
|
||||
|
||||
it 'discards a response when a newer message arrives during generation' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response) do
|
||||
create(:message, conversation: conversation, content: 'New context', message_type: :incoming)
|
||||
{ 'response' => 'Stale response', 'handoff_tool_called' => false }
|
||||
end
|
||||
allow(mock_agent_runner_service).to receive(:response_discarded?).and_return(true)
|
||||
|
||||
described_class.perform_now(conversation, assistant, trigger_message.id)
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(0)
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'passes message history with resolution markers to agent runner service' do
|
||||
same_second = Time.current.change(usec: 0)
|
||||
conversation.messages.find_by!(content: 'Hello').update!(created_at: same_second, updated_at: same_second)
|
||||
|
||||
@@ -28,6 +28,37 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with a trigger message id' do
|
||||
let(:trigger_message) do
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
|
||||
end
|
||||
let(:same_second) { Time.current.change(usec: 0) }
|
||||
let(:tool_context) do
|
||||
Struct.new(:state).new({ conversation: { id: conversation.id }, trigger_message_id: trigger_message.id })
|
||||
end
|
||||
|
||||
it 'hands off when the trigger message is still the latest message' do
|
||||
trigger_message
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to include('Conversation handed off')
|
||||
end.to change(Message, :count).by(1)
|
||||
end
|
||||
|
||||
it 'skips the handoff when a newer message has arrived' do
|
||||
trigger_message
|
||||
conversation.update!(status: :pending)
|
||||
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
expect(result).to eq('Handoff skipped because a newer customer message arrived')
|
||||
end.not_to change(Message, :count)
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reason provided' do
|
||||
it 'creates a private note with reason and hands off conversation' do
|
||||
reason = 'Customer needs specialized support'
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :playground? do
|
||||
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
@@ -52,6 +52,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
|
||||
end
|
||||
|
||||
it 'accepts the trigger message id' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
|
||||
|
||||
expect(service.instance_variable_get(:@trigger_message_id)).to eq(123)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_response' do
|
||||
@@ -99,6 +105,18 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'adds the trigger message id to the runner state' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
|
||||
|
||||
expect(mock_runner).to receive(:run).with(
|
||||
'I need help with my account',
|
||||
context: hash_including(state: hash_including(trigger_message_id: 123)),
|
||||
max_turns: 10
|
||||
)
|
||||
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
context 'when the latest user message is multimodal' do
|
||||
let(:multimodal_message_history) do
|
||||
[
|
||||
@@ -446,6 +464,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
attributes = provider.generation_attributes(nil, nil, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
|
||||
expect(attributes['langfuse.observation.metadata.discarded']).to eq('false')
|
||||
end
|
||||
|
||||
it 'marks tool call generations separately from final responses' do
|
||||
@@ -456,6 +475,18 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
|
||||
end
|
||||
|
||||
it 'marks a generation as discarded when a newer message has arrived' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
runner_service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
attribute_provider = Captain::Assistant::InstrumentationAttributeProvider.new(runner_service)
|
||||
message = instance_double(RubyLLM::Message, tool_calls: {})
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
attributes = attribute_provider.generation_attributes(nil, nil, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.discarded']).to eq('true')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_state' do
|
||||
@@ -574,6 +605,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
@@ -588,6 +620,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
tool_complete_callback = block
|
||||
runner
|
||||
end
|
||||
allow(runner).to receive(:on_run_complete).and_return(runner)
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
@@ -599,15 +632,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(context_wrapper.context[:captain_v2_handoff_tool_called]).to be true
|
||||
end
|
||||
|
||||
it 'does not register OTEL run callback when OTEL is disabled' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
it 'tracks discarded responses when OTEL is disabled' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
run_complete_callback = nil
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(false)
|
||||
allow(runner).to receive(:on_tool_complete).and_return(runner)
|
||||
expect(runner).not_to receive(:on_run_complete)
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
run_complete_callback.call('assistant', nil, Struct.new(:context).new({}))
|
||||
|
||||
expect(service.response_discarded?).to be true
|
||||
end
|
||||
|
||||
it 'sets credit_used=true when handoff tool is not used' do
|
||||
@@ -629,9 +671,38 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'true')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
end
|
||||
|
||||
it 'marks the trace discarded and does not use credit when a newer message arrived' do
|
||||
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
|
||||
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
|
||||
runner = instance_double(Agents::AgentRunner)
|
||||
run_complete_callback = nil
|
||||
span_class = Class.new do
|
||||
def set_attribute(*); end
|
||||
end
|
||||
root_span = instance_double(span_class)
|
||||
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
|
||||
|
||||
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
|
||||
allow(runner).to receive(:on_tool_complete).and_return(runner)
|
||||
allow(runner).to receive(:on_run_complete) do |&block|
|
||||
run_complete_callback = block
|
||||
runner
|
||||
end
|
||||
|
||||
service.send(:add_usage_metadata_callback, runner)
|
||||
create(:message, conversation: conversation, message_type: :incoming)
|
||||
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'true')
|
||||
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
|
||||
run_complete_callback.call('assistant', nil, context_wrapper)
|
||||
|
||||
expect(service.response_discarded?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'constants' do
|
||||
|
||||
+6
-2
@@ -22,9 +22,13 @@ RSpec.describe MessageTemplates::HookExecutionService do
|
||||
end
|
||||
|
||||
it 'schedules captain response job for incoming messages on pending conversations' do
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
allow(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later)
|
||||
|
||||
create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
message = create(:message, conversation: conversation, message_type: :incoming, account: account)
|
||||
|
||||
expect(Captain::Conversation::ResponseBuilderJob).to have_received(:perform_later).with(conversation, assistant, message.id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -345,6 +345,94 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
|
||||
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: brazil_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
|
||||
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: argentina_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '5491112345678'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel when display_phone_number contains formatting characters' do
|
||||
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: formatted_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '+1 415-555-2671'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
|
||||
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
|
||||
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
|
||||
end
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: normalized_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
|
||||
@@ -72,6 +72,21 @@ describe Whatsapp::SendOnWhatsappService do
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
|
||||
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
|
||||
conversation: conversation, account: conversation.account)
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
|
||||
end
|
||||
|
||||
it 'marks message as failed when template name is blank' do
|
||||
processor = instance_double(Whatsapp::TemplateProcessorService)
|
||||
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
||||
|
||||
Reference in New Issue
Block a user