Compare commits

..
Author SHA1 Message Date
Shivam Mishra f2fbb93698 feat: add better stack status tool 2025-08-06 14:44:47 +05:30
Shivam Mishra 38d174bda6 feat: remove betterstack tool 2025-08-06 14:43:40 +05:30
Shivam Mishra 7ca5d365ae fix: model 2025-08-05 12:21:31 +05:30
Shivam Mishra 6be85a40f7 Merge branch 'feat/scenario-agents' of github.com:chatwoot/chatwoot into feat/scenario-agents 2025-08-05 12:17:47 +05:30
Shivam MishraandGitHub 922c989574 Merge branch 'develop' into feat/scenario-agents 2025-08-05 12:17:38 +05:30
Shivam Mishra 59928bd85c style: remove extra line 2025-08-05 12:17:07 +05:30
Shivam Mishra 46c55a53bd chore: update ai-agents 2025-08-04 18:35:47 +05:30
Shivam Mishra 176e09cde7 fix: schema 2025-08-04 18:32:58 +05:30
Shivam Mishra b09c6b4aac feat: don't include response format in the prompt 2025-08-04 17:00:21 +05:30
Shivam Mishra 96e18d74c5 feat: include schema 2025-08-04 16:58:23 +05:30
Shivam Mishra 311093f0fb feat: handle structured response 2025-08-04 16:58:15 +05:30
Shivam Mishra 54514d9d21 feat: add response schema for captain 2025-08-04 16:58:00 +05:30
Shivam Mishra 4633d2a179 feat: add ai-agents 0.4.2 and RubyLLM Schema 2025-08-04 16:57:49 +05:30
Shivam Mishra 73c2690526 fix: handle conversation is nil 2025-08-04 15:42:43 +05:30
Shivam Mishra 25b545200e feat: update ai-agents 2025-08-04 15:41:24 +05:30
Shivam Mishra 23c35c8886 feat: allow temperature 2025-08-04 15:27:29 +05:30
Shivam Mishra 3f801fda2c feat: add accessors for temperature 2025-08-04 15:27:15 +05:30
Shivam Mishra ea7c5798ff fix: format 2025-08-01 15:49:18 +05:30
Shivam Mishra 1271459874 feat: add demo betterstack tool 2025-08-01 15:49:18 +05:30
Shivam Mishra 81098b02a8 feat: update assistant prompt 2025-08-01 15:49:18 +05:30
Shivam Mishra 18d6a51d6b feat: rake task to test chat locally 2025-08-01 15:49:18 +05:30
Shivam Mishra 9c9c1c301d fix: handoff tool reason 2025-08-01 15:49:18 +05:30
Shivam Mishra 5fb40e0b2e feat: add callback for runner 2025-08-01 15:49:18 +05:30
Shivam Mishra 9ba51a3fd4 feat: add debug 2025-08-01 15:49:18 +05:30
Shivam Mishra 05d83ce704 fix: priority not normalized correctly 2025-08-01 15:49:18 +05:30
Shivam Mishra 36b7a86bf7 chore: better prompt 2025-08-01 15:49:18 +05:30
Shivam Mishra e6d336f727 fix: prompt 2025-08-01 15:49:18 +05:30
PranavandGitHub feff61755f Merge branch 'develop' into feat/scenario-agents 2025-07-31 16:23:45 -07:00
Shivam Mishra 0c4e21bc6a refactor: agentable, remove included 2025-07-31 14:30:48 +05:30
Shivam Mishra a582b8284d style: run autofix 2025-07-31 14:26:49 +05:30
Shivam MishraandGitHub bbcbc6d52b Merge branch 'develop' into feat/scenario-agents 2025-07-31 10:00:37 +05:30
Shivam Mishra 28e837e8f7 feat: add handoff to assistant by default 2025-07-30 21:34:33 +05:30
Shivam Mishra f4ea90c2dc feat: add handoff tool 2025-07-30 21:34:18 +05:30
Shivam Mishra 1d9a28ad64 chore: remove exception tracker from tool 2025-07-30 21:00:56 +05:30
Shivam Mishra f36a3a2e19 feat: add exception tracker 2025-07-30 21:00:35 +05:30
Shivam Mishra a14df55210 fix: query 2025-07-30 20:58:53 +05:30
Shivam Mishra b3633d5c08 feat: add faq lookup tool 2025-07-30 20:30:14 +05:30
Shivam MishraandGitHub b52caf0341 Merge branch 'develop' into feat/scenario-agents 2025-07-30 18:04:48 +04:00
Shivam Mishra e37d61f836 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/scenario-agents 2025-07-30 19:30:36 +05:30
Shivam MishraandGitHub 10673a8d68 Merge branch 'feat/scenario-tools-association' into feat/scenario-agents 2025-07-16 17:14:13 +05:30
Shivam Mishra d488b8ced6 test: handle new tool mention syntax 2025-07-16 17:13:40 +05:30
Shivam MishraandGitHub 0381bdac96 Merge branch 'feat/scenario-tools-association' into feat/scenario-agents 2025-07-16 17:10:37 +05:30
Shivam Mishra 87fee33039 fix: new tool syntax 2025-07-16 17:09:38 +05:30
Shivam Mishra fe68542547 refactor: namespace for concerns 2025-07-16 17:09:38 +05:30
Shivam Mishra 2e1529c755 fix: tool mention format 2025-07-16 16:57:34 +05:30
Sojan JoseandGitHub eb20e39b11 Merge branch 'develop' into feat/scenario-tools-association 2025-07-16 01:34:46 -07:00
Shivam Mishra b771652656 feat: remove claude local 2025-07-16 10:30:21 +05:30
Shivam Mishra e6d720082c test: captain tools 2025-07-16 10:28:57 +05:30
Shivam Mishra d9a0d4eee0 refactor: safer state access 2025-07-16 10:28:57 +05:30
Shivam Mishra 2a0f2a8b1e feat: handle empty note 2025-07-16 10:28:57 +05:30
Shivam MishraandGitHub aedb59ea67 Merge branch 'develop' into feat/scenario-tools-association 2025-07-16 10:02:10 +05:30
Shivam MishraandGitHub 4e2abbaa3d Merge branch 'develop' into feat/scenario-tools-association 2025-07-16 09:54:45 +05:30
Shivam Mishra 9db096f046 refactor: namespace for the concern 2025-07-16 09:52:37 +05:30
Shivam Mishra 59b2679641 feat: remove search tool 2025-07-15 21:55:59 +05:30
Shivam Mishra b1d26887f0 chore: update tools.yml 2025-07-15 21:55:38 +05:30
Shivam Mishra 89a748eef6 feat: update tools 2025-07-15 21:55:09 +05:30
Shivam Mishra c00230c8af feat: remove unused tool 2025-07-15 21:53:34 +05:30
Shivam Mishra 2f6d581384 feat: get contact_id from the state 2025-07-15 21:53:12 +05:30
Shivam Mishra 468ef34f85 refactor: fetch conversation ID from the state 2025-07-15 21:50:01 +05:30
Shivam Mishra 7f4005cdcc feat: include contact 2025-07-15 20:57:22 +05:30
Shivam Mishra 57fdfe26f0 feat: pass context with indifferent access 2025-07-15 20:40:06 +05:30
Shivam Mishra 88ba4e4562 fix: conversation not passed 2025-07-15 20:39:52 +05:30
Shivam Mishra 394ba4ff73 fix: arguments 2025-07-15 20:21:22 +05:30
Shivam Mishra c0d1efa0ad feat: allow markdown 2025-07-15 20:13:37 +05:30
Shivam Mishra dfc80e6f20 chore: update intruction 2025-07-15 16:41:22 +05:30
Shivam Mishra 187b435eb9 feat: use feature toggle 2025-07-15 14:22:27 +05:30
Shivam Mishra 1fce8d1183 feat: use new tools 2025-07-15 14:22:27 +05:30
Shivam Mishra c51d2d3c93 feat: update tools to make this public 2025-07-15 14:22:27 +05:30
Shivam Mishra 4450bebf35 feat: add conversation context 2025-07-15 14:22:27 +05:30
Shivam Mishra b3ce7bd278 feat: add agentable with conversation context 2025-07-15 14:22:27 +05:30
Shivam Mishra 345a212447 feat: include guidelines and guardrails 2025-07-15 14:22:27 +05:30
Shivam Mishra 83450b7c24 fix: agent init 2025-07-15 14:22:27 +05:30
Shivam Mishra b58ec24d81 feat: set model 2025-07-15 14:22:27 +05:30
Shivam Mishra 5e799e7e6b feat: remove handoffs
We will register them separately
2025-07-15 14:22:27 +05:30
Shivam Mishra 01ad380822 feat: new response build 2025-07-15 14:22:27 +05:30
Shivam Mishra 84d2c0fd25 refactor: use the new prompt rendere 2025-07-15 14:22:27 +05:30
Shivam Mishra 87212d3b6e feat: save prompts as liquid 2025-07-15 14:22:27 +05:30
Shivam Mishra 4ca6ec1812 chore: ignore claude local 2025-07-15 14:22:27 +05:30
Shivam Mishra 4452d84097 fix: tool declaration 2025-07-15 14:22:27 +05:30
Shivam Mishra 9a81927154 chore: titltize 2025-07-15 14:22:27 +05:30
Shivam Mishra daed6eb1e5 feat: add tools resolution 2025-07-15 14:22:27 +05:30
Shivam Mishra 673669cd25 feat: add new tools 2025-07-15 14:22:27 +05:30
Shivam Mishra bdd5a77f45 feat: add agent instructions 2025-07-15 14:22:27 +05:30
Shivam Mishra 92150cb7c8 feat: add agents init 2025-07-15 14:22:27 +05:30
Shivam Mishra e52becbc40 style: autofix rubocop 2025-07-15 14:21:53 +05:30
7f3d4aaaa7 chore: update error
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-15 14:03:34 +05:30
Muhsin KelothandGitHub 7748ef2787 Merge branch 'develop' into feat/scenario-tools-association 2025-07-15 10:39:20 +05:30
Shivam MishraandGitHub f2d9895597 Merge branch 'develop' into feat/scenario-tools-association 2025-07-15 08:56:09 +05:30
Shivam Mishra db4e8588cd feat: add assistant policy for tools 2025-07-14 22:39:43 +05:30
Shivam Mishra 0c05599282 feat: load account user in intialize 2025-07-14 17:54:44 +05:30
Shivam Mishra 00e1f50458 chore: refactor tool 2025-07-14 17:52:01 +05:30
Shivam Mishra ee89943aa2 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/scenario-tools-association 2025-07-14 17:21:11 +05:30
9dc6b94537 refactor: update error message
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-14 16:17:11 +05:30
e9816f8d3d refactor: update error message
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-14 16:17:04 +05:30
Shivam MishraandGitHub 51d1c501cf Merge branch 'feat/scenarios' into feat/scenario-tools-association 2025-07-14 15:39:22 +05:30
Shivam MishraandGitHub 1539a8bc36 Merge branch 'develop' into feat/scenarios 2025-07-14 15:38:36 +05:30
Muhsin KelothandGitHub ffa8c0ce8a Merge branch 'develop' into feat/scenarios 2025-07-14 14:19:13 +05:30
Muhsin KelothandGitHub 4762edcba3 Merge branch 'feat/scenarios' into feat/scenario-tools-association 2025-07-12 17:23:05 +05:30
Muhsin KelothandGitHub 1bd2da4832 Merge branch 'develop' into feat/scenarios 2025-07-12 17:11:10 +05:30
Shivam MishraandGitHub b923bfecca Merge branch 'develop' into feat/scenarios 2025-07-11 17:06:39 +05:30
Shivam MishraandGitHub 46e758cc84 Merge branch 'feat/scenarios' into feat/scenario-tools-association 2025-07-11 15:52:58 +05:30
Shivam Mishra 9165cab1ec refactor: update migration 2025-07-10 20:29:23 +05:30
Shivam Mishra bda09cc064 feat: remove foeign key 2025-07-10 08:23:25 +05:30
Shivam Mishra 408cc4dd3f feat: remove foreign key 2025-07-10 08:23:25 +05:30
Shivam MishraandGitHub c6b636baee Merge branch 'develop' into feat/scenarios 2025-07-09 22:45:04 +05:30
Shivam MishraandGitHub bc24d1f02c Merge branch 'feat/scenarios' into feat/scenario-tools-association 2025-07-09 18:42:04 +05:30
Shivam Mishra 62a7cfa188 refactor: move scenarios controller
Match it with inboxes implemetation
2025-07-09 18:41:41 +05:30
Shivam Mishra c4fa5cd4d9 refactor: priority 2025-07-09 18:00:54 +05:30
Shivam Mishra d40fa27289 refactor: simplify not found state 2025-07-09 18:00:38 +05:30
Shivam Mishra c789ef555c refactor: simplify not found case 2025-07-09 18:00:11 +05:30
Shivam Mishra 0674edf81b refactor: private note tool 2025-07-09 17:55:18 +05:30
Shivam Mishra 9362d126aa feat: simplify contact note tool 2025-07-09 17:53:23 +05:30
Shivam Mishra 35797bd393 fix: eager loading to include concern 2025-07-09 17:21:10 +05:30
Shivam Mishra 68ff52c432 feat: add specs 2025-07-09 17:21:10 +05:30
Shivam Mishra 5d2afc065f feat: extract tool ids from text 2025-07-09 17:21:10 +05:30
Shivam Mishra cd8f245483 feat: add tools endpoint 2025-07-09 17:21:10 +05:30
Shivam Mishra 735979c834 feat: add tool resolution concern 2025-07-09 17:21:10 +05:30
Shivam Mishra 5cd6996d5e feat: add tools 2025-07-09 17:21:09 +05:30
Shivam Mishra ff49996950 chore: add ai agents gem 2025-07-09 17:21:09 +05:30
Shivam Mishra 14417ef14f style: run rubocop 2025-07-09 17:20:30 +05:30
Shivam Mishra 1393ad5143 chore: update factory for scenario 2025-07-09 17:20:00 +05:30
Shivam Mishra 4f262fc041 feat: add default value 2025-07-09 17:19:23 +05:30
Shivam Mishra aa639dd78e feat: add scenario controller 2025-07-09 14:24:07 +05:30
Shivam Mishra aa9c0b9f6b test: scenario model 2025-07-09 14:23:50 +05:30
Shivam Mishra 526d6e2a0a feat: add jbuilders 2025-07-09 14:22:59 +05:30
Shivam Mishra 96746cc592 feat: add scenarios model 2025-07-09 13:43:36 +05:30
91 changed files with 1513 additions and 3146 deletions
+1
View File
@@ -94,3 +94,4 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
CLAUDE.local.md
+2 -1
View File
@@ -179,7 +179,8 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.2.1'
gem 'ai-agents', '>= 0.4.3'
gem 'ruby_llm-schema'
gem 'shopify_api'
+5 -3
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.2.1)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@@ -720,7 +720,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.3.1)
ruby_llm (1.5.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -729,6 +729,7 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -910,7 +911,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.2.1)
ai-agents (>= 0.4.3)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1004,6 +1005,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm-schema
scout_apm
scss_lint
seed_dump
@@ -1,79 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -1,81 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
@@ -38,13 +38,11 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -1,5 +1,9 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -19,14 +19,14 @@ export default {
</script>
<template>
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<fluent-icon
class="ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass"
size="12"
/>
<span class="truncate">
{{ inbox.name }}
</span>
{{ inbox.name }}
</div>
</template>
@@ -1,12 +1,12 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
<script>
import { mapGetters } from 'vuex';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,224 +14,257 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
assignee() {
return this.chatMetadata.assignee || {};
},
const router = useRouter();
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactById = useMapGetter('contacts/getContact');
const inboxById = useMapGetter('inboxes/getInbox');
unreadCount() {
return this.chat.unread_count;
},
const chatMetadata = computed(() => props.chat.meta || {});
hasUnread() {
return this.unreadCount > 0;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const currentContact = computed(() => {
return contactById.value(chatMetadata.value.sender.id);
});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const unreadCount = computed(() => props.chat.unread_count);
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
conversationPath() {
const { activeInbox, chat } = this;
return frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
const hasUnread = computed(() => unreadCount.value > 0);
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
const isInboxNameVisible = computed(() => !activeInbox.value);
// Skip if already active
if (this.isActiveChat) return;
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
const stateInbox = inboxById.value(inboxId);
return stateInbox;
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -243,14 +276,13 @@ const deleteConversation = () => {
>
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
@@ -258,42 +290,30 @@ const deleteConversation = () => {
<Thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
/>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
>
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<div class="flex justify-between conversation-card--meta">
<InboxName v-if="showInboxName" :inbox="inbox" />
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
<PriorityMark :priority="chat.priority" />
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -301,27 +321,24 @@ const deleteConversation = () => {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
/>
<p
v-else
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span class="mx-0.5">
<span>
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -329,17 +346,12 @@ const deleteConversation = () => {
/>
</span>
<span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -372,3 +384,55 @@ const deleteConversation = () => {
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -263,7 +263,7 @@ export default {
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
export default {
@@ -14,6 +14,7 @@ export default {
WidgetFooter,
InputRadioGroup,
},
mixins: [globalConfigMixin],
props: {
welcomeHeading: {
type: String,
@@ -56,12 +57,6 @@ export default {
default: '',
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
widgetScreens: [
@@ -164,8 +159,9 @@ export default {
/>
<span>
{{
replaceInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
useInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT'),
globalConfig.installationName
)
}}
</span>
@@ -61,8 +61,8 @@ export default {
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact"
/>
</div>
</div>
@@ -75,4 +75,12 @@ export default {
.no-label-message {
@apply text-n-slate-11 mb-4;
}
::v-deep .conversation {
@apply pr-0;
.conversation--details {
@apply pl-2;
}
}
</style>
@@ -3,19 +3,14 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
components: {
ChannelItem,
PageHeader,
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
mixins: [globalConfigMixin],
data() {
return {
enabledFeatures: {},
@@ -74,7 +69,12 @@ export default {
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
/>
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
@@ -1,14 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -34,7 +29,10 @@ export default {
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.replaceInstallationName(item.body),
body: this.useInstallationName(
item.body,
this.globalConfig.installationName
),
}));
},
},
@@ -6,11 +6,11 @@ import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
@@ -22,12 +22,11 @@ export default {
PageHeader,
NextButton,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
return {
accountId,
replaceInstallationName,
v$: useVuelidate(),
};
},
@@ -67,6 +66,9 @@ export default {
getSelectablePages() {
return this.pageList.filter(item => !item.exists);
},
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
@@ -221,7 +223,12 @@ export default {
/>
</a>
<p class="py-6">
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
{{
useInstallationName(
$t('INBOX_MGMT.ADD.FB.HELP'),
globalConfig.installationName
)
}}
</p>
</div>
<div v-else>
@@ -242,7 +249,10 @@ export default {
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.DETAILS.TITLE')"
:header-content="
replaceInstallationName($t('INBOX_MGMT.ADD.DETAILS.DESC'))
useInstallationName(
$t('INBOX_MGMT.ADD.DETAILS.DESC'),
globalConfig.installationName
)
"
/>
</div>
@@ -1,9 +1,11 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import instagramClient from 'dashboard/api/channel/instagramClient';
export default {
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -13,6 +14,7 @@ export default {
DashboardAppsRow,
NextButton,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -1,14 +1,12 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const uiFlags = getters['integrations/getUIFlags'];
@@ -29,9 +27,7 @@ onMounted(() => {
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:description="$t('INTEGRATION_SETTINGS.DESCRIPTION')"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
feature-name="integrations"
/>
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -26,11 +26,11 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { replaceInstallationName } = useBranding();
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
if (dialogRef.value) {
@@ -82,7 +82,12 @@ const confirmDeletion = () => {
{{ integrationName }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{ replaceInstallationName(integrationDescription) }}
{{
useInstallationName(
integrationDescription,
globalConfig.installationName
)
}}
</p>
</div>
</div>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useBranding } from 'shared/composables/useBranding';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -28,9 +28,9 @@ const props = defineProps({
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const globalConfig = getters['globalConfig/get'];
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const integrationStatus = computed(() =>
props.enabled
@@ -80,7 +80,7 @@ const actionURL = computed(() =>
</router-link>
</div>
<p class="text-n-slate-11">
{{ replaceInstallationName(description) }}
{{ useInstallationName(description, globalConfig.installationName) }}
</p>
</div>
</div>
@@ -1,4 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -7,6 +8,8 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,7 +18,6 @@ const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const { replaceInstallationName } = useBranding();
const selectedChannelId = ref('');
const availableChannels = ref([]);
@@ -30,9 +29,16 @@ const errorDescription = computed(() => {
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(replaceInstallationName(errorDescription.value), false);
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
});
const fetchChannels = async () => {
@@ -1,7 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
@@ -18,10 +17,6 @@ export default {
EditWebhook,
WebhookRow,
},
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return {
loading: {},
@@ -104,7 +99,7 @@ export default {
<BaseSettingsHeader
v-if="integration.name"
:title="integration.name"
:description="replaceInstallationName(integration.description)"
:description="integration.description"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
@@ -1,25 +1,21 @@
<script>
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
@@ -47,7 +43,10 @@ export default {
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
"
/>
<WebhookForm
@@ -3,10 +3,10 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -37,17 +37,16 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
replaceInstallationName,
};
},
data() {
@@ -216,11 +215,7 @@ export default {
</div>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="
replaceInstallationName(
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')
)
"
:description="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')"
>
<FontSize
:value="currentFontSize"
@@ -293,7 +288,10 @@ export default {
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
"
>
<AccessToken
@@ -1,5 +1,5 @@
<script>
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
const {
LOGO_THUMBNAIL: logoThumbnail,
@@ -8,18 +8,13 @@ const {
} = window.globalConfig || {};
export default {
mixins: [globalConfigMixin],
props: {
disableBranding: {
type: Boolean,
default: false,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
globalConfig: {
@@ -66,7 +61,7 @@ export default {
:src="globalConfig.logoThumbnail"
/>
<span>
{{ replaceInstallationName($t('POWERED_BY')) }}
{{ useInstallationName($t('POWERED_BY'), globalConfig.brandName) }}
</span>
</a>
</div>
@@ -1,96 +0,0 @@
import { useBranding } from '../useBranding';
import { useMapGetter } from 'dashboard/composables/store.js';
// Mock the store composable
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
describe('useBranding', () => {
let mockGlobalConfig;
beforeEach(() => {
mockGlobalConfig = {
value: {
installationName: 'MyCompany',
},
};
useMapGetter.mockReturnValue(mockGlobalConfig);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('replaceInstallationName', () => {
it('should replace "Chatwoot" with installation name when both text and installation name are provided', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to MyCompany');
});
it('should replace multiple occurrences of "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Chatwoot is great! Use Chatwoot today.'
);
expect(result).toBe('MyCompany is great! Use MyCompany today.');
});
it('should return original text when installation name is not provided', () => {
mockGlobalConfig.value = {};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when globalConfig is not available', () => {
mockGlobalConfig.value = undefined;
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when text is empty or null', () => {
const { replaceInstallationName } = useBranding();
expect(replaceInstallationName('')).toBe('');
expect(replaceInstallationName(null)).toBe(null);
expect(replaceInstallationName(undefined)).toBe(undefined);
});
it('should handle text without "Chatwoot" gracefully', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to our platform');
expect(result).toBe('Welcome to our platform');
});
it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
});
it('should handle special characters in installation name', () => {
mockGlobalConfig.value = {
installationName: 'My-Company & Co.',
};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to My-Company & Co.');
});
});
});
@@ -1,26 +0,0 @@
/**
* Composable for branding-related utilities
* Provides methods to customize text with installation-specific branding
*/
import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" in text with the installation name from global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
const replaceInstallationName = text => {
if (!text) return text;
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/Chatwoot/g, installationName);
};
return {
replaceInstallationName,
};
}
@@ -0,0 +1,12 @@
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
export default {
methods: {
useInstallationName,
},
};
@@ -1,17 +1,18 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { required, minLength, email } from '@vuelidate/validators';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import FormInput from '../../../../components/Form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { FormInput, NextButton },
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return { v$: useVuelidate(), replaceInstallationName };
return { v$: useVuelidate() };
},
data() {
return {
@@ -23,6 +24,9 @@ export default {
error: '',
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
validations() {
return {
credentials: {
@@ -78,7 +82,12 @@ export default {
<p
class="mb-4 text-sm font-normal leading-6 tracking-normal text-n-slate-11"
>
{{ replaceInstallationName($t('RESET_PASSWORD.DESCRIPTION')) }}
{{
useInstallationName(
$t('RESET_PASSWORD.DESCRIPTION'),
globalConfig.installationName
)
}}
</p>
<div class="space-y-5">
<FormInput
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useBranding } from 'shared/composables/useBranding';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -11,10 +11,7 @@ export default {
Spinner,
Testimonials,
},
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
mixins: [globalConfigMixin],
data() {
return { isLoading: false };
},
@@ -64,7 +61,12 @@ export default {
<div class="px-1 text-sm text-n-slate-12">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<router-link class="text-link text-n-brand" to="/app/login">
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
{{
useInstallationName(
$t('LOGIN.TITLE'),
globalConfig.installationName
)
}}
</router-link>
</div>
</div>
@@ -3,6 +3,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
@@ -19,6 +20,7 @@ export default {
NextButton,
VueHcaptcha,
},
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
+7 -7
View File
@@ -8,7 +8,8 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { useBranding } from 'shared/composables/useBranding';
// mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
// components
import FormInput from '../../components/Form/Input.vue';
@@ -30,6 +31,7 @@ export default {
Spinner,
NextButton,
},
mixins: [globalConfigMixin],
props: {
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
@@ -38,11 +40,7 @@ export default {
authError: { type: String, default: '' },
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
v$: useVuelidate(),
};
return { v$: useVuelidate() };
},
data() {
return {
@@ -184,7 +182,9 @@ export default {
class="hidden w-auto h-8 mx-auto dark:block"
/>
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
{{
useInstallationName($t('LOGIN.TITLE'), globalConfig.installationName)
}}
</h2>
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
{{ $t('COMMON.OR') }}
-36
View File
@@ -1,36 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
+3 -8
View File
@@ -17,7 +17,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_created(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
return if performed_by_automation?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -34,7 +34,7 @@ class AutomationRuleListener < BaseListener
end
def conversation_opened(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
return if performed_by_automation?(event)
conversation = event.data[:conversation]
account = conversation.account
@@ -87,13 +87,8 @@ class AutomationRuleListener < BaseListener
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
end
def ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
conversation.additional_attributes['auto_reply'].present?
end
def ignore_message_created_event?(event)
message = event.data[:message]
performed_by_automation?(event) || message.activity? || message.auto_reply_email?
performed_by_automation?(event) || message.activity?
end
end
-1
View File
@@ -84,7 +84,6 @@ class Imap::ImapMailbox
additional_attributes: {
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
@@ -8,6 +8,13 @@ module IncomingEmailValidityHelper
# This can happen in cases like bounce emails for invalid contact email address
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
# Process bounced emails, as regular emails
return true if @processed_mail.bounced?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
true
end
@@ -17,4 +24,13 @@ module IncomingEmailValidityHelper
true
end
def auto_reply_email?
if @processed_mail.auto_reply?
Rails.logger.info "is_auto_reply? : #{processed_mail.auto_reply?}"
true
else
false
end
end
end
-1
View File
@@ -70,7 +70,6 @@ class SupportMailbox < ApplicationMailbox
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
-103
View File
@@ -1,103 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(10), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_enabled (enabled)
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
#
class AssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Enums
enum assignment_order: { round_robin: 0, balanced: 1 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
# Validate balanced assignment is only available for enterprise
validate :validate_balanced_assignment_enterprise_only
# Server-side validation to prevent bypass
before_save :enforce_enterprise_features
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
# Callbacks
after_update_commit :clear_assignment_caches
after_destroy :clear_assignment_caches
def can_use_balanced_assignment?
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
end
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
private
def validate_balanced_assignment_enterprise_only
return unless balanced?
return if can_use_balanced_assignment?
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
end
def enforce_enterprise_features
# Server-side enforcement to prevent API bypass
return unless balanced? && !can_use_balanced_assignment?
# Force to round_robin if enterprise not available
self.assignment_order = 'round_robin'
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
end
def clear_assignment_caches
# Clear Redis caches when policy is updated
Rails.cache.delete("assignment_v2:policy:#{id}")
inboxes.find_each do |inbox|
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
end
end
end
@@ -1,37 +0,0 @@
# frozen_string_literal: true
module AssignmentV2FeatureFlag
extend ActiveSupport::Concern
def assignment_v2_enabled?
# Check account-level feature flag
return false unless feature_enabled_for_account?
# Check for any inbox-level overrides
return false if inbox_level_override_disabled?
# Check system-wide killswitch
!GlobalConfig.get('assignment_v2_disabled', false)
end
private
def feature_enabled_for_account?
config = GlobalConfig.get('assignment_v2')
return false unless config&.dig('enabled')
# If no account allowlist, enable for all
allowed_accounts = config['accounts']
return true if allowed_accounts.blank?
# Check if account is in allowlist
allowed_accounts.include?(id)
end
def inbox_level_override_disabled?
return false unless respond_to?(:id)
# Allow per-inbox disabling during migration
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
end
end
@@ -14,18 +14,11 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
if inbox.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
else
# Use legacy assignment system
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
def should_run_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
return false unless inbox.enable_auto_assignment?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -1,128 +0,0 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
available_agents(check_capacity: true).pluck(:user_id)
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
def filter_agents_on_leave(inbox_members_scope)
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
# Filter out agents who are currently on leave
on_leave_user_ids = Enterprise::AgentLeave
.active
.where(account_id: account_id)
.pluck(:user_id)
return inbox_members_scope if on_leave_user_ids.empty?
inbox_members_scope.where.not(user_id: on_leave_user_ids)
end
end
@@ -1,54 +0,0 @@
# frozen_string_literal: true
module InboxChannelTypes
extend ActiveSupport::Concern
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def inbox_type
channel.name
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
end
end
@@ -1,68 +0,0 @@
# frozen_string_literal: true
module InboxNameSanitization
extend ActiveSupport::Concern
included do
before_validation :sanitize_name, unless: :new_record?
before_save :ensure_name_present
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return handle_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
return sanitized if sanitized.present?
email? ? (display_name_from_email || '') : ''
end
private
def handle_blank_name
email? ? (display_name_from_email || '') : ''
end
def sanitize_name
self.name = apply_sanitization_rules(name) if name.present?
end
def ensure_name_present
self.name = default_name_for_blank_name if name.blank?
self.name = apply_sanitization_rules(name) if name.present?
end
def default_name_for_blank_name
return channel.try(:bot_name) if web_widget?
readable_name = display_name_from_email if email?
readable_name ||= 'Inbox'
"#{readable_name} #{SecureRandom.hex(4)}"
end
def apply_sanitization_rules(name)
# Remove forbidden characters and spam-trigger symbols
# Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis)
sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '')
# Normalize whitespace
sanitized = sanitized.gsub(/\s+/, ' ')
# Remove leading and trailing non-word characters (but keep Unicode including emojis)
# Use negative lookahead to exclude emoji ranges
sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '')
sanitized.strip
end
def display_name_from_email
email_address = channel.try(:imap_email) || channel.try(:email)
return nil unless email_address
local_part = email_address.split('@').first
return nil unless local_part
# Convert underscores and hyphens to spaces and capitalize each word
local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ')
end
end
-25
View File
@@ -1,25 +0,0 @@
# frozen_string_literal: true
module InboxWebhooks
extend ActiveSupport::Concern
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
end
end
end
+86 -153
View File
@@ -44,11 +44,6 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include AssignmentV2FeatureFlag
include InboxAgentAvailability
include InboxChannelTypes
include InboxWebhooks
include InboxNameSanitization
# Not allowing characters:
validates :name, presence: true
@@ -77,10 +72,6 @@ class Inbox < ApplicationRecord
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -106,167 +97,109 @@ class Inbox < ApplicationRecord
update_account_cache
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
# Assignment V2 methods
def assignment_v2_enabled?
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
end
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
def inbox_type
channel.name
end
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
end
end
# Returns inbox members who are available for assignment
# This method performs all filtering upfront at the database level for optimal performance
#
# Filters applied:
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
#
# @param options [Hash] Additional filter options
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
#
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
#
# @example Get all available agents
# inbox.available_agents
#
# @example Get available agents excluding specific users
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
#
# @example Get available agents without capacity check (faster but less accurate)
# inbox.available_agents(check_capacity: false)
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
def member_ids_with_assignment_capacity
members.ids
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
def default_name_for_blank_name
email? ? display_name_from_email : ''
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
# Get IDs of inbox members within rate limits
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
inbox_member.id if rate_limiter.within_limits?
end
# Return filtered scope maintaining ActiveRecord relation
inbox_members_scope.where(id: valid_inbox_member_ids)
end
def filter_agents_on_leave(inbox_members_scope)
# Get account users on active leave
account_user_ids_on_leave = account.account_users
.joins(:leaves)
.where(leaves: { status: 'approved' })
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
.pluck(:id)
return inbox_members_scope if account_user_ids_on_leave.empty?
# Exclude inbox members whose account_users are on leave
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
inbox_members_scope.where.not(user_id: user_ids_on_leave)
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
end
def dispatch_create_event
-67
View File
@@ -1,67 +0,0 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
# unique_inbox_assignment_policy (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
# Callbacks
after_commit :clear_inbox_cache
# Scopes
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
def clear_inbox_cache
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
update_account_cache
end
end
-6
View File
@@ -195,12 +195,6 @@ class Message < ApplicationRecord
true
end
def auto_reply_email?
return false unless incoming_email? || inbox.email?
content_attributes.dig(:email, :auto_reply) == true
end
def valid_first_reply?
return false unless human_response? && !private?
return false if conversation.first_reply_created_at.present?
-23
View File
@@ -1,23 +0,0 @@
# frozen_string_literal: true
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
end
def show?
@account_user.administrator?
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end
+1 -2
View File
@@ -103,8 +103,7 @@ class MailPresenter < SimpleDelegator
references: references,
subject: subject,
text_content: text_content,
to: to,
auto_reply: auto_reply?
to: to
}
end
@@ -1,112 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless can_assign?(conversation)
agent = find_agent_for_conversation(conversation)
return false unless agent
assign_conversation_to_agent(conversation, agent)
end
def perform_bulk_assignment(limit: 50)
return 0 unless assignment_enabled?
conversations = unassigned_conversations(limit)
assigned_count = 0
conversations.find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def policy
@policy ||= inbox.assignment_policy
end
def assignment_enabled?
policy&.enabled?
end
def can_assign?(conversation)
assignment_enabled? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(check_rate_limits: true)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
def selector_service
@selector_service ||= if policy.assignment_order == 'balanced' && enterprise_enabled? && policy.can_use_balanced_assignment?
::Enterprise::AssignmentV2::BalancedSelector.new(inbox: inbox)
else
AssignmentV2::RoundRobinSelector.new(inbox: inbox)
end
end
def unassigned_conversations(limit)
scope = inbox.conversations
.unassigned
.open
# Apply conversation priority ordering
scope = case policy.conversation_priority
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
scope.limit(limit)
end
def assign_conversation_to_agent(conversation, agent)
conversation.update!(assignee: agent)
create_assignment_activity(conversation, agent)
record_assignment_in_rate_limiter(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
false
end
def create_assignment_activity(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def enterprise_enabled?
@enterprise_enabled ||= defined?(Enterprise)
end
def log_no_agents_available
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
end
def record_assignment_in_rate_limiter(conversation, agent)
rate_limiter = AssignmentV2::RateLimiter.new(inbox: inbox, user: agent)
rate_limiter.record_assignment(conversation)
rescue StandardError => e
Rails.logger.error "AssignmentV2: Failed to record assignment in rate limiter: #{e.message}"
end
end
@@ -1,85 +0,0 @@
# frozen_string_literal: true
# Rate limiter for assignment operations
# Uses Redis to track assignment counts per agent per time window
# based on assignment policy's fair_distribution_limit and fair_distribution_window
class AssignmentV2::RateLimiter
pattr_initialize [:inbox!, :user!]
# Check if the user has exceeded rate limits
# @return [Boolean] true if within limits, false if exceeded
def within_limits?
return true unless policy_exists?
current_count < rate_limit
end
# Record an assignment for rate limiting purposes
# @param conversation [Conversation] The conversation being assigned
def record_assignment(_conversation)
return unless policy_exists?
key = rate_limit_key
redis = Redis.new(Redis::Config.app)
redis.multi do |multi|
multi.incr(key)
multi.expire(key, time_window)
end
end
# Get current rate limit status for the user
# @return [Hash] Rate limit status information
def status
if policy_exists?
{
within_limits: within_limits?,
current_count: current_count,
limit: rate_limit,
reset_at: Time.zone.at(next_window_start)
}
else
{
within_limits: true,
current_count: 0,
limit: Float::INFINITY,
reset_at: nil
}
end
end
private
def policy
@policy ||= inbox.assignment_policy
end
def policy_exists?
policy.present? && policy.enabled?
end
def current_count
key = rate_limit_key
redis = Redis.new(Redis::Config.app)
redis.get(key).to_i
end
def rate_limit
policy&.fair_distribution_limit || 10
end
def time_window
policy&.fair_distribution_window || 3600
end
def rate_limit_key
"assignment_v2:rate_limit:#{user.id}:#{current_window}"
end
def current_window
(Time.current.to_i / time_window) * time_window
end
def next_window_start
current_window + time_window
end
end
@@ -1,37 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Extract user IDs from inbox members
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
# Use Redis queue for round robin
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
return nil unless selected_user_id
# Return the user object
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
end
def add_agent_to_queue(user_id)
round_robin_service.add_agent_to_queue(user_id)
end
def remove_agent_from_queue(user_id)
round_robin_service.remove_agent_from_queue(user_id)
end
def reset_queue
round_robin_service.reset_queue
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
@@ -4,7 +4,6 @@ class MessageTemplates::HookExecutionService
def perform
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
trigger_templates
end
@@ -88,16 +88,12 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: contact_name,
name: formatted_phone_number,
phone_number: phone_number,
additional_attributes: additional_attributes
}
end
def contact_name
params[:ProfileName].presence || formatted_phone_number
end
def additional_attributes
if twilio_channel.sms?
{
+15
View File
@@ -24,3 +24,18 @@
title: 'Add Label to Conversation'
description: 'Add a label to a conversation'
icon: 'tag'
- id: faq_lookup
title: 'FAQ Lookup'
description: 'Search FAQ responses using semantic similarity'
icon: 'search'
- id: handoff
title: 'Handoff to Human'
description: 'Hand off the conversation to a human agent'
icon: 'user-switch'
- id: better_stack_status
title: 'Better Stack Status'
description: 'Check the status of Better Stack services'
icon: 'server'
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
if api_key.present?
Agents.configure do |config|
config.openai_api_key = api_key
config.default_model = model
config.debug = false
end
end
rescue StandardError => e
Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}"
end
@@ -26,9 +26,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
@response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
end
return process_action('handoff') if handoff_requested?
@@ -104,4 +110,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def log_error(error)
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
def captain_v2_enabled?
return account.feature_enabled?('captain_integration_v2')
end
end
@@ -19,6 +19,7 @@
class Captain::Assistant < ApplicationRecord
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
self.table_name = 'captain_assistants'
@@ -35,6 +36,8 @@ class Captain::Assistant < ApplicationRecord
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
validates :name, presence: true
validates :description, presence: true
validates :account_id, presence: true
@@ -71,6 +74,33 @@ class Captain::Assistant < ApplicationRecord
private
def agent_name
name
end
def agent_tools
[
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self)
]
end
def prompt_context
{
name: name,
description: description,
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
key: scenario.title.parameterize.underscore,
description: scenario.description
}
end,
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
end
def default_avatar_url
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
end
+34
View File
@@ -22,6 +22,7 @@
#
class Captain::Scenario < ApplicationRecord
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
self.table_name = 'captain_scenarios'
@@ -37,10 +38,43 @@ class Captain::Scenario < ApplicationRecord
scope :enabled, -> { where(enabled: true) }
delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
before_save :resolve_tool_references
def prompt_context
{
title: title,
instructions: resolved_instructions,
tools: resolved_tools
}
end
private
def agent_name
"#{title} Agent".titleize
end
def agent_tools
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
end
def resolved_instructions
instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
"#{match} tool "
end
end
def resolved_tools
return [] if tools.blank?
available_tools = self.class.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
@@ -0,0 +1,56 @@
module Concerns::Agentable
extend ActiveSupport::Concern
def agent
Agents::Agent.new(
name: agent_name,
instructions: ->(context) { agent_instructions(context) },
tools: agent_tools,
model: agent_model,
temperature: temperature || 0.7,
response_schema: agent_response_schema
)
end
def agent_instructions(context = nil)
enhanced_context = prompt_context
if context
state = context.context[:state] || {}
conversation_data = state[:conversation] || {}
contact_data = state[:contact] || {}
enhanced_context = enhanced_context.merge(
conversation: conversation_data,
contact: contact_data
)
end
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
end
private
def agent_name
raise NotImplementedError, "#{self.class} must implement agent_name"
end
def template_name
self.class.name.demodulize.underscore
end
def agent_tools
[] # Default implementation, override if needed
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
end
def agent_response_schema
Captain::ResponseSchema
end
def prompt_context
raise NotImplementedError, "#{self.class} must implement prompt_context"
end
end
@@ -0,0 +1,157 @@
require 'agents'
class Captain::Assistant::AgentRunnerService
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
].freeze
CONTACT_STATE_ATTRIBUTES = %i[
id name email phone_number identifier contact_type
custom_attributes additional_attributes
].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
@assistant = assistant
@conversation = conversation
@callbacks = callbacks
end
def generate_response(message_history: [])
agents = build_and_wire_agents
context = build_context(message_history)
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
result = runner.run(message_to_process, context: context)
process_agent_result(result)
rescue StandardError => e
# when running the agent runner service in a rake task, the conversation might not have an account associated
# for regular production usage, it will run just fine
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
error_response(e.message)
end
private
def build_context(message_history)
conversation_history = message_history.map do |msg|
content = extract_text_from_content(msg[:content])
{
role: msg[:role].to_sym,
content: content,
agent_name: msg[:agent_name]
}
end
{
conversation_history: conversation_history,
state: build_state
}
end
def extract_last_user_message(message_history)
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
extract_text_from_content(last_user_msg[:content])
end
def extract_text_from_content(content)
# Handle structured output from agents
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
return content unless content.is_a?(Array)
text_parts = content.select { |part| part[:type] == 'text' }.pluck(:text)
text_parts.join(' ')
end
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
format_response(result.output)
end
def format_response(output)
return output.with_indifferent_access if output.is_a?(Hash)
# Fallback for backwards compatibility
{
'response' => output.to_s,
'reasoning' => 'Processed by agent'
}
end
def error_response(error_message)
{
'response' => 'conversation_handoff',
'reasoning' => "Error occurred: #{error_message}"
}
end
def build_state
state = {
account_id: @assistant.account_id,
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
if @conversation
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
end
state
end
def build_and_wire_agents
assistant_agent = @assistant.agent
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) }
[assistant_agent] + scenario_agents
end
def add_callbacks_to_runner(runner)
if @callbacks[:on_agent_thinking]
runner = runner.on_agent_thinking do |*args|
@callbacks[:on_agent_thinking].call(*args)
rescue StandardError => e
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
end
end
if @callbacks[:on_tool_start]
runner = runner.on_tool_start do |*args|
@callbacks[:on_tool_start].call(*args)
rescue StandardError => e
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
end
end
if @callbacks[:on_tool_complete]
runner = runner.on_tool_complete do |*args|
@callbacks[:on_tool_complete].call(*args)
rescue StandardError => e
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
end
end
if @callbacks[:on_agent_handoff]
runner = runner.on_agent_handoff do |*args|
@callbacks[:on_agent_handoff].call(*args)
rescue StandardError => e
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
end
end
runner
end
end
+25
View File
@@ -0,0 +1,25 @@
require 'liquid'
class Captain::PromptRenderer
class << self
def render(template_name, context = {})
template = load_template(template_name)
liquid_template = Liquid::Template.parse(template)
liquid_template.render(stringify_keys(context))
end
private
def load_template(template_name)
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
raise "Template not found: #{template_name}" unless File.exist?(template_path)
File.read(template_path)
end
def stringify_keys(hash)
hash.deep_stringify_keys
end
end
end
@@ -0,0 +1,80 @@
# System Context
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
# Your Identity
You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need.
{{ description }}
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this.
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
{% if conversation -%}
{% render 'conversation' %}
{% endif -%}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%}
- {{ guideline }}
{% endfor %}
{% endif -%}
{% if guardrails.size > 0 -%}
# Guardrails
Always respect these boundaries:
{% for guardrail in guardrails -%}
- {{ guardrail }}
{% endfor %}
{% endif -%}
# Decision Framework
## 1. Analyze the Request
First, understand what the user is asking:
- **Intent**: What are they trying to achieve?
- **Type**: Is it a question, task, complaint, or request?
- **Complexity**: Can you handle it or does it need specialized expertise?
## 2. Check for Specialized Scenarios
If the request matches any of these scenarios, hand off to the appropriate specialist:
{% for scenario in scenarios -%}
### handoff_to_{{ scenario.key }}
{{ scenario.description }}
{% endfor -%}
## 3. Handle the Request
If no specialized scenario matches, handle it yourself:
### For Questions and Information Requests
1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information
2. **If not found in FAQs**: Provide your best answer based on available context
3. **If unable to answer**: Use `handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
2. **Break down complex tasks**: Handle step by step or hand off if too complex
3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities
## Response Best Practices
- Be conversational but professional
- Provide actionable information
- Include relevant details from tool responses
# Human Handoff Protocol
Transfer to a human agent when:
- User explicitly requests human assistance
- You cannot find needed information after checking FAQs
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
When using the `handoff` tool, provide a clear reason that helps the human agent understand the context.
@@ -0,0 +1,24 @@
# System context
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task.
The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
# Your Role
You are a specialized agent called {{ title }}, your task is to handle the following scenario:
{{ instructions }}
{% if conversation -%}
{% render 'conversation' %}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools:
{% for tool in tools -%}
- {{ tool.id }}: {{ tool.description }}
{% endfor %}
{%- endif %}
@@ -0,0 +1,17 @@
# Contact Information
- Contact ID: {{ contact.id }}
- Name: {{ contact.name || "Unknown" }}
- Email: {{ contact.email || "None" }}
- Phone: {{ contact.phone_number || "None" }}
- Identifier: {{ contact.identifier || "None" }}
- Type: {{ contact.contact_type || "visitor" }}
{% if contact.custom_attributes -%}
{% for attribute in contact.custom_attributes -%}
- {{ attribute[0] }}: {{ attribute[1] }}
{% endfor -%}
{% endif -%}
{% if contact.additional_attributes -%}
{% for attribute in contact.additional_attributes -%}
- {{ attribute[0] }}: {{ attribute[1] }}
{% endfor -%}
{% endif -%}
@@ -0,0 +1,18 @@
# Current Conversation Context
- Conversation ID: {{ conversation.display_id }}
- Contact ID: {{ conversation.contact_id }}
- Status: {{ conversation.status }}
- Priority: {{ conversation.priority || "None" }}
{% if conversation.label_list.size > 0 -%}
- Labels: {{ conversation.label_list | join: ", " }}
{% endif -%}
{% if conversation.custom_attributes -%}
{% for attribute in conversation.custom_attributes -%}
- {{ attribute[0] }}: {{ attribute[1] }}
{% endfor -%}
{% endif -%}
{% if conversation.additional_attributes -%}
{% for attribute in conversation.additional_attributes -%}
- {{ attribute[0] }}: {{ attribute[1] }}
{% endfor -%}
{% endif -%}
@@ -0,0 +1,4 @@
class Captain::ResponseSchema < RubyLLM::Schema
string :response, description: 'The message to send to the user'
string :reasoning, description: "Agent's thought process"
end
@@ -0,0 +1,138 @@
class Captain::Tools::BetterStackStatusTool < Captain::Tools::BasePublicTool
description 'Check service status using Better Stack'
param :service, type: 'string', desc: 'The service name to check status for (optional)'
def perform(_tool_context, service: nil)
log_tool_usage('checking_status', { service: service })
begin
status_data = fetch_better_stack_status(service)
incidents_data = fetch_better_stack_incidents(service)
format_status_response(status_data, incidents_data, service)
rescue StandardError => e
log_tool_usage('error', { service: service, error: e.message })
"Error checking Better Stack status: #{e.message}"
end
end
private
def fetch_better_stack_status(service)
# API token should be configured in environment variables or secrets
api_token = ENV.fetch('BETTER_STACK_TOKEN')
raise 'Better Stack API token not configured' if api_token.blank?
# Base URL for Better Stack Status API
base_url = 'https://uptime.betterstack.com/api/v2'
# Endpoint depends on whether we're checking a specific service or overall status
endpoint = if service.present?
"/monitors?search=#{CGI.escape(service)}"
else
'/monitors'
end
make_api_request(base_url, endpoint, api_token)
end
def fetch_better_stack_incidents(service)
api_token = ENV.fetch('BETTER_STACK_TOKEN')
raise 'Better Stack API token not configured' if api_token.blank?
base_url = 'https://uptime.betterstack.com/api/v2'
# Endpoint for incidents, filter by service if provided
endpoint = if service.present?
"/incidents?monitor_name=#{CGI.escape(service)}"
else
'/incidents'
end
make_api_request(base_url, endpoint, api_token)
end
def make_api_request(base_url, endpoint, api_token)
uri = URI("#{base_url}#{endpoint}")
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{api_token}"
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.request(request)
raise "API request failed with status #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
def format_status_response(status_data, incidents_data, service)
monitors = status_data['data']
incidents = incidents_data['data']
if monitors.empty?
return service.present? ? "No services found matching '#{service}'" : 'No services configured in Better Stack'
end
result = if service.present?
"Status for services matching '#{service}':\n\n"
else
"Current Better Stack Status:\n\n"
end
result += format_monitors(monitors)
# Add incidents information if any exist
if incidents.present?
result += "\n\n🚨 Active Incidents (#{incidents.size}):\n\n"
result += format_incidents(incidents)
end
result
end
def format_monitors(monitors)
monitors.map do |monitor|
attributes = monitor['attributes']
status = attributes['status']
status_emoji = case status
when 'up'
'✅'
when 'down'
'❌'
else
'⚠️'
end
last_check = begin
Time.zone.parse(attributes['last_check_at'])
rescue StandardError
'Unknown'
end
last_check_formatted = last_check.is_a?(Time) ? last_check.strftime('%Y-%m-%d %H:%M:%S') : last_check
"#{status_emoji} #{attributes['url']}: #{status.upcase} (Last checked: #{last_check_formatted})"
end.join("\n")
end
def format_incidents(incidents)
incidents.map do |incident|
attributes = incident['attributes']
# Format start time
started_at = begin
Time.zone.parse(attributes['started_at'])
rescue StandardError
'Unknown'
end
started_at_formatted = started_at.is_a?(Time) ? started_at.strftime('%Y-%m-%d %H:%M:%S') : started_at
# Get monitor name and incident status
status = attributes['status'] || 'unknown'
# Format incident details
"🔴 [#{status.upcase}] #{attributes['name']} - #{attributes['title']}\n " \
"Started: #{started_at_formatted}\n " \
"#{attributes['url'] ? "Details: #{attributes['url']}" : ''}"
end.join("\n\n")
end
end
@@ -0,0 +1,39 @@
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
description 'Search FAQ responses using semantic similarity to find relevant answers'
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
def perform(_tool_context, query:)
log_tool_usage('searching', { query: query })
# Use existing vector search on approved responses
responses = @assistant.responses.approved.search(query).to_a
if responses.empty?
log_tool_usage('no_results', { query: query })
"No relevant FAQs found for: #{query}"
else
log_tool_usage('found_results', { query: query, count: responses.size })
format_responses(responses)
end
end
private
def format_responses(responses)
responses.map { |response| format_response(response) }.join
end
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
end
@@ -0,0 +1,52 @@
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
description 'Hand off the conversation to a human agent when unable to assist further'
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
def perform(tool_context, reason: nil)
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
# Log the handoff with reason
log_tool_usage('manual_handoff', {
conversation_id: conversation.id,
reason: reason || 'Agent requested handoff'
})
# Use existing handoff mechanism from ResponseBuilderJob
trigger_handoff(conversation, reason)
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
'Failed to handoff conversation'
end
private
def trigger_handoff(conversation, reason)
# post the reason as a private note
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: @assistant,
account: conversation.account,
inbox: conversation.inbox,
content: reason
)
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
end
# TODO: Future enhancement - Add team assignment capability
# This tool could be enhanced to:
# 1. Accept team_id parameter for routing to specific teams
# 2. Set conversation priority based on handoff reason
# 3. Add metadata for intelligent agent assignment
# 4. Support escalation levels (L1 -> L2 -> L3)
#
# Example future signature:
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
end
@@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool
end
def normalize_priority(priority)
priority == 'nil' || priority.blank? ? nil : priority
return nil if priority == 'nil' || priority.blank?
priority.downcase
end
def valid_priority?(priority)
+18 -7
View File
@@ -1,9 +1,3 @@
###############
# One library to capture_exception and send to the specific service.
# # e as exception, u for user and a for account (user and account are optional)
# Usage: ChatwootExceptionTracker(e, user: u, account: a).capture_exception
############
class ChatwootExceptionTracker
def initialize(exception, user: nil, account: nil)
@exception = exception
@@ -11,13 +5,30 @@ class ChatwootExceptionTracker
@account = account
end
def capture_exception
def capture_exception(additional_info = {})
write_exception_to_file(additional_info) if Rails.env.development?
capture_exception_with_sentry if ENV['SENTRY_DSN'].present?
Rails.logger.error @exception
end
private
def write_exception_to_file(additional_info = {})
error_dir = Rails.root.join('errors')
FileUtils.mkdir_p(error_dir)
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
filename = "#{timestamp}-#{SecureRandom.hex(4)}.log"
filepath = error_dir.join(filename)
File.open(filepath, 'w') do |file|
file.puts "Exception: #{@exception.class} - #{@exception.message}"
file.puts "User: #{@user&.id} (#{@user&.email})" if @user
file.puts "Account: #{@account&.id} (#{@account&.name})" if @account
file.puts "Backtrace:\n#{@exception.backtrace&.join("\n")}"
file.puts "Additional Info: #{JSON.pretty_generate(additional_info)}" if additional_info.present?
end
end
def capture_exception_with_sentry
Sentry.with_scope do |scope|
if @account.present?
-65
View File
@@ -1,65 +0,0 @@
# frozen_string_literal: true
# rubocop:disable Metrics/BlockLength
namespace :assignment_v2 do
desc 'Enable Assignment V2 for an account with default policy'
task :enable, [:account_id] => :environment do |_task, args|
account = Account.find(args[:account_id])
# Create default assignment policy if not exists
policy = account.assignment_policies.find_or_create_by!(name: 'Default Policy') do |p|
p.description = 'Default round-robin assignment policy'
p.assignment_order = 'round_robin'
p.conversation_priority = 'earliest_created'
p.enabled = true
end
puts "Assignment V2 enabled for account #{account.name} with policy #{policy.name}"
end
desc 'Disable Assignment V2 for an account'
task :disable, [:account_id] => :environment do |_task, args|
account = Account.find(args[:account_id])
# Disable all assignment policies
account.assignment_policies.find_each do |policy|
policy.update!(enabled: false)
end
puts "Assignment V2 disabled for account #{account.name}"
end
desc 'Run assignment for all enabled inboxes'
task run_all: :environment do
count = 0
Inbox.joins(:assignment_policy)
.where(assignment_policies: { enabled: true })
.find_each do |inbox|
AssignmentV2::AssignmentJob.perform_later(inbox_id: inbox.id)
count += 1
end
puts "Queued assignment jobs for #{count} inboxes"
end
desc 'Show Assignment V2 status'
task status: :environment do
puts 'Assignment V2 Status'
puts '==================='
total_policies = AssignmentPolicy.count
enabled_policies = AssignmentPolicy.enabled.count
puts "Total Assignment Policies: #{total_policies}"
puts "Enabled Policies: #{enabled_policies}"
puts
inboxes_with_v2 = Inbox.joins(:assignment_policy).count
total_inboxes = Inbox.count
puts "Total Inboxes: #{total_inboxes}"
puts "Inboxes with Assignment V2: #{inboxes_with_v2}"
end
end
# rubocop:enable Metrics/BlockLength
+198
View File
@@ -0,0 +1,198 @@
require 'io/console'
require 'readline'
namespace :captain do
desc 'Start interactive chat with Captain assistant - Usage: rake captain:chat[assistant_id] or rake captain:chat -- assistant_id'
task :chat, [:assistant_id] => :environment do |_, args|
assistant_id = args[:assistant_id] || ARGV[1]
unless assistant_id
puts '❌ Please provide an assistant ID'
puts 'Usage: rake captain:chat[assistant_id]'
puts "\nAvailable assistants:"
Captain::Assistant.includes(:account).each do |assistant|
puts " ID: #{assistant.id} - #{assistant.name} (Account: #{assistant.account.name})"
end
exit 1
end
assistant = Captain::Assistant.find_by(id: assistant_id)
unless assistant
puts "❌ Assistant with ID #{assistant_id} not found"
exit 1
end
# Clear ARGV to prevent gets from reading files
ARGV.clear
chat_session = CaptainChatSession.new(assistant)
chat_session.start
end
end
class CaptainChatSession
def initialize(assistant)
@assistant = assistant
@message_history = []
end
def start
show_assistant_info
puts "💡 Type 'exit', 'quit', or 'bye' to end the session"
puts "💡 Type 'clear' to clear message history"
puts "#{'-' * 50}"
loop do
puts '' # Add spacing before prompt
user_input = Readline.readline('👤 You: ', true)
next unless user_input # Handle Ctrl+D
user_input = user_input.strip
case user_input.downcase
when 'exit', 'quit', 'bye'
break
when 'clear'
clear_history
next
when ''
next
end
process_user_message(user_input)
end
puts "\nChat session ended"
puts "Final conversation log has #{@message_history.length} messages"
end
private
def show_assistant_info
puts "🤖 Starting chat with #{@assistant.name}"
puts "🏢 Account: #{@assistant.account.name}"
puts "🆔 Assistant ID: #{@assistant.id}"
# Show scenarios
scenarios = @assistant.scenarios.enabled
if scenarios.any?
puts "⚡ Enabled Scenarios (#{scenarios.count}):"
scenarios.each do |scenario|
tools_count = scenario.tools&.length || 0
puts "#{scenario.title} (#{tools_count} tools)"
if scenario.description.present?
description = scenario.description.length > 60 ? "#{scenario.description[0..60]}..." : scenario.description
puts " #{description}"
end
end
else
puts '⚡ No scenarios enabled'
end
# Show available tools
available_tools = Captain::Assistant.available_tool_ids
if available_tools.any?
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
else
puts '🔧 No tools available'
end
puts ''
end
def process_user_message(user_input)
# Add user message to history
add_to_history('user', user_input)
begin
print "🤖 #{@assistant.name}: "
# Track system messages to group them
@current_system_messages = []
# Define callbacks for detailed visibility
callbacks = {
on_agent_thinking: proc { |agent, input|
agent_name = extract_name(agent)
@current_system_messages << "#{agent_name} is thinking..."
add_to_history('system', "#{agent_name} is thinking...")
},
on_tool_start: proc { |tool, args|
tool_name = extract_tool_name(tool)
@current_system_messages << "Using tool: #{tool_name}"
add_to_history('system', "Using tool: #{tool_name}")
},
on_tool_complete: proc { |tool, result|
tool_name = extract_tool_name(tool)
@current_system_messages << "Tool #{tool_name} completed"
add_to_history('system', "Tool #{tool_name} completed")
},
on_agent_handoff: proc { |from, to, reason|
@current_system_messages << "Handoff: #{extract_name(from)}#{extract_name(to)} (#{reason})"
add_to_history('system', "Agent handoff: #{extract_name(from)}#{extract_name(to)} (#{reason})")
}
}
# Generate response using AgentRunnerService with callbacks
runner = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, callbacks: callbacks)
result = runner.generate_response(message_history: @message_history)
response_text = result['response'] || 'No response generated'
reasoning = result['reasoning']
# Print grouped system messages with dimmer styling
puts dim_text("\n" + @current_system_messages.join("\n")) if @current_system_messages.any?
# Print the response
puts response_text
puts dim_italic_text("(Reasoning: #{reasoning})") if reasoning && reasoning != 'Processed by agent'
# Add assistant response to history
add_to_history('assistant', response_text, reasoning: reasoning)
rescue StandardError => e
error_msg = "Error: #{e.message}"
puts "#{error_msg}"
add_to_history('system', error_msg)
end
end
def add_to_history(role, content, agent_name: nil, reasoning: nil)
message = {
role: role,
content: content,
timestamp: Time.current,
agent_name: agent_name || (role == 'assistant' ? @assistant.name : nil)
}
message[:reasoning] = reasoning if reasoning
@message_history << message
end
def clear_history
@message_history.clear
puts 'Message history cleared'
end
def dim_text(text)
# ANSI escape code for very dim gray text (bright black/dark gray)
"\e[90m#{text}\e[0m"
end
def dim_italic_text(text)
# ANSI escape codes for dim gray + italic text
"\e[90m\e[3m#{text}\e[0m"
end
def extract_tool_name(tool)
return tool if tool.is_a?(String)
tool.class.name.split('::').last.gsub('Tool', '')
rescue StandardError
tool.to_s
end
def extract_name(obj)
obj.respond_to?(:name) ? obj.name : obj.to_s
end
end
-34
View File
@@ -1,34 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :assignment_policy do
account
sequence(:name) { |n| "Assignment Policy #{n}" }
description { 'Test assignment policy' }
assignment_order { :round_robin }
conversation_priority { :earliest_created }
fair_distribution_limit { 10 }
fair_distribution_window { 3600 }
enabled { true }
trait :balanced do
assignment_order { :balanced }
end
trait :disabled do
enabled { false }
end
trait :longest_waiting do
conversation_priority { :longest_waiting }
end
trait :with_high_limit do
fair_distribution_limit { 50 }
end
trait :with_short_window do
fair_distribution_window { 300 } # 5 minutes
end
end
end
@@ -1,13 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_assignment_policy do
inbox
assignment_policy
# Ensure inbox and policy belong to same account
after(:build) do |inbox_policy|
inbox_policy.assignment_policy.account = inbox_policy.inbox.account if inbox_policy.inbox && inbox_policy.assignment_policy
end
end
end
@@ -1,194 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentJob, type: :job do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe '#perform' do
context 'with conversation_id' do
it 'assigns a single conversation' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
described_class.new.perform(conversation_id: conversation.id)
end
it 'handles non-existent conversation gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(conversation_id: 999_999)
end.not_to raise_error
end
end
context 'with inbox_id' do
let!(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create_list(:conversation, 3, inbox: inbox, assignee: nil)
create(:inbox_member, inbox: inbox, user: agent)
end
it 'assigns multiple conversations for inbox' do
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).and_return(3)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the number of assigned conversations' do
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(2)
expect(Rails.logger).to receive(:info).with("AssignmentV2::AssignmentJob: Assigned 2 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when inbox has no policy' do
inbox_assignment_policy.destroy!
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'skips assignment when policy is disabled' do
assignment_policy.update!(enabled: false)
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: inbox.id)
end
it 'handles non-existent inbox gracefully' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
# Should not raise error
expect do
described_class.new.perform(inbox_id: 999_999)
end.not_to raise_error
end
end
context 'without parameters' do
it 'logs error when no parameters provided' do
expect(Rails.logger).to receive(:error).with('AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided')
described_class.new.perform
end
it 'does not attempt assignment' do
expect(AssignmentV2::AssignmentService).not_to receive(:new)
described_class.new.perform
end
end
context 'with both parameters' do
it 'prioritizes conversation_id over inbox_id' do
service = instance_double(AssignmentV2::AssignmentService)
expect(AssignmentV2::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_for_conversation).with(conversation)
expect(service).not_to receive(:perform_bulk_assignment)
described_class.new.perform(conversation_id: conversation.id, inbox_id: inbox.id)
end
end
end
describe 'job configuration' do
it 'uses the low queue' do
expect(described_class.new.queue_name).to eq('low')
end
end
describe 'error handling' do
context 'when assignment service raises error' do
it 'propagates the error for retry' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_for_conversation).and_raise(StandardError, 'Assignment failed')
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(StandardError, 'Assignment failed')
end
end
context 'when database connection fails' do
it 'raises error for retry' do
allow(Conversation).to receive(:find_by).and_raise(ActiveRecord::ConnectionNotEstablished)
expect do
described_class.new.perform(conversation_id: conversation.id)
end.to raise_error(ActiveRecord::ConnectionNotEstablished)
end
end
end
describe 'concurrency and idempotency' do
it 'handles concurrent job execution safely' do
# Create multiple jobs for same inbox
jobs = []
3.times { jobs << described_class.new }
# All should execute without issues
expect do
jobs.each { |job| job.perform(inbox_id: inbox.id) }
end.not_to raise_error
end
it 'is idempotent for conversation assignment' do
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# First call assigns
expect(service).to receive(:perform_for_conversation).and_return(true)
described_class.new.perform(conversation_id: conversation.id)
# Second call should handle already assigned conversation
expect(service).to receive(:perform_for_conversation).and_return(false)
expect { described_class.new.perform(conversation_id: conversation.id) }.not_to raise_error
end
end
describe 'performance considerations' do
it 'processes large inbox assignments in batches' do
# Create many unassigned conversations
create_list(:conversation, 100, inbox: inbox, assignee: nil)
allow(Inbox).to receive(:find_by).with(id: inbox.id).and_return(inbox)
allow(account).to receive(:assignment_v2_enabled?).and_return(true)
service = instance_double(AssignmentV2::AssignmentService)
allow(AssignmentV2::AssignmentService).to receive(:new).and_return(service)
# Service should be called with default limit
expect(service).to receive(:perform_bulk_assignment).with(no_args).and_return(50)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
@@ -48,13 +48,6 @@ describe AutomationRuleListener do
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if conversation has auto_reply in additional_attributes' do
conversation.additional_attributes = { 'auto_reply' => true }
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_created(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
end
end
@@ -172,18 +165,6 @@ describe AutomationRuleListener do
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if message is auto reply email' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
email_conversation = create(:conversation, inbox: email_inbox, account: account)
email_message = create(:message, conversation: email_conversation, account: account, content_attributes: { email: { auto_reply: true } })
email_event = Events::Base.new('message_created', Time.zone.now, { message: email_message })
allow(condition_match).to receive(:present?).and_return(true)
listener.message_created(email_event)
expect(AutomationRules::ActionService).not_to have_received(:new)
end
it 'does not call AutomationRules::ActionService if conditions do not match based on content' do
message.update!(processed_message_content: 'hi', content: "hi\n\nhello")
allow(condition_match).to receive(:present?).and_return(false)
+1 -4
View File
@@ -111,8 +111,7 @@ RSpec.describe Imap::ImapMailbox do
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml') }
it 'does not create a new conversation' do
expect { class_instance.process(auto_reply_mail.mail, channel) }.to change(Conversation, :count)
expect(Conversation.last.additional_attributes['auto_reply']).to be true
expect { class_instance.process(auto_reply_mail.mail, channel) }.not_to change(Conversation, :count)
end
end
@@ -121,8 +120,6 @@ RSpec.describe Imap::ImapMailbox do
it 'processes the bounced email' do
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
expect(Message.last.content_attributes['email']['auto_reply']).to be true
expect(Conversation.last.additional_attributes['auto_reply']).to be true
end
end
+1 -2
View File
@@ -12,8 +12,7 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to]
end
context 'with reply uuid present' do
+1 -1
View File
@@ -56,7 +56,7 @@ RSpec.describe SupportMailbox do
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
text_content to]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
-127
View File
@@ -1,127 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
end
describe 'validations' do
subject { assignment_policy }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
it { is_expected.to validate_presence_of(:fair_distribution_limit) }
it { is_expected.to validate_numericality_of(:fair_distribution_limit).is_greater_than(0).is_less_than_or_equal_to(100) }
it { is_expected.to validate_presence_of(:fair_distribution_window) }
it { is_expected.to validate_numericality_of(:fair_distribution_window).is_greater_than(60).is_less_than_or_equal_to(86_400) }
context 'with balanced assignment validation' do
let(:enterprise_account) { create(:account) }
before do
allow(enterprise_account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
end
it 'allows balanced assignment for enterprise accounts' do
policy = build(:assignment_policy, account: enterprise_account, assignment_order: :balanced)
expect(policy).to be_valid
end
it 'rejects balanced assignment for non-enterprise accounts' do
policy = build(:assignment_policy, account: account, assignment_order: :balanced)
expect(policy).not_to be_valid
expect(policy.errors[:assignment_order]).to include('Balanced assignment is only available for enterprise accounts')
end
end
end
describe 'enums' do
it { is_expected.to define_enum_for(:assignment_order).with_values(round_robin: 0, balanced: 1) }
it { is_expected.to define_enum_for(:conversation_priority).with_values(earliest_created: 0, longest_waiting: 1) }
end
describe 'scopes' do
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
it 'filters enabled policies' do
expect(described_class.enabled).to include(enabled_policy)
expect(described_class.enabled).not_to include(disabled_policy)
end
it 'filters disabled policies' do
expect(described_class.disabled).to include(disabled_policy)
expect(described_class.disabled).not_to include(enabled_policy)
end
end
describe '#can_use_balanced_assignment?' do
context 'when account has enterprise agent capacity feature' do
before do
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(true)
end
it 'returns true' do
expect(assignment_policy.can_use_balanced_assignment?).to be true
end
end
context 'when account does not have enterprise features' do
before do
allow(account).to receive(:feature_enabled?).with(:enterprise_agent_capacity).and_return(false)
end
it 'returns false' do
expect(assignment_policy.can_use_balanced_assignment?).to be false
end
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = assignment_policy.webhook_data
expect(data).to include(
id: assignment_policy.id,
name: assignment_policy.name,
description: assignment_policy.description,
assignment_order: assignment_policy.assignment_order,
conversation_priority: assignment_policy.conversation_priority,
fair_distribution_limit: assignment_policy.fair_distribution_limit,
fair_distribution_window: assignment_policy.fair_distribution_window,
enabled: assignment_policy.enabled
)
end
end
describe 'cache invalidation' do
let(:inbox) { create(:inbox, account: account) }
before { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
it 'clears assignment caches on update' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
assignment_policy.update!(name: 'Updated Policy')
end
it 'clears assignment caches on destroy' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:policy:#{assignment_policy.id}")
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
assignment_policy.destroy!
end
end
end
-165
View File
@@ -1,165 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxAssignmentPolicy, type: :model do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
describe 'associations' do
it { is_expected.to belong_to(:inbox) }
it { is_expected.to belong_to(:assignment_policy) }
end
describe 'validations' do
subject { inbox_assignment_policy }
it { is_expected.to validate_uniqueness_of(:inbox_id) }
context 'with inbox and policy from different accounts' do
let(:other_account) { create(:account) }
let(:other_policy) { create(:assignment_policy, account: other_account) }
it 'validates inbox belongs to same account as policy' do
# Build without the factory callback that sets the accounts to be the same
invalid_policy = described_class.new(inbox: inbox, assignment_policy: other_policy)
expect(invalid_policy).not_to be_valid
expect(invalid_policy.errors[:inbox]).to include('must belong to the same account as the assignment policy')
end
end
end
describe 'delegations' do
it 'delegates account to inbox' do
expect(inbox_assignment_policy.account).to eq(account)
end
it 'delegates policy attributes' do
expect(inbox_assignment_policy.policy_name).to eq(assignment_policy.name)
expect(inbox_assignment_policy.policy_description).to eq(assignment_policy.description)
expect(inbox_assignment_policy.policy_assignment_order).to eq(assignment_policy.assignment_order)
expect(inbox_assignment_policy.policy_conversation_priority).to eq(assignment_policy.conversation_priority)
expect(inbox_assignment_policy.policy_fair_distribution_limit).to eq(assignment_policy.fair_distribution_limit)
expect(inbox_assignment_policy.policy_fair_distribution_window).to eq(assignment_policy.fair_distribution_window)
expect(inbox_assignment_policy.policy_enabled?).to eq(assignment_policy.enabled?)
end
end
describe 'scopes' do
let!(:enabled_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:disabled_policy) { create(:assignment_policy, account: account, enabled: false) }
let(:inbox2) { create(:inbox, account: account) }
let!(:enabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: enabled_policy) }
let!(:disabled_inbox_policy) { create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: disabled_policy) }
describe '.enabled' do
it 'returns only inbox policies with enabled assignment policies' do
expect(described_class.enabled).to include(enabled_inbox_policy)
expect(described_class.enabled).not_to include(disabled_inbox_policy)
end
end
describe '.disabled' do
it 'returns only inbox policies with disabled assignment policies' do
expect(described_class.disabled).to include(disabled_inbox_policy)
expect(described_class.disabled).not_to include(enabled_inbox_policy)
end
end
end
describe '#webhook_data' do
it 'returns correct data structure' do
data = inbox_assignment_policy.webhook_data
expect(data).to include(
id: inbox_assignment_policy.id,
inbox_id: inbox.id,
assignment_policy_id: assignment_policy.id
)
expect(data[:policy]).to eq(assignment_policy.webhook_data)
end
end
describe 'cache management' do
it 'clears inbox cache on create' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}")
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
end
it 'clears inbox cache on update' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
inbox_assignment_policy.update!(updated_at: Time.current)
end
it 'clears inbox cache on destroy' do
expect(Rails.cache).to receive(:delete).with("assignment_v2:inbox_policy:#{inbox.id}").at_least(:once)
inbox_assignment_policy.destroy!
end
it 'updates account cache' do
# AccountCacheRevalidator concern should trigger cache update
expect(inbox_assignment_policy).to receive(:update_account_cache)
inbox_assignment_policy.send(:clear_inbox_cache)
end
end
describe 'business logic constraints' do
it 'prevents multiple policies per inbox' do
# Ensure first policy exists
inbox_assignment_policy
policy2 = create(:assignment_policy, account: account)
# Try to create a second policy for the same inbox
duplicate_policy = described_class.new(inbox: inbox, assignment_policy: policy2)
expect(duplicate_policy).not_to be_valid
expect(duplicate_policy.errors[:inbox_id]).to include('has already been taken')
end
it 'allows reassigning to different policy' do
policy2 = create(:assignment_policy, account: account)
expect do
inbox_assignment_policy.update!(assignment_policy: policy2)
end.not_to raise_error
expect(inbox_assignment_policy.reload.assignment_policy).to eq(policy2)
end
end
describe 'edge cases' do
it 'handles nil associations gracefully' do
# Build without saving to test nil handling
policy = build(:inbox_assignment_policy, inbox: nil, assignment_policy: nil)
expect { policy.valid? }.not_to raise_error
expect(policy).not_to be_valid
end
it 'handles policy deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting policy should delete inbox assignment
assignment_policy.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
it 'handles inbox deletion cascade' do
inbox_policy_id = inbox_assignment_policy.id
# Deleting inbox should delete inbox assignment
inbox.destroy!
expect(described_class.find_by(id: inbox_policy_id)).to be_nil
end
end
end
-65
View File
@@ -548,69 +548,4 @@ RSpec.describe Message do
expect(presenter).to have_received(:outgoing_content)
end
end
describe '#auto_reply_email?' do
context 'when message is not an incoming email and inbox is not email' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
it 'returns false' do
expect(message.auto_reply_email?).to be false
end
end
context 'when message is an incoming email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
context 'when inbox is email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
end
end
+1 -8
View File
@@ -49,15 +49,13 @@ RSpec.describe MailPresenter do
:references,
:subject,
:text_content,
:to,
:auto_reply
:to
])
expect(data[:content_type]).to include('multipart/alternative')
expect(data[:date].to_s).to eq('2020-04-20T04:20:20-04:00')
expect(data[:message_id]).to eq(mail.message_id)
expect(data[:multipart]).to be(true)
expect(data[:subject]).to eq(decorated_mail.subject)
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'give email from in downcased format' do
@@ -138,11 +136,6 @@ RSpec.describe MailPresenter do
expect(decorated_auto_reply_mail.auto_reply?).to be true
expect(decorated_auto_reply_with_auto_submitted_mail.auto_reply?).to be true
end
it 'includes auto_reply status in serialized_data' do
expect(decorated_auto_reply_mail.serialized_data[:auto_reply]).to be true
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
end
end
end
end
@@ -1,329 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::AssignmentService do
before do
# Mock the GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
# Define the constant if not already defined
stub_const('ASSIGNEE_CHANGED', 'assignee.changed') unless defined?(ASSIGNEE_CHANGED)
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:inbox_member, inbox: inbox, user: agent3)
# Mock available agents to return inbox members
online_members = InboxMember.joins(:user).where(inbox: inbox, user: [agent1, agent2])
allow(inbox).to receive(:available_agents).and_return(online_members)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let!(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:service) { described_class.new(inbox: inbox) }
# Create agents
let!(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let!(:agent3) { create(:user, account: account, role: :agent, availability: :offline) }
# Make agents members of inbox
describe '#perform_for_conversation' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
context 'when policy is enabled' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'assigns conversation to an available agent' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'dispatches assignment event' do
# The dispatcher is called from the assignment service and also from conversation model
allow(Rails.configuration.dispatcher).to receive(:dispatch)
service.perform_for_conversation(conversation)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'assignee.changed',
anything,
hash_including(conversation: conversation, user: agent1)
).at_least(:once)
end
it 'returns false when no agents are available' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
allow(Rails.logger).to receive(:warn)
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to be_nil
end
end
context 'when conversation is already assigned' do
before { conversation.update!(assignee: agent1) }
it 'does not reassign conversation' do
expect(service.perform_for_conversation(conversation)).to be false
expect(conversation.reload.assignee).to eq(agent1)
end
end
context 'with round robin assignment' do
before do
assignment_policy.update!(assignment_order: :round_robin)
# Mock round robin selector to return agents in rotation
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
agent_index = 0
allow(selector).to receive(:select_agent) do
agent = [agent1, agent2][agent_index % 2]
agent_index += 1
agent
end
end
it 'assigns agents in rotation' do
conversations = create_list(:conversation, 4, inbox: inbox, assignee: nil)
assignments = conversations.map do |conv|
service.perform_for_conversation(conv)
conv.reload.assignee
end
# Should rotate between available agents
expect(assignments[0]).to eq(agent1)
expect(assignments[1]).to eq(agent2)
expect(assignments[2]).to eq(agent1) # Back to first agent
expect(assignments[3]).to eq(agent2) # Back to second agent
end
end
context 'with balanced assignment' do
before do
# For now, just use round robin since balanced is enterprise only
# The test is verifying the service works, not the specific algorithm
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent2)
end
it 'assigns conversations successfully' do
# Create existing assignments
create_list(:conversation, 3, inbox: inbox, assignee: agent1, status: :open)
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2)
end
it 'handles different conversation statuses' do
# Create resolved conversations (should not count)
create_list(:conversation, 5, inbox: inbox, assignee: agent1, status: :resolved)
# Create open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: :open)
new_conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(new_conversation)).to be true
expect(new_conversation.reload.assignee).to eq(agent2) # Selected by mock
end
end
context 'when error occurs' do
before do
# Mock the selector to return an agent
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'returns false and logs error on assignment failure' do
allow(conversation).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(conversation))
expect(Rails.logger).to receive(:error).with(/Failed to assign conversation/)
expect(service.perform_for_conversation(conversation)).to be false
end
end
end
describe '#perform_bulk_assignment' do
before do
create_list(:conversation, 5, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to return agents
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
call_count = 0
allow(selector).to receive(:select_agent) do
call_count += 1
call_count.odd? ? agent1 : agent2
end
end
context 'when policy is enabled' do
it 'assigns multiple conversations' do
assigned_count = service.perform_bulk_assignment(limit: 3)
expect(assigned_count).to eq(3)
expect(inbox.conversations.unassigned.count).to eq(2)
end
it 'respects conversation priority order' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different timestamps
old_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.hour.ago)
new_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, created_at: 1.minute.ago)
assignment_policy.update!(conversation_priority: :earliest_created)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).not_to be_nil
expect(new_conversation.reload.assignee).to be_nil
end
it 'handles longest_waiting priority' do
# Clear existing conversations first
Conversation.destroy_all
# Create conversations with different last activity
inactive_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 2.hours.ago)
active_conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open, last_activity_at: 5.minutes.ago)
assignment_policy.update!(conversation_priority: :longest_waiting)
# Re-create service after policy change
service_with_priority = described_class.new(inbox: inbox)
service_with_priority.perform_bulk_assignment(limit: 1)
expect(inactive_conversation.reload.assignee).not_to be_nil
expect(active_conversation.reload.assignee).to be_nil
end
it 'returns 0 when no conversations to assign' do
Conversation.find_each { |c| c.update!(assignee_id: agent1.id) }
expect(service.perform_bulk_assignment).to eq(0)
end
end
context 'when policy is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'does not assign any conversations' do
expect(service.perform_bulk_assignment).to eq(0)
expect(inbox.conversations.unassigned.count).to eq(5)
end
end
end
describe 'enterprise capacity features' do
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Mock enterprise availability
stub_const('Enterprise', Module.new)
stub_const('Enterprise::AssignmentV2::CapacityManager', Class.new)
# Mock the selector to return agent1
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
end
it 'uses round robin when enterprise features are available' do
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
it 'handles absence of enterprise features gracefully' do
# Remove enterprise constant
hide_const('Enterprise')
# Service should still work with round robin
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).to eq(agent1)
end
end
describe 'cache management' do
it 'uses cache for round robin state' do
assignment_policy.update!(assignment_order: :round_robin)
# Mock the selector and round robin service
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
# Create and assign conversations
conversation1 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation1)
conversation2 = create(:conversation, inbox: inbox, assignee: nil)
service.perform_for_conversation(conversation2)
# Just verify assignments worked
expect(conversation1.reload.assignee).to eq(agent1)
expect(conversation2.reload.assignee).to eq(agent1)
end
end
describe 'edge cases' do
it 'handles inbox without policy gracefully' do
inbox_assignment_policy.destroy!
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'handles empty agent list' do
allow(inbox).to receive(:available_agents).and_return(InboxMember.none)
conversation = create(:conversation, inbox: inbox, assignee: nil)
expect(service.perform_for_conversation(conversation)).to be false
end
it 'filters out agents without inbox membership' do
non_member_agent = create(:user, account: account, role: :agent, availability: :online)
conversation = create(:conversation, inbox: inbox, assignee: nil)
# Mock selector to return agent1 (who is a member)
selector = instance_double(AssignmentV2::RoundRobinSelector)
allow(AssignmentV2::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent).and_return(agent1)
expect(service.perform_for_conversation(conversation)).to be true
expect(conversation.reload.assignee).not_to eq(non_member_agent)
expect(conversation.reload.assignee).to eq(agent1)
end
end
end
@@ -1,293 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RateLimiter, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
redis = Redis.new(Redis::Config.app)
redis.flushdb if Rails.env.test?
# Ensure inbox_assignment_policy exists so the inbox has a policy
inbox_assignment_policy
end
let(:account) { create(:account) }
let(:policy) { create(:assignment_policy, account: account, fair_distribution_limit: 5, fair_distribution_window: 3600) }
let(:agent) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: policy) }
let(:rate_limiter) { described_class.new(inbox: inbox, user: agent) }
describe '#initialize' do
it 'sets up rate limiter with inbox and user' do
expect(rate_limiter.instance_variable_get(:@inbox)).to eq(inbox)
expect(rate_limiter.instance_variable_get(:@user)).to eq(agent)
end
end
describe '#within_limits?' do
context 'when agent has no assignments in current window' do
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent is below limit' do
before do
# Simulate 3 assignments in current window
3.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns true' do
expect(rate_limiter.within_limits?).to be true
end
end
context 'when agent reaches limit' do
before do
# Simulate reaching the limit (5 assignments)
5.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
context 'when agent exceeds limit' do
before do
# Simulate exceeding the limit
6.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'returns false' do
expect(rate_limiter.within_limits?).to be false
end
end
end
describe '#record_assignment' do
let(:conversation) { create(:conversation, inbox: inbox) }
it 'increments assignment count for agent' do
initial_status = rate_limiter.status
rate_limiter.record_assignment(conversation)
new_status = rate_limiter.status
expect(new_status[:current_count]).to eq(initial_status[:current_count] + 1)
end
it 'sets expiration on the key' do
rate_limiter.record_assignment(conversation)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
redis = Redis.new(Redis::Config.app)
ttl = redis.ttl(key)
expect(ttl).to be > 0
expect(ttl).to be <= 3600
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:multi).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
expect { rate_limiter.record_assignment(conversation) }.to raise_error(Redis::ConnectionError)
end
end
end
describe '#status' do
it 'returns correct status for agent with no assignments' do
status = rate_limiter.status
expect(status[:current_count]).to eq(0)
expect(status[:within_limits]).to be true
expect(status[:limit]).to eq(5)
end
it 'returns correct count after assignments' do
3.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:get).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
expect { rate_limiter.status }.to raise_error(Redis::ConnectionError)
end
end
end
describe 'remaining assignments' do
it 'returns full limit when no assignments made' do
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(5)
end
it 'returns correct remaining count' do
2.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(3)
end
it 'returns 0 when limit reached' do
5.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(0)
end
it 'returns negative when limit exceeded' do
6.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:limit] - status[:current_count]).to eq(-1)
end
end
describe 'assignment capacity checks' do
it 'returns true when agent has remaining capacity' do
2.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.within_limits?).to be true
end
it 'returns false when agent has no capacity' do
5.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.within_limits?).to be false
end
it 'correctly tracks multiple assignments' do
3.times { rate_limiter.record_assignment(create(:conversation)) }
status = rate_limiter.status
expect(status[:current_count]).to eq(3)
expect(status[:within_limits]).to be true
expect(status[:limit] - status[:current_count]).to eq(2)
end
end
describe 'multiple agents' do
let(:agent2) { create(:user, account: account) }
let(:rate_limiter2) { described_class.new(inbox: inbox, user: agent2) }
before do
2.times { rate_limiter.record_assignment(create(:conversation)) }
4.times { rate_limiter2.record_assignment(create(:conversation)) }
end
it 'tracks status independently for each agent' do
status1 = rate_limiter.status
status2 = rate_limiter2.status
expect(status1[:current_count]).to eq(2)
expect(status1[:within_limits]).to be true
expect(status1[:limit] - status1[:current_count]).to eq(3)
expect(status2[:current_count]).to eq(4)
expect(status2[:within_limits]).to be true
expect(status2[:limit] - status2[:current_count]).to eq(1)
end
end
describe 'reset functionality' do
before do
3.times { rate_limiter.record_assignment(create(:conversation)) }
end
it 'can be reset by clearing Redis key' do
status_before = rate_limiter.status
expect(status_before[:current_count]).to eq(3)
# Manually clear the key
redis = Redis.new(Redis::Config.app)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
redis.del(key)
status_after = rate_limiter.status
expect(status_after[:current_count]).to eq(0)
end
context 'when Redis fails' do
before do
redis_double = instance_double(Redis)
allow(Redis).to receive(:new).and_return(redis_double)
allow(redis_double).to receive(:del).and_raise(Redis::ConnectionError)
allow(Rails.logger).to receive(:error)
end
it 'raises error' do
redis = Redis.new(Redis::Config.app)
current_window = (Time.current.to_i / 3600) * 3600
key = "assignment_v2:rate_limit:#{agent.id}:#{current_window}"
expect { redis.del(key) }.to raise_error(Redis::ConnectionError)
end
end
end
describe 'window timing' do
it 'calculates reset time correctly' do
# Mock current time to make test predictable
travel_to(Time.zone.parse('2024-01-01 10:30:00')) do
status = rate_limiter.status
reset_time = status[:reset_at]
expect(reset_time).to be_a(Time)
expect(reset_time).to be > Time.current
expect(reset_time - Time.current).to be <= 3600
end
end
end
describe 'window boundaries' do
it 'resets count in new window' do
# Set up assignment in current window
2.times { rate_limiter.record_assignment(create(:conversation)) }
expect(rate_limiter.status[:current_count]).to eq(2)
# Travel to next window (advance by window size)
travel(3601.seconds) do
expect(rate_limiter.status[:current_count]).to eq(0)
expect(rate_limiter.within_limits?).to be true
end
end
end
describe 'concurrent access' do
it 'handles concurrent increments correctly' do
threads = []
results = []
mutex = Mutex.new
# Simulate concurrent assignment requests
5.times do
threads << Thread.new do
within_limits = rate_limiter.within_limits?
mutex.synchronize { results << within_limits }
rate_limiter.record_assignment(create(:conversation)) if within_limits
end
end
threads.each(&:join)
# Final count should not exceed the limit
final_count = rate_limiter.status[:current_count]
expect(final_count).to be <= 5
expect(results.count(true)).to eq(final_count)
end
end
end
@@ -1,145 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AssignmentV2::RoundRobinSelector, type: :service do
before do
# Mock GlobalConfig to avoid InstallationConfig issues
allow(GlobalConfig).to receive(:get).and_return({})
create(:inbox_member, inbox: inbox, user: user1)
create(:inbox_member, inbox: inbox, user: user2)
create(:inbox_member, inbox: inbox, user: user3)
end
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:policy) { create(:assignment_policy, account: account) }
let(:user1) { create(:user, account: account, availability: :online) }
let(:user2) { create(:user, account: account, availability: :online) }
let(:user3) { create(:user, account: account, availability: :offline) }
describe '#select_agent' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
context 'when Redis is available' do
before do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user1.id.to_s)
end
it 'returns an online agent' do
result = selector.select_agent(available_agents)
expect(result).to eq(user1)
end
it 'excludes offline agents' do
result = selector.select_agent(available_agents)
expect(result).not_to eq(user3)
end
it 'handles no available agent gracefully' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(nil)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
context 'when Redis fails' do
before do
allow(round_robin_service).to receive(:available_agent).and_raise(Redis::CannotConnectError)
end
it 'raises the error' do
expect { selector.select_agent(available_agents) }.to raise_error(Redis::CannotConnectError)
end
end
context 'with empty available agents' do
it 'returns nil when no agents are available' do
result = selector.select_agent(InboxMember.none)
expect(result).to be_nil
end
end
context 'with different user IDs' do
it 'correctly finds the inbox member by user_id' do
allow(round_robin_service).to receive(:available_agent).with(allowed_agent_ids: [user1.id.to_s, user2.id.to_s]).and_return(user2.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to eq(user2)
end
end
end
describe '#add_agent_to_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:add_agent_to_queue).with(user1.id)
selector.add_agent_to_queue(user1.id)
end
end
describe '#remove_agent_from_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:remove_agent_from_queue).with(user1.id)
selector.remove_agent_from_queue(user1.id)
end
end
describe '#reset_queue' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'delegates to round robin service' do
expect(round_robin_service).to receive(:reset_queue)
selector.reset_queue
end
end
describe 'edge cases' do
let(:selector) { described_class.new(inbox: inbox) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:available_agents) { InboxMember.where(inbox: inbox, user: [user1, user2]) }
before do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).with(inbox: inbox).and_return(round_robin_service)
end
it 'handles invalid user_id from round robin service' do
allow(round_robin_service).to receive(:available_agent).and_return('invalid_id')
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
it 'handles user_id not in available agents' do
other_user = create(:user, account: account)
allow(round_robin_service).to receive(:available_agent).and_return(other_user.id.to_s)
result = selector.select_agent(available_agents)
expect(result).to be_nil
end
end
end
@@ -111,35 +111,6 @@ describe MessageTemplates::HookExecutionService do
end
end
context 'when message is an auto reply email' do
it 'does not call any template hooks' do
contact = create(:contact)
conversation = create(:conversation, contact: contact)
conversation.inbox.update(greeting_enabled: true, enable_email_collect: true, greeting_message: 'Hi, this is a greeting message')
message = create(:message, conversation: conversation, content_type: :incoming_email)
message.content_attributes = { email: { auto_reply: true } }
message.save!
greeting_service = double
email_collect_service = double
out_of_office_service = double
allow(MessageTemplates::Template::Greeting).to receive(:new).and_return(greeting_service)
allow(greeting_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
described_class.new(message: message).perform
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
expect(MessageTemplates::Template::EmailCollect).not_to have_received(:new)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when it is after working hours' do
it 'calls ::MessageTemplates::Template::OutOfOffice' do
contact = create(:contact)
@@ -282,51 +282,5 @@ describe Twilio::IncomingMessageService do
expect(message.attachments.first.file_type).to eq('location')
end
end
context 'when ProfileName is provided for WhatsApp' do
it 'uses ProfileName as contact name' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello with profile name',
ProfileName: 'John Doe'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('John Doe')
end
it 'falls back to formatted phone number when ProfileName is blank' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello without profile name',
ProfileName: ''
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'uses formatted phone number when ProfileName is not provided' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Regular SMS message'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
end
end
end