diff --git a/.circleci/config.yml b/.circleci/config.yml index bc7053130..99795db91 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,15 +73,15 @@ jobs: libvips - run: - name: Install RVM and Ruby 3.3.3 + name: Install RVM and Ruby 3.4.4 command: | sudo apt-get install -y gpg gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB \curl -sSL https://get.rvm.io | bash -s stable echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV source ~/.rvm/scripts/rvm - rvm install "3.3.3" - rvm use 3.3.3 --default + rvm install "3.4.4" + rvm use 3.4.4 --default gem install bundler -v 2.5.16 - run: diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index c5530ac17..21a9fe909 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -12,7 +12,7 @@ services: args: VARIANT: 'ubuntu-22.04' NODE_VERSION: '23.7.0' - RUBY_VERSION: '3.3.3' + RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' USER_GID: '1000' @@ -25,7 +25,7 @@ services: args: VARIANT: 'ubuntu-22.04' NODE_VERSION: '23.7.0' - RUBY_VERSION: '3.3.3' + RUBY_VERSION: '3.4.4' # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. USER_UID: '1000' USER_GID: '1000' diff --git a/.rubocop.yml b/.rubocop.yml index dffd2283c..d1e1726a9 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -85,6 +85,10 @@ Metrics/ModuleLength: - lib/seeders/message_seeder.rb - spec/support/slack_stubs.rb +Rails/HelperInstanceVariable: + Exclude: + - enterprise/app/helpers/captain/chat_helper.rb + Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -112,6 +116,20 @@ Rails/TimeZoneAssignment: Rails/RedundantPresenceValidationOnBelongsTo: Enabled: false +Rails/InverseOf: + Exclude: + - enterprise/app/models/captain/assistant.rb + +Rails/UniqueValidationWithoutIndex: + Exclude: + - 'app/models/canned_response.rb' + - 'app/models/telegram_bot.rb' + - 'enterprise/app/models/captain_inbox.rb' + - 'app/models/channel/twitter_profile.rb' + - 'app/models/webhook.rb' + - 'app/models/contact.rb' + - 'app/models/integrations/hook.rb' + Style/ClassAndModuleChildren: EnforcedStyle: compact Exclude: @@ -154,15 +172,6 @@ Metrics/AbcSize: Exclude: - 'app/controllers/concerns/auth_helper.rb' -Rails/UniqueValidationWithoutIndex: - Exclude: - - 'app/models/channel/twitter_profile.rb' - - 'app/models/webhook.rb' - - 'app/models/contact.rb' - - 'app/models/integrations/hook.rb' - - 'app/models/canned_response.rb' - - 'app/models/telegram_bot.rb' - Rails/RenderInline: Exclude: - 'app/controllers/swagger_controller.rb' diff --git a/.ruby-version b/.ruby-version index 619b53766..f9892605c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.3 +3.4.4 diff --git a/Gemfile b/Gemfile index fe180439f..71d511ace 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,12 @@ source 'https://rubygems.org' -ruby '3.3.3' +ruby '3.4.4' ##-- base gems for rails --## + gem 'rack-cors', require: 'rack/cors' gem 'rails', '~> 7.2.0' + # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false diff --git a/Gemfile.lock b/Gemfile.lock index cb2d6df68..a1d03a122 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1069,4 +1069,4 @@ RUBY VERSION ruby 3.3.3p89 BUNDLED WITH - 2.5.16 + 2.5.16 \ No newline at end of file diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index ffa45db2e..788ae39d1 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -59,11 +59,13 @@ class ContactInboxBuilder end def create_contact_inbox - ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( + attrs = { contact_id: @contact.id, inbox_id: @inbox.id, source_id: @source_id - ) + } + + ::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false) rescue ActiveRecord::RecordNotUnique Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") update_old_contact_inbox diff --git a/app/javascript/dashboard/components-next/copilot/CopilotAgentMessage.vue b/app/javascript/dashboard/components-next/copilot/CopilotAgentMessage.vue index ef8c2faf4..f1ad2afc1 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotAgentMessage.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotAgentMessage.vue @@ -1,31 +1,17 @@ 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 = () => { 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