Compare commits

..
155 changed files with 861 additions and 2775 deletions
+1 -1
View File
@@ -1 +1 @@
4.16.1 4.16.0
+3 -19
View File
@@ -2,14 +2,6 @@
# It initializes with necessary attributes and provides a perform method # It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction. # to create a user and account user in a transaction.
class AgentBuilder 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. # Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user. # @param email [String] the email of the user.
# @param name [String] the name of the user. # @param name [String] the name of the user.
@@ -22,23 +14,15 @@ class AgentBuilder
# Creates a user and account user in a transaction. # Creates a user and account user in a transaction.
# @return [User] the created user. # @return [User] the created user.
def perform def perform
account.with_lock do ActiveRecord::Base.transaction do
raise LimitExceededError unless can_add_agent? @user = find_or_create_user
create_account_user
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
end end
@user @user
end end
private 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. # Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user. # @return [User] the found or created user.
def find_or_create_user def find_or_create_user
@@ -1,4 +1,5 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization before_action :check_authorization
before_action :agent_bot, except: [:index, :create] before_action :agent_bot, except: [:index, :create]
@@ -1,6 +1,8 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :fetch_agent, except: [:create, :index, :bulk_create]
before_action :check_authorization before_action :check_authorization
before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
def index def index
@agents = agents @agents = agents
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
) )
@agent = builder.perform @agent = builder.perform
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end end
def update def update
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def bulk_create def bulk_create
emails = params[:emails] emails = params[:emails]
bulk_create_agents(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
# This endpoint is used to bulk create agents during onboarding # 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 # onboarding_step key in present in Current account custom attributes, since this is a one time operation
clear_onboarding_step Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok head :ok
rescue AgentBuilder::LimitExceededError => e
render_payment_required(e.message)
end end
private private
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end end
def bulk_create_agents(emails) def validate_limit_for_bulk_create
Current.account.with_lock do limit_available = params[:emails].count <= available_agent_count
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
emails.each { |email| create_agent_from_email(email) } render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
end end
def create_agent_from_email(email) def validate_limit
builder = AgentBuilder.new( render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
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 end
def available_agent_count def available_agent_count
Current.account.usage_limits[:agents] - Current.account.account_users.count Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end end
def delete_user_record(agent) def delete_user_record(agent)
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update] before_action :authorize_account_update, only: [:update]
def show def show
@@ -1,9 +0,0 @@
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::Integrations::BaseController class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, except: [:create] before_action :fetch_hook, except: [:create]
before_action :check_authorization before_action :check_authorization
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
@hook = Current.account.hooks.find(params[:id]) @hook = Current.account.hooks.find(params[:id])
end end
def check_authorization
authorize(:hook)
end
def permitted_params def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {}) params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy def destroy
revoke_linear_token revoke_linear_token
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
before_action :check_authorization, only: [:destroy]
def destroy def destroy
@hook.destroy! @hook.destroy!
@@ -1,8 +1,7 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders] before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth] before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders] before_action :validate_contact, only: [:orders]
def auth def auth
@@ -1,4 +1,5 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create] before_action :fetch_label, except: [:index, :create]
before_action :check_authorization before_action :check_authorization
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
@@ -19,13 +18,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new( service = Whatsapp::EmbeddedSignupService.new(
account: Current.account, account: Current.account,
@@ -52,7 +44,8 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
def can_reconfigure_channel? def can_reconfigure_channel?
channel = @inbox.channel channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud' return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
# Reconfiguring a live embedded-signup channel requires the feature flag.
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true true
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank? return if metadata.blank?
Whatsapp::WebhookChannelFinderService.new( phone_number = normalized_phone_number(metadata[:display_phone_number])
display_phone_number: metadata[:display_phone_number], phone_number_id = metadata[:phone_number_id]
phone_number_id: metadata[:phone_number_id] channel = Channel::Whatsapp.find_by(phone_number: phone_number)
).perform
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}"
end end
def inactive_whatsapp_number? def inactive_whatsapp_number?
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CaptainAgentSessions extends ApiClient {
constructor() {
super('captain/agent_sessions', { accountScoped: true });
}
}
export default new CaptainAgentSessions();
@@ -26,25 +26,15 @@ class CaptainAssistant extends ApiClient {
}); });
} }
getMetrics({ assistantId, range, signal }) { getStats({ assistantId, range }) {
const requestConfig = { return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() }, params: { range, timezone_offset: getTimezoneOffset() },
}; });
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
} }
getFaqStats({ assistantId, signal }) { getSummary({ assistantId, range }) {
const requestConfig = {};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
}
getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, { return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset(), stats }, params: { range, timezone_offset: getTimezoneOffset() },
}); });
} }
@@ -10,13 +10,10 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data); return axios.get(`${this.url}/${callId}`).then(r => r.data);
} }
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation. initiate(conversationId, sdpOffer) {
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios return axios
.post(`${this.url}/initiate`, { .post(`${this.url}/initiate`, {
conversation_id: conversationId, conversation_id: conversationId,
contact_id: contactId,
inbox_id: inboxId,
sdp_offer: sdpOffer, sdp_offer: sdpOffer,
}) })
.then(r => r.data); .then(r => r.data);
@@ -28,12 +28,7 @@ const inboxes = computed(() => {
return { return {
name: inbox.name, name: inbox.name,
id: inbox.id, id: inbox.id,
icon: getInboxIconByType( icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
inbox.channelType,
inbox.medium,
'line',
inbox.voiceEnabled
),
}; };
}); });
}); });
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper'; import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue'; import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
@@ -26,11 +25,8 @@ const route = useRoute();
const kind = computed(() => getCallKind(props.call)); const kind = computed(() => getCallKind(props.call));
const contactName = computed(() => const contactName = computed(
(props.call.contact.name || props.call.contact.phoneNumber || '').replace( () => props.call.contact.name || props.call.contact.phoneNumber
/^\+/,
''
)
); );
const agentActionLabel = computed(() => { const agentActionLabel = computed(() => {
@@ -61,7 +57,7 @@ const resultLabel = computed(() => {
}); });
const providerIcon = computed(() => const providerIcon = computed(() =>
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium) props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
); );
const createdAtLabel = computed(() => const createdAtLabel = computed(() =>
@@ -151,7 +147,7 @@ const conversationRoute = computed(() => ({
<div <div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5" class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
> >
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5"> <div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar <Avatar
:src="call.contact.avatar" :src="call.contact.avatar"
:name="contactName" :name="contactName"
@@ -170,12 +166,9 @@ const conversationRoute = computed(() => ({
> >
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5"> <div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" /> <CallStatusBadge :kind="kind" class="shrink-0" />
<div <template v-if="agentActionLabel">
v-if="agentActionLabel"
class="gap-x-1.5 min-w-0 flex items-center"
>
<span <span
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14" class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
> >
{{ agentActionLabel }} {{ agentActionLabel }}
</span> </span>
@@ -196,7 +189,7 @@ const conversationRoute = computed(() => ({
{{ call.agent.name }} {{ call.agent.name }}
</span> </span>
</span> </span>
</div> </template>
<span <span
v-else-if="resultLabel" v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]" class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
@@ -216,10 +209,10 @@ const conversationRoute = computed(() => ({
content: call.inbox.name, content: call.inbox.name,
delay: { show: 500, hide: 0 }, delay: { show: 500, hide: 0 },
}" }"
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5" class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
> >
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" /> <Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0"> <span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }} {{ call.inbox.name }}
</span> </span>
</div> </div>
@@ -236,7 +229,7 @@ const conversationRoute = computed(() => ({
content: createdAtLabel, content: createdAtLabel,
delay: { show: 500, hide: 0 }, delay: { show: 500, hide: 0 },
}" }"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0" class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
> >
{{ createdAtLabel }} {{ createdAtLabel }}
</span> </span>
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown. // Null while a fetch is in flight so stale counts are never shown.
@@ -118,16 +117,10 @@ const moreFiltersSections = computed(() => [
}, },
]); ]);
const selectedAssignee = computed(
() => props.agents.find(agent => agent.id === assigneeId.value) || null
);
const selectedAssigneeLabel = computed( const selectedAssigneeLabel = computed(
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE') () =>
); props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
const isOtherActivitySelected = computed(() =>
OTHER_ACTIVITIES.includes(activity.value)
); );
const hasMoreFilters = computed(() => Boolean(inboxId.value)); const hasMoreFilters = computed(() => Boolean(inboxId.value));
@@ -150,7 +143,7 @@ const applyMoreFilter = ({ action, value }) => {
<template> <template>
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-3">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0"> <span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{ {{
totalCount === null totalCount === null
@@ -164,13 +157,13 @@ const applyMoreFilter = ({ action, value }) => {
color="blue" color="blue"
size="sm" size="sm"
:icon="ACTIVITY_ICONS[activity]" :icon="ACTIVITY_ICONS[activity]"
class="shrink-0 !h-7 !px-2" class="shrink-0"
@click="setActivity(null)" @click="setActivity(null)"
> >
{{ activeChipLabel }} {{ activeChipLabel }}
<Icon icon="i-lucide-x" /> <Icon icon="i-lucide-x" />
</Button> </Button>
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" /> <div class="w-px h-4 bg-n-strong shrink-0" />
<Button <Button
v-for="chip in inactiveChips" v-for="chip in inactiveChips"
:key="chip" :key="chip"
@@ -179,7 +172,7 @@ const applyMoreFilter = ({ action, value }) => {
size="sm" size="sm"
:icon="ACTIVITY_ICONS[chip]" :icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)" :label="activityLabel(chip)"
class="shrink-0 text-n-slate-11 !h-7 !px-2" class="shrink-0 text-n-slate-12"
@click="setActivity(chip)" @click="setActivity(chip)"
/> />
<OnClickOutside <OnClickOutside
@@ -191,10 +184,7 @@ const applyMoreFilter = ({ action, value }) => {
color="slate" color="slate"
size="sm" size="sm"
icon="i-lucide-phone" icon="i-lucide-phone"
class="!h-7 !px-2" class="text-n-slate-12"
:class="
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
"
@click="toggleMenu('activity')" @click="toggleMenu('activity')"
> >
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }} {{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
@@ -218,19 +208,10 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline" variant="outline"
color="slate" color="slate"
size="sm" size="sm"
icon="i-woot-empty-assignee" icon="i-lucide-user-round-cog"
class="max-w-52 !h-7 !px-2" class="max-w-52 text-n-slate-12"
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
@click="toggleMenu('assignee')" @click="toggleMenu('assignee')"
> >
<template v-if="selectedAssignee" #icon>
<Avatar
:src="selectedAssignee.thumbnail"
:name="selectedAssignee.name"
:size="16"
rounded-full
/>
</template>
<span class="truncate">{{ selectedAssigneeLabel }}</span> <span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" /> <Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button> </Button>
@@ -247,9 +228,8 @@ const applyMoreFilter = ({ action, value }) => {
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-list-filter" icon="i-lucide-list-filter"
class="!h-7 !px-2"
:color="hasMoreFilters ? 'blue' : 'slate'" :color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-11'" :class="hasMoreFilters ? '' : 'text-n-slate-12'"
@click="toggleMenu('more')" @click="toggleMenu('more')"
> >
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }} {{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
@@ -83,12 +83,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || ''); const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { const { medium, channel_type: type } = props.inbox;
medium, return getInboxIconByType(type, medium);
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
}); });
</script> </script>
@@ -16,6 +16,7 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls'; import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -82,18 +83,39 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession(); const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => { const startWhatsappCall = async (inboxId, conversationIdHint) => {
const response = await whatsappCallSession.initiateOutboundCall( // WhatsApp /initiate is conversation-scoped, so we must hand it a
conversationIdHint // conversation. Use the caller's hint when given (in-conversation flow);
? { conversationId: conversationIdHint } // otherwise pick the most recent one in the inbox.
: { contactId: props.contactId, inboxId } const conversationId =
); conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
// The composable returns { status: 'locked' } when an init is already in // The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than // flight or a call is already active; treat that as a soft no-op rather than
// claiming success. // claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) { if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and // Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of // surface whether the request was just sent or is already pending instead of
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name); const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { channelType, medium, voiceEnabled } = inbox.value; const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled); return getInboxIconByType(channelType, medium);
}); });
const lastActivityAt = computed(() => { const lastActivityAt = computed(() => {
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox); const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
const { channelType, medium, voiceEnabled } = inbox.value; const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled); return getInboxIconByType(channelType, medium);
}); });
const hasSlaThreshold = computed(() => { const hasSlaThreshold = computed(() => {
@@ -234,7 +234,6 @@ onMounted(() => resetContacts());
ref="popoverRef" ref="popoverRef"
:align="align" :align="align"
:show-content-border="false" :show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow" @show="onPopoverShow"
@hide="onPopoverHide" @hide="onPopoverHide"
> >
@@ -37,11 +37,10 @@ const transformInbox = ({
channelType, channelType,
phoneNumber, phoneNumber,
medium, medium,
voiceEnabled,
...rest ...rest
}) => ({ }) => ({
id, id,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled), icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({ label: generateLabelForContactableInboxesList({
name, name,
email, email,
@@ -55,7 +54,6 @@ const transformInbox = ({
phoneNumber, phoneNumber,
channelType, channelType,
medium, medium,
voiceEnabled,
...rest, ...rest,
}); });
@@ -79,20 +79,6 @@ describe('composeConversationHelper', () => {
channelType: INBOX_TYPES.EMAIL, channelType: INBOX_TYPES.EMAIL,
}); });
}); });
it('uses the voice glyph for a voice-enabled inbox', () => {
const inboxes = [
{
id: 2,
name: 'WhatsApp Cloud',
channelType: INBOX_TYPES.WHATSAPP,
voiceEnabled: true,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
});
}); });
describe('getCapitalizedNameFromEmail', () => { describe('getCapitalizedNameFromEmail', () => {
@@ -117,8 +117,8 @@ const downloadRecording = () => {
> >
<template #icon> <template #icon>
<Icon <Icon
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'" :icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0 text-n-slate-11" class="size-4 flex-shrink-0"
/> />
</template> </template>
</Button> </Button>
@@ -127,10 +127,10 @@ const downloadRecording = () => {
min="0" min="0"
:max="duration || 0" :max="duration || 0"
:value="currentTime" :value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11" class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek" @input="seek"
/> />
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0"> <span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }} {{ displayedTime }}
</span> </span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" /> <div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
@@ -58,12 +58,8 @@ const menuItems = computed(() => [
]); ]);
const icon = computed(() => { const icon = computed(() => {
const { const { medium, channel_type: type } = props.inbox;
medium, return getInboxIconByType(type, medium, 'outline');
channel_type: type,
voice_enabled: voiceEnabled,
} = props.inbox;
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
}); });
const handleAction = ({ action, value }) => { const handleAction = ({ action, value }) => {
@@ -9,7 +9,6 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction // null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null }, trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false }, clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
}); });
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
@@ -46,11 +45,7 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100" class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/> />
</div> </div>
<div v-if="loading" class="flex items-end justify-between gap-2"> <div class="flex items-end justify-between gap-2">
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-else class="flex items-end justify-between gap-2">
<span <span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12" class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
> >
@@ -9,10 +9,6 @@ const props = defineProps({
type: String, type: String,
default: '30', default: '30',
}, },
stats: {
type: Object,
default: null,
},
}); });
const route = useRoute(); const route = useRoute();
@@ -24,41 +20,22 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref(''); const welcomeMarkdown = ref('');
const isLoading = ref(false); const isLoading = ref(false);
// Increments on every fetch so a slow response for a superseded
// range/stats/assistant can't overwrite the latest request's state.
let fetchToken = 0;
const fetchSummary = async () => { const fetchSummary = async () => {
fetchToken += 1;
const token = fetchToken;
if (!props.stats) {
welcomeMarkdown.value = '';
isLoading.value = false;
return;
}
isLoading.value = true; isLoading.value = true;
let message = '';
try { try {
const { data } = await CaptainAssistant.getSummary({ const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value, assistantId: assistantId.value,
range: props.range, range: props.range,
stats: props.stats,
}); });
message = data.message ?? ''; welcomeMarkdown.value = data.message ?? '';
} catch { } catch {
message = ''; welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
} }
if (token !== fetchToken) return;
welcomeMarkdown.value = message;
isLoading.value = false;
}; };
watch([() => props.range, () => props.stats, assistantId], fetchSummary, { watch([() => props.range, assistantId], fetchSummary, { immediate: true });
immediate: true,
});
// Render through the shared markdown formatter (html disabled, so it is safe) // Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It // used everywhere else for Captain output, instead of a bespoke parser. It
@@ -3,13 +3,8 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators'; import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
isTwilioComplete, import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
@@ -45,23 +40,30 @@ const templateBody = computed(() => {
return props.template.body || ''; return props.template.body || '';
}); });
// Media-template detection and variable extraction are shared with the mobile const hasMediaTemplate = computed(() => {
// app via @chatwoot/utils. return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); });
const hasVariables = computed(() => { const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null; return templateBody.value?.match(VARIABLE_PATTERN) !== null;
}); });
const mediaVariableKey = computed(() => const mediaVariableKey = computed(() => {
getTwilioMediaVariableKey(props.template) if (!hasMediaTemplate.value) return null;
); const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null); const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() => const templateMediaUrl = computed(() => {
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' if (!hasMediaTemplate.value) return '';
);
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => { const variablePattern = computed(() => {
if (!hasVariables.value) return []; if (!hasVariables.value) return [];
@@ -81,10 +83,26 @@ const renderedTemplate = computed(() => {
return rendered; return rendered;
}); });
// Completeness validation is shared with the mobile app via @chatwoot/utils. const isFormInvalid = computed(() => {
const isFormInvalid = computed( if (!hasVariables.value && !hasMediaVariable.value) return false;
() => !isTwilioComplete(props.template, processedParams.value)
); if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate( const v$ = useVuelidate(
{ {
@@ -117,11 +135,19 @@ const sendMessage = () => {
const { friendly_name, language } = props.template; const { friendly_name, language } = props.template;
// For media templates, reduce the media URL to a filename before sending. // Process parameters and extract filename from media URL if needed
const processedParameters = applyTwilioMediaFilename( const processedParameters = { ...processedParams.value };
props.template,
processedParams.value // For media templates, extract filename from full URL
); if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = { const payload = {
message: renderedTemplate.value, message: renderedTemplate.value,
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, toRef } from 'vue'; import { computed, toRef } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useChannelIcon, useChannelBrandIcon } from './provider'; import { useChannelIcon, useChannelBrandIcon } from './provider';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@@ -20,6 +21,7 @@ defineOptions({ inheritAttrs: false });
const inboxRef = toRef(props, 'inbox'); const inboxRef = toRef(props, 'inbox');
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
const channelIcon = useChannelIcon(inboxRef); const channelIcon = useChannelIcon(inboxRef);
const brandIcon = useChannelBrandIcon(inboxRef); const brandIcon = useChannelBrandIcon(inboxRef);
@@ -29,7 +31,13 @@ const icon = computed(() =>
</script> </script>
<template> <template>
<span class="inline-flex" v-bind="$attrs"> <span class="relative inline-flex" v-bind="$attrs">
<Icon :icon="icon" class="size-full" /> <Icon :icon="icon" class="size-full" />
<span
v-if="hasVoiceBadge"
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
>
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
</span>
</span> </span>
</template> </template>
@@ -1,9 +1,4 @@
import { import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
INBOX_TYPES,
TWILIO_CHANNEL_MEDIUM,
isVoiceCallEnabled,
getInboxVoiceIcon,
} from 'dashboard/helper/inbox';
import { computed } from 'vue'; import { computed } from 'vue';
const channelTypeIconMap = { const channelTypeIconMap = {
@@ -66,9 +61,16 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp'; icon = 'i-woot-whatsapp';
} }
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph. // Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
if (isVoiceCallEnabled(inboxDetails)) { // is presented as a Voice channel, so show the phone icon.
icon = getInboxVoiceIcon(type, inboxDetails.medium); const voiceEnabled =
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
if (
type === INBOX_TYPES.TWILIO &&
voiceEnabled &&
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
) {
icon = 'i-woot-voice';
} }
return icon ?? 'i-ri-global-fill'; return icon ?? 'i-ri-global-fill';
@@ -19,32 +19,13 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp'); expect(icon).toBe('i-woot-whatsapp');
}); });
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => { it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = { const inbox = {
channel_type: 'Channel::TwilioSms', channel_type: 'Channel::TwilioSms',
voice_enabled: true, voice_enabled: true,
}; };
const { value: icon } = useChannelIcon(inbox); const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice-call'); expect(icon).toBe('i-woot-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::Whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
});
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-whatsapp-voice');
}); });
it('returns correct icon for Line channel', () => { it('returns correct icon for Line channel', () => {
@@ -1,342 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMessageContext } from './provider.js';
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation, variant, createdAt } = useMessageContext();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const isOpen = ref(false);
const showSparkle = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const session = computed(() =>
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
);
const hasFetched = computed(() =>
store.getters['captainAgentSessions/hasFetched'](props.messageId)
);
const isLoading = computed(
() =>
!hasFetched.value ||
store.getters['captainAgentSessions/isFetching'](props.messageId)
);
const citations = computed(() => session.value?.citations || []);
const scenarioTitles = computed(() =>
(session.value?.scenarios || []).reduce((map, scenario) => {
map[scenario.id] = scenario.title;
return map;
}, {})
);
// Fallback for agents without a matching scenario title:
// "chatwoot_assistant" → "Chatwoot assistant",
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
const humanizeAgentName = agentName => {
const label = agentName
.replace(/^scenario_\d+_/, '')
.replace(/_agent$/, '')
.replaceAll('_', ' ')
.trim();
return label.charAt(0).toUpperCase() + label.slice(1);
};
const handoffLabel = agentName => {
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
};
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
// Tool names arrive as RubyLLM identifiers like
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
// show "FAQ Lookup" / "Get Status Page Overview" instead.
const humanizeToolName = name => {
return (name || '')
.split('--')
.pop()
.replace(/^custom_/, '')
.split('_')
.filter(Boolean)
.map(word =>
ACRONYMS.includes(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ');
};
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
const humanizeArgumentKey = key =>
key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const formatArguments = args => {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
.join(', ');
};
// Timeline of what Captain did during the run: tool calls (with their
// arguments) and scenario/agent handoffs. Message bodies and raw tool
// results are intentionally not echoed here.
const steps = computed(() => {
const runContext = session.value?.runContext;
const result = [];
let currentAgent = null;
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
if (entry?.role !== 'assistant') return;
const agentName = entry.agentName;
if (agentName && agentName !== currentAgent) {
if (currentAgent !== null) {
result.push({ type: 'handoff', name: handoffLabel(agentName) });
}
currentAgent = agentName;
}
(entry.toolCalls || []).forEach(call => {
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
// the agent_name change above already yields a handoff step for them.
if (call.name?.startsWith('handoff_to_')) return;
result.push({
type: 'tool',
name: humanizeToolName(call.name),
detail: formatArguments(call.arguments),
});
});
});
return result;
});
// The final assistant entry stores structured content ({response, reasoning});
// surface the model's reasoning for the reply it produced.
const reasoning = computed(() => {
const runContext = session.value?.runContext;
if (!Array.isArray(runContext)) return '';
const entry = [...runContext]
.reverse()
.find(item => item?.role === 'assistant' && item.content?.reasoning);
return entry?.content?.reasoning || '';
});
const STEP_ICONS = {
tool: 'i-ph-wrench',
handoff: 'i-ph-user-switch',
};
const STEP_KEYPATHS = {
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
};
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
// Model and credits are only surfaced to super admins and in development.
const devDetails = computed(() => {
if (!session.value) return null;
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
model: session.value.llmModel,
});
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
credits: session.value.creditsConsumed,
});
return `${model} · ${credits}`;
});
// With the sparkle at the row start, the meta gets pushed to the opposite end;
// without it, fall back to the message orientation.
const rowLayoutClass = computed(() => {
if (showSparkle.value) return 'justify-between';
return orientation.value === ORIENTATION.LEFT
? 'justify-start'
: 'justify-end';
});
// Blend the sparkle with the bubble background: amber on private notes,
// slate everywhere else. Tokens adapt to dark mode on their own.
const sparkleColorClass = computed(() => {
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
return isOpen.value
? 'text-n-amber-12/80'
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
}
return isOpen.value
? 'text-n-slate-12'
: 'text-n-slate-11/60 hover:text-n-slate-12';
});
const popoverAlign = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
);
const prefetch = () => {
store.dispatch('captainAgentSessions/fetch', {
messageId: props.messageId,
createdAt: createdAt.value,
});
};
const onPopoverShow = () => {
isOpen.value = true;
prefetch();
};
const onPopoverHide = () => {
isOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
<Popover
v-if="showSparkle"
:align="popoverAlign"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
:class="sparkleColorClass"
@mouseenter="prefetch"
@focus="prefetch"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
<span class="text-xs">
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
</span>
</button>
<template #content>
<div class="flex flex-col gap-4 p-4 w-80">
<span v-if="isLoading" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!session" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="steps.length" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
</span>
<div class="flex flex-col">
<div
v-for="(step, index) in steps"
:key="index"
class="flex gap-2.5"
>
<div class="flex flex-col items-center">
<span
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 w-px min-h-2 bg-n-weak"
/>
</div>
<div
class="flex flex-col min-w-0 gap-0.5"
:class="index < steps.length - 1 ? 'pb-3' : ''"
>
<I18nT
:keypath="STEP_KEYPATHS[step.type]"
tag="span"
class="text-xs leading-5 text-n-slate-11"
>
<template #name>
<span class="font-medium text-n-slate-12">
{{ step.name }}
</span>
</template>
</I18nT>
<span
v-if="step.detail"
class="text-xs text-n-slate-11 break-words"
>
{{ step.detail }}
</span>
</div>
</div>
</div>
</div>
<div v-if="citations.length" class="flex flex-col gap-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<span class="text-xs text-n-slate-10">
{{
t(
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
citations.length
)
}}
</span>
</div>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="citation in citations"
:key="citation.id"
class="text-xs text-n-slate-12"
>
<a
v-if="citation.link"
:href="citation.link"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-n-blue-11 hover:underline"
>
{{ citation.title || citation.link }}
</a>
<span v-else>{{ citation.title }}</span>
</li>
</ul>
</div>
<div v-if="reasoning" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
{{ reasoning }}
</p>
</div>
<span v-if="devDetails" class="text-xs text-n-slate-11">
{{ devDetails }}
</span>
</template>
</div>
</template>
</Popover>
<slot name="meta" />
</div>
</template>
@@ -457,11 +457,10 @@ function handleReplyTo() {
const avatarInfo = computed(() => { const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) { if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium, voice_enabled } = const { name, avatar_url, channel_type, medium } = inbox.value;
inbox.value;
const iconName = avatar_url const iconName = avatar_url
? null ? null
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled); : getInboxIconByType(channel_type, medium);
return { return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'), name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '', src: avatar_url || '',
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue'; import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
@@ -10,38 +9,16 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants'; import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const props = defineProps({ const props = defineProps({
hideMeta: { type: Boolean, default: false }, hideMeta: { type: Boolean, default: false },
}); });
const { const { variant, orientation, inReplyTo, shouldGroupWithNext } =
variant, useMessageContext();
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = { const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12', [MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]: [MESSAGE_VARIANTS.PRIVATE]:
@@ -137,21 +114,16 @@ const replyToPreview = computed(() => {
/> />
</div> </div>
<slot /> <slot />
<template v-if="shouldShowMeta"> <MessageMeta
<CaptainGenerationDetails v-if="shouldShowMeta"
v-if="isCaptainMessage" :class="[
:message-id="id" flexOrientationClass,
class="mt-2" variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
> variant === MESSAGE_VARIANTS.PRIVATE
<template #meta> ? 'text-n-amber-12/50'
<MessageMeta :class="[emailMetaClass, metaColorClass]" /> : 'text-n-slate-11',
</template> ]"
</CaptainGenerationDetails> class="mt-2"
<MessageMeta />
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div> </div>
</template> </template>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return; if (!canCallBack.value || isInitiatingCall.value) return;
try { try {
if (isWhatsapp.value) { if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall({ const response = await whatsappCallSession.initiateOutboundCall(
conversationId: conversationId.value, conversationId.value
}); );
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet. // Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) { if (!response?.id) {
@@ -1,11 +1,7 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition'; import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -20,10 +16,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: { showContentBorder: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -49,12 +41,8 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align } { align: props.align }
); );
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => { const show = async () => {
isActive.value = true; isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) { if (!isMobile.value) {
await nextTick(); await nextTick();
updatePosition(); updatePosition();
@@ -68,22 +56,6 @@ const hide = () => {
emit('hide'); emit('hide');
}; };
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => { const toggle = async () => {
if (isActive.value) hide(); if (isActive.value) hide();
else await show(); else await show();
@@ -13,7 +13,6 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators'; import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import { import {
buildTemplateParameters, buildTemplateParameters,
@@ -85,10 +84,29 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value); return replaceTemplateVariables(bodyText.value, processedParams.value);
}); });
// Completeness validation is shared with the mobile app via @chatwoot/utils. const isFormInvalid = computed(() => {
const isFormInvalid = computed( if (!hasVariables.value && !hasMediaHeader.value) return false;
() => !isWhatsAppComplete(props.template, processedParams.value)
); if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate( const v$ = useVuelidate(
{ {
@@ -77,15 +77,6 @@ const handleClose = () => {
emit('close'); emit('close');
}; };
const handleFocusOut = event => {
// Keep the menu open while focus stays inside it (e.g. the label search
// input); close it once focus leaves the menu entirely.
if (menuRef.value?.contains(event.relatedTarget)) {
return;
}
handleClose();
};
onUnmounted(() => { onUnmounted(() => {
isLocked.value = false; isLocked.value = false;
}); });
@@ -98,7 +89,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer" class="fixed outline-none z-[9999] cursor-pointer"
:style="position" :style="position"
tabindex="0" tabindex="0"
@focusout="handleFocusOut" @blur="handleClose"
> >
<slot /> <slot />
</div> </div>
@@ -8,8 +8,6 @@ import {
EditorState, EditorState,
Selection, Selection,
imageResizeView, imageResizeView,
toggleMark,
wrapInList,
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { import {
suggestionsPlugin, suggestionsPlugin,
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview'; import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph'; import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds'; 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 { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers'; import { isEscape } from 'shared/helpers/KeyboardHelpers';
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
const startWhatsappCall = async () => { const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return; if (whatsappCallSession.isInitiating.value) return;
try { try {
const response = await whatsappCallSession.initiateOutboundCall({ const response = await whatsappCallSession.initiateOutboundCall(
conversationId: props.chat.id, props.chat.id
}); );
// Composable returns LOCKED when init is already in flight or a call is // Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner. // active; soft no-op so a parallel click doesn't trigger a banner.
@@ -33,7 +33,9 @@ import {
// constants // constants
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links'; import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals'; import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID', currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded', listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}), }),
isOpen() { isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN; return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,13 @@ export default {
instagramInbox instagramInbox
); );
}, },
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() { replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY'); return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -454,7 +464,15 @@ export default {
> >
<div ref="topBannerRef"> <div ref="topBannerRef">
<Banner <Banner
v-if="!currentChat.can_reply" v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert" color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg" class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage" :banner-message="replyWindowBannerMessage"
@@ -7,13 +7,10 @@ import {
getSortedAgentsByAvailability, getSortedAgentsByAvailability,
getAgentsByUpdatedPresence, getAgentsByUpdatedPresence,
} from 'dashboard/helper/agentHelper.js'; } from 'dashboard/helper/agentHelper.js';
import { picoSearch } from '@scmmishra/pico-search';
import MenuItem from './menuItem.vue'; import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const MENU = { const MENU = {
MARK_AS_READ: 'mark-as-read', MARK_AS_READ: 'mark-as-read',
@@ -34,8 +31,6 @@ export default {
MenuItem, MenuItem,
MenuItemWithSubmenu, MenuItemWithSubmenu,
AgentLoadingPlaceholder, AgentLoadingPlaceholder,
NextInput,
Icon,
}, },
props: { props: {
chatId: { chatId: {
@@ -92,7 +87,6 @@ export default {
data() { data() {
return { return {
MENU, MENU,
labelSearchQuery: '',
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: { readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -222,14 +216,6 @@ export default {
// Don't show snooze if the conversation is already snoozed/resolved/pending // Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN; return this.status === wootConstants.STATUS_TYPE.OPEN;
}, },
filteredLabels() {
const labels = this.labelSearchQuery
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
: this.labels;
// Assigned labels first, keeping each group's existing order.
const isAssigned = label => this.conversationLabels.includes(label.title);
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
},
}, },
mounted() { mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]); this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -349,49 +335,21 @@ export default {
:option="labelMenuConfig" :option="labelMenuConfig"
:sub-menu-available="!!labels.length" :sub-menu-available="!!labels.length"
> >
<div class="pb-1 w-[12.5rem]"> <MenuItem
<NextInput v-for="label in labels"
v-model="labelSearchQuery" :key="label.id"
type="search" :option="generateMenuLabelConfig(label, 'label')"
size="sm" :variant="
class="w-full" conversationLabels.includes(label.title)
custom-input-class="!ps-8 !text-xs" ? 'label-assigned'
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')" : 'label'
@click.stop "
@keydown.stop @click.stop="
> conversationLabels.includes(label.title)
<template #prefix> ? $emit('removeLabel', label)
<Icon : $emit('assignLabel', label)
icon="i-lucide-search" "
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2" />
/>
</template>
</NextInput>
</div>
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
<MenuItem
v-for="label in filteredLabels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
:variant="
conversationLabels.includes(label.title)
? 'label-assigned'
: 'label'
"
@mousedown.prevent
@click.stop="
conversationLabels.includes(label.title)
? $emit('removeLabel', label)
: $emit('assignLabel', label)
"
/>
<p
v-if="!filteredLabels.length"
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
>
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
</p>
</div>
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<MenuItemWithSubmenu <MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])" v-if="isAllowed([MENU.AGENT])"
@@ -15,7 +15,7 @@ defineProps({
</script> </script>
<template> <template>
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button"> <div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
<fluent-icon <fluent-icon
v-if="variant === 'icon' && option.icon" v-if="variant === 'icon' && option.icon"
:icon="option.icon" :icon="option.icon"
@@ -52,7 +52,7 @@ defineProps({
<Icon <Icon
v-if="variant === 'label-assigned'" v-if="variant === 'label-assigned'"
icon="i-lucide-check" icon="i-lucide-check"
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white" class="flex-shrink-0 size-3.5 mr-1"
/> />
</div> </div>
</template> </template>
@@ -1,9 +1,9 @@
import { useMapGetter } from 'dashboard/composables/store';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue'; import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList'; import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures'; import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n // Mock vue-i18n
vi.mock('vue-i18n', () => ({ vi.mock('vue-i18n', () => ({
@@ -94,32 +94,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length); 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', () => { it('handles empty assignable agents', () => {
mockUseMapGetter({ mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []), 'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { import {
getAgentsByUpdatedPresence, getAgentsByUpdatedPresence,
getSortedAgentsByAvailability, getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper'; } from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/** /**
* A composable function that provides a list of agents for assignment. * A composable function that provides a list of agents for assignment.
@@ -53,11 +53,7 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>} * @type {import('vue').ComputedRef<Array>}
*/ */
const agentsList = computed(() => { const agentsList = computed(() => {
const agents = (assignableAgents.value || []).map(agent => const agents = assignableAgents.value || [];
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence( const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents, agents,
currentUser.value, currentUser.value,
@@ -308,8 +308,7 @@ export function useWhatsappCallSession() {
} }
}; };
// target: { conversationId } or { contactId, inboxId } const initiateOutboundCall = async conversationId => {
const initiateOutboundCall = async target => {
// Module-scoped lock + active-session guard so a second click — from the // Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel) // same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup // OR while a call is already live — can't tear down the in-flight setup
@@ -321,7 +320,10 @@ export function useWhatsappCallSession() {
isInitiatingOutbound.value = true; isInitiatingOutbound.value = true;
try { try {
const sdpOffer = await prepareOutboundOffer(); const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(target, sdpOffer); const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
if (response?.id) { if (response?.id) {
activeCallId = response.id; activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered; // A connect webhook that raced ahead of this response was buffered;
@@ -352,7 +354,7 @@ export function useWhatsappCallSession() {
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED || data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) { ) {
return { status: data.status, conversation_id: data.conversation_id }; return { status: data.status };
} }
throw e; throw e;
} finally { } finally {
@@ -78,3 +78,5 @@ export default {
}, },
}; };
export const DEFAULT_REDIRECT_URL = '/app/'; export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
+2 -1
View File
@@ -7,7 +7,8 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations', AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns', CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign', WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation', WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer', WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure', WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses', CANNED_RESPONSES: 'canned_responses',
+19 -2
View File
@@ -127,8 +127,25 @@ export const getHostNameFromURL = url => {
} }
}; };
// Shared with the mobile app via @chatwoot/utils. /**
export { extractFilenameFromUrl } from '@chatwoot/utils'; * Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
/** /**
* Normalizes a comma/newline separated list of domains * Normalizes a comma/newline separated list of domains
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => { export const getAgentsByAvailability = (agents, availability) => {
return agents return agents
.filter(agent => agent.availability_status === availability) .filter(agent => agent.availability_status === availability)
.sort((a, b) => (a.name || '').localeCompare(b.name || '')); .sort((a, b) => a.name.localeCompare(b.name));
}; };
/** /**
@@ -1,6 +1,4 @@
import { import {
InputRule,
inputRules,
MessageMarkdownSerializer, MessageMarkdownSerializer,
MessageMarkdownTransformer, MessageMarkdownTransformer,
messageSchema, messageSchema,
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; 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. * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
+1 -27
View File
@@ -55,30 +55,11 @@ export const getVoiceCallProvider = inbox => {
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null; export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
// Combined channel + voice-wave badge glyph per voice-call provider.
export const VOICE_CALL_ICONS = {
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
};
export const getVoiceCallIcon = provider =>
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
export const TWILIO_CHANNEL_MEDIUM = { export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp', WHATSAPP: 'whatsapp',
SMS: 'sms', SMS: 'sms',
}; };
export const getInboxVoiceIcon = (channelType, medium) => {
const isWhatsapp =
channelType === INBOX_TYPES.WHATSAPP ||
(channelType === INBOX_TYPES.TWILIO &&
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
return getVoiceCallIcon(
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
);
};
const INBOX_ICON_MAP_FILL = { const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill', [INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill', [INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -201,14 +182,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
} }
}; };
export const getInboxIconByType = ( export const getInboxIconByType = (type, medium, variant = 'fill') => {
type,
medium,
variant = 'fill',
voiceEnabled = false
) => {
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
const iconMap = const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE; variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon = const defaultIcon =
@@ -26,18 +26,6 @@ describe('agentHelper', () => {
offlineAgentsData 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', () => { describe('getSortedAgentsByAvailability', () => {
@@ -1,11 +1,8 @@
import { import {
INBOX_TYPES, INBOX_TYPES,
VOICE_CALL_PROVIDERS,
getInboxClassByType, getInboxClassByType,
getInboxIconByType, getInboxIconByType,
getInboxVoiceIcon,
getInboxWarningIconClass, getInboxWarningIconClass,
getVoiceCallIcon,
} from '../inbox'; } from '../inbox';
describe('#Inbox Helpers', () => { describe('#Inbox Helpers', () => {
@@ -169,63 +166,4 @@ describe('#Inbox Helpers', () => {
); );
}); });
}); });
describe('getVoiceCallIcon', () => {
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for the twilio provider', () => {
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
'i-woot-voice-call'
);
});
it('falls back to the generic voice-call glyph for an unknown provider', () => {
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
});
});
describe('getInboxVoiceIcon', () => {
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-woot-whatsapp-voice'
);
});
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-woot-voice-call'
);
});
});
describe('getInboxIconByType with voice enabled', () => {
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
).toBe('i-woot-whatsapp-voice');
});
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
'i-woot-voice-call'
);
});
it('returns the normal channel icon when voice is not enabled', () => {
expect(
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
).toBe('i-woot-whatsapp');
});
});
}); });
@@ -156,18 +156,12 @@ describe('templateHelper', () => {
]); ]);
}); });
it('should handle templates with no variables but a media header', () => { it('should handle templates with no variables', () => {
const emptyTemplate = templates.find( const emptyTemplate = templates.find(
t => t.name === 'no_variable_template' t => t.name === 'no_variable_template'
); );
const result = buildTemplateParameters(emptyTemplate); const result = buildTemplateParameters(emptyTemplate, false);
// hasMediaHeader is derived from the template, so the document header is kept. expect(result).toEqual({});
expect(result.body).toBeUndefined();
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
}); });
it('should build parameters for templates with multiple component types', () => { it('should build parameters for templates with multiple component types', () => {
@@ -1,16 +1,19 @@
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils'; // Constants
// Constants and pure template helpers are shared with the mobile app via
// @chatwoot/utils so the logic lives in one place.
export {
MEDIA_FORMATS,
COMPONENT_TYPES,
findComponentByType,
processVariable,
} from '@chatwoot/utils';
export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY'; export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => { export const allKeysRequired = value => {
const keys = Object.keys(value); const keys = Object.keys(value);
@@ -24,7 +27,70 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
}); });
}; };
// The media-header flag is derived from the template inside the shared helper; export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
// the second argument is kept for backwards-compatible call sites. const allVariables = {};
export const buildTemplateParameters = template =>
buildWhatsAppProcessedParams(template); const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to", "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", "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.", "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.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:", "REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection", "REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
@@ -70,20 +72,6 @@
"RATING_TITLE": "Rating", "RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback", "FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available", "REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CAPTAIN_GENERATION": {
"TITLE": "How was this reply generated?",
"GENERATED_BY": "Generated by Captain",
"LOADING": "Loading details…",
"EMPTY": "No generation details available for this message.",
"TIMELINE": "Generation steps",
"STEP_TOOL": "Called {name}",
"STEP_HANDOFF": "Handed off to {name}",
"REASONING": "Reasoning",
"SOURCES": "Knowledge base",
"SOURCES_SUMMARY": "{count} result | {count} results",
"MODEL": "Generated with {model}",
"CREDITS": "Credits: {credits}"
},
"CARD": { "CARD": {
"SHOW_LABELS": "Show labels", "SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels", "HIDE_LABELS": "Hide labels",
@@ -193,8 +181,6 @@
}, },
"ASSIGN_AGENT": "Assign agent", "ASSIGN_AGENT": "Assign agent",
"ASSIGN_LABEL": "Assign label", "ASSIGN_LABEL": "Assign label",
"SEARCH_LABELS": "Search labels",
"NO_LABELS_FOUND": "No labels found",
"AGENTS_LOADING": "Loading agents...", "AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team", "ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation", "DELETE": "Delete conversation",
@@ -58,7 +58,9 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again", "ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.", "NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore." "DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
}, },
"TIKTOK": { "TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok", "CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
if (!inbox.value) return null; if (!inbox.value) return null;
const { channelType, medium, voiceEnabled } = inbox.value; const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled); return getInboxIconByType(channelType, medium);
}); });
</script> </script>
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => { const inboxIcon = computed(() => {
if (!inbox.value) return null; if (!inbox.value) return null;
const { channelType, medium, voiceEnabled } = inbox.value; const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled); return getInboxIconByType(channelType, medium);
}); });
const fileAttachments = computed(() => { const fileAttachments = computed(() => {
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed, ref, watch, onMounted } from 'vue'; import { computed, ref, watch } from 'vue';
import { until } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
@@ -50,9 +49,7 @@ const isVoiceEnabled = computed(
const calls = computed(() => callHistoryStore.records); const calls = computed(() => callHistoryStore.records);
const meta = computed(() => callHistoryStore.meta); const meta = computed(() => callHistoryStore.meta);
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching); const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
const accountUiFlags = useMapGetter('accounts/getUIFlags'); const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
const isInitializing = ref(true);
// Filters are seeded from the URL so a shared link restores the same view. // Filters are seeded from the URL so a shared link restores the same view.
const activity = ref( const activity = ref(
@@ -101,25 +98,20 @@ const onPageChange = page => {
fetchCalls(); fetchCalls();
}; };
onMounted(async () => { // inboxes/get flips isFetching true synchronously, so the spinner shows on the
try { // first render and the setup CTA never flashes; hit the calls endpoint only
await Promise.all([ // once inboxes confirm voice is on.
store.dispatch('inboxes/get'), store.dispatch('inboxes/get').then(() => {
until(() => accountUiFlags.value.isFetchingItem).toBe(false), if (!isVoiceEnabled.value) return;
]); // Only admins see the assignee filter, so only they need the agent list.
if (!isVoiceEnabled.value) return; if (isAdmin.value) store.dispatch('agents/get');
// Only admins see the assignee filter, so only they need the agent list. fetchCalls();
if (isAdmin.value) store.dispatch('agents/get');
await fetchCalls();
} finally {
isInitializing.value = false;
}
}); });
</script> </script>
<template> <template>
<div <div
v-if="isInitializing" v-if="inboxesUiFlags.isFetching"
class="flex items-center justify-center w-full h-full bg-n-surface-1" class="flex items-center justify-center w-full h-full bg-n-surface-1"
> >
<Spinner :size="24" /> <Spinner :size="24" />
@@ -129,22 +121,22 @@ onMounted(async () => {
v-else v-else
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1" class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
> >
<header class="shrink-0"> <header class="px-6 pt-6 pb-4 shrink-0">
<div class="w-full px-6 pt-6"> <div class="w-full">
<h1 class="text-xl font-medium text-n-slate-12"> <h1 class="text-xl font-medium text-n-slate-12">
{{ t('CALLS_PAGE.HEADER') }} {{ t('CALLS_PAGE.HEADER') }}
</h1> </h1>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</div> </div>
<CallsFilterBar
v-model:activity="activity"
v-model:assignee-id="assigneeId"
v-model:inbox-id="inboxId"
class="mt-5 pb-4 border-b border-n-weak mx-6"
:total-count="isFetching ? null : meta.count"
:agents="agents"
:inboxes="voiceInboxes"
:show-assignee="isAdmin"
/>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full"> <div class="w-full">
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -26,83 +26,21 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month'); const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId); const assistantId = computed(() => route.params.assistantId);
const metricStats = ref(null); const stats = ref(null);
const faqStats = ref(null);
const isFetchingMetrics = ref(false);
// Increments on every fetch so a response (or retry) from a superseded const fetchStats = async () => {
// range/assistant can't clobber the latest request's state. try {
let metricsFetchToken = 0; const { data } = await CaptainAssistant.getStats({
let faqStatsFetchToken = 0;
let metricsAbortController = null;
let faqStatsAbortController = null;
const fetchMetrics = async () => {
metricsFetchToken += 1;
const token = metricsFetchToken;
metricsAbortController?.abort();
metricsAbortController = new AbortController();
const { signal } = metricsAbortController;
metricStats.value = null;
isFetchingMetrics.value = true;
const requestMetrics = () =>
CaptainAssistant.getMetrics({
assistantId: assistantId.value, assistantId: assistantId.value,
range: selectedRange.value, range: selectedRange.value,
signal,
}); });
stats.value = data;
let data = null;
try {
({ data } = await requestMetrics());
} catch { } catch {
// One silent retry before giving up, unless the request was aborted. stats.value = null;
try {
if (token === metricsFetchToken && !signal.aborted)
({ data } = await requestMetrics());
} catch {
data = null;
}
}
if (token !== metricsFetchToken || signal.aborted) return;
metricStats.value = data;
isFetchingMetrics.value = false;
};
const fetchFaqStats = async () => {
faqStatsFetchToken += 1;
const token = faqStatsFetchToken;
faqStatsAbortController?.abort();
faqStatsAbortController = new AbortController();
const { signal } = faqStatsAbortController;
faqStats.value = null;
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(() => { watch([selectedRange, assistantId], fetchStats, { immediate: true });
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 // `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign. // neutral, so we can colour the delta independently of its sign.
@@ -122,7 +60,7 @@ const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`; hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => { const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = metricStats.value?.[statKey]; const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null }; if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : ''; const sign = data.trend > 0 ? '+' : '';
@@ -216,9 +154,9 @@ const closeDrilldown = () => {
<div class="flex flex-col gap-6 pb-8"> <div class="flex flex-col gap-6 pb-8">
<InboxBanner /> <InboxBanner />
<CoverageBanner :knowledge="faqStats ?? undefined" /> <CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" :stats="summaryStats" /> <WelcomeCard :range="selectedRange" />
<div <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" 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"
@@ -231,15 +169,12 @@ const closeDrilldown = () => {
:trend="metric.trend" :trend="metric.trend"
:hint="metric.hint" :hint="metric.hint"
:trend-good="metric.trendGood" :trend-good="metric.trendGood"
:loading="isFetchingMetrics" :clickable="canDrilldown && Boolean(metric.metric)"
:clickable="
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
"
@click="openDrilldown(metric)" @click="openDrilldown(metric)"
/> />
</div> </div>
<KnowledgeCard :knowledge="faqStats ?? undefined" /> <KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks /> <QuickLinks />
</div> </div>
@@ -18,7 +18,9 @@ export function useChannelConfig() {
// app id (not the 'none' sentinel) and the signup configuration id. // app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () => whatsapp: () =>
(!isOnChatwootCloud.value || (!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) && isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
)) &&
Boolean(installationConfig.whatsappAppId) && Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' && installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId), Boolean(installationConfig.whatsappConfigurationId),
@@ -135,7 +135,7 @@ onMounted(() => {
<BaseTableCell class="max-w-0"> <BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0"> <div class="flex items-center gap-4 min-w-0">
<Avatar <Avatar
:name="bot.name || ''" :name="bot.name"
:src="bot.thumbnail" :src="bot.thumbnail"
:size="40" :size="40"
class="flex-shrink-0" class="flex-shrink-0"
@@ -79,15 +79,13 @@ const breadcrumbItems = computed(() => {
}); });
const buildInboxList = allInboxes => const buildInboxList = allInboxes =>
allInboxes?.map( allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({ name,
name, id,
id, email,
email, phoneNumber,
phoneNumber, icon: getInboxIconByType(channelType, medium, 'line'),
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled), })) || [];
})
) || [];
const policyInboxes = computed(() => const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes) buildInboxList(selectedPolicy.value?.inboxes)
@@ -64,23 +64,13 @@ const allInboxes = computed(
inboxes.value inboxes.value
?.slice() ?.slice()
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map( .map(({ name, id, email, phoneNumber, channelType, medium }) => ({
({ name,
name, id,
id, email,
email, phoneNumber,
phoneNumber, icon: getInboxIconByType(channelType, medium, 'line'),
channelType, })) || []
medium,
voiceEnabled,
}) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
})
) || []
); );
const formData = computed(() => ({ const formData = computed(() => ({
@@ -29,8 +29,7 @@ const inboxIcon = computed(() => {
return getInboxIconByType( return getInboxIconByType(
props.inbox.channelType, props.inbox.channelType,
props.inbox.medium, props.inbox.medium,
'line', 'line'
props.inbox.voiceEnabled
); );
}); });
@@ -103,7 +103,6 @@ export default {
<label :class="{ error: v$.selectedAgentIds.$error }"> <label :class="{ error: v$.selectedAgentIds.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }} {{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
<div <div
data-testid="agent-selector"
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2" class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
> >
<TagInput <TagInput
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = computed(() => currentAccount.value?.features || {}); const enabledFeatures = ref({});
const hasTiktokConfigured = computed(() => { const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId; return window.chatwootConfig?.tiktokAppId;
@@ -105,6 +105,10 @@ const channelList = computed(() => {
return channels; return channels;
}); });
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => { const initChannelAuth = channel => {
const params = { const params = {
sub_page: channel, sub_page: channel,
@@ -112,6 +116,10 @@ const initChannelAuth = channel => {
}; };
router.push({ name: 'settings_inboxes_page_channel', params }); router.push({ name: 'settings_inboxes_page_channel', params });
}; };
onMounted(() => {
initializeEnabledFeatures();
});
</script> </script>
<template> <template>
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue'; import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue'; import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue'; import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue'; import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue'; import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default { export default {
components: { components: {
Banner,
BotConfiguration, BotConfiguration,
CollaboratorsPage, CollaboratorsPage,
ConfigurationPage, ConfigurationPage,
@@ -80,6 +84,7 @@ export default {
WhatsappManualMigrationBanner, WhatsappManualMigrationBanner,
Widget, Widget,
AccessToken, AccessToken,
Icon,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@@ -281,12 +286,8 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId); return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
}, },
inboxIcon() { inboxIcon() {
const { const { medium, channel_type: type } = this.inbox;
medium, return getInboxIconByType(type, medium, 'line');
channel_type: type,
voice_enabled: voiceEnabled,
} = this.inbox;
return getInboxIconByType(type, medium, 'line', voiceEnabled);
}, },
bannerMaxWidth() { bannerMaxWidth() {
const narrowTabs = ['collaborators', 'bot-configuration']; const narrowTabs = ['collaborators', 'bot-configuration'];
@@ -347,6 +348,12 @@ export default {
instagramUnauthorized() { instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required; return this.isAnInstagramChannel && this.inbox.reauthorization_required;
}, },
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() { tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required; return this.isATiktokChannel && this.inbox.reauthorization_required;
}, },
@@ -383,11 +390,6 @@ export default {
return ( return (
this.isAWhatsAppCloudChannel && this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp && this.isEmbeddedSignupWhatsApp &&
(!this.isOnChatwootCloud ||
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
)) &&
this.inbox.reauthorization_required this.inbox.reauthorization_required
); );
}, },
@@ -813,6 +815,29 @@ export default {
:class="bannerMaxWidth" :class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog" @start="openWhatsAppManualMigrationDialog"
/> />
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div <div
v-if="selectedTabKey === 'inbox-settings'" v-if="selectedTabKey === 'inbox-settings'"
@@ -42,7 +42,9 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
selectedProvider.value === PROVIDER_TYPES.WHATSAPP && selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
hasWhatsappAppId.value && hasWhatsappAppId.value &&
(!isOnChatwootCloud.value || (!isOnChatwootCloud.value ||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) isCloudFeatureEnabled(
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
))
); );
}); });
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import { useBranding } from 'shared/composables/useBranding';
const props = defineProps({ const props = defineProps({
senderNameType: { senderNameType: {
@@ -23,7 +22,6 @@ const props = defineProps({
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const { t } = useI18n(); const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const senderNameKeyOptions = computed(() => [ const senderNameKeyOptions = computed(() => [
{ {
@@ -32,7 +30,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'), content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: { preview: {
senderName: 'Smith', senderName: 'Smith',
businessName: replaceInstallationName('Chatwoot'), businessName: 'Chatwoot',
email: '<support@yourbusiness.com>', email: '<support@yourbusiness.com>',
}, },
}, },
@@ -42,7 +40,7 @@ const senderNameKeyOptions = computed(() => [
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'), content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: { preview: {
senderName: '', senderName: '',
businessName: replaceInstallationName('Chatwoot'), businessName: 'Chatwoot',
email: '<support@yourbusiness.com>', email: '<support@yourbusiness.com>',
}, },
}, },
@@ -53,7 +51,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption => const userName = keyOption =>
isKeyOptionFriendly(keyOption.key) isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName ? keyOption.preview.senderName
: props.businessName || keyOption.preview.businessName; : keyOption.preview.businessName;
const toggleSenderNameType = key => { const toggleSenderNameType = key => {
emit('update', key); emit('update', key);
@@ -56,7 +56,6 @@ export default {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}), }),
isEmbeddedSignupWhatsApp() { isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup'; return this.inbox.provider_config?.source === 'embedded_signup';
@@ -66,9 +65,7 @@ export default {
this.isEmbeddedSignupWhatsApp && this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount( this.isFeatureEnabledonAccount(
this.accountId, this.accountId,
this.isOnChatwootCloud FEATURE_FLAGS.WHATSAPP_RECONFIGURE
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
) )
); );
}, },
@@ -1,62 +0,0 @@
import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
import camelcaseKeys from 'camelcase-keys';
const SET_SESSION = 'SET_SESSION';
const SET_FETCHING = 'SET_FETCHING';
// Session capture runs right after the message is broadcast (and well after,
// for handoff notes created mid-run), so a 404 on a fresh message may just
// mean the session isn't written yet. Skip caching those so a later
// hover/click retries; older misses are permanent (V1 messages, failed runs).
const RECENT_MESSAGE_WINDOW_SECONDS = 60;
// Caches Captain agent-session metadata per message id. A missing session
// (404) is cached as null so the UI shows an empty state without refetching.
export default {
namespaced: true,
state: {
sessions: {},
fetchingIds: [],
},
getters: {
getSessionByMessageId: state => messageId => state.sessions[messageId],
isFetching: state => messageId => state.fetchingIds.includes(messageId),
hasFetched: state => messageId => messageId in state.sessions,
},
actions: {
fetch: async ({ state, commit }, { messageId, createdAt }) => {
if (messageId in state.sessions) return;
if (state.fetchingIds.includes(messageId)) return;
commit(SET_FETCHING, { messageId, isFetching: true });
try {
const { data } = await CaptainAgentSessionsAPI.show(messageId);
commit(SET_SESSION, {
messageId,
session: camelcaseKeys(data, { deep: true }),
});
} catch (error) {
const isRecentMessage =
createdAt &&
Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
// Only a 404 means "no session exists"; transient failures (5xx,
// network errors) stay uncached so a later hover retries.
if (error.response?.status === 404 && !isRecentMessage) {
commit(SET_SESSION, { messageId, session: null });
}
} finally {
commit(SET_FETCHING, { messageId, isFetching: false });
}
},
},
mutations: {
[SET_SESSION](state, { messageId, session }) {
state.sessions = { ...state.sessions, [messageId]: session };
},
[SET_FETCHING](state, { messageId, isFetching }) {
state.fetchingIds = isFetching
? [...state.fetchingIds, messageId]
: state.fetchingIds.filter(id => id !== messageId);
},
},
};
-2
View File
@@ -50,7 +50,6 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams'; import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings'; import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks'; import webhooks from './modules/webhooks';
import captainAgentSessions from './captain/agentSessions';
import captainAssistants from './captain/assistant'; import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document'; import captainDocuments from './captain/document';
import captainResponses from './captain/response'; import captainResponses from './captain/response';
@@ -116,7 +115,6 @@ export default createStore({
teams, teams,
userNotificationSettings, userNotificationSettings,
webhooks, webhooks,
captainAgentSessions,
captainAssistants, captainAssistants,
captainDocuments, captainDocuments,
captainResponses, captainResponses,
@@ -7,7 +7,6 @@ import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel'; import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel'; import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api'; import { throwErrorMessage } from '../utils/api';
import { isSendableTemplate } from '@chatwoot/utils';
import AnalyticsHelper from '../../helper/AnalyticsHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events'; import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
@@ -68,8 +67,45 @@ export const getters = {
return []; return [];
} }
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils. return templates.filter(template => {
return templates.filter(isSendableTemplate); // Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
}, },
getNewConversationInboxes($state) { getNewConversationInboxes($state) {
return $state.records.filter(inbox => { return $state.records.filter(inbox => {
@@ -68,10 +68,6 @@ const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot' () => props.selectedItem?.assignee_type === 'AgentBot'
); );
const selectedItemName = computed(() =>
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
);
const selectedThumbnail = computed( const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url () => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
); );
@@ -99,16 +95,16 @@ const selectedThumbnail = computed(
<h4 <h4
v-else v-else
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12" class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
:title="selectedItemName" :title="selectedItem.name"
> >
{{ selectedItemName }} {{ selectedItem.name }}
</h4> </h4>
</div> </div>
<Avatar <Avatar
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)" v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail" :src="selectedThumbnail"
:status="selectedItem.availability_status" :status="selectedItem.availability_status"
:name="selectedItemName" :name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined" :icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24" :size="24"
hide-offline-status hide-offline-status
@@ -53,9 +53,7 @@ export default {
computed: { computed: {
filteredOptions() { filteredOptions() {
return this.options.filter(option => { return this.options.filter(option => {
return (option.name || '') return option.name.toLowerCase().includes(this.search.toLowerCase());
.toLowerCase()
.includes(this.search.toLowerCase());
}); });
}, },
noResult() { noResult() {
@@ -73,13 +73,13 @@ describe('useBranding', () => {
expect(result).toBe('Welcome to our platform'); expect(result).toBe('Welcome to our platform');
}); });
it('should replace "Chatwoot" regardless of casing', () => { it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding(); const { replaceInstallationName } = useBranding();
const result = replaceInstallationName( const result = replaceInstallationName(
'Welcome to chatwoot, Chatwoot and CHATWOOT' 'Welcome to chatwoot and CHATWOOT'
); );
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany'); expect(result).toBe('Welcome to chatwoot and CHATWOOT');
}); });
it('should handle special characters in installation name', () => { it('should handle special characters in installation name', () => {
@@ -7,8 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() { export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
/** /**
* Replaces "Chatwoot" (any casing) in text with the installation name from * Replaces "Chatwoot" in text with the installation name from global config
* global config
* @param {string} text - The text to process * @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name * @returns {string} - Text with "Chatwoot" replaced by installation name
*/ */
@@ -18,7 +17,7 @@ export function useBranding() {
const installationName = globalConfig.value?.installationName; const installationName = globalConfig.value?.installationName;
if (!installationName) return text; if (!installationName) return text;
return text.replace(/chatwoot/gi, installationName); return text.replace(/Chatwoot/g, installationName);
}; };
return { return {
+12 -37
View File
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
const TRANSCRIPT_COOLDOWN_MS = 15000;
export default { export default {
components: { components: {
ChatInputWrap, ChatInputWrap,
@@ -26,9 +24,6 @@ export default {
data() { data() {
return { return {
inReplyTo: null, inReplyTo: null,
isSendingTranscript: false,
transcriptCooldown: false,
transcriptCooldownTimer: null,
}; };
}, },
computed: { computed: {
@@ -62,9 +57,6 @@ export default {
mounted() { mounted() {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo); emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
}, },
beforeUnmount() {
clearTimeout(this.transcriptCooldownTimer);
},
methods: { methods: {
...mapActions('conversation', ['sendMessage', 'sendAttachment']), ...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']), ...mapActions('conversationAttributes', ['getAttributes']),
@@ -98,35 +90,19 @@ export default {
toggleReplyTo(message) { toggleReplyTo(message) {
this.inReplyTo = message; this.inReplyTo = message;
}, },
startTranscriptCooldown() {
this.transcriptCooldown = true;
clearTimeout(this.transcriptCooldownTimer);
this.transcriptCooldownTimer = setTimeout(() => {
this.transcriptCooldown = false;
}, TRANSCRIPT_COOLDOWN_MS);
},
async sendTranscript() { async sendTranscript() {
if ( if (this.hasEmail) {
!this.hasEmail || try {
this.isSendingTranscript || await sendEmailTranscript();
this.transcriptCooldown emitter.emit(BUS_EVENTS.SHOW_ALERT, {
) { message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
return; type: 'success',
} });
this.isSendingTranscript = true; } catch (error) {
try { emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
await sendEmailTranscript(); message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
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;
} }
}, },
}, },
@@ -168,7 +144,6 @@ export default {
v-if="showEmailTranscriptButton" v-if="showEmailTranscriptButton"
type="clear" type="clear"
class="font-normal" class="font-normal"
:disabled="isSendingTranscript || transcriptCooldown"
@click="sendTranscript" @click="sendTranscript"
> >
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }} {{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
+5 -5
View File
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end end
def get_channel_from_wb_payload(wb_params) def get_channel_from_wb_payload(wb_params)
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {} phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
Whatsapp::WebhookChannelFinderService.new( phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
display_phone_number: metadata[:display_phone_number], channel = Channel::Whatsapp.find_by(phone_number: phone_number)
phone_number_id: metadata[:phone_number_id] # validate to ensure the phone number id matches the whatsapp channel
).perform return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end end
end end
@@ -113,14 +113,12 @@ class Whatsapp::IncomingMessageBaseService
end end
def set_conversation def set_conversation
# Scope reuse to the contact across all its contact_inboxes in this inbox: WhatsApp coexistence
# gives one contact multiple source_ids (phone + BSUID), so reopen must not be limited to a single contact_inbox.
conversations = @contact.conversations.where(inbox_id: @inbox.id)
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
@conversation = if @inbox.lock_to_single_conversation @conversation = if @inbox.lock_to_single_conversation
conversations.last @contact_inbox.conversations.last
else else
conversations.where.not(status: :resolved).last @contact_inbox.conversations
.where.not(status: :resolved).last
end end
return if @conversation return if @conversation
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end end
def perform_reply def perform_reply
return send_template_message if template_params.present? should_send_template_message = template_params.present? || !message.conversation.can_reply?
return send_session_message if message.conversation.can_reply? if should_send_template_message
send_template_message
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window')) else
send_session_message
end
end end
def send_template_message def send_template_message
@@ -1,35 +0,0 @@
# 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
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '4.16.1' version: '4.16.0'
development: development:
<<: *shared <<: *shared
+1 -1
View File
@@ -265,6 +265,6 @@
enabled: false enabled: false
column: feature_flags_ext_1 column: feature_flags_ext_1
- name: whatsapp_embedded_signup_inbox_creation - name: whatsapp_embedded_signup_inbox_creation
display_name: WhatsApp Embedded Signup Flow display_name: WhatsApp Embedded Signup Inbox Creation
enabled: false enabled: false
column: feature_flags_ext_1 column: feature_flags_ext_1
+3 -27
View File
@@ -31,11 +31,10 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip) (default_allowed_ips + env_allowed_ips).include?(remote_ip)
end end
# Rails allows paths with extensions and trailing slashes, so compare against a normalized path. # Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# For example, /auth, /auth.json, and /auth/ should all use the same throttle. # example /auth & /auth.json would both work
def path_without_extensions def path_without_extensions
normalized_path = path[/^[^.]+/] path[/^[^.]+/]
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
end end
end end
@@ -189,11 +188,6 @@ class Rack::Attack
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req| throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank? req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end end
## Prevent Transcript Bombing on Widget API ###
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
end
end end
##-----------------------------------------------## ##-----------------------------------------------##
@@ -218,24 +212,6 @@ class Rack::Attack
match_data[:account_id] if match_data.present? match_data[:account_id] if match_data.present?
end end
## Prevent abuse of agent create APIs (per account, covers bulk_create)
throttle('/api/v1/accounts/:account_id/agents POST',
limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
next unless req.post?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent delete API (per account)
throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ## ## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req| throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path) match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
-1
View File
@@ -154,7 +154,6 @@ en:
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.' 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_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' 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: reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.' generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.' not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
+1 -3
View File
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
resources :assistants do resources :assistants do
member do member do
post :playground post :playground
get :metrics get :stats
get :faq_stats
get :summary get :summary
get :drilldown get :drilldown
end end
@@ -77,7 +76,6 @@ Rails.application.routes.draw do
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
resources :scenarios resources :scenarios
end end
resources :agent_sessions, only: [:show]
resources :assistant_responses resources :assistant_responses
resources :message_reports, only: [:create] resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create] resources :bulk_actions, only: [:create]
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
build_metrics(current, previous) build_metrics(current, previous)
end 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 private
attr_reader :window attr_reader :window
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
handoff_rate: pack(current[:handoff], previous[:handoff], :point), handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent), hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point), reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute) conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
} }
end end
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
auto_resolution: rate(resolution[:resolved], handled), auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled), handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round, hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range, resolution[:resolved]), reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1) depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
} }
end end
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
# derived from the assistant's handled conversations (not current inbox membership) so a later # 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) # 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. # and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
def reopen_rate(range, resolved_count) def reopen_rate(range)
return 0 if resolved_count.zero?
resolved_scope = account.reporting_events resolved_scope = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range, .where(name: RESOLVED_EVENT_NAMES, created_at: range,
conversation_id: handled_scope(range).select(:conversation_id)) conversation_id: handled_scope(range).select(:conversation_id))
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
'ON resolves.conversation_id = reporting_events.conversation_id ' \ 'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time') 'AND reporting_events.event_end_time >= resolves.event_end_time')
.distinct.count('reporting_events.conversation_id') .distinct.count('reporting_events.conversation_id')
rate(reopened, resolved_count) 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 end
def rate(numerator, denominator) def rate(numerator, denominator)
@@ -1,25 +0,0 @@
class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController
before_action :set_message
before_action :authorize_conversation
def show
@agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id)
return head :not_found if @agent_session.blank?
@citations = Current.account.captain_assistant_responses
.where(id: @agent_session.faq_ids)
.includes(:documentable)
@scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids)
.pluck(:id, :title).to_h
end
private
def set_message
@message = Current.account.messages.find(params[:id])
end
def authorize_conversation
authorize @message.conversation, :show?
end
end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -1,7 +1,8 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown] before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index def index
@assistants = account_assistants.ordered @assistants = account_assistants.ordered
@@ -42,17 +43,12 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools @tools = assistant.available_agent_tools
end end
def metrics def stats
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end end
def faq_stats
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
end
def summary def summary
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset]) result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
result = cached_or_generated_summary(window, summary_stats)
if result[:error] if result[:error]
render json: { error: result[:error] }, status: :unprocessable_content render json: { error: result[:error] }, status: :unprocessable_content
@@ -73,8 +69,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
params.permit(:metric, :range, :timezone_offset, :page, :per_page) params.permit(:metric, :range, :timezone_offset, :page, :per_page)
end end
def cached_or_generated_summary(window, stats) def cached_or_generated_summary(builder)
cache_key = summary_cache_key(window.range) cache_key = summary_cache_key(builder.range)
cached = Rails.cache.read(cache_key) cached = Rails.cache.read(cache_key)
return cached if cached return cached if cached
@@ -82,25 +78,14 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
account: Current.account, account: Current.account,
assistant: @assistant, assistant: @assistant,
first_name: Current.user.name.to_s.split.first, first_name: Current.user.name.to_s.split.first,
stats: stats, stats: builder.metrics,
period: window.period period: builder.period
).perform ).perform
# Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour. # 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] Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
result result
end end
def summary_stats
params.require(:stats).permit(
conversations_handled: %i[current],
hours_saved: %i[current],
auto_resolution_rate: %i[current trend],
handoff_rate: %i[current trend],
reopen_rate: %i[current trend],
knowledge: %i[coverage approved documents]
).to_h.deep_symbolize_keys
end
def summary_cache_key(range) def summary_cache_key(range)
"captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}" "captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
end end
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params before_action :validate_params
before_action :type_matches? before_action :type_matches?
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :ensure_custom_tools_enabled before_action :ensure_custom_tools_enabled
before_action -> { check_authorization(Captain::CustomTool) } before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy] before_action :set_custom_tool, only: [:show, :update, :destroy]
@@ -1,4 +1,5 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]

Some files were not shown because too many files have changed in this diff Show More