-
{{ $t('CAPTAIN.COPILOT.YOU') }}
-
- {{ message.content }}
-
+
+
{{ $t('CAPTAIN.COPILOT.YOU') }}
+
+ {{ message.content }}
diff --git a/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue b/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue
index 1877e1630..0a37600bf 100644
--- a/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue
+++ b/app/javascript/dashboard/components-next/copilot/CopilotAssistantMessage.vue
@@ -9,7 +9,6 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
-import Avatar from '../avatar/Avatar.vue';
const props = defineProps({
message: {
@@ -46,33 +45,25 @@ const useCopilotResponse = () => {
-
-
+ {{ $t('CAPTAIN.NAME') }}
+
+ {{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
+
+
-
-
{{ $t('CAPTAIN.NAME') }}
-
- {{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
-
-
diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb
index 4fe931b9f..9fc7a435d 100644
--- a/app/mailboxes/application_mailbox.rb
+++ b/app/mailboxes/application_mailbox.rb
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
end
def in_reply_to_matches?(in_reply_to)
- Array.wrap(in_reply_to).any? { _1.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
+ Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
end
# checks if follow this pattern send it to reply_mailbox
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index bcb79c05b..3f1a20037 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -128,6 +128,12 @@ class Conversation < ApplicationRecord
additional_attributes&.dig('conversation_language')
end
+ # Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
+ # Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
+ # if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
+ # if you compare or copy these values in Ruby, also in our specs
+ # So in specs rely on to be_with(1.second) instead of to eq()
+ # TODO: Migrate to use a timestamp with microsecond precision
def last_activity_at
self[:last_activity_at] || created_at
end
diff --git a/app/services/message_templates/template/csat_survey.rb b/app/services/message_templates/template/csat_survey.rb
index 3a7ca2605..dd9cf3bd6 100644
--- a/app/services/message_templates/template/csat_survey.rb
+++ b/app/services/message_templates/template/csat_survey.rb
@@ -12,7 +12,6 @@ class MessageTemplates::Template::CsatSurvey
private
delegate :contact, :account, :inbox, to: :conversation
- delegate :csat_config, to: :inbox
def should_send_csat_survey?
return true unless survey_rules_configured?
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index 7a335c87d..c38577599 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -137,14 +137,19 @@ class Twilio::IncomingMessageService
end
def download_with_auth(media_url)
- Down.download(
- media_url,
- http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
- )
+ auth_credentials = if twilio_channel.api_key_sid.present?
+ # When using api_key_sid, the auth token should be the api_secret_key
+ [twilio_channel.api_key_sid, twilio_channel.auth_token]
+ else
+ # When using account_sid, the auth token is the account's auth token
+ [twilio_channel.account_sid, twilio_channel.auth_token]
+ end
+
+ Down.download(media_url, http_basic_authentication: auth_credentials)
end
def handle_download_attachment_error(error, media_url)
- Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
+ Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
Down.download(media_url)
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
diff --git a/config/initializers/01_redis.rb b/config/initializers/01_redis.rb
index 664dcd435..93c12fce7 100644
--- a/config/initializers/01_redis.rb
+++ b/config/initializers/01_redis.rb
@@ -1,3 +1,7 @@
+# TODO: Phase out the custom ConnectionPool wrappers ($alfred / $velma),
+# switch to plain Redis clients here and let Rails 7.1+ handle pooling
+# via `pool:` in RedisCacheStore (see rack_attack initializer).
+
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index fdbd47008..fe3f6c554 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -11,7 +11,12 @@ class Rack::Attack
# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
# https://github.com/rack/rack-attack/issues/102
- Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma)
+ # Rails 7.1 automatically adds its own ConnectionPool around RedisCacheStore.
+ # Because `$velma` is *already* a ConnectionPool, double-wrapping causes
+ # Redis calls like `get` to hit the outer wrapper and explode.
+ # `pool: false` tells Rails to skip its internal pool and use ours directly.
+ # TODO: We can use build in connection pool in future upgrade
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
class Request < ::Rack::Request
# You many need to specify a method to fetch the correct remote IP address
diff --git a/deployment/chatwoot-web.1.service b/deployment/chatwoot-web.1.service
index 049ec85db..0d1d33804 100644
--- a/deployment/chatwoot-web.1.service
+++ b/deployment/chatwoot-web.1.service
@@ -16,10 +16,10 @@ KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
-Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
+Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
Environment="RAILS_ENV=production"
Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true"
-Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
-Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
+Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
+Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
diff --git a/deployment/chatwoot-worker.1.service b/deployment/chatwoot-worker.1.service
index 04ee83a69..ea893d20c 100644
--- a/deployment/chatwoot-worker.1.service
+++ b/deployment/chatwoot-worker.1.service
@@ -21,10 +21,10 @@ MemoryHigh=1.4G
MemorySwapMax=0
OOMPolicy=stop
-Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
+Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
Environment="RAILS_ENV=production"
Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true"
-Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
-Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
+Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
+Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh
index 7809b6d0e..75320bbd3 100644
--- a/deployment/setup_20.04.sh
+++ b/deployment/setup_20.04.sh
@@ -338,8 +338,8 @@ function setup_chatwoot() {
sudo -i -u chatwoot << EOF
rvm --version
rvm autolibs disable
- rvm install "ruby-3.3.3"
- rvm use 3.3.3 --default
+ rvm install "ruby-3.4.4"
+ rvm use 3.4.4 --default
git clone https://github.com/chatwoot/chatwoot.git
cd chatwoot
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 008884ce1..2d753337d 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,6 +1,6 @@
# pre-build stage
FROM node:23-alpine as node
-FROM ruby:3.3.3-alpine3.19 AS pre-builder
+FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
@@ -90,13 +90,13 @@ RUN if [ "$RAILS_ENV" = "production" ]; then \
RUN git rev-parse HEAD > /app/.git_sha
# Remove unnecessary files
-RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
- && find /gems/ruby/3.3.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
+RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
+ && find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
&& rm -rf .git \
&& rm .gitignore
# final build stage
-FROM ruby:3.3.3-alpine3.19
+FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
diff --git a/enterprise/app/controllers/api/v1/accounts/custom_roles_controller.rb b/enterprise/app/controllers/api/v1/accounts/custom_roles_controller.rb
index 01650a7de..2d6823b90 100644
--- a/enterprise/app/controllers/api/v1/accounts/custom_roles_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/custom_roles_controller.rb
@@ -6,12 +6,12 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
@custom_roles = Current.account.custom_roles
end
+ def show; end
+
def create
@custom_role = Current.account.custom_roles.create!(permitted_params)
end
- def show; end
-
def update
@custom_role.update!(permitted_params)
end
diff --git a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb
index e64256bc7..ec879b042 100644
--- a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb
@@ -6,12 +6,12 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
@sla_policies = Current.account.sla_policies
end
+ def show; end
+
def create
@sla_policy = Current.account.sla_policies.create!(permitted_params)
end
- def show; end
-
def update
@sla_policy.update!(permitted_params)
end
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 39a12b662..c661caebe 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -50,7 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def message_content(message)
return message.content if message.content.present?
- 'User has shared an attachment' if message.attachments.any?
+ return 'User has shared an attachment' if message.attachments.any?
'User has shared a message without content'
end
diff --git a/enterprise/app/services/captain/tool_registry_service.rb b/enterprise/app/services/captain/tool_registry_service.rb
index f2c234060..8d6632e57 100644
--- a/enterprise/app/services/captain/tool_registry_service.rb
+++ b/enterprise/app/services/captain/tool_registry_service.rb
@@ -1,23 +1,24 @@
class Captain::ToolRegistryService
attr_reader :registered_tools, :tools
- def initialize(assistant)
+ def initialize(assistant, user: nil)
@assistant = assistant
+ @user = user
@registered_tools = []
@tools = {}
end
def register_tool(tool_class)
- tool = tool_class.new(@assistant)
+ tool = tool_class.new(@assistant, user: @user)
return unless tool.active?
@tools[tool.name] = tool
@registered_tools << tool.to_registry_format
end
- def method_missing(method_name, *arguments)
+ def method_missing(method_name, *)
if @tools.key?(method_name.to_s)
- @tools[method_name.to_s].execute(*arguments)
+ @tools[method_name.to_s].execute(*)
else
super
end
diff --git a/enterprise/app/services/captain/tools/base_service.rb b/enterprise/app/services/captain/tools/base_service.rb
index 9143726b7..72fde2df8 100644
--- a/enterprise/app/services/captain/tools/base_service.rb
+++ b/enterprise/app/services/captain/tools/base_service.rb
@@ -36,4 +36,18 @@ class Captain::Tools::BaseService
def active?
true
end
+
+ private
+
+ def user_has_permission(permission)
+ return false if @user.blank?
+
+ account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id)
+ return false if account_user.blank?
+
+ return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present?
+
+ # Default permission for agents without custom roles
+ account_user.administrator? || account_user.agent?
+ end
end
diff --git a/enterprise/app/services/captain/tools/copilot/get_article_service.rb b/enterprise/app/services/captain/tools/copilot/get_article_service.rb
new file mode 100644
index 000000000..9c2ee02da
--- /dev/null
+++ b/enterprise/app/services/captain/tools/copilot/get_article_service.rb
@@ -0,0 +1,39 @@
+class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService
+ def name
+ 'get_article'
+ end
+
+ def description
+ 'Get details of an article including its content and metadata'
+ end
+
+ def parameters
+ {
+ type: 'object',
+ properties: {
+ article_id: {
+ type: 'number',
+ description: 'The ID of the article to retrieve'
+ }
+ },
+ required: %w[article_id]
+ }
+ end
+
+ def execute(arguments)
+ article_id = arguments['article_id']
+
+ Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" }
+
+ return 'Missing required parameters' if article_id.blank?
+
+ article = Article.find_by(id: article_id, account_id: @assistant.account_id)
+ return 'Article not found' if article.nil?
+
+ article.to_llm_text
+ end
+
+ def active?
+ user_has_permission('knowledge_base_manage')
+ end
+end
diff --git a/enterprise/app/services/captain/tools/copilot/get_contact_service.rb b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb
new file mode 100644
index 000000000..290433301
--- /dev/null
+++ b/enterprise/app/services/captain/tools/copilot/get_contact_service.rb
@@ -0,0 +1,39 @@
+class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService
+ def name
+ 'get_contact'
+ end
+
+ def description
+ 'Get details of a contact including their profile information'
+ end
+
+ def parameters
+ {
+ type: 'object',
+ properties: {
+ contact_id: {
+ type: 'number',
+ description: 'The ID of the contact to retrieve'
+ }
+ },
+ required: %w[contact_id]
+ }
+ end
+
+ def execute(arguments)
+ contact_id = arguments['contact_id']
+
+ Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}"
+
+ return 'Missing required parameters' if contact_id.blank?
+
+ contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id)
+ return 'Contact not found' if contact.nil?
+
+ contact.to_llm_text
+ end
+
+ def active?
+ user_has_permission('contact_manage')
+ end
+end
diff --git a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
new file mode 100644
index 000000000..64b52d012
--- /dev/null
+++ b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
@@ -0,0 +1,41 @@
+class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService
+ def name
+ 'get_conversation'
+ end
+
+ def description
+ 'Get details of a conversation including messages and contact information'
+ end
+
+ def parameters
+ {
+ type: 'object',
+ properties: {
+ conversation_id: {
+ type: 'number',
+ description: 'The ID of the conversation to retrieve'
+ }
+ },
+ required: %w[conversation_id]
+ }
+ end
+
+ def execute(arguments)
+ conversation_id = arguments['conversation_id']
+
+ Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}"
+
+ return 'Missing required parameters' if conversation_id.blank?
+
+ conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
+ return 'Conversation not found' if conversation.blank?
+
+ conversation.to_llm_text
+ end
+
+ def active?
+ user_has_permission('conversation_manage') ||
+ user_has_permission('conversation_unassigned_manage') ||
+ user_has_permission('conversation_participating_manage')
+ end
+end
diff --git a/enterprise/app/services/captain/tools/copilot/search_articles_service.rb b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb
new file mode 100644
index 000000000..5061968ad
--- /dev/null
+++ b/enterprise/app/services/captain/tools/copilot/search_articles_service.rb
@@ -0,0 +1,71 @@
+class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService
+ def name
+ 'search_articles'
+ end
+
+ def description
+ 'Search articles based on parameters'
+ end
+
+ def parameters
+ {
+ type: 'object',
+ properties: properties,
+ required: ['query']
+ }
+ end
+
+ def execute(arguments)
+ query = arguments['query']
+ category_id = arguments['category_id']
+ status = arguments['status']
+
+ Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}"
+
+ return 'Missing required parameters' if query.blank?
+
+ articles = fetch_articles(query, category_id, status)
+
+ return 'No articles found' unless articles.exists?
+
+ total_count = articles.count
+ articles = articles.limit(100)
+
+ <<~RESPONSE
+ #{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
+ #{articles.map(&:to_llm_text).join("\n---\n")}
+ RESPONSE
+ end
+
+ def active?
+ user_has_permission('knowledge_base_manage')
+ end
+
+ private
+
+ def fetch_articles(query, category_id, status)
+ articles = Article.where(account_id: @assistant.account_id)
+ articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
+ articles = articles.where(category_id: category_id) if category_id.present?
+ articles = articles.where(status: status) if status.present?
+ articles
+ end
+
+ def properties
+ {
+ query: {
+ type: 'string',
+ description: 'Search articles by title or content (partial match)'
+ },
+ category_id: {
+ type: 'number',
+ description: 'Filter articles by category ID'
+ },
+ status: {
+ type: 'string',
+ enum: %w[draft published archived],
+ description: 'Filter articles by status'
+ }
+ }
+ end
+end
diff --git a/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb
new file mode 100644
index 000000000..557fc731a
--- /dev/null
+++ b/enterprise/app/services/captain/tools/copilot/search_contacts_service.rb
@@ -0,0 +1,61 @@
+class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService
+ def name
+ 'search_contacts'
+ end
+
+ def description
+ 'Search contacts based on query parameters'
+ end
+
+ def parameters
+ {
+ type: 'object',
+ properties: properties,
+ required: []
+ }
+ end
+
+ def execute(arguments)
+ email = arguments['email']
+ phone_number = arguments['phone_number']
+ name = arguments['name']
+
+ Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}"
+
+ contacts = Contact.where(account_id: @assistant.account_id)
+ contacts = contacts.where(email: email) if email.present?
+ contacts = contacts.where(phone_number: phone_number) if phone_number.present?
+ contacts = contacts.where('LOWER(name) ILIKE ?', "%#{name.downcase}%") if name.present?
+
+ return 'No contacts found' unless contacts.exists?
+
+ contacts = contacts.limit(100)
+
+ <<~RESPONSE
+ #{contacts.map(&:to_llm_text).join("\n---\n")}
+ RESPONSE
+ end
+
+ def active?
+ user_has_permission('contact_manage')
+ end
+
+ private
+
+ def properties
+ {
+ email: {
+ type: 'string',
+ description: 'Filter contacts by email'
+ },
+ phone_number: {
+ type: 'string',
+ description: 'Filter contacts by phone number'
+ },
+ name: {
+ type: 'string',
+ description: 'Filter contacts by name (partial match)'
+ }
+ }
+ end
+end
diff --git a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
index 6b6b063ea..f97604793 100644
--- a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
+++ b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb
@@ -33,6 +33,12 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
RESPONSE
end
+ def active?
+ user_has_permission('conversation_manage') ||
+ user_has_permission('conversation_unassigned_manage') ||
+ user_has_permission('conversation_participating_manage')
+ end
+
private
def get_conversations(status, contact_id, priority)
diff --git a/spec/builders/v2/reports/conversations/report_builder_spec.rb b/spec/builders/v2/reports/conversations/report_builder_spec.rb
index d3cde98e6..db7a0ac45 100644
--- a/spec/builders/v2/reports/conversations/report_builder_spec.rb
+++ b/spec/builders/v2/reports/conversations/report_builder_spec.rb
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end
describe '#timeseries' do
- include_examples 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
- include_examples 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
+ it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
+ it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
end
describe '#aggregate_value' do
- include_examples 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
- include_examples 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
+ it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
+ it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
end
end
diff --git a/spec/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb
index beb4a6a63..c8d97fe3a 100644
--- a/spec/enterprise/services/captain/tool_registry_service_spec.rb
+++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
class TestTool < Captain::Tools::BaseService
attr_accessor :tool_active
- def initialize(*args)
+ def initialize(assistant, user: nil)
super
@tool_active = true
end
diff --git a/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb
new file mode 100644
index 000000000..72f4e1cb4
--- /dev/null
+++ b/spec/enterprise/services/captain/tools/copilot/get_article_service_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::Copilot::GetArticleService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:service) { described_class.new(assistant, user: user) }
+
+ describe '#name' do
+ it 'returns the correct service name' do
+ expect(service.name).to eq('get_article')
+ end
+ end
+
+ describe '#description' do
+ it 'returns the service description' do
+ expect(service.description).to eq('Get details of an article including its content and metadata')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the expected parameter schema' do
+ expect(service.parameters).to eq(
+ {
+ type: 'object',
+ properties: {
+ article_id: {
+ type: 'number',
+ description: 'The ID of the article to retrieve'
+ }
+ },
+ required: %w[article_id]
+ }
+ )
+ end
+ end
+
+ describe '#active?' do
+ context 'when user is an admin' do
+ let(:user) { create(:user, :administrator, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with knowledge_base_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role without knowledge_base_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when article_id is blank' do
+ it 'returns error message' do
+ expect(service.execute({})).to eq('Missing required parameters')
+ end
+ end
+
+ context 'when article is not found' do
+ it 'returns not found message' do
+ expect(service.execute({ 'article_id' => 999 })).to eq('Article not found')
+ end
+ end
+
+ context 'when article exists' do
+ let(:portal) { create(:portal, account: account) }
+ let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
+
+ it 'returns the article in llm text format' do
+ result = service.execute({ 'article_id' => article.id })
+ expect(result).to eq(article.to_llm_text)
+ end
+
+ context 'when article belongs to different account' do
+ let(:other_account) { create(:account) }
+ let(:other_portal) { create(:portal, account: other_account) }
+ let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
+
+ it 'returns not found message' do
+ expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb
new file mode 100644
index 000000000..de319bfa1
--- /dev/null
+++ b/spec/enterprise/services/captain/tools/copilot/get_contact_service_spec.rb
@@ -0,0 +1,110 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::Copilot::GetContactService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:service) { described_class.new(assistant, user: user) }
+
+ describe '#name' do
+ it 'returns the correct service name' do
+ expect(service.name).to eq('get_contact')
+ end
+ end
+
+ describe '#description' do
+ it 'returns the service description' do
+ expect(service.description).to eq('Get details of a contact including their profile information')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the expected parameter schema' do
+ expect(service.parameters).to eq(
+ {
+ type: 'object',
+ properties: {
+ contact_id: {
+ type: 'number',
+ description: 'The ID of the contact to retrieve'
+ }
+ },
+ required: %w[contact_id]
+ }
+ )
+ end
+ end
+
+ describe '#active?' do
+ context 'when user is an admin' do
+ let(:user) { create(:user, :administrator, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with contact_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role without contact_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when contact_id is blank' do
+ it 'returns error message' do
+ expect(service.execute({})).to eq('Missing required parameters')
+ end
+ end
+
+ context 'when contact is not found' do
+ it 'returns not found message' do
+ expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found')
+ end
+ end
+
+ context 'when contact exists' do
+ let(:contact) { create(:contact, account: account) }
+
+ it 'returns the contact in llm text format' do
+ result = service.execute({ 'contact_id' => contact.id })
+ expect(result).to eq(contact.to_llm_text)
+ end
+
+ context 'when contact belongs to different account' do
+ let(:other_account) { create(:account) }
+ let(:other_contact) { create(:contact, account: other_account) }
+
+ it 'returns not found message' do
+ expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
new file mode 100644
index 000000000..4d7f1adc7
--- /dev/null
+++ b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
@@ -0,0 +1,142 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::Copilot::GetConversationService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:service) { described_class.new(assistant, user: user) }
+
+ describe '#name' do
+ it 'returns the correct service name' do
+ expect(service.name).to eq('get_conversation')
+ end
+ end
+
+ describe '#description' do
+ it 'returns the service description' do
+ expect(service.description).to eq('Get details of a conversation including messages and contact information')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the expected parameter schema' do
+ expect(service.parameters).to eq(
+ {
+ type: 'object',
+ properties: {
+ conversation_id: {
+ type: 'number',
+ description: 'The ID of the conversation to retrieve'
+ }
+ },
+ required: %w[conversation_id]
+ }
+ )
+ end
+ end
+
+ describe '#active?' do
+ context 'when user is an admin' do
+ let(:user) { create(:user, :administrator, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with conversation_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with conversation_unassigned_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with conversation_participating_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role without any conversation permissions' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when conversation_id is blank' do
+ it 'returns error message' do
+ expect(service.execute({})).to eq('Missing required parameters')
+ end
+ end
+
+ context 'when conversation is not found' do
+ it 'returns not found message' do
+ expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation exists' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+
+ it 'returns the conversation in llm text format' do
+ result = service.execute({ 'conversation_id' => conversation.display_id })
+ expect(result).to eq(conversation.to_llm_text)
+ end
+
+ context 'when conversation belongs to different account' do
+ let(:other_account) { create(:account) }
+ let(:other_inbox) { create(:inbox, account: other_account) }
+ let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
+
+ it 'returns not found message' do
+ expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb
new file mode 100644
index 000000000..e4504d7bd
--- /dev/null
+++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb
@@ -0,0 +1,167 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:service) { described_class.new(assistant, user: user) }
+
+ describe '#name' do
+ it 'returns the correct service name' do
+ expect(service.name).to eq('search_articles')
+ end
+ end
+
+ describe '#description' do
+ it 'returns the service description' do
+ expect(service.description).to eq('Search articles based on parameters')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the expected parameter schema' do
+ expect(service.parameters).to eq(
+ {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description: 'Search articles by title or content (partial match)'
+ },
+ category_id: {
+ type: 'number',
+ description: 'Filter articles by category ID'
+ },
+ status: {
+ type: 'string',
+ enum: %w[draft published archived],
+ description: 'Filter articles by status'
+ }
+ },
+ required: ['query']
+ }
+ )
+ end
+ end
+
+ describe '#active?' do
+ context 'when user is an admin' do
+ let(:user) { create(:user, :administrator, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user is an agent' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role with knowledge_base_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has custom role without knowledge_base_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when query is blank' do
+ it 'returns error message' do
+ expect(service.execute({})).to eq('Missing required parameters')
+ end
+ end
+
+ context 'when no articles are found' do
+ before do
+ allow(Article).to receive(:where).and_return(Article.none)
+ end
+
+ it 'returns no articles found message' do
+ expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
+ end
+ end
+
+ context 'when articles are found' do
+ let(:portal) { create(:portal, account: account) }
+ let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
+ let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
+
+ before do
+ article1
+ article2
+ end
+
+ it 'returns formatted articles with count' do
+ result = service.execute({ 'query' => 'Test' })
+ expect(result).to include('Total number of articles: 2')
+ expect(result).to include(article1.to_llm_text)
+ expect(result).to include(article2.to_llm_text)
+ end
+
+ context 'when filtered by category' do
+ let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
+ let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
+
+ before do
+ article3
+ end
+
+ it 'returns only articles from the specified category' do
+ result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
+ expect(result).to include('Total number of articles: 1')
+ expect(result).to include(article3.to_llm_text)
+ expect(result).not_to include(article1.to_llm_text)
+ expect(result).not_to include(article2.to_llm_text)
+ end
+ end
+
+ context 'when filtered by status' do
+ let(:article3) do
+ create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published')
+ end
+ let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
+
+ before do
+ article3
+ article4
+ end
+
+ it 'returns only articles with the specified status' do
+ result = service.execute({ 'query' => 'Test', 'status' => 'published' })
+ expect(result).to include(article3.to_llm_text)
+ expect(result).not_to include(article4.to_llm_text)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb
new file mode 100644
index 000000000..f54b2eddf
--- /dev/null
+++ b/spec/enterprise/services/captain/tools/copilot/search_contacts_service_spec.rb
@@ -0,0 +1,113 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::Copilot::SearchContactsService do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:service) { described_class.new(assistant, user: user) }
+
+ describe '#name' do
+ it 'returns the correct service name' do
+ expect(service.name).to eq('search_contacts')
+ end
+ end
+
+ describe '#description' do
+ it 'returns the service description' do
+ expect(service.description).to eq('Search contacts based on query parameters')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the expected parameter schema' do
+ expect(service.parameters).to eq(
+ {
+ type: 'object',
+ properties: {
+ email: {
+ type: 'string',
+ description: 'Filter contacts by email'
+ },
+ phone_number: {
+ type: 'string',
+ description: 'Filter contacts by phone number'
+ },
+ name: {
+ type: 'string',
+ description: 'Filter contacts by name (partial match)'
+ }
+ },
+ required: []
+ }
+ )
+ end
+ end
+
+ describe '#active?' do
+ context 'when user has contact_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user does not have contact_manage permission' do
+ let(:user) { create(:user, account: account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
+ describe '#execute' do
+ context 'when contacts are found' do
+ let(:contact1) { create(:contact, account: account, email: 'test1@example.com', name: 'Test Contact 1', phone_number: '+1234567890') }
+ let(:contact2) { create(:contact, account: account, email: 'test2@example.com', name: 'Test Contact 2', phone_number: '+1234567891') }
+
+ before do
+ contact1
+ contact2
+ end
+
+ it 'returns contacts when filtered by email' do
+ result = service.execute({ 'email' => 'test1@example.com' })
+ expect(result).to include(contact1.to_llm_text)
+ expect(result).not_to include(contact2.to_llm_text)
+ end
+
+ it 'returns contacts when filtered by phone number' do
+ result = service.execute({ 'phone_number' => '+1234567890' })
+ expect(result).to include(contact1.to_llm_text)
+ expect(result).not_to include(contact2.to_llm_text)
+ end
+
+ it 'returns contacts when filtered by name' do
+ result = service.execute({ 'name' => 'Contact 1' })
+ expect(result).to include(contact1.to_llm_text)
+ expect(result).not_to include(contact2.to_llm_text)
+ end
+
+ it 'returns all matching contacts when no filters are provided' do
+ result = service.execute({})
+ expect(result).to include(contact1.to_llm_text)
+ expect(result).to include(contact2.to_llm_text)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
index e60ad6f28..ab0865bc2 100644
--- a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb
@@ -26,6 +26,64 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
end
end
+ describe '#active?' do
+ context 'when user has conversation_manage permission' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
+ let(:user) { create(:user, account: account) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has conversation_unassigned_manage permission' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
+ let(:user) { create(:user, account: account) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has conversation_participating_manage permission' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
+ let(:user) { create(:user, account: account) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns true' do
+ expect(service.active?).to be true
+ end
+ end
+
+ context 'when user has no relevant conversation permissions' do
+ let(:custom_role) { create(:custom_role, account: account, permissions: []) }
+ let(:user) { create(:user, account: account) }
+
+ before do
+ account_user = AccountUser.find_by(user: user, account: account)
+ account_user.update(role: :agent, custom_role: custom_role)
+ end
+
+ it 'returns false' do
+ expect(service.active?).to be false
+ end
+ end
+ end
+
describe '#execute' do
let(:contact) { create(:contact, account: account) }
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 9395f3e55..aef91603d 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -793,8 +793,8 @@ RSpec.describe Conversation do
end
context 'when a new conversation is created' do
- it 'sets last_activity_at to the created_at time' do
- expect(conversation.last_activity_at).to eq(conversation.created_at)
+ it 'sets last_activity_at to the created_at time (within DB precision)' do
+ expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
end
end
diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
index 29f7136f1..85bb08d74 100644
--- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
+++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb
@@ -183,7 +183,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
# Verify that not all messages are included (some were truncated)
- expect(messages.count).to be > result.scan(/John Doe:/).count
+ expect(messages.count).to be > result.scan('John Doe:').count
end
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do