Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eff3d200 | ||
|
|
565a26be21 | ||
|
|
b324a147e6 | ||
|
|
428b07157c | ||
|
|
9e281bd655 | ||
|
|
63837ebef3 | ||
|
|
bdee98d965 | ||
|
|
174630bec1 | ||
|
|
927e6573c9 | ||
|
|
a363f22d82 | ||
|
|
a1c32ca96c | ||
|
|
7deda0f82a | ||
|
|
a450d6437d | ||
|
|
a9149ed211 | ||
|
|
f52f988b6f | ||
|
|
a33ee3a1d0 | ||
|
|
3c813a6d15 | ||
|
|
77d37fb889 | ||
|
|
1a73682c9a | ||
|
|
10ae8218d2 | ||
|
|
5f92426e85 | ||
|
|
cfb6643ed5 | ||
|
|
7929fbd8d9 | ||
|
|
68feaf99f6 | ||
|
|
906263ee20 | ||
|
|
f298b00477 | ||
|
|
7ef91bee53 | ||
|
|
0d2c0a5656 | ||
|
|
89df93e1dd | ||
|
|
0e54bd17e5 | ||
|
|
ee3600be46 | ||
|
|
6830c3273f | ||
|
|
9530cca197 | ||
|
|
2c728341e0 | ||
|
|
1f713d6dae | ||
|
|
2b8d1a67b3 | ||
|
|
f62b5e51d9 | ||
|
|
9488b7d005 | ||
|
|
f4e0481b00 | ||
|
|
b355443642 | ||
|
|
12a53d5a1e | ||
|
|
67dbcca638 | ||
|
|
8097e329d0 | ||
|
|
8df903b008 | ||
|
|
1957cd15dc | ||
|
|
abaf4d9ccb | ||
|
|
88d3876c2b | ||
|
|
55e7cafbac | ||
|
|
29eecfafcb | ||
|
|
1fafa7d13d | ||
|
|
dd2f3d80cc | ||
|
|
852a219e0b | ||
|
|
be381b940d | ||
|
|
3728d71176 | ||
|
|
9660b48c27 | ||
|
|
a44645a7de | ||
|
|
1e57cff70a | ||
|
|
4c398b3c85 | ||
|
|
a521175422 | ||
|
|
a921a38db9 | ||
|
|
7c5d6f8d72 | ||
|
|
7bb90d84cd | ||
|
|
466763054c | ||
|
|
21ea2ae08d | ||
|
|
fd91ab84af | ||
|
|
e6ae59b8b2 | ||
|
|
35f14c7e02 | ||
|
|
e2c449a0c2 | ||
|
|
40b77ca001 | ||
|
|
40e529fe4d | ||
|
|
d7b25d7db0 | ||
|
|
e2cd372d2d | ||
|
|
17da6ee3ec | ||
|
|
b81f916002 | ||
|
|
a1fdba2783 | ||
|
|
dea4d47602 | ||
|
|
2728a90a2f | ||
|
|
d55c5660a3 | ||
|
|
92bcd2f214 | ||
|
|
cc23867774 | ||
|
|
7a8903051d | ||
|
|
d1d464dfb0 | ||
|
|
1e6291616d | ||
|
|
2e2fcdec47 | ||
|
|
3436632a3b | ||
|
|
7a73a68753 | ||
|
|
55d68d930d | ||
|
|
e328492463 | ||
|
|
ac9b055ada | ||
|
|
82f28aa7fb | ||
|
|
5c0cbcbaaa | ||
|
|
5d5fa0c21a | ||
|
|
e12e686244 | ||
|
|
cdccbb39aa | ||
|
|
30ee4a61fc |
+1
-1
@@ -1 +1 @@
|
||||
4.16.1
|
||||
4.16.0
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -22,23 +14,15 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
bulk_create_agents(emails)
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
end
|
||||
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
before_action :ensure_execution_delay_allowed, only: [:create, :update]
|
||||
|
||||
def index
|
||||
@automation_rules = Current.account.automation_rules
|
||||
@@ -48,6 +49,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def clone
|
||||
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
||||
new_rule = automation_rule.dup
|
||||
# dup copies execution_delay; drop it when the feature is off so clone can't create new
|
||||
# delayed rules that create/update would reject.
|
||||
new_rule.execution_delay = nil unless delayed_automations_enabled?
|
||||
new_rule.save!
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
@@ -55,13 +59,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
private
|
||||
|
||||
def automation_rules_permit
|
||||
permitted_attributes = [:name, :description, :event_name, :active]
|
||||
permitted_attributes << :execution_delay if delayed_automations_enabled?
|
||||
|
||||
params.permit(
|
||||
:name, :description, :event_name, :active,
|
||||
*permitted_attributes,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_execution_delay_allowed
|
||||
return if delayed_automations_enabled?
|
||||
return if params[:execution_delay].blank?
|
||||
|
||||
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delayed_automations_enabled?
|
||||
Current.account.feature_enabled?('delayed_automations')
|
||||
end
|
||||
|
||||
def fetch_automation_rule
|
||||
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
getStats({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
|
||||
@@ -77,15 +77,6 @@ const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleFocusOut = event => {
|
||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
||||
// input); close it once focus leaves the menu entirely.
|
||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
isLocked.value = false;
|
||||
});
|
||||
@@ -98,7 +89,7 @@ onUnmounted(() => {
|
||||
class="fixed outline-none z-[9999] cursor-pointer"
|
||||
:style="position"
|
||||
tabindex="0"
|
||||
@focusout="handleFocusOut"
|
||||
@blur="handleClose"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -93,6 +95,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -170,6 +173,13 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -454,7 +464,15 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -7,13 +7,10 @@ import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
} from 'dashboard/helper/agentHelper.js';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const MENU = {
|
||||
MARK_AS_READ: 'mark-as-read',
|
||||
@@ -34,8 +31,6 @@ export default {
|
||||
MenuItem,
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
NextInput,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
chatId: {
|
||||
@@ -92,7 +87,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
labelSearchQuery: '',
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -222,14 +216,6 @@ export default {
|
||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
filteredLabels() {
|
||||
const labels = this.labelSearchQuery
|
||||
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
|
||||
: this.labels;
|
||||
// Assigned labels first, keeping each group's existing order.
|
||||
const isAssigned = label => this.conversationLabels.includes(label.title);
|
||||
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
@@ -349,49 +335,21 @@ export default {
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<div class="pb-1 w-[12.5rem]">
|
||||
<NextInput
|
||||
v-model="labelSearchQuery"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
custom-input-class="!ps-8 !text-xs"
|
||||
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
|
||||
/>
|
||||
</template>
|
||||
</NextInput>
|
||||
</div>
|
||||
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
|
||||
<MenuItem
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredLabels.length"
|
||||
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.AGENT])"
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
@@ -52,7 +52,7 @@ defineProps({
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,3 +78,5 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
|
||||
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
|
||||
return automation;
|
||||
};
|
||||
|
||||
export const formatDelay = minutes => {
|
||||
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
||||
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const isCustomAttribute = (attrs, key) => {
|
||||
return attrs.find(attr => attr.key === key);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -28,6 +28,35 @@
|
||||
"PLACEHOLDER": "Please select one",
|
||||
"ERROR": "Event is required"
|
||||
},
|
||||
"EXECUTE": {
|
||||
"LABEL": "Delayed execution",
|
||||
"AFTER_DELAY": "Run after",
|
||||
"UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"ERROR": "Delay must be between 10 minutes and 30 days",
|
||||
"ENDS_IF_LABEL": "Won't run if",
|
||||
"ENDS_IF": {
|
||||
"STATUS": "the conversation's status changes, or its conditions no longer match.",
|
||||
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
|
||||
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
|
||||
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
|
||||
},
|
||||
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
|
||||
},
|
||||
"TRIGGER": {
|
||||
"LABEL": "Trigger",
|
||||
"WHEN_LABEL": "When",
|
||||
"STATUS_LABEL": "Status is",
|
||||
"INBOX_LABEL": "Inbox",
|
||||
"OPTIONS": {
|
||||
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
|
||||
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
|
||||
"CONVERSATION_STATUS": "Conversation in a status"
|
||||
}
|
||||
},
|
||||
"CONDITIONS": {
|
||||
"LABEL": "Conditions"
|
||||
},
|
||||
@@ -49,7 +78,13 @@
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
"404": "No automation rules found",
|
||||
"SECTIONS": {
|
||||
"INSTANT": "Automations",
|
||||
"DELAYED": "Delayed execution"
|
||||
},
|
||||
"DELAY_BADGE": "Runs after {delay}",
|
||||
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Automation Rule",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -193,8 +195,6 @@
|
||||
},
|
||||
"ASSIGN_AGENT": "Assign agent",
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"SEARCH_LABELS": "Search labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
|
||||
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
const stats = ref(null);
|
||||
const isFetching = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
let fetchToken = 0;
|
||||
let abortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
const fetchStats = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
stats.value = null;
|
||||
isFetching.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
const requestStats = () =>
|
||||
CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
if (token === fetchToken && !signal.aborted)
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
if (token !== fetchToken || signal.aborted) return;
|
||||
stats.value = data;
|
||||
isFetching.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
onUnmounted(() => abortController?.abort());
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
@@ -122,7 +90,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = metricStats.value?.[statKey];
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
:loading="isFetching"
|
||||
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
execution_delay: null,
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
+351
-76
@@ -6,6 +6,11 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -71,6 +77,28 @@ const INPUT_TYPE_MAP = {
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
|
||||
const MIN_DELAY_MINUTES = 10;
|
||||
const MAX_DELAY_MINUTES = 43200; // 30 days
|
||||
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
|
||||
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
|
||||
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
|
||||
const DELAYED_TRIGGERS = [
|
||||
{ key: 'conversation_status', eventName: 'conversation_updated' },
|
||||
{
|
||||
key: 'customer_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'outgoing',
|
||||
},
|
||||
{
|
||||
key: 'agent_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'incoming',
|
||||
},
|
||||
];
|
||||
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
|
||||
const DEFAULT_TRIGGER_STATUS = 'pending';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
@@ -78,6 +106,7 @@ const { operators } = useOperators();
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
const isDelayed = ref(false);
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
@@ -184,6 +213,167 @@ const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
const allowsDelayedExecution = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
|
||||
);
|
||||
|
||||
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
|
||||
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
|
||||
// { id, name } (MultiSelect) — empty means the rule applies to every inbox.
|
||||
const selectedTrigger = ref(DEFAULT_TRIGGER);
|
||||
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
|
||||
const triggerInboxes = ref([]);
|
||||
|
||||
const isStatusTrigger = computed(
|
||||
() => selectedTrigger.value === 'conversation_status'
|
||||
);
|
||||
|
||||
// FilterSelect expects { label, value }.
|
||||
const statusSelectOptions = computed(() =>
|
||||
(props.getConditionDropdownValues('status') || [])
|
||||
.filter(option => option.id !== 'all')
|
||||
.map(option => ({ value: option.id, label: option.name }))
|
||||
);
|
||||
|
||||
const triggerSelectOptions = computed(() =>
|
||||
DELAYED_TRIGGERS.map(trigger => ({
|
||||
value: trigger.key,
|
||||
label: t(
|
||||
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
// MultiSelect expects (and returns) { id, name } options.
|
||||
const inboxOptions = computed(
|
||||
() => props.getConditionDropdownValues('inbox_id') || []
|
||||
);
|
||||
|
||||
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
|
||||
// they can predict when the rule runs. Conversation rules key on status; message rules key on
|
||||
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
|
||||
const waitEndsKey = computed(() => {
|
||||
if (eventName.value !== 'message_created') return 'STATUS';
|
||||
const messageType = (automation.value?.conditions || []).find(
|
||||
condition => condition.attribute_key === 'message_type'
|
||||
);
|
||||
const raw = Array.isArray(messageType?.values)
|
||||
? messageType.values[0]
|
||||
: messageType?.values;
|
||||
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
|
||||
const value = raw && typeof raw === 'object' ? raw.id : raw;
|
||||
if (value === 'outgoing') return 'CUSTOMER_REPLY';
|
||||
if (value === 'incoming') return 'AGENT_REPLY';
|
||||
return 'GENERIC';
|
||||
});
|
||||
|
||||
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
|
||||
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
|
||||
const delayUnit = ref(DURATION_UNITS.HOURS);
|
||||
|
||||
const executionDelayInvalid = computed(
|
||||
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
|
||||
);
|
||||
|
||||
// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than
|
||||
// read from `automation`, whose model prop only settles a tick later.
|
||||
const syncDelayFromDelay = delay => {
|
||||
isDelayed.value = Boolean(delay);
|
||||
const minutes = delay || DEFAULT_DELAY_MINUTES;
|
||||
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
|
||||
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
|
||||
else delayUnit.value = DURATION_UNITS.MINUTES;
|
||||
delayMinutes.value = minutes;
|
||||
};
|
||||
|
||||
watch([isDelayed, delayMinutes], () => {
|
||||
if (!automation.value || !allowsDelayedExecution.value) return;
|
||||
automation.value.execution_delay = isDelayed.value
|
||||
? delayMinutes.value
|
||||
: null;
|
||||
});
|
||||
|
||||
const buildTriggerCondition = (attributeKey, values) => ({
|
||||
attribute_key: attributeKey,
|
||||
filter_operator: 'equal_to',
|
||||
values,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
|
||||
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
|
||||
const applyDelayedTrigger = () => {
|
||||
const trigger = DELAYED_TRIGGERS.find(
|
||||
item => item.key === selectedTrigger.value
|
||||
);
|
||||
if (!automation.value || !trigger) return;
|
||||
automation.value.event_name = trigger.eventName;
|
||||
const conditions = [
|
||||
trigger.messageType
|
||||
? buildTriggerCondition('message_type', trigger.messageType)
|
||||
: buildTriggerCondition('status', triggerStatus.value),
|
||||
];
|
||||
if (triggerInboxes.value.length) {
|
||||
conditions.push(
|
||||
buildTriggerCondition(
|
||||
'inbox_id',
|
||||
triggerInboxes.value.map(inbox => inbox.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
automation.value.conditions = conditions;
|
||||
};
|
||||
|
||||
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
|
||||
// formatting; return its plain value either way.
|
||||
const rawConditionValue = condition => {
|
||||
const raw = Array.isArray(condition?.values)
|
||||
? condition.values[0]
|
||||
: condition?.values;
|
||||
return raw && typeof raw === 'object' ? raw.id : raw;
|
||||
};
|
||||
|
||||
// Populate the trigger controls from an existing delayed rule when editing.
|
||||
const hydrateTriggerFromAutomation = () => {
|
||||
const conditions = automation.value?.conditions || [];
|
||||
const byKey = key => conditions.find(c => c.attribute_key === key);
|
||||
const messageType = rawConditionValue(byKey('message_type'));
|
||||
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
|
||||
else if (messageType === 'outgoing')
|
||||
selectedTrigger.value = 'customer_unresponsive';
|
||||
else {
|
||||
selectedTrigger.value = 'conversation_status';
|
||||
triggerStatus.value =
|
||||
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
|
||||
}
|
||||
const inboxValues = byKey('inbox_id')?.values || [];
|
||||
const inboxIds = inboxValues.map(value =>
|
||||
value && typeof value === 'object' ? value.id : value
|
||||
);
|
||||
triggerInboxes.value = inboxOptions.value.filter(inbox =>
|
||||
inboxIds.includes(inbox.id)
|
||||
);
|
||||
};
|
||||
|
||||
// Turning the wait on (create) sets the default trigger's event + conditions.
|
||||
watch(isDelayed, delayed => {
|
||||
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
|
||||
// re-writes the same values, so it stays idempotent (no reference change → no loop).
|
||||
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
|
||||
if (isDelayed.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
@@ -216,8 +406,9 @@ const syncCustomAttributeTypes = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const open = (executionDelay = null) => {
|
||||
resetValidation();
|
||||
syncDelayFromDelay(executionDelay);
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
@@ -230,8 +421,13 @@ const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
|
||||
errors.value.execution_delay = true;
|
||||
}
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
// The API rejects the param when the feature is off; existing values are kept server-side.
|
||||
if (!allowsDelayedExecution.value) delete payload.execution_delay;
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
@@ -266,84 +462,163 @@ defineExpose({ open, close });
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
<!-- Wait Start (choose the delay first, then the trigger) -->
|
||||
<div v-if="allowsDelayedExecution" class="mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label class="mb-0" :class="{ error: errors.execution_delay }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
|
||||
</label>
|
||||
<ToggleSwitch v-model="isDelayed" />
|
||||
</div>
|
||||
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
<div class="flex items-center gap-2 w-64">
|
||||
<DurationInput
|
||||
v-model="delayMinutes"
|
||||
v-model:unit="delayUnit"
|
||||
:min="MIN_DELAY_MINUTES"
|
||||
:max="MAX_DELAY_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</div>
|
||||
<span
|
||||
v-if="isDelayed && executionDelayInvalid"
|
||||
class="text-xs text-n-ruby-9"
|
||||
>
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Wait End -->
|
||||
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
|
||||
<div v-if="isDelayed" class="mb-6">
|
||||
<label class="mb-1">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="selectedTrigger"
|
||||
:options="triggerSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="triggerStatus"
|
||||
:options="statusSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
|
||||
</span>
|
||||
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 pt-2 mb-0">
|
||||
<span class="text-n-slate-12 font-medium">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
|
||||
</span>
|
||||
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 pt-1 mb-0">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Instant flow: raw Event + Conditions -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</template>
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import { formatDelay } from 'dashboard/helper/automationHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
@@ -43,6 +44,16 @@ const automationActive = computed({
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ automation.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="automation.execution_delay"
|
||||
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{
|
||||
$t('AUTOMATION.LIST.DELAY_BADGE', {
|
||||
delay: formatDelay(automation.execution_delay),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ automation.description }}
|
||||
|
||||
+21
-17
@@ -34,29 +34,33 @@ const {
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const open = () => formRef.value?.open();
|
||||
const syncAutomationFromSelected = (source = props.selectedResponse) => {
|
||||
if (!source?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
automation.value = formatAutomation(
|
||||
source,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
};
|
||||
|
||||
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
|
||||
// automation still holds the previously selected rule (its execution_delay hydrates the form).
|
||||
const open = rule => {
|
||||
syncAutomationFromSelected(rule);
|
||||
formRef.value?.open(rule?.execution_delay);
|
||||
};
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(() => props.selectedResponse, syncAutomationFromSelected, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
@@ -35,6 +35,31 @@ const filteredRecords = computed(() => {
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['name', 'description']);
|
||||
});
|
||||
|
||||
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
|
||||
const hasDelayedRecords = computed(() =>
|
||||
records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
const sections = computed(() => {
|
||||
const instant = [];
|
||||
const delayed = [];
|
||||
filteredRecords.value.forEach(automation =>
|
||||
(automation.execution_delay ? delayed : instant).push(automation)
|
||||
);
|
||||
return [
|
||||
{
|
||||
key: 'instant',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
|
||||
items: instant,
|
||||
},
|
||||
{
|
||||
key: 'delayed',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
|
||||
items: delayed,
|
||||
},
|
||||
].filter(section => section.items.length);
|
||||
});
|
||||
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
@@ -52,6 +77,14 @@ const isSLAEnabled = computed(() =>
|
||||
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
|
||||
);
|
||||
|
||||
const showDelayDisabledBanner = computed(
|
||||
() =>
|
||||
!getters['accounts/isFeatureEnabledonAccount'].value(
|
||||
accountId.value,
|
||||
'delayed_automations'
|
||||
) && records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
@@ -74,7 +107,7 @@ const hideAddPopup = () => {
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
editDialogRef.value?.open(response);
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
editDialogRef.value?.close();
|
||||
@@ -128,11 +161,11 @@ const submitAutomation = async (payload, mode) => {
|
||||
hideAddPopup();
|
||||
hideEditPopup();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
const fallbackMessage =
|
||||
mode === 'edit'
|
||||
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
|
||||
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
useAlert(error?.response?.data?.error || fallbackMessage);
|
||||
}
|
||||
};
|
||||
const toggleAutomation = async ({ id, name, status }) => {
|
||||
@@ -212,25 +245,49 @@ const tableHeaders = computed(() => {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="showDelayDisabledBanner"
|
||||
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
|
||||
>
|
||||
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
|
||||
</div>
|
||||
<template v-if="filteredRecords.length">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="mb-6 last:mb-0"
|
||||
>
|
||||
<h4
|
||||
v-if="hasDelayedRecords"
|
||||
class="mb-2 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ section.label }}
|
||||
</h4>
|
||||
<BaseTable :headers="tableHeaders" :items="section.items">
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
:items="[]"
|
||||
:no-data-message="
|
||||
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
|
||||
"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
<template #row />
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -80,6 +84,7 @@ export default {
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -343,6 +348,12 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -809,6 +820,29 @@ export default {
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -26,9 +24,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -62,9 +57,6 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -98,35 +90,19 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -168,7 +144,6 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(pending_execution)
|
||||
return if delayed_automations_disabled?
|
||||
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
|
||||
return unless pending_execution.account.feature_enabled?('delayed_automations')
|
||||
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
|
||||
return unless pending_execution.claim!
|
||||
|
||||
skip_reason = skip_reason_for(pending_execution)
|
||||
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
|
||||
|
||||
execute(pending_execution)
|
||||
rescue StandardError => e
|
||||
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
|
||||
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reason_for(pending_execution)
|
||||
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
|
||||
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
|
||||
end
|
||||
|
||||
def structural_skip_reason(pending_execution)
|
||||
rule = pending_execution.automation_rule
|
||||
return 'rule_inactive' if rule.nil? || !rule.active?
|
||||
return 'conversation_gone' if pending_execution.conversation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def behavioral_skip_reason(pending_execution)
|
||||
return 'episode_moved' unless pending_execution.episode_current?
|
||||
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def conditions_still_match?(pending_execution)
|
||||
AutomationRules::ConditionsFilterService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.conversation,
|
||||
{ message: pending_execution.message }
|
||||
).perform.present?
|
||||
end
|
||||
|
||||
def execute(pending_execution)
|
||||
AutomationRules::ActionService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.account,
|
||||
pending_execution.conversation
|
||||
).perform
|
||||
pending_execution.update!(status: :executed)
|
||||
end
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
|
||||
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
|
||||
# are rescheduled before that sweep's per-row jobs could mark them expired.
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(account)
|
||||
AutomationRulePendingExecution.reschedule_paused(account)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
DEFAULT_SWEEP_LIMIT = 1000
|
||||
|
||||
def perform
|
||||
return if delayed_automations_disabled?
|
||||
|
||||
started_at = Time.current
|
||||
purged = AutomationRulePendingExecution.purge_terminal!
|
||||
|
||||
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
|
||||
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
|
||||
|
||||
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged, started_at: started_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
|
||||
def sweep_limit
|
||||
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
|
||||
end
|
||||
|
||||
def log_summary(enqueued:, capped:, purged:, started_at:)
|
||||
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, duration_ms: ((Time.current - started_at) * 1000).round }
|
||||
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to trigger pending executions
|
||||
AutomationRules::TriggerPendingExecutionsJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
||||
{ message: message, changed_attributes: changed_attributes }).perform
|
||||
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,7 +52,20 @@ class AutomationRuleListener < BaseListener
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, conversation) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
|
||||
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
|
||||
# message silently becoming instant is worse than skipping.
|
||||
def execute_rule(rule, account, conversation, message: nil)
|
||||
if rule.execution_delay.present?
|
||||
return unless account.feature_enabled?('delayed_automations')
|
||||
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
|
||||
else
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class Account < ApplicationRecord
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
has_many :macros, dependent: :destroy_async
|
||||
has_many :campaigns, dependent: :destroy_async
|
||||
has_many :canned_responses, dependent: :destroy_async
|
||||
@@ -111,6 +112,7 @@ class Account < ApplicationRecord
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -189,6 +191,10 @@ class Account < ApplicationRecord
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
end
|
||||
|
||||
def resume_delayed_automations
|
||||
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
#
|
||||
# Table name: automation_rules
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# execution_delay :integer
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -21,7 +22,13 @@ class AutomationRule < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
include Reauthorizable
|
||||
|
||||
EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days
|
||||
# Conversation-level delayed rules key their episode on status; only status and attributes
|
||||
# that never change after the delay (inbox) are safe to also filter on.
|
||||
DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze
|
||||
|
||||
belongs_to :account
|
||||
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
|
||||
has_many_attached :files
|
||||
|
||||
validate :json_conditions_format
|
||||
@@ -29,8 +36,13 @@ class AutomationRule < ApplicationRecord
|
||||
validate :query_operator_presence
|
||||
validate :query_operator_value
|
||||
validates :account_id, presence: true
|
||||
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
|
||||
validate :execution_delay_supported_conditions
|
||||
validate :execution_delay_supported_event
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
# Discard rows armed under the old definition; they re-arm on the next matching event.
|
||||
after_update :discard_stale_pending_executions, if: :execution_config_changed?
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
@@ -95,6 +107,34 @@ class AutomationRule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
|
||||
# cannot use attribute_changed conditions.
|
||||
def execution_delay_supported_conditions
|
||||
return if execution_delay.blank? || conditions.blank?
|
||||
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
|
||||
|
||||
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
# Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse
|
||||
# distinct periods into one episode, so only status and immutable filters (inbox) are allowed.
|
||||
def execution_delay_supported_event
|
||||
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
|
||||
return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) }
|
||||
|
||||
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
def execution_config_changed?
|
||||
saved_change_to_execution_delay? || saved_change_to_event_name? ||
|
||||
saved_change_to_conditions? || saved_change_to_actions?
|
||||
end
|
||||
|
||||
def discard_stale_pending_executions
|
||||
# armed = pending + stale processing, which the sweep would otherwise reclaim.
|
||||
pending_executions.armed.delete_all
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
query_operator = condition['query_operator']
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: automation_rule_pending_executions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# due_at :datetime not null
|
||||
# episode_key :string not null
|
||||
# skip_reason :string
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# automation_rule_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_automation_rule_pending_executions_on_account_id (account_id)
|
||||
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
|
||||
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
|
||||
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
|
||||
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
|
||||
#
|
||||
class AutomationRulePendingExecution < ApplicationRecord
|
||||
# Rows older than this never fire (bounds backlog replay after downtime).
|
||||
DUE_WINDOW = 3.days
|
||||
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
|
||||
STALE_PROCESSING_TIMEOUT = 15.minutes
|
||||
# Terminal rows are purged after this to keep the table bounded.
|
||||
RETENTION_WINDOW = 30.days
|
||||
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
|
||||
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
|
||||
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
|
||||
|
||||
belongs_to :automation_rule
|
||||
belongs_to :conversation
|
||||
belongs_to :account
|
||||
belongs_to :message, optional: true
|
||||
|
||||
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3 }
|
||||
|
||||
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
|
||||
scope :sweepable, lambda {
|
||||
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
|
||||
}
|
||||
|
||||
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
|
||||
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
|
||||
|
||||
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
|
||||
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
|
||||
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
|
||||
|
||||
def self.schedule(rule:, conversation:, message: nil)
|
||||
key = arm_episode_key_for(conversation, message)
|
||||
anchor = arm_anchor_for(conversation, message)
|
||||
create!(
|
||||
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
|
||||
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
end
|
||||
|
||||
# The episode is already armed. Status episodes keep their first clock (a status change would
|
||||
# give a new key), so only message episodes advance or re-arm here.
|
||||
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
return unless message
|
||||
|
||||
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
|
||||
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
|
||||
# older message can't pull due_at backwards and fire before the delay elapses.
|
||||
return unless message.id > row.message_id
|
||||
|
||||
due_at = rule.execution_delay.minutes.since(anchor)
|
||||
if row.condition_skipped?
|
||||
# A message episode key can recur (no new incoming reply) while conditions swing back into
|
||||
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
|
||||
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
|
||||
elsif !row.terminal?
|
||||
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
|
||||
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
|
||||
# unchanged). Re-anchoring a row still processing (its worker died mid-run) back to pending
|
||||
# also keeps a stale reclaim from firing the old clock instead of the latest one.
|
||||
row.update!(status: :pending, due_at: due_at, message_id: message.id)
|
||||
end
|
||||
end
|
||||
|
||||
# The wait is measured from when the qualifying event happened, not when this (possibly
|
||||
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
|
||||
# the timestamps the episode keys track.
|
||||
def self.arm_anchor_for(conversation, message)
|
||||
if message.nil?
|
||||
conversation.status_changed_at.presence || conversation.created_at
|
||||
elsif message.incoming?
|
||||
conversation.waiting_since.presence || message.created_at
|
||||
else
|
||||
message.created_at
|
||||
end
|
||||
end
|
||||
|
||||
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
|
||||
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
|
||||
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
|
||||
def self.arm_episode_key_for(conversation, message)
|
||||
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
|
||||
|
||||
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
|
||||
end
|
||||
|
||||
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
|
||||
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
|
||||
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
|
||||
def self.microsecond_stamp(time)
|
||||
time&.strftime('%s%6N') || '0'
|
||||
end
|
||||
|
||||
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
|
||||
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
|
||||
def self.episode_key_for(conversation, message)
|
||||
if message.nil?
|
||||
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
|
||||
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
|
||||
"status:#{microsecond_stamp(conversation.status_changed_at.presence || conversation.created_at)}"
|
||||
elsif message.incoming?
|
||||
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
|
||||
# here: at fire time a nil waiting_since means the agent replied (episode ended).
|
||||
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
|
||||
else
|
||||
# A new customer message changes the max incoming id, invalidating this episode.
|
||||
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.purge_terminal!
|
||||
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
|
||||
.in_batches(of: 1000).delete_all
|
||||
end
|
||||
|
||||
# Rows that came due while an account had delayed automations paused would expire the moment
|
||||
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
|
||||
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
|
||||
def self.reschedule_paused(account)
|
||||
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
|
||||
overdue.find_each { |row| row.update!(due_at: Time.current) }
|
||||
end
|
||||
|
||||
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
|
||||
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
|
||||
# renews the lock, keeping the row out of the stale window while this worker holds it.
|
||||
def claim!
|
||||
with_lock do
|
||||
next false unless claimable?
|
||||
|
||||
update!(status: :processing, updated_at: Time.current)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def episode_current?
|
||||
self.class.episode_key_for(conversation, message) == episode_key
|
||||
end
|
||||
|
||||
def condition_skipped?
|
||||
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
|
||||
end
|
||||
|
||||
def terminal?
|
||||
executed? || skipped?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claimable?
|
||||
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
|
||||
# such a row must wait for a later sweep instead of firing early.
|
||||
(pending? && due_at <= Time.current) || (processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago)
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,7 @@
|
||||
# priority :integer
|
||||
# snoozed_until :datetime
|
||||
# status :integer default("open"), not null
|
||||
# status_changed_at :datetime
|
||||
# uuid :uuid not null
|
||||
# waiting_since :datetime
|
||||
# created_at :datetime not null
|
||||
@@ -124,8 +125,10 @@ class Conversation < ApplicationRecord
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :attachments, through: :messages
|
||||
has_many :reporting_events, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
|
||||
before_save :ensure_snooze_until_reset
|
||||
before_save :set_status_changed_at
|
||||
before_create :determine_conversation_status
|
||||
before_create :ensure_waiting_since
|
||||
|
||||
@@ -272,6 +275,10 @@ class Conversation < ApplicationRecord
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
def set_status_changed_at
|
||||
self.status_changed_at = Time.current if new_record? || status_changed?
|
||||
end
|
||||
|
||||
def ensure_waiting_since
|
||||
self.waiting_since = created_at
|
||||
end
|
||||
|
||||
@@ -7,4 +7,5 @@ json.conditions automation_rule.conditions
|
||||
json.actions automation_rule.actions
|
||||
json.created_on automation_rule.created_at.to_i
|
||||
json.active automation_rule.active?
|
||||
json.execution_delay automation_rule.execution_delay
|
||||
json.files automation_rule.file_base_data if automation_rule.files.any?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.16.1'
|
||||
version: '4.16.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -268,3 +268,8 @@
|
||||
display_name: WhatsApp Embedded Signup Flow
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: delayed_automations
|
||||
display_name: Delayed Automations
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -567,3 +567,10 @@
|
||||
value: 'https://us.cloud.langfuse.com'
|
||||
locked: false
|
||||
## ---- End of LLM Observability ---- ##
|
||||
|
||||
- name: DISABLE_DELAYED_AUTOMATIONS
|
||||
display_title: 'Disable delayed automations'
|
||||
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
|
||||
value: false
|
||||
locked: false
|
||||
type: boolean
|
||||
|
||||
@@ -143,20 +143,6 @@ features:
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
conversation_faq_matching:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
|
||||
@@ -626,7 +626,6 @@ en:
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
conversation_faq_matching: 'Conversation FAQ matching'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
|
||||
+1
-2
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :metrics
|
||||
get :faq_stats
|
||||
get :stats
|
||||
get :summary
|
||||
get :drilldown
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :automation_rules, :execution_delay, :integer
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :conversations, :status_changed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :automation_rule_pending_executions do |t|
|
||||
t.references :automation_rule, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :account, null: false
|
||||
t.bigint :message_id
|
||||
t.datetime :due_at, null: false
|
||||
t.string :episode_key, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :skip_reason
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :automation_rule_pending_executions, [:status, :due_at]
|
||||
add_index :automation_rule_pending_executions,
|
||||
[:automation_rule_id, :conversation_id, :episode_key],
|
||||
unique: true, name: 'uniq_automation_pending_execution_episode'
|
||||
end
|
||||
end
|
||||
@@ -277,6 +277,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.index ["user_id", "user_type"], name: "user_index"
|
||||
end
|
||||
|
||||
create_table "automation_rule_pending_executions", force: :cascade do |t|
|
||||
t.bigint "automation_rule_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "message_id"
|
||||
t.datetime "due_at", null: false
|
||||
t.string "episode_key", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "skip_reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
|
||||
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
|
||||
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
|
||||
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
|
||||
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
|
||||
end
|
||||
|
||||
create_table "automation_rules", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
@@ -287,6 +305,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "execution_delay"
|
||||
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||
end
|
||||
|
||||
@@ -788,6 +807,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.datetime "status_changed_at"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
|
||||
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def faq_stats
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window
|
||||
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
|
||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||
knowledge: knowledge
|
||||
}
|
||||
end
|
||||
|
||||
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||
reopen: reopen_rate(range, resolution[:resolved]),
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
|
||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||
def reopen_rate(range, resolved_count)
|
||||
return 0 if resolved_count.zero?
|
||||
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_count)
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def knowledge
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -42,14 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def metrics
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
|
||||
end
|
||||
|
||||
def summary
|
||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||
result = cached_or_generated_summary(window, summary_stats)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LOCK_TIMEOUT = 10.minutes
|
||||
|
||||
retry_on_lock_conflict wait: 30.seconds, attempts: 30
|
||||
|
||||
def perform(conversation, assistant)
|
||||
inbox = conversation.inbox
|
||||
|
||||
return unless conversation.resolved?
|
||||
return unless inbox.captain_active?
|
||||
|
||||
return if assistant.config['feature_faq'].blank?
|
||||
|
||||
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_key(assistant, conversation)
|
||||
format(
|
||||
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
|
||||
assistant_id: assistant.id,
|
||||
language: Captain::Llm::ConversationFaqService.language_for(conversation)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,6 @@ class CaptainListener < BaseListener
|
||||
return unless conversation.inbox.captain_active?
|
||||
|
||||
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
|
||||
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) if assistant.config['feature_faq'].present?
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def metrics?
|
||||
true
|
||||
end
|
||||
|
||||
def faq_stats?
|
||||
def stats?
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqContentService
|
||||
def initialize(assistant, conversation)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def generate
|
||||
[
|
||||
'Business Context:',
|
||||
JSON.pretty_generate(business_context),
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :assistant, :conversation
|
||||
|
||||
def conversation_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_message(message) }.join
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
return unless source_message?(message)
|
||||
|
||||
message_content = message.content_for_llm
|
||||
return if message_content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{message_content}\n"
|
||||
end
|
||||
|
||||
def source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def business_context
|
||||
{
|
||||
product_name: assistant.config['product_name'],
|
||||
assistant_description: assistant.description,
|
||||
instructions: assistant.config['instructions'],
|
||||
response_guidelines: assistant.response_guidelines,
|
||||
guardrails: assistant.guardrails
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqPromptsService
|
||||
class << self
|
||||
def generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The input starts with trusted business context. Use it to reject conversations about other businesses or topics, but never use it as the source of an FAQ answer.
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, identify the human support agent message or messages that together provide a complete public answer to the same question. Combine facts only across related agent messages; never combine separate questions or unrelated topics. If those messages do not provide a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def same_faq
|
||||
<<~PROMPT
|
||||
Decide whether the new FAQ and existing FAQ are the same.
|
||||
|
||||
Return `same_faq` as true only when both questions ask the same thing and both answers give the same guidance.
|
||||
Wording, grammar, level of detail, and examples may differ. Return false when either FAQ adds, removes, contradicts, or changes a condition,
|
||||
policy, procedure, audience, product, plan, time frame, or outcome. Related FAQs are not the same FAQ. When uncertain, return false.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "same_faq": true }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,201 +2,166 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
MATCH_LIMIT = 5
|
||||
LLM_FEATURE = 'conversation_faq_generation'.freeze
|
||||
|
||||
def self.language_for(conversation)
|
||||
language = conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s
|
||||
normalize_language(language)
|
||||
end
|
||||
|
||||
def self.normalize_language(language)
|
||||
language.to_s.tr('-', '_').split('_').first.downcase
|
||||
end
|
||||
|
||||
private_class_method :normalize_language
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@content = Captain::Llm::ConversationFaqContentService.new(assistant, conversation).generate
|
||||
@embedding_service = Captain::Llm::EmbeddingService.new(account_id: conversation.account_id)
|
||||
@content = conversation_faq_content
|
||||
end
|
||||
|
||||
def generate_suggestions
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
# Skips processing if there was no human interaction
|
||||
def generate_and_deduplicate
|
||||
return [] if no_human_interaction?
|
||||
|
||||
generate.map { |faq| route_candidate(faq) }
|
||||
new_faqs = generate
|
||||
return [] if new_faqs.empty?
|
||||
|
||||
duplicate_faqs, unique_faqs = find_and_separate_duplicates(new_faqs)
|
||||
save_new_faqs(unique_faqs)
|
||||
log_duplicate_faqs(duplicate_faqs) if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :content, :conversation, :assistant, :embedding_service
|
||||
attr_reader :content, :conversation, :assistant
|
||||
|
||||
def conversation_faq_content
|
||||
[
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_faq_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
def conversation_faq_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_conversation_faq_message(message) }.join
|
||||
end
|
||||
|
||||
def format_conversation_faq_message(message)
|
||||
return unless faq_source_message?(message)
|
||||
|
||||
content = message.content_for_llm
|
||||
return if content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{content}\n"
|
||||
end
|
||||
|
||||
def faq_source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
def route_candidate(faq)
|
||||
embedding = embedding_service.get_embedding(candidate_text(faq))
|
||||
def find_and_separate_duplicates(faqs)
|
||||
duplicate_faqs = []
|
||||
unique_faqs = []
|
||||
|
||||
return discard_observation(faq) if matching_record(approved_faqs, faq, embedding)
|
||||
return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding)
|
||||
faqs.each do |faq|
|
||||
combined_text = "#{faq['question']}: #{faq['answer']}"
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
|
||||
similar_faqs = find_similar_faqs(embedding)
|
||||
|
||||
suggestion = matching_record(open_suggestions_for_language, faq, embedding)
|
||||
suggestion ||= assistant.faq_suggestions.create!(
|
||||
question: faq.fetch('question'),
|
||||
answer: faq.fetch('answer'),
|
||||
embedding: embedding,
|
||||
language: faq_language
|
||||
)
|
||||
|
||||
attach_observation(suggestion, faq)
|
||||
end
|
||||
|
||||
def matching_record(relation, faq, embedding)
|
||||
likely_matches(relation, embedding).find { |record| same_faq?(faq, record) }
|
||||
end
|
||||
|
||||
def likely_matches(relation, embedding)
|
||||
return [] unless relation.exists?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
# Force an exact search because IVFFlat can miss matches after relation filters.
|
||||
# SET LOCAL keeps the planner change scoped to this transaction.
|
||||
ApplicationRecord.connection.execute('SET LOCAL enable_indexscan = off')
|
||||
relation
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(MATCH_LIMIT)
|
||||
.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
end
|
||||
|
||||
def same_faq?(candidate, existing_record)
|
||||
comparison = {
|
||||
candidate: candidate.slice('question', 'answer'),
|
||||
existing: { question: existing_record.question, answer: existing_record.answer }
|
||||
}
|
||||
prompt = Captain::Llm::ConversationFaqPromptsService.same_faq
|
||||
faq_match_model = Llm::FeatureRouter.resolve(feature: 'conversation_faq_matching', account: conversation.account)[:model]
|
||||
response = instrument_llm_call(match_instrumentation_params(prompt, comparison, faq_match_model)) do
|
||||
chat(model: faq_match_model)
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(prompt)
|
||||
.ask(comparison.to_json)
|
||||
if similar_faqs.any?
|
||||
duplicate_faqs << { faq: faq, similar_faqs: similar_faqs }
|
||||
else
|
||||
unique_faqs << faq
|
||||
end
|
||||
end
|
||||
|
||||
same_faq = JSON.parse(sanitize_json_response(response.content)).fetch('same_faq')
|
||||
raise TypeError, 'same_faq must be a boolean' unless [true, false].include?(same_faq)
|
||||
|
||||
same_faq
|
||||
rescue JSON::ParserError, KeyError, TypeError, RubyLLM::Error => e
|
||||
Rails.logger.error "FAQ match failed: #{e.message}"
|
||||
raise
|
||||
[duplicate_faqs, unique_faqs]
|
||||
end
|
||||
|
||||
def attach_observation(suggestion, faq)
|
||||
suggestion.with_lock do
|
||||
existing_observation = suggestion.observations.find_by(conversation: conversation)
|
||||
next existing_observation if existing_observation
|
||||
def find_similar_faqs(embedding)
|
||||
similar_faqs = assistant
|
||||
.responses
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
Rails.logger.debug(similar_faqs.map { |faq| [faq.question, faq.neighbor_distance] })
|
||||
similar_faqs.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
|
||||
observation = suggestion.observations.create!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :attached
|
||||
def save_new_faqs(faqs)
|
||||
faqs.map do |faq|
|
||||
assistant.responses.create!(
|
||||
question: faq['question'],
|
||||
answer: faq['answer'],
|
||||
status: 'pending',
|
||||
documentable: conversation
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.source_count + 1)
|
||||
observation
|
||||
end
|
||||
end
|
||||
|
||||
def discard_observation(faq)
|
||||
Captain::FaqObservation.find_or_create_by!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :discarded
|
||||
)
|
||||
end
|
||||
def log_duplicate_faqs(duplicate_faqs)
|
||||
return if duplicate_faqs.empty?
|
||||
|
||||
def open_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).open.by_language(faq_language)
|
||||
end
|
||||
|
||||
def dismissed_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).dismissed.by_language(faq_language)
|
||||
end
|
||||
|
||||
def approved_faqs
|
||||
assistant.responses.approved
|
||||
end
|
||||
|
||||
def candidate_text(faq)
|
||||
"#{faq.fetch('question')}: #{faq.fetch('answer')}"
|
||||
Rails.logger.info "Found #{duplicate_faqs.length} duplicate FAQs:"
|
||||
duplicate_faqs.each do |duplicate|
|
||||
Rails.logger.info(
|
||||
"Q: #{duplicate[:faq]['question']}\n" \
|
||||
"A: #{duplicate[:faq]['answer']}\n\n" \
|
||||
"Similar existing FAQs: #{duplicate[:similar_faqs].map { |f| "Q: #{f.question} A: #{f.answer}" }.join(', ')}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(generation_instrumentation_params) do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(content)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_generation_response(response.content)
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def generation_instrumentation_params
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: 'llm.captain.conversation_faq',
|
||||
model: model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'conversation_faq',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: content }
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
}
|
||||
end
|
||||
|
||||
def match_instrumentation_params(prompt, comparison, faq_match_model)
|
||||
{
|
||||
span_name: 'llm.captain.faq_match',
|
||||
model: faq_match_model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
feature_name: 'conversation_faq_match',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: comparison.to_json }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
metadata: { assistant_id: @assistant.id }
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language))
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
end
|
||||
|
||||
def faq_language
|
||||
@faq_language ||= self.class.language_for(conversation)
|
||||
end
|
||||
|
||||
def language_name(language)
|
||||
ISO_639.find(language)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
def parse_generation_response(response)
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
|
||||
|
||||
@@ -51,6 +51,62 @@ class Captain::Llm::SystemPromptsService
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def conversation_faq_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def notes_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a note taker looking to convert the conversation with a contact into actionable notes for the CRM.
|
||||
|
||||
@@ -89,7 +89,6 @@ module Redis::RedisKeys
|
||||
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
|
||||
CAPTAIN_CONVERSATION_FAQ_MUTEX = 'CAPTAIN_CONVERSATION_FAQ_LOCK::%<assistant_id>s::%<language>s'.freeze
|
||||
|
||||
## Auto Assignment Keys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.16.1",
|
||||
"version": "4.16.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -86,6 +86,9 @@
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
|
||||
Generated
+22
-6
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.23
|
||||
version: 1.3.23
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
@@ -180,6 +180,15 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -452,8 +461,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
@@ -3992,6 +4001,9 @@ packages:
|
||||
prosemirror-tables@1.5.0:
|
||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
@@ -5124,7 +5136,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -9023,7 +9035,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-transform: 1.10.0
|
||||
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
@@ -9039,6 +9051,10 @@ snapshots:
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
@@ -23,12 +23,6 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -73,17 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -451,4 +451,71 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'execution_delay handling' do
|
||||
let(:delayed_rule_params) do
|
||||
{
|
||||
name: 'Delayed rule',
|
||||
event_name: 'conversation_updated',
|
||||
execution_delay: 240,
|
||||
conditions: [{ attribute_key: 'status', filter_operator: 'equal_to', values: ['pending'], query_operator: nil }],
|
||||
actions: [{ action_name: 'add_label', action_params: ['stale'] }]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is enabled' do
|
||||
before { account.enable_features!('delayed_automations') }
|
||||
|
||||
it 'persists and serializes execution_delay' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(body[:execution_delay]).to eq(240)
|
||||
expect(account.automation_rules.last.execution_delay).to eq(240)
|
||||
end
|
||||
|
||||
it 'copies execution_delay on clone' do
|
||||
automation_rule = create(:automation_rule, account: account, execution_delay: 240)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}/clone",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to eq(240)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is disabled' do
|
||||
it 'rejects a payload carrying execution_delay with 422' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(account.automation_rules.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'still accepts payloads without execution_delay' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params.except(:execution_delay)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to be_nil
|
||||
end
|
||||
|
||||
it 'strips execution_delay when cloning an existing delayed rule' do
|
||||
automation_rule = create(:automation_rule, account: account, execution_delay: 240)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}/clone",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
it 'deletes the linear integration' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -23,19 +21,11 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: admin.create_new_auth_token,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
@@ -159,33 +159,19 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'deletes the shopify integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized and keeps the integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to(change { account.hooks.count })
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
|
||||
expect(metrics.keys).to contain_exactly(
|
||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||
:hours_saved, :reopen_rate, :conversation_depth
|
||||
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
|
||||
)
|
||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||
end
|
||||
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#faq_stats' do
|
||||
describe '#metrics knowledge' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
it 'reports zero coverage when there are no responses' do
|
||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
end
|
||||
|
||||
@@ -21,27 +21,6 @@ RSpec.describe 'Agents API', type: :request do
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
|
||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
||||
competing_agent_created = false
|
||||
|
||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
||||
unless competing_agent_created
|
||||
create(:user, account: account, role: :agent)
|
||||
competing_agent_created = true
|
||||
end
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
expect(User.from_email(params[:email])).to be_nil
|
||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_faq: true }) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, first_reply_created_at: Time.zone.now) }
|
||||
let(:faq_service) { instance_double(Captain::Llm::ConversationFaqService, generate_suggestions: []) }
|
||||
let(:lock_manager) { instance_double(Redis::LockManager, lock: true, unlock: true) }
|
||||
let(:lock_key) { "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::en" }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
|
||||
conversation.update!(status: :resolved)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(Captain::Llm::ConversationFaqService).to receive(:new).and_return(faq_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'uses the assistant captured when the job was enqueued' do
|
||||
replacement_assistant = create(:captain_assistant, account: account, config: { feature_faq: true })
|
||||
inbox.captain_inbox.update!(captain_assistant: replacement_assistant)
|
||||
|
||||
expect(inbox.reload.captain_assistant).to eq(replacement_assistant)
|
||||
expect(Captain::Llm::ConversationFaqService).to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(faq_service)
|
||||
expect(faq_service).to receive(:generate_suggestions)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
it 'locks FAQ grouping for the assistant and normalized language' do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
expected_key = "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::pt"
|
||||
|
||||
expect(lock_manager).to receive(:lock).with(expected_key, described_class::LOCK_TIMEOUT).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(expected_key)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
context 'when another job holds the grouping lock' do
|
||||
before do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, described_class::LOCK_TIMEOUT).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not generate suggestions concurrently' do
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(conversation, assistant)
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,7 @@ describe CaptainListener do
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
|
||||
expect(Captain::Llm::ConversationFaqJob).not_to receive(:perform_later)
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
@@ -42,8 +42,11 @@ describe CaptainListener do
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'enqueues FAQ suggestion generation' do
|
||||
expect(Captain::Llm::ConversationFaqJob).to receive(:perform_later).with(conversation, assistant)
|
||||
it 'generates and deduplicates FAQs' do
|
||||
expect(Captain::Llm::ConversationFaqService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
|
||||
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
|
||||
permissions :index?, :show?, :playground? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqPromptsService do
|
||||
describe '.generator' do
|
||||
it 'allows a complete FAQ answer to use several related agent messages' do
|
||||
prompt = described_class.generator
|
||||
|
||||
expect(prompt).to include('message or messages that together provide a complete public answer')
|
||||
expect(prompt).to include('Combine facts only across related agent messages')
|
||||
expect(prompt).not_to include('no single human agent message')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:captain_assistant) { create(:captain_assistant) }
|
||||
let(:conversation) { create(:conversation, account: captain_assistant.account, first_reply_created_at: Time.zone.now) }
|
||||
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
|
||||
let(:service) { described_class.new(captain_assistant, conversation) }
|
||||
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
@@ -15,8 +15,6 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
|
||||
end
|
||||
let(:embedding_one) { [1.0] + Array.new(1535, 0.0) }
|
||||
let(:embedding_two) { [0.0, 1.0] + Array.new(1534, 0.0) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
@@ -28,10 +26,11 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#generate_suggestions' do
|
||||
describe '#generate_and_deduplicate' do
|
||||
context 'when successful' do
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ generation feature model' do
|
||||
@@ -39,7 +38,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ default ahead of the legacy global installation model' do
|
||||
@@ -49,7 +48,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
model: Llm::Models.default_model_for('conversation_faq_generation')
|
||||
).and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'keeps account conversation FAQ model overrides ahead of the feature default' do
|
||||
@@ -58,7 +57,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat)
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'resolves the feature model from the conversation account' do
|
||||
@@ -67,7 +66,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
described_class.new(captain_assistant, conversation).generate_suggestions
|
||||
described_class.new(captain_assistant, conversation).generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'sends only customer and human support agent messages to the LLM' do
|
||||
@@ -85,7 +84,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox,
|
||||
message_type: :activity, content: 'Activity message')
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer question') &&
|
||||
@@ -105,7 +104,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
sender: nil, message_type: :outgoing, content: 'Human replied from the native app',
|
||||
content_attributes: { external_echo: true })
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
|
||||
expected_content = satisfy do |content|
|
||||
content.include?('User: Customer asks in a native channel') &&
|
||||
@@ -134,27 +133,22 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
block.call
|
||||
end
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
|
||||
it 'creates suggestions instead of trusted FAQs for valid conversation content' do
|
||||
it 'creates new FAQs for valid conversation content' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(2)
|
||||
expect(Captain::FaqObservation.count).to eq(2)
|
||||
expect(captain_assistant.responses.count).to be_zero
|
||||
service.generate_and_deduplicate
|
||||
end.to change(captain_assistant.responses, :count).by(2)
|
||||
end
|
||||
|
||||
it 'saves open suggestions with one attached source each' do
|
||||
service.generate_suggestions
|
||||
it 'saves FAQs with pending status linked to conversation' do
|
||||
service.generate_and_deduplicate
|
||||
expect(
|
||||
captain_assistant.faq_suggestions.pluck(:question, :answer, :status, :source_count, :language)
|
||||
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
|
||||
).to contain_exactly(
|
||||
['What is the purpose?', 'To help users.', 'open', 1, 'en'],
|
||||
['How does it work?', 'Through AI.', 'open', 1, 'en']
|
||||
)
|
||||
expect(Captain::FaqObservation.attached.pluck(:conversation_id, :language)).to contain_exactly(
|
||||
[conversation.id, 'en'], [conversation.id, 'en']
|
||||
['What is the purpose?', 'To help users.', 'pending', conversation.id],
|
||||
['How does it work?', 'Through AI.', 'pending', conversation.id]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -163,292 +157,37 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'returns an empty array without generating FAQs' do
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
|
||||
it 'does not call the LLM API' do
|
||||
expect(RubyLLM).not_to receive(:chat)
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finding duplicates' do
|
||||
let(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
|
||||
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'discards candidates the LLM confirms are covered by an approved FAQ' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
|
||||
it 'uses the conversation FAQ matching feature model' do
|
||||
expect(RubyLLM).to receive(:chat).with(
|
||||
model: Llm::Models.default_model_for('conversation_faq_matching')
|
||||
).at_least(:once).and_return(mock_chat)
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
|
||||
it 'uses the account model override for conversation FAQ matching' do
|
||||
conversation.account.update!(captain_models: { 'conversation_faq_matching' => 'gpt-5-mini' })
|
||||
|
||||
expect(RubyLLM).to receive(:chat).with(model: 'gpt-5-mini').at_least(:once).and_return(mock_chat)
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
|
||||
it 'resolves the matching feature model from the conversation account' do
|
||||
allow(Llm::FeatureRouter).to receive(:resolve).and_call_original
|
||||
expect(Llm::FeatureRouter).to receive(:resolve).with(
|
||||
feature: 'conversation_faq_matching',
|
||||
account: conversation.account
|
||||
).and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
context 'when FAQ comparison cannot be completed' do
|
||||
let(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'Similar question', answer: 'Similar answer', embedding: embedding_one)
|
||||
end
|
||||
let(:comparison_response) { instance_double(RubyLLM::Message, content: comparison_response_content) }
|
||||
let(:comparison_response_content) { 'invalid json' }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? comparison_response : mock_response
|
||||
end
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'raises when the comparison response is malformed' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(JSON::ParserError)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
|
||||
context 'when the response omits the comparison result' do
|
||||
let(:comparison_response_content) { {}.to_json }
|
||||
|
||||
it 'raises instead of treating the response as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(KeyError)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the comparison result is not a boolean' do
|
||||
let(:comparison_response_content) { { same_faq: 'false' }.to_json }
|
||||
|
||||
it 'raises instead of treating the response as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(TypeError, 'same_faq must be a boolean')
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the comparison provider fails' do
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
raise RubyLLM::Error.new(nil, 'API Error') if input.start_with?('{')
|
||||
|
||||
mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises instead of treating the failure as a non-match' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to raise_error(RubyLLM::Error)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the classifier confirms a non-match' do
|
||||
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: false }.to_json) }
|
||||
|
||||
before do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a new suggestion' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an open suggestion is the same FAQ' do
|
||||
let(:sample_faqs) { [{ 'question' => 'How can I use the feature?', 'answer' => 'Enable it in settings.' }] }
|
||||
let(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one
|
||||
).tap do |suggestion|
|
||||
suggestion.observations.create!(
|
||||
conversation: create(:conversation, account: captain_assistant.account),
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.observations.attached.count)
|
||||
end
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_suggestion
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'attaches the observation and increments the source count' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(existing_suggestion.observations, :count).by(1)
|
||||
expect(existing_suggestion.reload.source_count).to eq(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a similar open suggestion uses another language' do
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let!(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one, language: 'en', source_count: 1)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
end
|
||||
|
||||
it 'creates a separate suggestion in the conversation language' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(captain_assistant.faq_suggestions, :count).by(1)
|
||||
|
||||
expect(captain_assistant.faq_suggestions.pluck(:language)).to contain_exactly('en', 'pt')
|
||||
expect(existing_suggestion.reload.source_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an open suggestion uses another locale variant of the same language' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, first_reply_created_at: Time.zone.now) }
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let(:existing_suggestion) do
|
||||
captain_assistant.faq_suggestions.create!(
|
||||
question: 'Como habilito o recurso?',
|
||||
answer: 'Ative nas configuracoes.',
|
||||
embedding: embedding_one,
|
||||
language: 'pt',
|
||||
source_count: 1
|
||||
let(:similar_neighbor) do
|
||||
OpenStruct.new(
|
||||
id: 1,
|
||||
question: existing_response.question,
|
||||
answer: existing_response.answer,
|
||||
neighbor_distance: 0.1
|
||||
)
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_suggestion
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
|
||||
end
|
||||
|
||||
it 'attaches the observation to the existing base-language suggestion' do
|
||||
it 'filters out duplicate FAQs based on embedding similarity' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(existing_suggestion.observations, :count).by(1)
|
||||
|
||||
expect(existing_suggestion.reload.source_count).to eq(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to eq(1)
|
||||
expect(existing_suggestion.observations.last.language).to eq('pt')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a similar approved FAQ uses another language' do
|
||||
let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] }
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account,
|
||||
question: 'How do I enable the feature?', answer: 'Turn it on in settings.',
|
||||
embedding: embedding_one)
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'deduplicates against the approved FAQ' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(1)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation and account locales share a base language' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
|
||||
additional_attributes: { conversation_language: 'pt' })
|
||||
end
|
||||
let!(:existing_response) do
|
||||
create(:captain_assistant_response, assistant: captain_assistant, account: account,
|
||||
question: 'Como ativo o recurso?', answer: 'Ative nas configuracoes.',
|
||||
embedding: embedding_one)
|
||||
end
|
||||
let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) }
|
||||
|
||||
before do
|
||||
existing_response
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one)
|
||||
allow(mock_chat).to receive(:ask) do |input|
|
||||
input.start_with?('{') ? match_response : mock_response
|
||||
end
|
||||
end
|
||||
|
||||
it 'deduplicates against approved FAQs in the same base language' do
|
||||
expect do
|
||||
service.generate_suggestions
|
||||
end.to change(Captain::FaqObservation.discarded, :count).by(2)
|
||||
expect(captain_assistant.faq_suggestions.count).to be_zero
|
||||
service.generate_and_deduplicate
|
||||
end.not_to change(captain_assistant.responses, :count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -460,7 +199,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
it 'returns empty array and logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -475,7 +214,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
|
||||
it 'handles JSON parsing errors gracefully' do
|
||||
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -489,7 +228,7 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
end
|
||||
|
||||
it 'returns empty array' do
|
||||
expect(service.generate_suggestions).to eq([])
|
||||
expect(service.generate_and_deduplicate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -497,44 +236,22 @@ RSpec.describe Captain::Llm::ConversationFaqService do
|
||||
describe 'language handling' do
|
||||
context 'when conversation has different language' do
|
||||
let(:account) { create(:account, locale: 'fr') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
|
||||
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
|
||||
end
|
||||
|
||||
it 'uses account language for system prompt' do
|
||||
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
|
||||
.with('french')
|
||||
.at_least(:once)
|
||||
.and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation language differs from account language' do
|
||||
let(:account) { create(:account, locale: 'en') }
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) do
|
||||
create(:conversation, account: account, first_reply_created_at: Time.zone.now,
|
||||
additional_attributes: { conversation_language: 'pt-BR' })
|
||||
end
|
||||
|
||||
before do
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(embedding_one, embedding_two)
|
||||
end
|
||||
|
||||
it 'uses the conversation language for the system prompt' do
|
||||
expect(Captain::Llm::ConversationFaqPromptsService).to receive(:generator)
|
||||
.with('portuguese')
|
||||
.at_least(:once)
|
||||
.and_call_original
|
||||
|
||||
service.generate_suggestions
|
||||
service.generate_and_deduplicate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
FactoryBot.define do
|
||||
factory :automation_rule_pending_execution do
|
||||
account
|
||||
automation_rule { association :automation_rule, account: account }
|
||||
conversation { association :conversation, account: account }
|
||||
# Derive from production so the row is episode_current (matches the conversation's status).
|
||||
episode_key { AutomationRulePendingExecution.episode_key_for(conversation, nil) }
|
||||
due_at { 1.hour.from_now }
|
||||
status { :pending }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ProcessPendingExecutionJob do
|
||||
subject(:job) { described_class.new }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['pending'], 'attribute_key' => 'status', 'query_operator' => nil,
|
||||
'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
let(:pending_execution) do
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation)
|
||||
# The sweep only enqueues due rows, so make it due before the job runs.
|
||||
AutomationRulePendingExecution.last.tap { |row| row.update!(due_at: 1.minute.ago) }
|
||||
end
|
||||
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
account.enable_features!('delayed_automations')
|
||||
end
|
||||
|
||||
it 'runs the actions and marks the row executed when every guard passes' do
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_executed
|
||||
expect(conversation.reload.label_list).to include('stale')
|
||||
end
|
||||
|
||||
it 'skips with rule_inactive when the rule was disabled' do
|
||||
rule.update!(active: false)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('rule_inactive')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'pauses (keeps pending) while the account flag is off, then fires when re-enabled' do
|
||||
account.disable_features!('delayed_automations')
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_pending
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
|
||||
account.enable_features!('delayed_automations')
|
||||
described_class.new.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_executed
|
||||
expect(conversation.reload.label_list).to include('stale')
|
||||
end
|
||||
|
||||
it 'skips with episode_moved when the conversation left the armed status' do
|
||||
pending_execution
|
||||
conversation.update!(status: :resolved)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('episode_moved')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'skips with conditions_changed when the conversation drifts but the episode is intact' do
|
||||
# A message_created (reply-chase) rule whose extra condition is the conversation status.
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['pending'], 'attribute_key' => 'status',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
# Status change fails the condition but leaves the reply_chase episode (max incoming id) intact.
|
||||
conversation.update!(status: :open)
|
||||
job.perform(row)
|
||||
|
||||
expect(row.reload).to be_skipped
|
||||
expect(row.skip_reason).to eq('conditions_changed')
|
||||
end
|
||||
|
||||
it 'skips with expired when the row is past the due window' do
|
||||
pending_execution.update!(due_at: 4.days.ago)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('expired')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'leaves the row untouched without executing when the kill switch is set' do
|
||||
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
|
||||
GlobalConfig.clear_cache
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_pending
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'runs the actions once when the same row is processed twice concurrently' do
|
||||
allow(AutomationRules::ActionService).to receive(:new).and_call_original
|
||||
duplicate = AutomationRulePendingExecution.find(pending_execution.id)
|
||||
|
||||
job.perform(pending_execution.reload)
|
||||
described_class.new.perform(duplicate)
|
||||
|
||||
expect(AutomationRules::ActionService).to have_received(:new).once
|
||||
expect(pending_execution.reload).to be_executed
|
||||
end
|
||||
|
||||
it 'leaves the row processing and reports the error when an action blows up' do
|
||||
action_service = instance_double(AutomationRules::ActionService)
|
||||
allow(AutomationRules::ActionService).to receive(:new).and_return(action_service)
|
||||
allow(action_service).to receive(:perform).and_raise(StandardError, 'boom')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_processing
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
|
||||
it 'sends the follow-up exactly once for the reply-chase story' do
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
job.perform(row.reload)
|
||||
|
||||
expect(row.reload).to be_executed
|
||||
expect(conversation.messages.outgoing.where(content: 'Just checking in').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'cancels the follow-up when the customer replied before it was due' do
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
job.perform(row.reload)
|
||||
|
||||
expect(row.reload).to be_skipped
|
||||
expect(row.skip_reason).to eq('episode_moved')
|
||||
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Just checking in')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::TriggerPendingExecutionsJob do
|
||||
subject(:job) { described_class.new }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
account.enable_features!('delayed_automations')
|
||||
end
|
||||
|
||||
it 'enqueues a per-row job for due pending rows but not future ones' do
|
||||
due_row = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
future_row = create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).to have_been_enqueued.with(due_row)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).not_to have_been_enqueued.with(future_row)
|
||||
end
|
||||
|
||||
it 're-enqueues stale processing rows so they get retried' do
|
||||
stale_row = travel_to(20.minutes.ago) do
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing, due_at: 19.minutes.from_now)
|
||||
end
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).with(stale_row)
|
||||
end
|
||||
|
||||
it 'caps enqueues at the configured sweep limit' do
|
||||
create(:installation_config, name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT', serialized_value: { value: 1 }.with_indifferent_access)
|
||||
create_list(:automation_rule_pending_execution, 2, account: account, due_at: 1.minute.ago)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
end
|
||||
|
||||
it 'purges terminal rows past the retention window' do
|
||||
old_row = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
|
||||
|
||||
job.perform
|
||||
|
||||
expect { old_row.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'skips rows for accounts with delayed automations disabled so they cannot starve others' do
|
||||
enabled_row = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
disabled_account = create(:account) # delayed_automations off by default
|
||||
create(:automation_rule_pending_execution, account: disabled_account, due_at: 2.minutes.ago)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).to have_been_enqueued.with(enabled_row)
|
||||
end
|
||||
|
||||
it 'does nothing when the kill switch is set' do
|
||||
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
|
||||
GlobalConfig.clear_cache
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
|
||||
expect { job.perform }.not_to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob)
|
||||
end
|
||||
end
|
||||
@@ -26,10 +26,9 @@ RSpec.describe Llm::Models do
|
||||
end
|
||||
end
|
||||
|
||||
it 'routes each FAQ operation independently' do
|
||||
it 'routes document and conversation FAQ generation independently' do
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
|
||||
expect(described_class.default_model_for('conversation_faq_matching')).to eq('gpt-4.1-mini')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -247,4 +247,37 @@ describe AutomationRuleListener do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delayed rules' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_updated', account: account, execution_delay: 60) }
|
||||
let(:event) do
|
||||
Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation, changed_attributes: {} })
|
||||
end
|
||||
|
||||
before { allow(condition_match).to receive(:present?).and_return(true) }
|
||||
|
||||
context 'when the delayed_automations feature is enabled' do
|
||||
before { account.enable_features!('delayed_automations') }
|
||||
|
||||
it 'records a pending execution instead of running actions' do
|
||||
expect { listener.conversation_updated(event) }.to change(AutomationRulePendingExecution, :count).by(1)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new)
|
||||
expect(AutomationRulePendingExecution.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'still runs rules without a delay immediately' do
|
||||
automation_rule.update!(execution_delay: nil)
|
||||
|
||||
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is disabled' do
|
||||
it 'neither arms a pending execution nor falls back to immediate execution' do
|
||||
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,6 +112,21 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'resuming delayed automations' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'enqueues the resume job when delayed_automations is turned back on' do
|
||||
expect { account.enable_features!('delayed_automations') }
|
||||
.to have_enqueued_job(AutomationRules::ResumePausedExecutionsJob).with(account)
|
||||
end
|
||||
|
||||
it 'does not enqueue the resume job when the flag is turned off' do
|
||||
account.enable_features!('delayed_automations')
|
||||
expect { account.disable_features!('delayed_automations') }
|
||||
.not_to have_enqueued_job(AutomationRules::ResumePausedExecutionsJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'feature flag columns' do
|
||||
let(:account) { described_class.new(name: 'Test Account') }
|
||||
|
||||
@@ -122,7 +137,8 @@ RSpec.describe Account do
|
||||
feature_data_import: 1 << 1,
|
||||
feature_api_and_webhooks: 1 << 2,
|
||||
feature_whatsapp_reconfigure: 1 << 3,
|
||||
feature_whatsapp_embedded_signup_inbox_creation: 1 << 4
|
||||
feature_whatsapp_embedded_signup_inbox_creation: 1 << 4,
|
||||
feature_delayed_automations: 1 << 5
|
||||
)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRulePendingExecution do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
|
||||
describe '.episode_key_for' do
|
||||
it 'derives status episodes from status_changed_at' do
|
||||
expect(described_class.episode_key_for(conversation, nil)).to eq("status:#{conversation.status_changed_at.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'matches between an in-memory arm and a DB-reloaded fire (no float rounding drift)' do
|
||||
conversation.status_changed_at = Time.zone.at(1_784_102_080.844761923r)
|
||||
arm_key = described_class.episode_key_for(conversation, nil)
|
||||
conversation.save!
|
||||
expect(arm_key).to eq(described_class.episode_key_for(conversation.reload, nil))
|
||||
end
|
||||
|
||||
it 'falls back to created_at when status_changed_at is blank' do
|
||||
conversation.update!(status_changed_at: nil)
|
||||
expect(described_class.episode_key_for(conversation.reload, nil)).to eq("status:#{conversation.created_at.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'derives awaiting_agent episodes from waiting_since (sub-second) for incoming messages' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
expect(described_class.episode_key_for(conversation.reload, message)).to eq("awaiting_agent:#{conversation.waiting_since.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'distinguishes two waiting periods that fall within the same second' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
first_key = described_class.episode_key_for(conversation.reload, message)
|
||||
|
||||
# Agent replies then customer re-waits within the same second: keys must differ.
|
||||
conversation.update!(waiting_since: conversation.waiting_since + 0.4)
|
||||
expect(described_class.episode_key_for(conversation.reload, message)).not_to eq(first_key)
|
||||
end
|
||||
|
||||
it 'arms an awaiting_agent episode from the message created_at when waiting_since is not yet written' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
# Simulate the race where the listener arms before update_waiting_since commits.
|
||||
conversation.update!(waiting_since: nil)
|
||||
armed_key = described_class.arm_episode_key_for(conversation.reload, message)
|
||||
|
||||
# Once waiting_since settles to the message's created_at, the strict fire-time key matches.
|
||||
conversation.update!(waiting_since: message.created_at)
|
||||
expect(armed_key).to eq("awaiting_agent:#{message.created_at.strftime('%s%6N')}")
|
||||
expect(armed_key).to eq(described_class.episode_key_for(conversation.reload, message))
|
||||
end
|
||||
|
||||
it 'derives reply_chase episodes from the max incoming message id for outgoing messages' do
|
||||
incoming = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq("reply_chase:#{incoming.id}")
|
||||
end
|
||||
|
||||
it 'uses 0 for reply_chase when there is no incoming message' do
|
||||
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq('reply_chase:0')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.schedule' do
|
||||
it 'creates a pending row due after the rule delay' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
|
||||
row = described_class.last
|
||||
expect(row).to have_attributes(account_id: account.id, conversation_id: conversation.id, status: 'pending')
|
||||
expect(row.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'anchors due_at to the event time, not when a backlogged listener runs' do
|
||||
conversation.update!(status_changed_at: 30.minutes.ago)
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
|
||||
# A 60-minute rule on a status that changed 30 minutes ago is already 30 minutes into its wait.
|
||||
expect(described_class.last.due_at).to be_within(5.seconds).of(30.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'does not reset the clock for a repeated status episode' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
original_due_at = described_class.last.due_at
|
||||
|
||||
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation) }
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.due_at).to be_within(1.second).of(original_due_at)
|
||||
end
|
||||
|
||||
it 'moves the clock and anchor for a repeated reply_chase episode' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
|
||||
travel_to(30.minutes.from_now) do
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
# due_at is re-anchored to the new reply's created_at, not the original schedule time.
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(second_reply.id)
|
||||
expect(described_class.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not let a late older reply move the reply_chase clock backwards' do
|
||||
older_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
newer_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
|
||||
# The newer reply's job runs first and arms the episode.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer_reply)
|
||||
armed_due_at = described_class.last.due_at
|
||||
|
||||
# The older reply's job arrives late; it must not pull the clock or message_id back.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: older_reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(newer_reply.id)
|
||||
expect(described_class.last.due_at).to eq(armed_due_at)
|
||||
end
|
||||
|
||||
it 'does not re-arm an executed reply_chase episode' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
described_class.last.update!(status: :executed)
|
||||
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last).to be_executed
|
||||
end
|
||||
|
||||
it 're-arms a condition-skipped episode when a newer qualifying message arrives' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
described_class.last.update!(status: :skipped, skip_reason: 'conditions_changed')
|
||||
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(row).to be_pending
|
||||
expect(row.skip_reason).to be_nil
|
||||
expect(row.message_id).to eq(second_reply.id)
|
||||
end
|
||||
|
||||
it 're-anchors a reply_chase row stuck in processing when a newer reply arrives' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
# The worker claimed the row and then died, leaving it in processing with the old clock.
|
||||
described_class.last.update!(status: :processing)
|
||||
|
||||
travel_to(30.minutes.from_now) do
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(row).to be_pending
|
||||
expect(row.message_id).to eq(second_reply.id)
|
||||
expect(row.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not re-arm an episode skipped for a non-condition reason' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
described_class.last.update!(status: :skipped, skip_reason: 'episode_moved')
|
||||
|
||||
newer_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer_reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last).to be_skipped
|
||||
end
|
||||
|
||||
it 'keeps the awaiting_agent clock but tracks the newest incoming message' do
|
||||
first_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_message)
|
||||
original_due_at = described_class.last.due_at
|
||||
|
||||
second_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation, message: second_message) }
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
# The wait still counts from waiting_since, so the clock is unchanged...
|
||||
expect(row.due_at).to be_within(1.second).of(original_due_at)
|
||||
# ...but the row tracks the newest qualifying message, not the stale first one.
|
||||
expect(row.message_id).to eq(second_message.id)
|
||||
end
|
||||
|
||||
it 'keeps the newest message when an older incoming collision arrives last' do
|
||||
first_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_message)
|
||||
|
||||
older = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
newer = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
|
||||
# The newer message re-arms first; a late older-message collision must not overwrite it.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: older)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(newer.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#episode_current?' do
|
||||
it 'is true while the conversation stays in the armed status' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
expect(described_class.last.episode_current?).to be(true)
|
||||
end
|
||||
|
||||
it 'is false after a status transition' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
conversation.update!(status: :resolved)
|
||||
expect(described_class.last.reload.episode_current?).to be(false)
|
||||
end
|
||||
|
||||
it 'is false for awaiting_agent episodes once the agent replies (waiting_since cleared)' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: message)
|
||||
|
||||
conversation.update!(waiting_since: nil)
|
||||
expect(described_class.last.episode_current?).to be(false)
|
||||
end
|
||||
|
||||
it 'is false for reply_chase episodes once the customer replies' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
|
||||
create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
expect(described_class.last.episode_current?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#claim!' do
|
||||
let(:row) { create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago) }
|
||||
|
||||
it 'claims a pending row exactly once so a duplicate enqueue cannot double-fire' do
|
||||
expect(row.claim!).to be(true)
|
||||
expect(row.reload).to be_processing
|
||||
expect(described_class.find(row.id).claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'does not claim a row whose due_at was pushed into the future (reply-chase reschedule)' do
|
||||
row.update!(due_at: 1.hour.from_now)
|
||||
expect(row.claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'does not claim terminal rows' do
|
||||
row.update!(status: :executed)
|
||||
expect(row.claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'reclaims a processing row only after its lock goes stale' do
|
||||
row.update!(status: :processing)
|
||||
expect(row.claim!).to be(false)
|
||||
|
||||
travel_to(20.minutes.from_now) { expect(row.claim!).to be(true) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.sweepable' do
|
||||
it 'selects due pending rows and stale processing rows, but not future or fresh ones' do
|
||||
due = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
|
||||
create(:automation_rule_pending_execution, account: account, status: :processing)
|
||||
stale = travel_to(20.minutes.ago) do
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing)
|
||||
end
|
||||
|
||||
expect(described_class.sweepable).to contain_exactly(due, stale)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.purge_terminal!' do
|
||||
it 'deletes terminal rows past the retention window and keeps everything else' do
|
||||
old_executed = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
|
||||
recent_skipped = create(:automation_rule_pending_execution, account: account, status: :skipped)
|
||||
pending = create(:automation_rule_pending_execution, account: account, conversation: conversation)
|
||||
|
||||
described_class.purge_terminal!
|
||||
|
||||
expect(described_class.pluck(:id)).to contain_exactly(recent_skipped.id, pending.id)
|
||||
expect { old_executed.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.reschedule_paused' do
|
||||
it 'resets rows overdue past the window so a resumed account replays them instead of expiring' do
|
||||
expired = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 5.days.ago)
|
||||
within_window = create(:automation_rule_pending_execution, account: account, due_at: 2.days.ago)
|
||||
|
||||
described_class.reschedule_paused(account)
|
||||
|
||||
expect(expired.reload.due_at).to be_within(5.seconds).of(Time.current)
|
||||
expect(within_window.reload.due_at).to be_within(5.seconds).of(2.days.ago)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -137,4 +137,118 @@ RSpec.describe AutomationRule do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'execution_delay validations' do
|
||||
let(:rule) { build(:automation_rule, account: create(:account)) }
|
||||
|
||||
it 'allows nil (immediate execution)' do
|
||||
rule.execution_delay = nil
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows delays between 10 minutes and 30 days' do
|
||||
rule.execution_delay = 240
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects delays below 10 minutes' do
|
||||
rule.execution_delay = 5
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects delays above 30 days' do
|
||||
rule.execution_delay = 43_201
|
||||
expect(rule).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects non-integer delays' do
|
||||
rule.execution_delay = 10.5
|
||||
expect(rule).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects a delay combined with an attribute_changed condition' do
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'attribute_changed',
|
||||
'values' => { 'from' => ['open'], 'to' => ['pending'] }, 'query_operator' => nil }]
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to include('cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
it 'rejects a delayed conversation-level rule with a mutable non-status condition' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'priority', 'filter_operator' => 'equal_to', 'values' => ['urgent'], 'query_operator' => nil }]
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to include('only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation-level rule with only status conditions' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation_created rule (arms on creation)' do
|
||||
rule.event_name = 'conversation_created'
|
||||
rule.execution_delay = 10
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['open'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation-level rule scoped by status and inbox (immutable)' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => 'AND' },
|
||||
{ 'attribute_key' => 'inbox_id', 'filter_operator' => 'equal_to', 'values' => [1], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed message_created rule with a non-status condition' do
|
||||
rule.event_name = 'message_created'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'message_type', 'filter_operator' => 'equal_to', 'values' => ['outgoing'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'discarding stale pending executions on edit' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:status_condition) { { 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => nil } }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
conditions: [status_condition], actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
|
||||
before { AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation) }
|
||||
|
||||
it 'discards armed rows when the actions change' do
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
expect(rule.pending_executions.pending).to be_empty
|
||||
end
|
||||
|
||||
it 'discards armed rows when the delay changes' do
|
||||
rule.update!(execution_delay: 120)
|
||||
expect(rule.pending_executions.pending).to be_empty
|
||||
end
|
||||
|
||||
it 'discards a stale processing row that the sweep would otherwise reclaim' do
|
||||
rule.pending_executions.first.update!(status: :processing)
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
expect(rule.pending_executions.armed).to be_empty
|
||||
end
|
||||
|
||||
it 'frees the episode slot so the new definition re-arms for the same episode' do
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation)
|
||||
expect(rule.pending_executions.pending.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'leaves armed rows untouched on a name-only edit' do
|
||||
rule.update!(name: 'Renamed rule')
|
||||
expect(rule.pending_executions.pending.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1230,4 +1230,28 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#status_changed_at' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'is set on create' do
|
||||
expect(conversation.status_changed_at).to be_present
|
||||
end
|
||||
|
||||
it 'is updated on every status transition' do
|
||||
original = conversation.status_changed_at
|
||||
|
||||
travel_to(1.hour.from_now) { conversation.update!(status: :resolved) }
|
||||
|
||||
expect(conversation.reload.status_changed_at).to be > original
|
||||
end
|
||||
|
||||
it 'is untouched by non-status saves' do
|
||||
original = conversation.status_changed_at
|
||||
|
||||
travel_to(1.hour.from_now) { conversation.update!(priority: :high) }
|
||||
|
||||
expect(conversation.reload.status_changed_at).to be_within(1.second).of(original)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user