Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/rollup-refactor

This commit is contained in:
Shivam Mishra
2026-03-13 10:45:19 +05:30
125 changed files with 6100 additions and 1684 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:
+1
View File
@@ -268,6 +268,7 @@ group :development, :test do
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring'
gem 'spring-watcher-listen'
end
+11
View File
@@ -473,6 +473,12 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
@@ -910,6 +916,9 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
@@ -970,6 +979,7 @@ GEM
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1143,6 +1153,7 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end
@@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end
def process_avatar_from_url
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
@@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
return messages.value.map(message => {
const payload = {
role: message.sender,
content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
};
const resetConversation = () => {
@@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {
@@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<NextButton
ghost
@@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -78,7 +78,7 @@ const onSend = () => {
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
@@ -162,7 +162,8 @@ export default {
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
!this.is360DialogWhatsAppChannel &&
!this.copilot.isActive.value
);
},
showWhatsappTemplates() {
@@ -1464,7 +1465,7 @@ export default {
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
@apply relative py-0 px-3 -mt-px;
}
.emoji-dialog {
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'agents/getVerifiedAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
@@ -111,7 +111,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
'agents/getVerifiedAgents': { value: mockAgents },
});
});
@@ -167,7 +167,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
'agents/getVerifiedAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
@@ -20,7 +20,7 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const agents = useMapGetter('agents/getVerifiedAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -13,7 +13,7 @@ export const useMacros = () => {
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
/**
* Get dropdown values based on the specified type
@@ -191,7 +191,7 @@ onMounted(() => {
<template>
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:header-title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:is-fetching="isFetching"
:show-know-more="false"
:show-pagination-footer="false"
+39 -12
View File
@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
event_end_time = event.timestamp
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_resolved',
value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
create_bot_resolved_event(conversation, reporting_event)
@@ -72,42 +73,52 @@ class ReportingEventListener < BaseListener
def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# check if a conversation_bot_handoff event exists for this conversation
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
return if bot_handoff_event.present?
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_bot_handoff',
value: time_to_handoff,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def conversation_captain_inference_resolved(event)
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
end
def conversation_captain_inference_handoff(event)
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# Find the most recent resolved event for this conversation
last_resolved_event = ReportingEvent.where(
conversation_id: conversation.id,
name: 'conversation_resolved'
).order(event_end_time: :desc).first
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
# For first-time openings, value is 0
# For reopenings, calculate time since resolution
if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
start_time = last_resolved_event.event_end_time
else
time_since_resolved = 0
@@ -115,12 +126,12 @@ class ReportingEventListener < BaseListener
start_time = conversation.created_at
end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
end
private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
reporting_event = ReportingEvent.new(
name: 'conversation_opened',
value: time_since_resolved,
@@ -130,11 +141,27 @@ class ReportingEventListener < BaseListener
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
end
def create_captain_inference_event(event, event_name)
conversation = extract_conversation_and_account(event)[0]
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
ReportingEvent.create!(
name: event_name,
value: time_to_event,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: event.timestamp
)
end
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation
+1 -1
View File
@@ -143,7 +143,7 @@ class Conversation < ApplicationRecord
end
def last_incoming_message
messages&.incoming&.last
messages.where(account_id: account_id)&.incoming&.last
end
def toggle_status
+19 -13
View File
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
attr_accessor :preserve_waiting_since
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum content_type: {
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :conversation
belongs_to :sender, polymorphic: true, optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
set_waiting_since_on_incoming_message
end
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
def clear_waiting_since_on_outgoing_response
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
return
end
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
end
def set_waiting_since_on_incoming_message
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
def set_conversation_activity
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(last_activity_at: created_at)
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
@@ -24,7 +24,7 @@ class Conversations::EventDataPresenter < SimpleDelegator
private
def push_messages
[messages.chat.last&.push_event_data].compact
[messages.where(account_id: account_id).chat.last&.push_event_data].compact
end
def push_meta
+3 -1
View File
@@ -47,7 +47,9 @@ class ActionService
@agent = @account.users.find_by(id: agent_ids)
@conversation.update!(assignee_id: @agent.id) if @agent.present?
return unless @agent.present? && @agent.confirmed?
@conversation.update!(assignee_id: @agent.id)
end
def remove_label(labels)
+2 -53
View File
@@ -2,6 +2,7 @@ require 'json'
class FilterService
include Filters::FilterHelper
include Filters::CustomAttributeFilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
@@ -137,26 +138,8 @@ class FilterService
end
end
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
private
def attribute_model
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
end
def attribute_data_type
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
@@ -173,44 +156,10 @@ class FilterService
else
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
rescue Date::Error, ArgumentError, FloatDomainError, TypeError
rescue ArgumentError, FloatDomainError, TypeError
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else
ActiveRecord::Base.sanitize_sql_array(
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
end
def custom_attribute(attribute_key, account, custom_attribute_type)
current_account = account || Current.account
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key)
end
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
ActiveRecord::Base.sanitize_sql_array(
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end
def equals_to_filter_string(filter_operator, current_index)
return "IN (:value_#{current_index})" if filter_operator == 'equal_to'
@@ -0,0 +1,55 @@
module Filters::CustomAttributeFilterHelper
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
private
def attribute_model
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
end
def attribute_data_type
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else
ActiveRecord::Base.sanitize_sql_array(
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
end
def custom_attribute(attribute_key, account, custom_attribute_type)
current_account = account || Current.account
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key)
end
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
ActiveRecord::Base.sanitize_sql_array(
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end
end
@@ -0,0 +1,3 @@
<% if field.data %>
<%= field.datetime %>
<% end %>
-1
View File
@@ -191,7 +191,6 @@
- name: assignment_v2
display_name: Assignment V2
enabled: false
chatwoot_internal: true
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
+2 -1
View File
@@ -185,7 +185,8 @@ class Rack::Attack
###-----------------------------------------------###
## Prevent Abuse of Converstion Transcript APIs ###
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript', limit: 30, period: 1.hour) do |req|
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript',
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_TRANSCRIPT', '1000').to_i, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
match_data[:account_id] if match_data.present?
end
+2
View File
@@ -238,8 +238,10 @@ en:
activity:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
open: 'Conversation was marked open by %{user_name}'
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
agent_bot:
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
)
response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
message_history: playground_message_history
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
additional_message: playground_params[:message_content],
message_history: message_history
)
end
render json: response
end
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
(playground_params[:message_history] || []).map do |message|
{
role: message[:role],
content: message[:content],
agent_name: message[:agent_name]
}.compact
end
end
def playground_message_history
history = message_history
current_message = playground_params[:message_content]
return history if current_message.blank?
current_user_message = { role: 'user', content: current_message }
return history if history.last == current_user_message
history + [current_user_message]
end
def captain_v2_enabled?
@assistant.account.feature_enabled?('captain_integration_v2')
end
end
@@ -5,7 +5,6 @@ module Captain::ChatHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
@@ -86,7 +85,8 @@ module Captain::ChatHelper
temperature: temperature,
metadata: {
assistant_id: @assistant&.id,
channel_type: resolved_channel_type
channel_type: resolved_channel_type,
source: @source
}.compact
}
end
@@ -130,7 +130,6 @@ module Captain::ChatHelper
end
def log_chat_completion_request
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
"for messages #{@messages} with #{@tools&.length || 0} tools")
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
end
end
@@ -1,15 +1,16 @@
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
queue_as :low
def perform(inbox)
return if inbox.account.captain_disable_auto_resolve
Current.executed_by = inbox.captain_assistant
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation|
create_outgoing_message(conversation, inbox)
conversation.resolved!
if inbox.account.feature_enabled?('captain_tasks')
perform_with_evaluation(inbox)
else
perform_time_based(inbox)
end
ensure
Current.reset
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
private
def create_outgoing_message(conversation, inbox)
def perform_time_based(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
create_resolution_message(conversation, inbox)
conversation.resolved!
end
end
def perform_with_evaluation(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
evaluation = evaluate_conversation(conversation, inbox)
next unless still_resolvable_after_evaluation?(conversation)
if evaluation[:complete]
resolve_conversation(conversation, inbox, evaluation[:reason])
else
handoff_conversation(conversation, inbox, evaluation[:reason])
end
end
end
def evaluate_conversation(conversation, inbox)
Captain::ConversationCompletionService.new(
account: inbox.account,
conversation_display_id: conversation.display_id
).perform
end
def resolvable_pending_conversations(inbox)
inbox.conversations.pending
.where('last_activity_at < ?', auto_resolve_cutoff_time)
.limit(Limits::BULK_ACTIONS_LIMIT)
end
def still_resolvable_after_evaluation?(conversation)
conversation.reload
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
rescue ActiveRecord::RecordNotFound
false
end
def auto_resolve_cutoff_time
Time.now.utc - 1.hour
end
def resolve_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
create_resolution_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
reason_type: :inference
) { conversation.resolved! }
conversation.dispatch_captain_inference_resolved_event
end
def handoff_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
create_handoff_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
reason_type: :inference
) { conversation.bot_handoff! }
conversation.dispatch_captain_inference_handoff_event
send_out_of_office_message_if_applicable(conversation.reload)
end
def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def create_private_note(conversation, inbox, content)
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content
)
end
def create_resolution_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
}
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
)
end
end
def create_handoff_message(conversation, inbox)
handoff_message = inbox.captain_assistant.config['handoff_message']
return if handoff_message.blank?
conversation.messages.create!(
message_type: :outgoing,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: handoff_message,
preserve_waiting_since: true
)
end
end
+1 -1
View File
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.title.parameterize.underscore,
key: scenario.handoff_key,
description: scenario.description
}
end,
+39 -1
View File
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
# based on the available length budget.
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
HANDOFF_KEY_PREFIX = 'scenario'.freeze
HANDOFF_KEY_SUFFIX = 'agent'.freeze
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
MAX_HANDOFF_SLUG_LENGTH = 24
self.table_name = 'captain_scenarios'
belongs_to :assistant, class_name: 'Captain::Assistant'
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
before_save :resolve_tool_references
def handoff_key
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
end
def prompt_context
{
title: title,
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
private
def agent_name
"#{title} Agent".parameterize(separator: '_')
handoff_key
end
def handoff_id_key
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
"#{HANDOFF_KEY_PREFIX}_draft"
end
def compact_handoff_slug
slug = title.to_s.parameterize(separator: '_').presence
return nil if slug.blank?
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
return nil if max_slug_length <= 0
slug.first(max_slug_length).sub(/_+\z/, '').presence
end
def dynamic_slug_max_length
# handoff_to_#{scenario_<id>_<slug>_agent}
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
end
def agent_tools
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
key = captain_activity_key
return unless key
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
end
private
def captain_status_reason
captain_activity_reason.presence
end
def captain_activity_key
if resolved? && Current.captain_resolve_reason.present?
'conversations.activity.captain.resolved_by_tool'
elsif resolved?
'conversations.activity.captain.resolved'
elsif open?
'conversations.activity.captain.open'
end
return captain_resolved_activity_key if resolved?
return captain_open_activity_key if open?
end
def captain_resolved_activity_key
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
'conversations.activity.captain.resolved'
end
def captain_open_activity_key
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
'conversations.activity.captain.open'
end
end
@@ -1,8 +1,30 @@
module Enterprise::Conversation
attr_accessor :captain_activity_reason, :captain_activity_reason_type
def dispatch_captain_inference_resolved_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
end
def dispatch_captain_inference_handoff_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
end
def list_of_keys
super + %w[sla_policy_id]
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
self.captain_activity_reason = reason
self.captain_activity_reason_type = reason_type
yield
ensure
self.captain_activity_reason = previous_reason
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
@@ -13,4 +35,10 @@ module Enterprise::Conversation
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
end
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
@assistant = assistant
@conversation = conversation
@callbacks = callbacks
@source = source
end
def generate_response(message_history: [])
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
process_agent_result(result)
rescue StandardError => e
# when running the agent runner service in a rake task, the conversation might not have an account associated
# for regular production usage, it will run just fine
# In rake/local runs, conversation may not be present, so account is optional here.
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation
state
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox
state[:contact_inbox] =
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
end
def build_and_wire_agents
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s)
@@ -1,11 +1,12 @@
class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil)
def initialize(assistant: nil, conversation_id: nil, source: nil)
super()
@assistant = assistant
@conversation_id = conversation_id
@source = source
@messages = [system_message]
@response = ''
@@ -0,0 +1,4 @@
class Captain::ConversationCompletionSchema < RubyLLM::Schema
boolean :complete, description: 'Whether the conversation is complete and can be closed'
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
end
@@ -0,0 +1,68 @@
# Evaluates whether a conversation is complete and can be auto-resolved.
# Used by InboxPendingConversationsResolutionJob to determine if inactive
# conversations should be resolved or handed off to human agents.
#
# NOTE: This service intentionally does NOT count toward Captain usage limits.
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
# checks for usage tracking. This is an internal operational evaluation,
# not a customer-facing value-add, so we don't charge for it.
class Captain::ConversationCompletionService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
pattr_initialize [:account!, :conversation_display_id!]
def perform
content = format_messages_as_string
return default_incomplete_response('No messages found') if content.blank?
response = make_api_call(
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('conversation_completion') },
{ role: 'user', content: content }
],
schema: RESPONSE_SCHEMA
)
return default_incomplete_response(response[:error]) if response[:error].present?
parse_response(response[:message])
end
private
def prompt_from_file(file_name)
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
end
def format_messages_as_string
messages = conversation_messages(start_from: 0)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
"#{sender_type}: #{msg[:content]}"
end.join("\n")
end
def parse_response(message)
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
{
complete: message['complete'] == true,
reason: message['reason'] || 'No reason provided'
}
end
def default_incomplete_response(reason)
{ complete: false, reason: reason }
end
def event_name
'captain.conversation_completion'
end
def build_follow_up_context?
false
end
end
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
@@ -0,0 +1,21 @@
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant suggested the customer try something or take an action — they may still be attempting it
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
- The customer raised multiple questions or issues and not all were addressed
A conversation is COMPLETE only if ALL of these are true:
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
Analyze the conversation and respond with ONLY a JSON object (no other text):
{"complete": true, "reason": "brief explanation"}
or
{"complete": false, "reason": "brief explanation"}
@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
Current.captain_resolve_reason = reason
begin
conversation.resolved!
ensure
Current.captain_resolve_reason = nil
end
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
end
@@ -0,0 +1,12 @@
# Overrides the quota check from Enterprise::Captain::BaseTaskService
# so that conversation completion evaluation always runs regardless of
# the customer's Captain usage quota. This is an internal operational
# check, not a customer-facing feature — it should never be blocked
# by quota exhaustion.
module Enterprise::Captain::ConversationCompletionService
private
def responses_available?
true
end
end
+8 -6
View File
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
"#{endpoint}/v1"
end
def make_api_call(model:, messages:, tools: [])
def make_api_call(model:, messages:, schema: nil, tools: [])
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
end
return response unless build_follow_up_context? && response[:message].present?
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def execute_ruby_llm_request(model:, messages:, tools: [])
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, tools: tools)
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, tools: [])
def build_chat(context, model:, messages:, schema: nil, tools: [])
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
.reorder('id desc')
.each do |message|
content = message.content_for_llm
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
next if content.blank?
break if character_count + content.length > TOKEN_LIMIT
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length
-2
View File
@@ -4,7 +4,6 @@ module Current
thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by
thread_mattr_accessor :contact
thread_mattr_accessor :captain_resolve_reason
def self.reset
Current.user = nil
@@ -12,6 +11,5 @@ module Current
Current.account_user = nil
Current.executed_by = nil
Current.contact = nil
Current.captain_resolve_reason = nil
end
end
+2
View File
@@ -21,6 +21,8 @@ module Events::Types
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
CONVERSATION_OPENED = 'conversation.opened'
CONVERSATION_RESOLVED = 'conversation.resolved'
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
+4
View File
@@ -14,4 +14,8 @@ class GlobalConfigService
GlobalConfig.clear_cache
i.value
end
def self.account_signup_enabled?
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
end
end
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
end
def set_usage_metrics(span, result)
+11 -9
View File
@@ -36,8 +36,8 @@
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.7",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/fluent": "^1.2.32",
@@ -49,7 +49,7 @@
"@scmmishra/pico-search": "0.6.0",
"@sentry/vue": "^8.55.0",
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/vue-table": "^8.20.5",
"@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.1.4",
@@ -59,7 +59,7 @@
"@vueuse/components": "^12.0.0",
"@vueuse/core": "^12.0.0",
"activestorage": "^5.2.6",
"axios": "^1.13.2",
"axios": "^1.13.6",
"camelcase-keys": "^9.1.3",
"chart.js": "~4.4.4",
"color2k": "^2.0.2",
@@ -68,7 +68,7 @@
"countries-and-timezones": "^3.6.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
"dompurify": "3.2.4",
"dompurify": "3.3.2",
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
@@ -99,7 +99,7 @@
"vue": "^3.5.12",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
"vue-dompurify-html": "^5.3.0",
"vue-i18n": "9.14.5",
"vue-letter": "^0.2.1",
"vue-router": "~4.4.5",
@@ -111,7 +111,7 @@
"wavesurfer.js": "7.8.6"
},
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.9.0",
"@egoist/tailwindcss-icons": "^1.9.2",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.10",
"@iconify-json/lucide": "^1.2.82",
@@ -142,7 +142,7 @@
"prettier": "^3.3.3",
"prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4",
"tailwindcss": "^3.4.13",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21",
"vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5"
@@ -160,7 +160,9 @@
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.21",
"vitest": "3.0.5"
"vitest": "3.0.5",
"minimatch@<4": "3.1.5",
"minimatch@>=9.0.0 <9.0.7": "9.0.9"
}
},
"lint-staged": {
+152 -240
View File
@@ -8,6 +8,8 @@ overrides:
vite-node: 2.0.1
vite: 5.4.21
vitest: 3.0.5
minimatch@<4: 3.1.5
minimatch@>=9.0.0 <9.0.7: 9.0.9
importers:
@@ -29,11 +31,11 @@ importers:
specifier: ^0.0.52
version: 0.0.52
'@formkit/core':
specifier: ^1.6.7
version: 1.6.7
specifier: ^1.7.2
version: 1.7.2
'@formkit/vue':
specifier: ^1.6.7
version: 1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))
specifier: ^1.7.2
version: 1.7.2(vue@3.5.12(typescript@5.6.2))
'@hcaptcha/vue3-hcaptcha':
specifier: ^1.3.0
version: 1.3.0(vue@3.5.12(typescript@5.6.2))
@@ -68,8 +70,8 @@ importers:
specifier: 2.2.1
version: 2.2.1
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.15(tailwindcss@3.4.13)
specifier: ^0.5.19
version: 0.5.19(tailwindcss@3.4.19)
'@tanstack/vue-table':
specifier: ^8.20.5
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
@@ -98,8 +100,8 @@ importers:
specifier: ^5.2.6
version: 5.2.8
axios:
specifier: ^1.13.2
version: 1.13.2
specifier: ^1.13.6
version: 1.13.6
camelcase-keys:
specifier: ^9.1.3
version: 9.1.3
@@ -125,8 +127,8 @@ importers:
specifier: ^1.3.3
version: 1.3.8(date-fns@2.21.1)
dompurify:
specifier: 3.2.4
version: 3.2.4
specifier: 3.3.2
version: 3.3.2
flag-icons:
specifier: ^7.2.3
version: 7.2.3
@@ -218,8 +220,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3(vue@3.5.12(typescript@5.6.2))
vue-dompurify-html:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
specifier: ^5.3.0
version: 5.3.0(vue@3.5.12(typescript@5.6.2))
vue-i18n:
specifier: 9.14.5
version: 9.14.5(vue@3.5.12(typescript@5.6.2))
@@ -249,8 +251,8 @@ importers:
version: 7.8.6
devDependencies:
'@egoist/tailwindcss-icons':
specifier: ^1.9.0
version: 1.9.0(tailwindcss@3.4.13)
specifier: ^1.9.2
version: 1.9.2(tailwindcss@3.4.19)
'@histoire/plugin-vue':
specifier: 0.17.15
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
@@ -342,8 +344,8 @@ importers:
specifier: ^8.2.4
version: 8.2.6
tailwindcss:
specifier: ^3.4.13
version: 3.4.13
specifier: ^3.4.19
version: 3.4.19
vite:
specifier: 5.4.21
version: 5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
@@ -402,9 +404,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@asamuzakjp/css-color@4.1.0':
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
@@ -700,8 +699,8 @@ packages:
peerDependencies:
postcss-selector-parser: ^6.0.10
'@egoist/tailwindcss-icons@1.9.0':
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
'@egoist/tailwindcss-icons@1.9.2':
resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==}
peerDependencies:
tailwindcss: '*'
@@ -874,46 +873,35 @@ packages:
'@floating-ui/utils@0.2.7':
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
'@formkit/core@1.6.7':
resolution: {integrity: sha512-wEoWK7crcCPRV5KJfEGLjjIS+qwbuD8I5Ur0zTtKRQrdO4oRL6kVoubxQOpgnq1l8sWfcRY8Wpf22Wna2LD20Q==}
'@formkit/core@1.7.2':
resolution: {integrity: sha512-XDRVqkDtOziU3z44hdvgWEqGpiv6nTNeCmP8cnESKkr24a4keQuEwmfDVvibYV0ywyTMg6ylp2lyklCYeRHykQ==}
'@formkit/dev@1.6.7':
resolution: {integrity: sha512-mMtkfvfkl1P1v0haizUE4DadalbG9/3m0ZymmMKKb0F3pojQJFtdfohy67ZQKtmzy4bamowHyEUr+XzLbKY2EA==}
'@formkit/dev@1.7.2':
resolution: {integrity: sha512-W38xbbFS4h4LTV22kUC6ZbPHBmlM2lVbAz5PSYU3SPaNc4FeXp4GTe4GzLEmcS82B+5L1zbSOgGB43kWZts7wA==}
'@formkit/i18n@1.6.7':
resolution: {integrity: sha512-i9Mnc2XHCm2c10fppEIxdGv+jqOaixO22iFXX3xF+AkJnxtOzV5hP4f3/TeG+BNahGUq8vj+e1y+VMnjS6duxA==}
'@formkit/i18n@1.7.2':
resolution: {integrity: sha512-Zs6f+rtP2j2Nnt1HkNrL85WU6rtS1l1Q0BAQjCMGQPsbrppiL7/TZ5bNIrVm0DTntLgG3firDS7bNWAxPSwerQ==}
'@formkit/inputs@1.6.7':
resolution: {integrity: sha512-VLxoAJn5VGOEXkGI499lju5Irnu12cu+spI1HL//46nlVkqnb1XcV0k0MK9K+hogz4fGmyZUvHf0lxU6dh7ZZQ==}
'@formkit/inputs@1.7.2':
resolution: {integrity: sha512-4xs7RJN8EFGctTCNRXBTvor2O8RvuEEK3q2Hl/RMU5lhhn5tmck90fkkeAr9o+rWRoSNiV8XgbLQArQ/B1IYPw==}
'@formkit/observer@1.6.7':
resolution: {integrity: sha512-ei5z5ernNMKKiBuoRcFgEthhP1i+KKb02hsPsikLA3XehuoJdWIejn9AAq6jOEGbUMZ5XAAgFJcxzO5tnKuPnw==}
'@formkit/observer@1.7.2':
resolution: {integrity: sha512-KUr5mcu2SAeHOOK1FaMyFigtA8hkHLsUkaPFWpTC81j79o1AUjJppmfPaX6PbtlHLeMs2Zq8Qhp6VAhi7D2WJg==}
'@formkit/rules@1.6.7':
resolution: {integrity: sha512-adzOuTvf6ghZbV0g0ZH9+MU9jfoF4DojBztAbqzFP/fT4d+WxhSHHlkWq6PU66fHPy3OH4DkWdx9trL1wGHuzQ==}
'@formkit/rules@1.7.2':
resolution: {integrity: sha512-2e5qKlXzmL9LAetncFp7xkHEX/UAJsU3bo1iL3idloH3HALf5fIdg3lgOKwCNDMdLjbfiKJ6lVwFaeFfFpcDKw==}
'@formkit/themes@1.6.7':
resolution: {integrity: sha512-TIiWr4TMAFUg1pQz2E4GErfAhBv2Q2VbWlk6pqXPWI8UyPTjmcinEnCSIWDCX6FPPqiYShBnh8123nTO7pyvjA==}
peerDependencies:
tailwindcss: ^3.2.0
unocss: 0.x.x
windicss: ^3.0.0
peerDependenciesMeta:
tailwindcss:
optional: true
unocss:
optional: true
windicss:
optional: true
'@formkit/themes@1.7.2':
resolution: {integrity: sha512-AaZHy7l9D44Ya3cQRqZ8RIpaTTsCjTB1gX13NlpqYSs6yG0yBTmJ7L7kW60bOGdhs/vmIwSel578EBwz98nybQ==}
'@formkit/utils@1.6.7':
resolution: {integrity: sha512-aU3CDLzCkC5Dnx6iS3swbsIbys7E+2VOaLWFRnS7wk7kFa8EnENi67qc2E2KFE05RT4UCEAIYMAQY6wvek29gA==}
'@formkit/utils@1.7.2':
resolution: {integrity: sha512-bBF6alUBOqFfJHjVB95Vck0hp36vlw4QfFJxGfTO6BX68AEaFzzzabtpwfy0DbcHtwHh4Yn7l/rOWGxXEve+QQ==}
'@formkit/validation@1.6.7':
resolution: {integrity: sha512-4wUUG+Pz3hPeiLccYiXAzsrF7SXk28PYAeHJeBngIv9K82ieljBJpvvuCJDyA6SeSMOvmbI92TG4wx4u5cDLOw==}
'@formkit/validation@1.7.2':
resolution: {integrity: sha512-JJsLP7AI/++VujdwtBeUcPhCqY3FXSKZ7hajT7Ow5Feab7JLqVPjxVMFbXDgEOLjq0ex1NX/I+EQHfm5sqMRsA==}
'@formkit/vue@1.6.7':
resolution: {integrity: sha512-w3kjQD0lvtImyyTiy+fLaIZM/4r6sLDbpBIwke8ZA/d5orzNE96JPaloVKbH+HwCo6+z/1mclh1pW1S+7RgMcw==}
'@formkit/vue@1.7.2':
resolution: {integrity: sha512-jrvXl2ZhS6X5klTbP4N5zc0Dg37AjbAf8SCVoz7mfligVkmwrrF4SN3waMBcH+n9gqT+vrkOYb6bQXdvjUdnCQ==}
peerDependencies:
vue: ^3.4.0
@@ -985,8 +973,8 @@ packages:
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/utils@3.1.0':
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
'@intlify/core-base@9.14.2':
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
@@ -1291,10 +1279,10 @@ packages:
peerDependencies:
size-limit: 8.2.6
'@tailwindcss/typography@0.5.15':
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
@@ -1579,8 +1567,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -1712,8 +1700,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
axios@1.13.6:
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1745,8 +1733,8 @@ packages:
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -1922,9 +1910,6 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
@@ -2166,8 +2151,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dompurify@3.3.2:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@@ -2450,9 +2436,6 @@ packages:
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
engines: {node: '>=12.0.0'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
@@ -2645,10 +2628,6 @@ packages:
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
engines: {node: '>=18'}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
globalthis@1.0.3:
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
engines: {node: '>= 0.4'}
@@ -2796,8 +2775,8 @@ packages:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
immutable@4.3.8:
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
@@ -3001,6 +2980,10 @@ packages:
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
hasBin: true
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
js-beautify@1.15.1:
resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==}
engines: {node: '>=14'}
@@ -3077,9 +3060,6 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
launch-editor@2.9.1:
resolution: {integrity: sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==}
@@ -3097,8 +3077,8 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
lilconfig@3.1.2:
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
@@ -3131,10 +3111,6 @@ packages:
lit@2.2.6:
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -3147,12 +3123,6 @@ packages:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -3287,15 +3257,11 @@ packages:
min-document@2.19.0:
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
@@ -3308,8 +3274,8 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mlly@1.8.1:
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
mpd-parser@0.21.0:
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
@@ -3606,9 +3572,6 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -3915,9 +3878,6 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -4269,8 +4229,8 @@ packages:
resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==}
engines: {node: '>=10.0.0'}
tailwindcss@3.4.13:
resolution: {integrity: sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
hasBin: true
@@ -4432,8 +4392,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
@@ -4622,10 +4582,10 @@ packages:
'@vue/composition-api':
optional: true
vue-dompurify-html@5.1.0:
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
vue-dompurify-html@5.3.0:
resolution: {integrity: sha512-HJQGBHbfSPcb6Mu97McdKbX7TqRHZa6Ji8OCpCNyuHca5QvQZ8IiuwghFPSO8OkSQfqXPNPKFMZdCOrnGGmOSQ==}
peerDependencies:
vue: ^3.0.0
vue: ^3.4.36
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
@@ -4944,8 +4904,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@antfu/utils@8.1.1': {}
'@asamuzakjp/css-color@4.1.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -5262,12 +5220,10 @@ snapshots:
dependencies:
postcss-selector-parser: 6.1.1
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)':
'@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19)':
dependencies:
'@iconify/utils': 2.3.0
tailwindcss: 3.4.13
transitivePeerDependencies:
- supports-color
'@iconify/utils': 3.1.0
tailwindcss: 3.4.19
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -5354,7 +5310,7 @@ snapshots:
ignore: 5.2.4
import-fresh: 3.3.0
js-yaml: 4.1.0
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -5368,7 +5324,7 @@ snapshots:
ignore: 5.2.4
import-fresh: 3.3.0
js-yaml: 4.1.0
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -5385,67 +5341,61 @@ snapshots:
'@floating-ui/utils@0.2.7': {}
'@formkit/core@1.6.7':
'@formkit/core@1.7.2':
dependencies:
'@formkit/utils': 1.6.7
'@formkit/utils': 1.7.2
'@formkit/dev@1.6.7':
'@formkit/dev@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/core': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/i18n@1.6.7':
'@formkit/i18n@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/validation': 1.6.7
'@formkit/core': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/validation': 1.7.2
'@formkit/inputs@1.6.7':
'@formkit/inputs@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/core': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/observer@1.6.7':
'@formkit/observer@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/core': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/rules@1.6.7':
'@formkit/rules@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/validation': 1.6.7
'@formkit/core': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/validation': 1.7.2
'@formkit/themes@1.6.7(tailwindcss@3.4.13)':
'@formkit/themes@1.7.2':
dependencies:
'@formkit/core': 1.6.7
optionalDependencies:
tailwindcss: 3.4.13
'@formkit/core': 1.7.2
'@formkit/utils@1.6.7': {}
'@formkit/utils@1.7.2': {}
'@formkit/validation@1.6.7':
'@formkit/validation@1.7.2':
dependencies:
'@formkit/core': 1.6.7
'@formkit/observer': 1.6.7
'@formkit/utils': 1.6.7
'@formkit/core': 1.7.2
'@formkit/observer': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/vue@1.6.7(tailwindcss@3.4.13)(vue@3.5.12(typescript@5.6.2))':
'@formkit/vue@1.7.2(vue@3.5.12(typescript@5.6.2))':
dependencies:
'@formkit/core': 1.6.7
'@formkit/dev': 1.6.7
'@formkit/i18n': 1.6.7
'@formkit/inputs': 1.6.7
'@formkit/observer': 1.6.7
'@formkit/rules': 1.6.7
'@formkit/themes': 1.6.7(tailwindcss@3.4.13)
'@formkit/utils': 1.6.7
'@formkit/validation': 1.6.7
'@formkit/core': 1.7.2
'@formkit/dev': 1.7.2
'@formkit/i18n': 1.7.2
'@formkit/inputs': 1.7.2
'@formkit/observer': 1.7.2
'@formkit/rules': 1.7.2
'@formkit/themes': 1.7.2
'@formkit/utils': 1.7.2
'@formkit/validation': 1.7.2
vue: 3.5.12(typescript@5.6.2)
transitivePeerDependencies:
- tailwindcss
- unocss
- windicss
'@hcaptcha/vue3-hcaptcha@1.3.0(vue@3.5.12(typescript@5.6.2))':
dependencies:
@@ -5511,7 +5461,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.3.5
minimatch: 3.1.2
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -5549,18 +5499,11 @@ snapshots:
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
'@iconify/utils@3.1.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.2
mlly: 1.8.0
transitivePeerDependencies:
- supports-color
mlly: 1.8.1
'@intlify/core-base@9.14.2':
dependencies:
@@ -5847,13 +5790,10 @@ snapshots:
semver: 7.5.3
size-limit: 8.2.6
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)':
'@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13
tailwindcss: 3.4.19
'@tanstack/table-core@8.20.5': {}
@@ -6230,7 +6170,7 @@ snapshots:
acorn@8.14.0: {}
acorn@8.15.0: {}
acorn@8.16.0: {}
activestorage@5.2.8:
dependencies:
@@ -6391,7 +6331,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.0.0
axios@1.13.2:
axios@1.13.6:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
@@ -6431,7 +6371,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.1:
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
@@ -6633,8 +6573,6 @@ snapshots:
confbox@0.1.8: {}
confbox@0.2.2: {}
config-chain@1.1.13:
dependencies:
ini: 1.3.8
@@ -6850,7 +6788,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.4:
dompurify@3.3.2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -6877,7 +6815,7 @@ snapshots:
dependencies:
'@one-ini/wasm': 0.1.1
commander: 10.0.1
minimatch: 9.0.1
minimatch: 9.0.9
semver: 7.6.3
ee-first@1.1.1: {}
@@ -7144,7 +7082,7 @@ snapshots:
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
minimatch: 3.1.5
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.0
@@ -7224,7 +7162,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 3.1.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.3
strip-ansi: 6.0.1
@@ -7270,8 +7208,6 @@ snapshots:
expect-type@1.1.0: {}
exsolve@1.0.8: {}
extend-shallow@2.0.1:
dependencies:
is-extendable: 0.1.1
@@ -7456,7 +7392,7 @@ snapshots:
dependencies:
foreground-child: 3.3.0
jackspeak: 3.4.3
minimatch: 9.0.5
minimatch: 9.0.9
minipass: 7.1.2
package-json-from-dist: 1.0.0
path-scurry: 1.11.1
@@ -7466,7 +7402,7 @@ snapshots:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
@@ -7487,8 +7423,6 @@ snapshots:
globals@15.14.0: {}
globals@15.15.0: {}
globalthis@1.0.3:
dependencies:
define-properties: 1.2.0
@@ -7684,7 +7618,7 @@ snapshots:
ignore@6.0.2: {}
immutable@4.3.7:
immutable@4.3.8:
optional: true
import-fresh@3.3.0:
@@ -7874,6 +7808,8 @@ snapshots:
jiti@1.21.6: {}
jiti@1.21.7: {}
js-beautify@1.15.1:
dependencies:
config-chain: 1.1.13
@@ -7990,8 +7926,6 @@ snapshots:
kind-of@6.0.3: {}
kolorist@1.8.0: {}
launch-editor@2.9.1:
dependencies:
picocolors: 1.1.0
@@ -8008,7 +7942,7 @@ snapshots:
lilconfig@2.1.0: {}
lilconfig@3.1.2: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -8059,12 +7993,6 @@ snapshots:
lit-element: 3.3.3
lit-html: 2.8.0
local-pkg@1.1.2:
dependencies:
mlly: 1.8.0
pkg-types: 2.3.0
quansync: 0.2.11
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -8077,10 +8005,6 @@ snapshots:
dependencies:
p-locate: 6.0.0
lodash.castarray@4.4.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
lodash.truncate@4.4.2: {}
@@ -8217,17 +8141,13 @@ snapshots:
dependencies:
dom-walk: 0.1.2
minimatch@3.1.2:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.11
minimatch@9.0.1:
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.1
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.1
brace-expansion: 2.0.2
minimist@1.2.8: {}
@@ -8235,12 +8155,12 @@ snapshots:
mitt@3.0.1: {}
mlly@1.8.0:
mlly@1.8.1:
dependencies:
acorn: 8.15.0
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
ufo: 1.6.3
mpd-parser@0.21.0:
dependencies:
@@ -8511,13 +8431,7 @@ snapshots:
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.8
mlly: 1.8.1
pathe: 2.0.3
pngjs@5.0.0: {}
@@ -8634,7 +8548,7 @@ snapshots:
postcss-load-config@4.0.2(postcss@8.4.47):
dependencies:
lilconfig: 3.1.2
lilconfig: 3.1.3
yaml: 2.5.1
optionalDependencies:
postcss: 8.4.47
@@ -8889,8 +8803,6 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
quansync@0.2.11: {}
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
@@ -9035,7 +8947,7 @@ snapshots:
sass@1.79.3:
dependencies:
chokidar: 4.0.3
immutable: 4.3.7
immutable: 4.3.8
source-map-js: 1.2.1
optional: true
@@ -9289,7 +9201,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
tailwindcss@3.4.13:
tailwindcss@3.4.19:
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
@@ -9299,12 +9211,12 @@ snapshots:
fast-glob: 3.3.2
glob-parent: 6.0.2
is-glob: 4.0.3
jiti: 1.21.6
lilconfig: 2.1.0
jiti: 1.21.7
lilconfig: 3.1.3
micromatch: 4.0.8
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.0
picocolors: 1.1.1
postcss: 8.4.47
postcss-import: 15.1.0(postcss@8.4.47)
postcss-js: 4.0.1(postcss@8.4.47)
@@ -9324,7 +9236,7 @@ snapshots:
terser@5.33.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
acorn: 8.16.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
@@ -9333,7 +9245,7 @@ snapshots:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.4.5
minimatch: 9.0.5
minimatch: 9.0.9
text-segmentation@1.0.3:
dependencies:
@@ -9487,7 +9399,7 @@ snapshots:
uc.micro@2.1.0: {}
ufo@1.6.1: {}
ufo@1.6.3: {}
unbox-primitive@1.0.2:
dependencies:
@@ -9672,9 +9584,9 @@ snapshots:
dependencies:
vue: 3.5.12(typescript@5.6.2)
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
vue-dompurify-html@5.3.0(vue@3.5.12(typescript@5.6.2)):
dependencies:
dompurify: 3.2.4
dompurify: 3.3.2
vue: 3.5.12(typescript@5.6.2)
vue-eslint-parser@9.4.3(eslint@8.57.0):
@@ -145,5 +145,24 @@ describe ContactIdentifyAction do
expect(contact.phone_number).to be_nil
end
end
context 'when params have not changed' do
it 'skips save and does not issue an UPDATE query' do
contact.update!(name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' })
params = { name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' } }
# any_instance is needed because merge lookup can reassign @contact to a different Ruby object
expect_any_instance_of(Contact).not_to receive(:save!) # rubocop:disable RSpec/AnyInstance
described_class.new(contact: contact, params: params).perform
end
it 'still enqueues avatar job even when attributes have not changed' do
contact.update!(name: 'test')
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
described_class.new(contact: contact, params: params).perform
end
end
end
end
@@ -25,6 +25,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body.size).to eq(account.users.count)
end
@@ -122,6 +123,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(other_agent.reload.name).to eq(params[:name])
end
@@ -171,6 +173,7 @@ RSpec.describe 'Agents API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body['email']).to eq(params[:email])
expect(account.users.last.name).to eq('NewUser')
end
@@ -45,6 +45,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_body = response.parsed_body
contact_emails = response_body['payload'].pluck('email')
contact_inboxes_source_ids = response_body['payload'].flat_map { |c| c['contact_inboxes'].pluck('source_id') }
@@ -331,6 +332,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact2.email)
expect(response.body).not_to include(contact1.email)
end
@@ -443,6 +445,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact2.email)
expect(response.body).to include(contact1.email)
end
@@ -497,6 +500,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(contact.name)
end
end
@@ -620,6 +624,7 @@ RSpec.describe 'Contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(contact.reload.name).to eq('Test Blub')
# custom attributes are merged properly without overwriting existing ones
expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' })
@@ -31,6 +31,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(conversation.messages.count).to eq(1)
expect(conversation.messages.first.content).to eq(params[:content])
end
@@ -182,6 +183,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:contact][:id]).to eq(conversation.contact_id)
end
end
@@ -27,6 +27,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:data][:meta][:all_count]).to eq(1)
expect(body[:data][:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
@@ -165,6 +166,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data.count).to eq(2)
end
@@ -234,6 +236,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
end
@@ -282,6 +285,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
@@ -342,6 +346,7 @@ RSpec.describe 'Conversations API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:additional_attributes]).to eq(additional_attributes)
end
@@ -449,9 +454,11 @@ RSpec.describe 'Conversations API', type: :request do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(conversation.reload.status).to eq('open')
end
@@ -32,6 +32,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
end
@@ -95,6 +96,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
@@ -383,6 +385,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include('test.com')
end
@@ -478,6 +481,7 @@ RSpec.describe 'Inboxes API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(inbox.reload.enable_auto_assignment).to be_falsey
expect(inbox.reload.portal_id).to eq(portal.id)
expect(response.parsed_body['name']).to eq 'new test inbox'
@@ -22,6 +22,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body.first['id']).to eq(account.teams.first.id)
end
end
@@ -45,6 +46,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.parsed_body['id']).to eq(team.id)
end
end
@@ -83,6 +85,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(Team.count).to eq(2)
end
end
@@ -121,6 +124,7 @@ RSpec.describe 'Teams API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(team.reload.name).to eq('new-team')
end
end
@@ -81,6 +81,29 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
post api_v1_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
it 'does not respond 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
@@ -126,6 +149,7 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(account.name)
expect(response.body).to include(account.locale)
expect(response.body).to include(account.domain)
@@ -161,22 +185,22 @@ RSpec.describe 'Accounts API', type: :request do
end
end
describe 'PUT /api/v1/accounts/{account.id}' do
describe 'PATCH /api/v1/accounts/{account.id}' do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}"
patch "/api/v1/accounts/#{account.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthorized user' do
it 'returns unauthorized' do
put "/api/v1/accounts/#{account.id}",
headers: agent.create_new_auth_token
patch "/api/v1/accounts/#{account.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
@@ -196,11 +220,20 @@ RSpec.describe 'Accounts API', type: :request do
company_size: '1-10'
}
it 'returns a valid schema' do
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to conform_schema(200)
end
it 'modifies an account' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq(params[:name])
@@ -219,19 +252,19 @@ RSpec.describe 'Accounts API', type: :request do
it 'updates onboarding step to invite_team if onboarding step is present in account custom attributes' do
account.update(custom_attributes: { onboarding_step: 'account_update' })
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes['onboarding_step']).to be_nil
end
@@ -239,10 +272,10 @@ RSpec.describe 'Accounts API', type: :request do
it 'Throws error 422' do
params[:name] = 'test' * 999
put "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
@@ -21,6 +21,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
json_response = response.parsed_body
expect(json_response['id']).to eq(agent.id)
expect(json_response['email']).to eq(agent.email)
@@ -50,6 +51,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
json_response = response.parsed_body
agent.reload
expect(json_response['id']).to eq(agent.id)
@@ -64,6 +66,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
agent.reload
expect(agent.custom_attributes['phone_number']).to eq('+123456789')
@@ -91,6 +94,7 @@ RSpec.describe 'Profile API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(agent.reload.valid_password?('Test1234!')).to be true
end
@@ -94,6 +94,29 @@ RSpec.describe 'Accounts API', type: :request do
end
end
context 'when ENABLE_ACCOUNT_SIGNUP is stored as boolean false' do
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
end
after do
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'responds 404 on requests' do
params = { email: email, password: 'Password1!' }
post api_v2_accounts_url,
params: params,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
let(:account_builder) { double }
let(:account) { create(:account) }
@@ -106,6 +106,26 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
end
it 'blocks signup if config is stored as boolean false' do
GlobalConfig.clear_cache
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
InstallationConfig.create!(name: 'ENABLE_ACCOUNT_SIGNUP', value: false, locked: false)
with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('does-not-exist-for-sure@example.com')
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
end
ensure
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
GlobalConfig.clear_cache
end
it 'allows login' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'test@example.com')
@@ -144,6 +144,7 @@ RSpec.describe 'Platform Accounts API', type: :request do
headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include(account.name)
end
end
@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
message_content: 'Hello assistant',
message_history: [
{ role: 'user', content: 'Previous message' },
{ role: 'assistant', content: 'Previous response' }
{ role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
]
}
end
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
context 'when it is an agent' do
it 'generates a response' do
chat_service = instance_double(Captain::Llm::AssistantChatService)
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
context 'when captain v2 is disabled' do
it 'generates a response with the legacy assistant chat service' do
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
)
expect(json_response[:content]).to eq('Assistant response')
end
end
context 'when message_history is not provided' do
it 'uses empty array as default' do
params_without_history = { message_content: 'Hello assistant' }
chat_service = instance_double(Captain::Llm::AssistantChatService)
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_without_history,
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
)
end
end
context 'when captain v2 is enabled' do
before do
account.enable_features('captain_integration_v2')
end
it 'generates a response with the agent runner service' do
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent_runner_service).to have_received(:generate_response).with(
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
)
expect(json_response[:response]).to eq('Assistant response')
end
it 'does not duplicate the latest user message if it is already in history' do
params_with_latest_message = {
message_content: 'Hello assistant',
message_history: [{ role: 'user', content: 'Hello assistant' }]
}
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_with_latest_message,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent_runner_service).to have_received(:generate_response).with(
message_history: params_with_latest_message[:message_history]
)
end
end
end
end
@@ -2,16 +2,14 @@ require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.minute.ago, status: :pending) }
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
stub_const('Limits::BULK_ACTIONS_LIMIT', 3)
inbox.reload
end
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
.to have_enqueued_job.on_queue('low')
end
it 'resolves only the eligible pending conversations' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(recent_pending_conversation.reload.status).to eq('pending')
expect(open_conversation.reload.status).to eq('open')
end
it 'creates exactly one outgoing message with configured content' do
custom_message = 'This is a custom resolution message.'
captain_assistant.update!(config: { 'resolution_message' => custom_message })
expect do
context 'when captain_tasks is disabled' do
it 'resolves pending conversations inactive for over 1 hour' do
described_class.perform_now(inbox)
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(custom_message)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
end
it 'does not resolve recent pending conversations' do
described_class.perform_now(inbox)
expect(recent_pending_conversation.reload.status).to eq('pending')
end
it 'does not affect open conversations' do
described_class.perform_now(inbox)
expect(open_conversation.reload.status).to eq('open')
end
it 'does not call ConversationCompletionService' do
allow(Captain::ConversationCompletionService).to receive(:new)
described_class.perform_now(inbox)
expect(Captain::ConversationCompletionService).not_to have_received(:new)
end
end
it 'creates an outgoing message with default auto resolution message if not configured' do
captain_assistant.update!(config: {})
context 'when captain_tasks is enabled' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
end
described_class.perform_now(inbox)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
)
end
it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
it 'adds the correct activity message after resolution by Captain' do
described_class.perform_now(inbox)
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
# Mock the service to return complete for all conversations
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
described_class.perform_now(inbox)
# Only resolvable conversation should be evaluated (not recent or open)
expect(Captain::ConversationCompletionService).to have_received(:new).with(
account: inbox.account,
conversation_display_id: resolvable_pending_conversation.display_id
)
expect(recent_pending_conversation.reload.status).to eq('pending')
expect(open_conversation.reload.status).to eq('open')
end
it 'skips auto-action if conversation receives new activity after evaluation' do
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform) do
resolvable_pending_conversation.update!(last_activity_at: Time.current)
{ complete: true, reason: 'Customer question was answered' }
end
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('pending')
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
end
end
context 'when LLM evaluation returns complete' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Customer question was answered' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'resolves the conversation' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
end
it 'creates a private note with the reason' do
described_class.perform_now(inbox)
private_note = resolvable_pending_conversation.messages.where(private: true).last
expect(private_note.content).to eq('Auto-resolved: Customer question was answered')
end
it 'creates resolution message with configured content' do
custom_message = 'This is a custom resolution message.'
captain_assistant.update!(config: { 'resolution_message' => custom_message })
inbox.reload
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(custom_message)
end
it 'creates resolution message with default if not configured' do
captain_assistant.update!(config: {})
inbox.reload
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(I18n.t('conversations.activity.auto_resolution_message'))
end
it 'adds the correct activity message after resolution' do
described_class.perform_now(inbox)
expected_content = I18n.t(
'conversations.activity.captain.resolved_with_reason',
user_name: captain_assistant.name,
reason: 'no outstanding questions'
)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
)
end
it 'creates a captain inference resolved reporting event' do
perform_enqueued_jobs do
described_class.perform_now(inbox)
end
inference_event = ReportingEvent.find_by(
conversation_id: resolvable_pending_conversation.id,
name: 'conversation_captain_inference_resolved'
)
expect(inference_event).to be_present
end
end
context 'when LLM evaluation returns incomplete' do
let(:handoff_reason) { 'Assistant asked for order number but customer did not respond' }
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'hands off the conversation to agents (status becomes open)' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('open')
end
it 'creates a private note with the reason' do
described_class.perform_now(inbox)
private_note = resolvable_pending_conversation.messages.where(private: true).last
expect(private_note.content).to eq("Auto-handoff: #{handoff_reason}")
end
it 'creates handoff message with configured content' do
handoff_message = 'Connecting you to a human agent...'
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(handoff_message)
expect(public_message.additional_attributes['preserve_waiting_since']).to be_nil
end
it 'preserves existing waiting_since when handoff message is configured' do
handoff_message = 'Connecting you to a human agent...'
original_waiting_since = 3.hours.ago
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
resolvable_pending_conversation.update!(waiting_since: original_waiting_since)
allow(MessageTemplates::Template::OutOfOffice).to receive(:perform_if_applicable)
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
end
it 'does not create handoff message if not configured' do
captain_assistant.update!(config: {})
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
expect do
described_class.perform_now(inbox)
end.not_to(change { resolvable_pending_conversation.messages.where(private: false).count })
end
it 'adds the correct activity message after handoff' do
described_class.perform_now(inbox)
expected_content = I18n.t(
'conversations.activity.captain.open_with_reason',
user_name: captain_assistant.name,
reason: 'pending clarification from customer'
)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
)
end
it 'creates a captain inference handoff reporting event' do
perform_enqueued_jobs do
described_class.perform_now(inbox)
end
inference_event = ReportingEvent.find_by(
conversation_id: resolvable_pending_conversation.id,
name: 'conversation_captain_inference_handoff'
)
expect(inference_event).to be_present
end
end
context 'when handoff occurs outside business hours' do
let(:handoff_reason) { 'Customer has not responded to clarifying question' }
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently unavailable.')
end
it 'sends OOO message for non-campaign conversations' do
travel_to '01.11.2020 13:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
ooo_message = resolvable_pending_conversation.messages.template.last
expect(ooo_message).to be_present
expect(ooo_message.content).to eq('We are currently unavailable.')
end
end
it 'does not send OOO message for campaign conversations' do
campaign = create(:campaign, account: inbox.account, inbox: inbox)
resolvable_pending_conversation.update!(campaign: campaign)
travel_to '01.11.2020 13:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.messages.template).to be_empty
end
end
it 'does not send OOO message during business hours' do
travel_to '26.10.2020 10:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.messages.template).to be_empty
end
end
end
context 'when LLM evaluation fails' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: 'API Error' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'hands off as safe default' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('open')
end
end
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
@@ -0,0 +1,163 @@
require 'rails_helper'
RSpec.describe Captain::ConversationCompletionService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
end
describe '#perform' do
context 'when conversation is complete' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
create(:message, conversation: conversation, message_type: :incoming, content: 'Thanks!')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns complete: true with reason' do
result = service.perform
expect(result[:complete]).to be true
expect(result[:reason]).to eq('Customer question was fully answered')
end
end
context 'when conversation is incomplete' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => false, 'reason' => 'Assistant asked for order number but customer did not respond' },
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Where is my order?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'Can you please share your order number?')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns complete: false with reason' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('Assistant asked for order number but customer did not respond')
end
end
context 'when conversation has no messages' do
it 'returns incomplete with appropriate reason' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('No messages found')
end
end
context 'when LLM returns non-hash response' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: 'unexpected string response',
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns incomplete as safe default' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('Invalid response format')
end
end
context 'when API call fails' do
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
allow(mock_chat).to receive(:ask).and_raise(StandardError.new('API Error'))
end
it 'returns incomplete with error message' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('API Error')
end
end
context 'when captain_tasks feature is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
end
it 'does not evaluate the conversation as complete' do
result = service.perform
expect(result[:complete]).not_to be true
end
end
context 'when customer quota is exhausted' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
input_tokens: 100,
output_tokens: 20
)
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'still runs the evaluation bypassing quota check' do
result = service.perform
expect(result[:error]).to be_nil
expect(result[:complete]).to be true
expect(result[:reason]).to eq('Customer question was fully answered')
end
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
end
end
@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: 'Test reason')
end
it 'creates a conversation_bot_handoff reporting event' do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
Current.executed_by = assistant
perform_enqueued_jobs do
tool.perform(tool_context, reason: 'Customer needs specialized support')
end
reporting_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
expect(reporting_event).to be_present
ensure
Current.reset
end
it 'logs tool usage with reason' do
reason = 'Customer needs help'
expect(tool).to receive(:log_tool_usage).with(
@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
)
end
it 'clears captain_resolve_reason after execution' do
tool.perform(tool_context, reason: 'Possible spam')
it 'creates a conversation_resolved reporting event' do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
expect(Current.captain_resolve_reason).to be_nil
expect do
perform_enqueued_jobs do
tool.perform(tool_context, reason: 'Possible spam')
end
end.to change { ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_resolved').count }.by(1)
end
end
@@ -8,8 +8,7 @@ describe CaptainListener do
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
@@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
describe '#handoff_key' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'uses id plus readable slug for persisted scenarios' do
scenario = create(:captain_scenario, assistant: assistant, account: account,
title: 'Handle complex refund requests requiring manager approval steps')
expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_")
expect(scenario.handoff_key).to end_with('_agent')
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'uses a truncated slug key for unsaved scenarios' do
scenario = build(:captain_scenario, assistant: assistant, account: account,
title: 'Troubleshoot payment gateway errors for recurring subscription charges')
expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/)
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'stays within length budget even for large ids' do
scenario = build(:captain_scenario, assistant: assistant, account: account,
title: 'A very long scenario title used only for budget verification')
allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789)
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'exposes handoff keys in assistant prompt context' do
scenario = create(:captain_scenario, assistant: assistant, account: account)
prompt_context = assistant.send(:prompt_context)
scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title }
expect(scenario_config[:key]).to eq(scenario.handoff_key)
end
end
describe 'tool validation and population' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
@@ -24,7 +24,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
@@ -43,7 +43,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
@@ -52,7 +52,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
expect(conversation.reload.status).to eq('open')
end
@@ -62,7 +62,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
@@ -76,7 +76,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'schedules captain response job regardless of time' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
@@ -95,7 +95,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
it 'performs handoff within business hours when quota exceeded' do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
expect(conversation.reload.status).to eq('open')
end
@@ -110,7 +110,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
@@ -122,7 +122,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
end
@@ -130,7 +130,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :outgoing)
create(:message, conversation: conversation, message_type: :outgoing, account: account)
end
end
@@ -144,7 +144,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.not_to(change { conversation.reload.messages.template.count })
end
@@ -160,7 +160,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.not_to(change { conversation.reload.messages.template.count })
end
end
@@ -174,7 +174,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
@@ -193,7 +193,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
@@ -211,7 +211,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
@@ -230,7 +230,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
@@ -245,7 +245,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'schedules captain response job for incoming messages on pending campaign conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(campaign_conversation, assistant)
create(:message, conversation: campaign_conversation, message_type: :incoming)
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
end
it 'does not send greeting template on campaign conversations' do
@@ -255,7 +255,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
end
@@ -271,7 +271,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
@@ -284,7 +284,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
create(:message, conversation: campaign_conversation, message_type: :incoming)
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
end
@@ -304,7 +304,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
)
expect do
create(:message, conversation: campaign_conversation, message_type: :incoming)
create(:message, conversation: campaign_conversation, message_type: :incoming, account: account)
end.not_to(change { campaign_conversation.messages.template.count })
end
end
@@ -332,7 +332,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'sends out of office message after handoff due to quota exceeded' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.messages.template.count }.by(1)
expect(conversation.reload.status).to eq('open')
@@ -356,7 +356,7 @@ RSpec.describe MessageTemplates::HookExecutionService do
it 'does not send out of office message after handoff' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
+111 -4
View File
@@ -27,12 +27,24 @@ describe ReportingEventListener do
end
it 'creates conversation_resolved event with business hour value' do
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
event = Events::Base.new('conversation.resolved', updated_at, conversation: new_conversation)
listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
end
end
it 'uses event timestamp even when conversation updated_at changes later' do
resolved_at = conversation.created_at + 20.minutes
allow(conversation).to receive(:updated_at).and_return(resolved_at + 10.minutes)
event = Events::Base.new('conversation.resolved', resolved_at, conversation: conversation)
listener.conversation_resolved(event)
reporting_event = account.reporting_events.where(name: 'conversation_resolved').first
expect(reporting_event.value).to eq 1200
expect(reporting_event.event_end_time).to be_within(1.second).of(resolved_at)
end
describe 'conversation_bot_resolved' do
# create an agent bot
let!(:agent_bot_inbox) { create(:inbox, account: account) }
@@ -261,11 +273,55 @@ describe ReportingEventListener do
end
it 'creates conversation_bot_handoff event with business hour value' do
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: new_conversation)
event = Events::Base.new('conversation.bot_handoff', updated_at, conversation: new_conversation)
listener.conversation_bot_handoff(event)
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
end
end
it 'uses event timestamp even when conversation updated_at changes later' do
handoff_at = conversation.created_at + 10.minutes
allow(conversation).to receive(:updated_at).and_return(handoff_at + 15.minutes)
event = Events::Base.new('conversation.bot_handoff', handoff_at, conversation: conversation)
listener.conversation_bot_handoff(event)
reporting_event = account.reporting_events.where(name: 'conversation_bot_handoff').first
expect(reporting_event.value).to eq 600
expect(reporting_event.event_end_time).to be_within(1.second).of(handoff_at)
end
end
describe '#conversation_captain_inference_resolved' do
it 'creates conversation_captain_inference_resolved event' do
expect(account.reporting_events.where(name: 'conversation_captain_inference_resolved').count).to be 0
decision_time = conversation.created_at + 60.seconds
event = Events::Base.new('conversation.captain_inference_resolved', decision_time, conversation: conversation)
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
listener.conversation_captain_inference_resolved(event)
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_resolved').first
expect(reporting_event).to be_present
expect(reporting_event.value).to eq 60
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
end
end
describe '#conversation_captain_inference_handoff' do
it 'creates conversation_captain_inference_handoff event' do
expect(account.reporting_events.where(name: 'conversation_captain_inference_handoff').count).to be 0
decision_time = conversation.created_at + 90.seconds
event = Events::Base.new('conversation.captain_inference_handoff', decision_time, conversation: conversation)
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
listener.conversation_captain_inference_handoff(event)
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_handoff').first
expect(reporting_event).to be_present
expect(reporting_event.value).to eq 90
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
end
end
describe '#conversation_opened' do
@@ -274,7 +330,8 @@ describe ReportingEventListener do
it 'creates conversation_opened event with value 0' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
opened_at = Time.zone.now
event = Events::Base.new('conversation.opened', opened_at, conversation: new_conversation)
listener.conversation_opened(event)
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
@@ -282,7 +339,7 @@ describe ReportingEventListener do
expect(opened_event.value).to eq 0
expect(opened_event.value_in_business_hours).to eq 0
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
expect(opened_event.event_end_time).to be_within(1.second).of(opened_at)
end
end
@@ -334,6 +391,17 @@ describe ReportingEventListener do
expect(reopened_event.user_id).to eq(user.id)
end
it 'uses event timestamp even when conversation updated_at changes later' do
allow(reopened_conversation).to receive(:updated_at).and_return(reopened_time + 20.minutes)
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
context 'when business hours enabled for inbox' do
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
@@ -406,6 +474,45 @@ describe ReportingEventListener do
end
end
context 'when latest resolved event is after the conversation opened event timestamp' do
let(:previous_resolved_time) { Time.zone.parse('March 22, 2022 09:00') }
let(:future_resolved_time) { Time.zone.parse('March 22, 2022 11:00') }
let(:reopened_time) { Time.zone.parse('March 22, 2022 10:00') }
let(:reopened_conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
end
before do
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: reopened_conversation.id,
user_id: user.id,
event_start_time: reopened_conversation.created_at,
event_end_time: previous_resolved_time)
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: reopened_conversation.id,
user_id: user.id,
event_start_time: previous_resolved_time,
event_end_time: future_resolved_time)
end
it 'ignores future resolved events when computing reopen duration' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600)
expect(reopened_event.event_start_time).to be_within(1.second).of(previous_resolved_time)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
end
context 'when agent bot resolves and conversation is reopened' do
# This implicitly tests that the first_response time is correctly calculated
# By checking that a conversation reopened event is created with the correct values
+19
View File
@@ -366,6 +366,25 @@ RSpec.describe Message do
expect(conversation.waiting_since).to be_nil
end
end
context 'when bot response should preserve waiting_since' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
it 'does not clear waiting_since when preserve_waiting_since is set' do
original_waiting_since = 45.minutes.ago
conversation.update!(waiting_since: original_waiting_since)
create(
:message,
conversation: conversation,
message_type: :outgoing,
sender: agent_bot,
preserve_waiting_since: true
)
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
end
end
end
context 'with webhook_data' do
+4
View File
@@ -75,6 +75,10 @@ RSpec.configure do |config|
config.include ActiveSupport::Testing::TimeHelpers
config.include ActionCable::TestHelper
config.include ActiveJob::TestHelper
# OpenAPI response validation via Skooma
path_to_openapi = Rails.root.join('swagger/swagger.json')
config.include Skooma::RSpec[path_to_openapi], type: :request
end
Shoulda::Matchers.configure do |config|
+20
View File
@@ -50,6 +50,26 @@ describe ActionService do
action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil
end
context 'when agent is confirmed' do
it 'assigns the agent to the conversation' do
inbox_member
action_service.assign_agent([agent.id])
expect(conversation.reload.assignee).to eq(agent)
end
end
context 'when agent is unconfirmed' do
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
it 'does not assign unconfirmed agent to the conversation' do
unconfirmed_inbox_member
original_assignee = conversation.assignee
action_service.assign_agent([unconfirmed_agent.id])
expect(conversation.reload.assignee).to eq(original_assignee)
end
end
end
describe '#assign_team' do
@@ -15,7 +15,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::Greeting).to receive(:new)
# described class gets called in message after commit
create(:message, conversation: conversation, message_type: 'activity', content: 'Conversation marked resolved!!')
create(:message, conversation: conversation, account: conversation.account, message_type: 'activity', content: 'Conversation marked resolved!!')
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
@@ -36,7 +36,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::Greeting).to receive(:new)
# described class gets called in message after commit
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
expect(MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
@@ -54,7 +54,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new).with(conversation: message.conversation)
end
end
@@ -75,7 +75,7 @@ describe MessageTemplates::HookExecutionService do
allow(greeting_service).to receive(:perform).and_return(true)
# described class gets called in message after commit
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::Greeting).to have_received(:new).with(conversation: message.conversation)
expect(greeting_service).to have_received(:perform)
@@ -90,7 +90,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
# described class gets called in message after commit
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
end
@@ -105,7 +105,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(true)
# described class gets called in message after commit
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new).with(conversation: message.conversation)
end
@@ -123,7 +123,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
end
@@ -139,7 +139,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
@@ -151,7 +151,7 @@ describe MessageTemplates::HookExecutionService do
conversation = create(:conversation, contact: contact)
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
message = create(:message, conversation: conversation, content_type: :incoming_email)
message = create(:message, conversation: conversation, account: conversation.account, content_type: :incoming_email)
message.content_attributes = { email: { auto_reply: true } }
message.save!
@@ -188,7 +188,7 @@ describe MessageTemplates::HookExecutionService do
allow(out_of_office_service).to receive(:perform).and_return(true)
# described class gets called in message after commit
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation)
expect(out_of_office_service).to have_received(:perform)
@@ -202,13 +202,13 @@ describe MessageTemplates::HookExecutionService do
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago)
create(:message, conversation: conversation, account: conversation.account, message_type: :outgoing, created_at: 2.minutes.ago)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
expect(out_of_office_service).not_to have_received(:perform)
@@ -221,13 +221,13 @@ describe MessageTemplates::HookExecutionService do
conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office')
conversation.inbox.working_hours.today.update!(closed_all_day: true)
create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
create(:message, conversation: conversation, account: conversation.account, private: true, message_type: :outgoing, created_at: 2.minutes.ago)
out_of_office_service = double
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation)
create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation)
expect(out_of_office_service).to have_received(:perform)
@@ -247,7 +247,7 @@ describe MessageTemplates::HookExecutionService do
allow(out_of_office_service).to receive(:perform).and_return(true)
# described class gets called in message after commit
message = create(:message, conversation: conversation, message_type: 'outgoing')
message = create(:message, conversation: conversation, account: conversation.account, message_type: 'outgoing')
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
expect(out_of_office_service).not_to have_received(:perform)
@@ -265,7 +265,7 @@ describe MessageTemplates::HookExecutionService do
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(false)
message = create(:message, conversation: conversation)
message = create(:message, conversation: conversation, account: conversation.account)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation)
expect(out_of_office_service).not_to receive(:perform)
end
+7
View File
@@ -0,0 +1,7 @@
require 'rails_helper'
RSpec.describe 'OpenAPI document', type: :request do
it 'is valid against the OpenAPI 3.1.0 meta-schema' do
expect(skooma_openapi_schema).to be_valid_document
end
end
+6
View File
@@ -70,6 +70,8 @@ platform_account:
$ref: ./resource/platform_account.yml
team:
$ref: ./resource/team.yml
label:
$ref: ./resource/label.yml
integrations_app:
$ref: ./resource/integrations/app.yml
integrations_hook:
@@ -142,6 +144,10 @@ inbox_update_payload:
team_create_update_payload:
$ref: ./request/team/create_update_payload.yml
# Label
label_create_update_payload:
$ref: ./request/label/create_update_payload.yml
# Custom Filter
custom_filter_create_update_payload:
$ref: ./request/custom_filter/create_update_payload.yml
@@ -18,20 +18,23 @@ properties:
example: 'support@example.com'
# Settings parameters (stored in settings JSONB column)
auto_resolve_after:
type: integer
type:
- integer
- 'null'
minimum: 10
maximum: 1439856
nullable: true
description: Auto resolve conversations after specified minutes
example: 1440
auto_resolve_message:
type: string
nullable: true
type:
- string
- 'null'
description: Message to send when auto resolving
example: "This conversation has been automatically resolved due to inactivity"
auto_resolve_ignore_waiting:
type: boolean
nullable: true
type:
- boolean
- 'null'
description: Whether to ignore waiting conversations for auto resolve
example: false
# Custom attributes parameters (stored in custom_attributes JSONB column)
@@ -1,7 +1,6 @@
type: object
required:
- source_id
- inbox_id
properties:
source_id:
type: string
@@ -0,0 +1,18 @@
type: object
properties:
title:
type: string
description: The label title
example: support
description:
type: string
description: A short description for the label
example: Conversations that need support follow-up
color:
type: string
description: Hex color code for the label
example: '#1f93ff'
show_on_sidebar:
type: boolean
description: Whether the label should appear in the sidebar
example: true
@@ -26,9 +26,7 @@ properties:
type: object
description: Cache keys for the account
features:
type: array
items:
type: string
type: object
description: Enabled features for the account
settings:
type: object
@@ -48,16 +46,24 @@ properties:
description: Custom attributes of the account
properties:
plan_name:
type: string
type:
- string
- 'null'
description: Subscription plan name
subscribed_quantity:
type: number
type:
- number
- 'null'
description: Subscribed quantity
subscription_status:
type: string
type:
- string
- 'null'
description: Subscription status
subscription_ends_on:
type: string
type:
- string
- 'null'
format: date
description: Subscription end date
industry:
@@ -3,7 +3,9 @@ allOf:
- type: object
properties:
latest_chatwoot_version:
type: string
type:
- string
- 'null'
description: Latest version of Chatwoot available
example: "3.0.0"
subscribed_features:
+3 -1
View File
@@ -31,5 +31,7 @@ properties:
type: string
description: The thumbnail of the agent
custom_role_id:
type: integer
type:
- integer
- 'null'
description: The custom role id of the agent
+6 -4
View File
@@ -38,8 +38,9 @@ properties:
type: integer
description: Version number of the audit log entry
comment:
type: string
nullable: true
type:
- string
- 'null'
description: Optional comment associated with the audit log entry
request_uuid:
type: string
@@ -48,6 +49,7 @@ properties:
type: integer
description: Unix timestamp when the audit log entry was created
remote_address:
type: string
nullable: true
type:
- string
- 'null'
description: IP address from which the action was performed
@@ -31,9 +31,10 @@ properties:
type: string
description: Status of the message
source_id:
type: string
type:
- string
- 'null'
description: Source ID of the message
nullable: true
content_type:
type: string
description: Type of the content
@@ -41,13 +42,15 @@ properties:
type: object
description: Attributes of the content
sender_type:
type: string
type:
- string
- 'null'
description: Type of the sender
nullable: true
sender_id:
type: integer
type:
- integer
- 'null'
description: ID of the sender
nullable: true
external_source_ids:
type: object
description: External source IDs
@@ -55,9 +58,10 @@ properties:
type: object
description: Additional attributes of the message
processed_message_content:
type: string
type:
- string
- 'null'
description: Processed message content
nullable: true
sentiment:
type: object
description: Sentiment analysis of the message
@@ -66,9 +70,10 @@ properties:
description: Conversation details
properties:
assignee_id:
type: integer
type:
- integer
- 'null'
description: ID of the assignee
nullable: true
unread_count:
type: integer
description: Count of unread messages
@@ -11,7 +11,9 @@ properties:
type: string
description: Country of the contact
country_code:
type: string
type:
- string
- 'null'
description: Country code of the contact
created_at_ip:
type: string
@@ -26,16 +28,18 @@ properties:
type: integer
description: The ID of the contact
identifier:
type: string
type:
- string
- 'null'
description: The identifier of the contact
nullable: true
name:
type: string
description: The name of the contact
phone_number:
type: string
type:
- string
- 'null'
description: The phone number of the contact
nullable: true
thumbnail:
type: string
description: The thumbnail of the contact
@@ -22,6 +22,7 @@ properties:
type: string
description: Type of channel
provider:
type: string
description: Provider of the inbox
nullable: true
type:
- string
- 'null'
description: Provider of the inbox
@@ -11,7 +11,9 @@ properties:
type: string
description: Country of the contact
country_code:
type: string
type:
- string
- 'null'
description: Country code of the contact
created_at_ip:
type: string
@@ -21,9 +23,10 @@ properties:
description: Availability status of the contact
enum: ["online", "offline"]
email:
type: string
type:
- string
- 'null'
description: The email address of the contact
nullable: true
id:
type: integer
description: The ID of the contact
@@ -31,16 +34,18 @@ properties:
type: string
description: The name of the contact
phone_number:
type: string
type:
- string
- 'null'
description: The phone number of the contact
nullable: true
blocked:
type: boolean
description: Whether the contact is blocked
identifier:
type: string
type:
- string
- 'null'
description: The identifier of the contact
nullable: true
thumbnail:
type: string
description: The thumbnail of the contact
@@ -48,9 +53,10 @@ properties:
type: object
description: The custom attributes of the contact
last_activity_at:
type: integer
type:
- integer
- 'null'
description: Timestamp of last activity
nullable: true
created_at:
type: integer
description: Timestamp when contact was created
@@ -4,5 +4,7 @@ properties:
type: integer
description: Total number of contacts
current_page:
type: string
description: Current page number
type:
- string
- integer
description: Current page number
+19 -8
View File
@@ -43,7 +43,9 @@ properties:
type: boolean
description: Whether the conversation is muted
snoozed_until:
type: number
type:
- number
- 'null'
description: The time at which the conversation will be unmuted
status:
type: string
@@ -56,29 +58,38 @@ properties:
type: number
description: The time at which conversation was updated
timestamp:
type: string
type: number
description: The time at which conversation was created
first_reply_created_at:
type: number
type:
- number
- 'null'
description: The time at which the first reply was created
unread_count:
type: number
description: The number of unread messages
last_non_activity_message:
type: object
$ref: '#/components/schemas/message'
oneOf:
- $ref: '#/components/schemas/message'
- type: 'null'
description: The last non activity message
last_activity_at:
type: number
description: The last activity at of the conversation
priority:
type: string
type:
- string
- 'null'
description: The priority of the conversation
waiting_since:
type: number
type:
- number
- 'null'
description: The time at which the conversation was waiting
sla_policy_id:
type: number
type:
- number
- 'null'
description: The ID of the SLA policy
applied_sla:
type: object
@@ -45,11 +45,18 @@ properties:
contact:
$ref: '#/components/schemas/contact_detail'
description: Contact details
agent_last_seen_at:
type: string
description: Timestamp when the agent last saw the conversation
assignee:
allOf:
- $ref: '#/components/schemas/agent'
description: The agent assigned to the conversation
nullable: true
agent_last_seen_at:
type:
- string
- 'null'
description: Timestamp when the agent last saw the conversation
assignee_last_seen_at:
type: string
description: Timestamp when the assignee last saw the conversation
nullable: true
type:
- string
- 'null'
description: Timestamp when the assignee last saw the conversation
@@ -13,7 +13,9 @@ properties:
type: string
description: The availability status of the sender
email:
type: string
type:
- string
- 'null'
description: The email of the sender
id:
type: number
@@ -22,16 +24,22 @@ properties:
type: string
description: The name of the sender
phone_number:
type: string
type:
- string
- 'null'
description: The phone number of the sender
blocked:
type: boolean
description: Whether the sender is blocked
identifier:
type: string
type:
- string
- 'null'
description: The identifier of the sender
thumbnail:
type: string
type:
- string
- 'null'
description: Avatar URL of the contact
custom_attributes:
type: object
+44 -14
View File
@@ -28,16 +28,22 @@ properties:
type: string
description: Script used to load the website widget
welcome_title:
type: string
type:
- string
- 'null'
description: Welcome title to be displayed on the widget
welcome_tagline:
type: string
type:
- string
- 'null'
description: Welcome tagline to be displayed on the widget
greeting_enabled:
type: boolean
description: The flag which shows whether greeting is enabled
greeting_message:
type: string
type:
- string
- 'null'
description: A greeting message when the user starts the conversation
channel_id:
type: number
@@ -55,7 +61,9 @@ properties:
type: object
description: Configuration settings for auto assignment
out_of_office_message:
type: string
type:
- string
- 'null'
description: Message to show when agents are out of office
working_hours:
type: array
@@ -70,16 +78,24 @@ properties:
type: boolean
description: Whether the inbox is closed for the entire day
open_hour:
type: number
type:
- number
- 'null'
description: Hour when inbox opens (0-23)
open_minutes:
type: number
type:
- number
- 'null'
description: Minutes of the hour when inbox opens (0-59)
close_hour:
type: number
type:
- number
- 'null'
description: Hour when inbox closes (0-23)
close_minutes:
type: number
type:
- number
- 'null'
description: Minutes of the hour when inbox closes (0-59)
open_all_day:
type: boolean
@@ -88,7 +104,9 @@ properties:
type: string
description: Timezone configuration for the inbox
callback_webhook_url:
type: string
type:
- string
- 'null'
description: Webhook URL for callbacks
allow_messages_after_resolved:
type: boolean
@@ -100,26 +118,38 @@ properties:
type: string
description: Type of sender name to display (e.g., friendly)
business_name:
type: string
type:
- string
- 'null'
description: Business name associated with the inbox
hmac_mandatory:
type: boolean
description: Whether HMAC verification is mandatory
selected_feature_flags:
type: object
type:
- array
- 'null'
description: Selected feature flags for the inbox
items:
type: string
reply_time:
type: string
description: Expected reply time
messaging_service_sid:
type: string
type:
- string
- 'null'
description: Messaging service SID for SMS providers
phone_number:
type: string
type:
- string
- 'null'
description: Phone number associated with the inbox
medium:
type: string
description: Medium of communication (e.g., sms, email)
provider:
type: string
type:
- string
- 'null'
description: Provider of the channel
+17
View File
@@ -0,0 +1,17 @@
type: object
properties:
id:
type: number
description: The ID of the label
title:
type: string
description: The title of the label
description:
type: string
description: The description of the label
color:
type: string
description: Hex color code for the label
show_on_sidebar:
type: boolean
description: Whether the label should appear in the sidebar
+34 -14
View File
@@ -17,37 +17,49 @@ properties:
description: The ID of the conversation
message_type:
type: integer
enum: [0, 1, 2]
enum: [0, 1, 2, 3]
description: The type of the message
created_at:
type: integer
description: The time at which message was created
updated_at:
type: integer
type:
- integer
- string
description: The time at which message was updated
private:
type: boolean
description: The flags which shows whether the message is private or not
status:
type: string
enum: ["sent", "delivered", "read", "failed"]
type:
- string
- 'null'
enum: ["sent", "delivered", "read", "failed", null]
description: The status of the message
source_id:
type: string
type:
- string
- 'null'
description: The source ID of the message
content_type:
type: string
enum: ["text", "input_select", "cards", "form"]
type:
- string
- 'null'
enum: ["text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call", null]
description: The type of the template message
content_attributes:
type: object
description: The content attributes for each content_type
sender_type:
type: string
enum: ["contact", "agent", "agent_bot"]
type:
- string
- 'null'
enum: ["Contact", "User", "AgentBot", "Captain::Assistant", null]
description: The type of the sender
sender_id:
type: number
type:
- number
- 'null'
description: The ID of the sender
external_source_ids:
type: object
@@ -56,16 +68,24 @@ properties:
type: object
description: The additional attributes of the message
processed_message_content:
type: string
type:
- string
- 'null'
description: The processed message content
sentiment:
type: object
type:
- object
- 'null'
description: The sentiment of the message
conversation:
type: object
type:
- object
- 'null'
description: The conversation object
attachment:
type: object
type:
- object
- 'null'
description: The file object attached to the image
sender:
type: object
@@ -18,7 +18,7 @@ properties:
description: "The type of the message (0: incoming, 1: outgoing, 2: activity, 3: template)"
content_type:
type: string
enum: ["text", "input_select", "cards", "form", "input_csat"]
enum: ["text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call"]
description: The type of the message content
status:
type: string
@@ -29,9 +29,15 @@ properties:
description: The content attributes for each content_type
properties:
in_reply_to:
type: string
type:
- string
- 'null'
description: ID of the message this is replying to
nullable: true
echo_id:
type:
- string
- 'null'
description: The echo ID of the message, used for deduplication
created_at:
type: integer
description: The timestamp when message was created
@@ -39,9 +45,38 @@ properties:
type: boolean
description: The flag which shows whether the message is private or not
source_id:
type: string
type:
- string
- 'null'
description: The source ID of the message
nullable: true
sender:
$ref: '#/components/schemas/contact_detail'
description: The sender of the message (only for incoming messages)
description: The sender of the message (only for incoming messages)
attachments:
type: array
description: The list of attachments associated with the message
items:
type: object
properties:
id:
type: number
description: The ID of the attachment
message_id:
type: number
description: The ID of the message
file_type:
type: string
enum: ["image", "video", "audio", "file", "location", "fallback", "share", "story_mention", "contact", "ig_reel"]
description: The type of the attached file
account_id:
type: number
description: The ID of the account
data_url:
type: string
description: The URL of the attached file
thumb_url:
type: string
description: The thumbnail URL of the attached file
file_size:
type: number
description: The size of the attached file in bytes
+9 -6
View File
@@ -4,16 +4,19 @@ properties:
type: integer
description: Total number of articles
archived_articles_count:
type: integer
nullable: true
type:
- integer
- 'null'
description: Number of archived articles
published_count:
type: integer
nullable: true
type:
- integer
- 'null'
description: Number of published articles
draft_articles_count:
type: integer
nullable: true
type:
- integer
- 'null'
description: Number of draft articles
categories_count:
type: integer
@@ -26,16 +26,19 @@ properties:
type: number
description: ID of the account
conversation_id:
type: number
nullable: true
type:
- number
- 'null'
description: ID of the conversation
inbox_id:
type: number
nullable: true
type:
- number
- 'null'
description: ID of the inbox
user_id:
type: number
nullable: true
type:
- number
- 'null'
description: ID of the user/agent
created_at:
type: string
@@ -13,16 +13,19 @@ items:
type: number
description: Number of conversations resolved by the agent during the date range
avg_resolution_time:
type: number
nullable: true
type:
- number
- 'null'
description: Average time (in seconds) to resolve conversations. Null if no data available.
avg_first_response_time:
type: number
nullable: true
type:
- number
- 'null'
description: Average time (in seconds) for the first response. Null if no data available.
avg_reply_time:
type: number
nullable: true
type:
- number
- 'null'
description: Average time (in seconds) between replies. Null if no data available.
example:
- id: 1

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