From b71a0da10d742ea1045342ef56cbf9ed1a7a7a7e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 21 Jul 2025 16:42:12 +0530 Subject: [PATCH 01/74] feat: scenario tools [CW-4597] (#11908) Co-authored-by: Muhsin Keloth Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sojan Jose --- Gemfile | 1 + Gemfile.lock | 12 ++ config/agents/tools.yml | 26 +++ config/routes.rb | 3 + .../accounts/captain/assistants_controller.rb | 4 + enterprise/app/models/captain/assistant.rb | 1 + enterprise/app/models/captain/scenario.rb | 54 +++++- .../models/concerns/captain_tools_helpers.rb | 76 ++++++++ .../app/policies/captain/assistant_policy.rb | 4 + .../captain/assistants/tools.json.jbuilder | 6 + .../captain/tools/add_contact_note_tool.rb | 26 +++ .../tools/add_label_to_conversation_tool.rb | 34 ++++ .../captain/tools/add_private_note_tool.rb | 33 ++++ .../lib/captain/tools/base_public_tool.rb | 45 +++++ .../lib/captain/tools/update_priority_tool.rb | 48 +++++ .../tools/add_contact_note_tool_spec.rb | 116 +++++++++++ .../add_label_to_conversation_tool_spec.rb | 125 ++++++++++++ .../tools/add_private_note_tool_spec.rb | 124 ++++++++++++ .../tools/update_priority_tool_spec.rb | 117 ++++++++++++ .../models/captain/scenario_spec.rb | 110 ++++++++++- .../concerns/captain_tools_helpers_spec.rb | 180 ++++++++++++++++++ 21 files changed, 1139 insertions(+), 6 deletions(-) create mode 100644 config/agents/tools.yml create mode 100644 enterprise/app/models/concerns/captain_tools_helpers.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder create mode 100644 enterprise/lib/captain/tools/add_contact_note_tool.rb create mode 100644 enterprise/lib/captain/tools/add_label_to_conversation_tool.rb create mode 100644 enterprise/lib/captain/tools/add_private_note_tool.rb create mode 100644 enterprise/lib/captain/tools/base_public_tool.rb create mode 100644 enterprise/lib/captain/tools/update_priority_tool.rb create mode 100644 spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb create mode 100644 spec/enterprise/models/concerns/captain_tools_helpers_spec.rb diff --git a/Gemfile b/Gemfile index b4752c745..53ba9f783 100644 --- a/Gemfile +++ b/Gemfile @@ -177,6 +177,7 @@ gem 'reverse_markdown' gem 'iso-639' gem 'ruby-openai' +gem 'ai-agents', '>= 0.2.1' gem 'shopify_api' diff --git a/Gemfile.lock b/Gemfile.lock index 8315a5374..d55cfa0a9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,6 +126,8 @@ GEM jbuilder (~> 2) rails (>= 4.2, < 7.2) selectize-rails (~> 0.6) + ai-agents (0.2.1) + ruby_llm (~> 1.3) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -717,6 +719,15 @@ GEM ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) + ruby_llm (1.3.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + zeitwerk (~> 2) ruby_parser (3.20.0) sexp_processor (~> 4.16) sass (3.7.4) @@ -895,6 +906,7 @@ DEPENDENCIES administrate (>= 0.20.1) administrate-field-active_storage (>= 1.0.3) administrate-field-belongs_to_search (>= 0.9.0) + ai-agents (>= 0.2.1) annotate attr_extras audited (~> 5.4, >= 5.4.1) diff --git a/config/agents/tools.yml b/config/agents/tools.yml new file mode 100644 index 000000000..b994e93d3 --- /dev/null +++ b/config/agents/tools.yml @@ -0,0 +1,26 @@ +###### Captain Agent Tools Configuration ####### +# id: Tool identifier used to resolve the class (Captain::Tools::{PascalCase(id)}Tool) +# title: Human-readable tool name for frontend display +# description: Brief description of what the tool does +# icon: Icon name for frontend display (frontend icon system) +####################################################### + +- id: add_contact_note + title: 'Add Contact Note' + description: 'Add a note to a contact profile' + icon: 'note-add' + +- id: add_private_note + title: 'Add Private Note' + description: 'Add a private note to a conversation (internal only)' + icon: 'eye-off' + +- id: update_priority + title: 'Update Priority' + description: 'Update conversation priority level' + icon: 'exclamation-triangle' + +- id: add_label_to_conversation + title: 'Add Label to Conversation' + description: 'Add a label to a conversation' + icon: 'tag' diff --git a/config/routes.rb b/config/routes.rb index f262febdf..681c21e44 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,9 @@ Rails.application.routes.draw do member do post :playground end + collection do + get :tools + end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :scenarios end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 1bd928271..aaf81677a 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -32,6 +32,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base render json: response end + def tools + @tools = Captain::Assistant.available_agent_tools + end + private def set_assistant diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 4ce4c4afd..cdf2b53f3 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -18,6 +18,7 @@ # class Captain::Assistant < ApplicationRecord include Avatarable + include Concerns::CaptainToolsHelpers self.table_name = 'captain_assistants' diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index ff8e63c64..ecfba396f 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -21,6 +21,8 @@ # index_captain_scenarios_on_enabled (enabled) # class Captain::Scenario < ApplicationRecord + include Concerns::CaptainToolsHelpers + self.table_name = 'captain_scenarios' belongs_to :assistant, class_name: 'Captain::Assistant' @@ -31,14 +33,60 @@ class Captain::Scenario < ApplicationRecord validates :instruction, presence: true validates :assistant_id, presence: true validates :account_id, presence: true + validate :validate_instruction_tools scope :enabled, -> { where(enabled: true) } - before_save :populate_tools + before_save :resolve_tool_references private - def populate_tools - # TODO: Implement tools population logic + # Validates that all tool references in the instruction are valid. + # Parses the instruction for tool references and checks if they exist + # in the available tools configuration. + # + # @return [void] + # @api private + # @example Valid instruction + # scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document" + # scenario.valid? # => true + # + # @example Invalid instruction + # scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process" + # scenario.valid? # => false + # scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"] + def validate_instruction_tools + return if instruction.blank? + + tool_ids = extract_tool_ids_from_text(instruction) + return if tool_ids.empty? + + available_tool_ids = self.class.available_tool_ids + invalid_tools = tool_ids - available_tool_ids + + return unless invalid_tools.any? + + errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}") + end + + # Resolves tool references from the instruction text into the tools field. + # Parses the instruction for tool references and materializes them as + # tool IDs stored in the tools JSONB field. + # + # @return [void] + # @api private + # @example + # scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)" + # scenario.save! + # scenario.tools # => ["add_private_note", "update_priority"] + # + # scenario.instruction = "No tools mentioned here" + # scenario.save! + # scenario.tools # => nil + def resolve_tool_references + return if instruction.blank? + + tool_ids = extract_tool_ids_from_text(instruction) + self.tools = tool_ids.presence end end diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb new file mode 100644 index 000000000..5a660310c --- /dev/null +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -0,0 +1,76 @@ +# Provides helper methods for working with Captain agent tools including +# tool resolution, text parsing, and metadata retrieval. +module Concerns::CaptainToolsHelpers + extend ActiveSupport::Concern + + # Regular expression pattern for matching tool references in text. + # Matches patterns like [Tool name](tool://tool_id) following markdown link syntax. + TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} + + class_methods do + # Returns all available agent tools with their metadata. + # Only includes tools that have corresponding class files and can be resolved. + # + # @return [Array] Array of tool hashes with :id, :title, :description, :icon + def available_agent_tools + @available_agent_tools ||= load_agent_tools + end + + # Resolves a tool class from a tool ID. + # Converts snake_case tool IDs to PascalCase class names and constantizes them. + # + # @param tool_id [String] The snake_case tool identifier + # @return [Class, nil] The tool class if found, nil if not resolvable + def resolve_tool_class(tool_id) + class_name = "Captain::Tools::#{tool_id.classify}Tool" + class_name.safe_constantize + end + + # Returns an array of all available tool IDs. + # Convenience method that extracts just the IDs from available_agent_tools. + # + # @return [Array] Array of available tool IDs + def available_tool_ids + @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] } + end + + private + + # Loads agent tools from the YAML configuration file. + # Filters out tools that cannot be resolved to actual classes. + # + # @return [Array] Array of resolvable tools with metadata + # @api private + def load_agent_tools + tools_config = YAML.load_file(Rails.root.join('config/agents/tools.yml')) + + tools_config.filter_map do |tool_config| + tool_class = resolve_tool_class(tool_config['id']) + + if tool_class + { + id: tool_config['id'], + title: tool_config['title'], + description: tool_config['description'], + icon: tool_config['icon'] + } + else + Rails.logger.warn "Tool class not found for ID: #{tool_config['id']}" + nil + end + end + end + end + + # Extracts tool IDs from text containing tool references. + # Parses text for (tool://tool_id) patterns and returns unique tool IDs. + # + # @param text [String] Text to parse for tool references + # @return [Array] Array of unique tool IDs found in the text + def extract_tool_ids_from_text(text) + return [] if text.blank? + + tool_matches = text.scan(TOOL_REFERENCE_REGEX) + tool_matches.flatten.uniq + end +end diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index 2a03fc0ea..7fbb47aa9 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy true end + def tools? + @account_user.administrator? + end + def create? @account_user.administrator? end diff --git a/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder new file mode 100644 index 000000000..3a993caeb --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/assistants/tools.json.jbuilder @@ -0,0 +1,6 @@ +json.array! @tools do |tool| + json.id tool[:id] + json.title tool[:title] + json.description tool[:description] + json.icon tool[:icon] +end diff --git a/enterprise/lib/captain/tools/add_contact_note_tool.rb b/enterprise/lib/captain/tools/add_contact_note_tool.rb new file mode 100644 index 000000000..e1475abf0 --- /dev/null +++ b/enterprise/lib/captain/tools/add_contact_note_tool.rb @@ -0,0 +1,26 @@ +class Captain::Tools::AddContactNoteTool < Captain::Tools::BasePublicTool + description 'Add a note to a contact profile' + param :note, type: 'string', desc: 'The note content to add to the contact' + + def perform(tool_context, note:) + contact = find_contact(tool_context.state) + return 'Contact not found' unless contact + + return 'Note content is required' if note.blank? + + log_tool_usage('add_contact_note', { contact_id: contact.id, note_length: note.length }) + + create_contact_note(contact, note) + "Note added successfully to contact #{contact.name} (ID: #{contact.id})" + end + + private + + def create_contact_note(contact, note) + contact.notes.create!(content: note) + end + + def permissions + %w[contact_manage] + end +end diff --git a/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb new file mode 100644 index 000000000..429f33b7b --- /dev/null +++ b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb @@ -0,0 +1,34 @@ +class Captain::Tools::AddLabelToConversationTool < Captain::Tools::BasePublicTool + description 'Add a label to a conversation' + param :label_name, type: 'string', desc: 'The name of the label to add' + + def perform(tool_context, label_name:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + label_name = label_name&.strip&.downcase + return 'Label name is required' if label_name.blank? + + label = find_label(label_name) + return 'Label not found' unless label + + add_label_to_conversation(conversation, label_name) + + log_tool_usage('added_label', conversation_id: conversation.id, label: label_name) + + "Label '#{label_name}' added to conversation ##{conversation.display_id}" + end + + private + + def find_label(label_name) + account_scoped(Label).find_by(title: label_name) + end + + def add_label_to_conversation(conversation, label_name) + conversation.add_labels(label_name) + rescue StandardError => e + Rails.logger.error "Failed to add label to conversation: #{e.message}" + raise + end +end diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb new file mode 100644 index 000000000..36e1ef977 --- /dev/null +++ b/enterprise/lib/captain/tools/add_private_note_tool.rb @@ -0,0 +1,33 @@ +class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool + description 'Add a private note to a conversation' + param :note, type: 'string', desc: 'The private note content' + + def perform(tool_context, note:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + return 'Note content is required' if note.blank? + + log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length }) + create_private_note(conversation, note) + + 'Private note added successfully' + end + + private + + def create_private_note(conversation, note) + conversation.messages.create!( + account: @assistant.account, + inbox: conversation.inbox, + sender: @assistant, + message_type: :outgoing, + content: note, + private: true + ) + end + + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] + end +end diff --git a/enterprise/lib/captain/tools/base_public_tool.rb b/enterprise/lib/captain/tools/base_public_tool.rb new file mode 100644 index 000000000..e1f779b36 --- /dev/null +++ b/enterprise/lib/captain/tools/base_public_tool.rb @@ -0,0 +1,45 @@ +require 'agents' + +class Captain::Tools::BasePublicTool < Agents::Tool + def initialize(assistant) + @assistant = assistant + super() + end + + def active? + # Public tools are always active + true + end + + def permissions + # Override in subclasses to specify required permissions + # Returns empty array for public tools (no permissions required) + [] + end + + private + + def account_scoped(model_class) + model_class.where(account_id: @assistant.account_id) + end + + def find_conversation(state) + conversation_id = state&.dig(:conversation, :id) + return nil unless conversation_id + + account_scoped(::Conversation).find_by(id: conversation_id) + end + + def find_contact(state) + contact_id = state&.dig(:contact, :id) + return nil unless contact_id + + account_scoped(::Contact).find_by(id: contact_id) + end + + def log_tool_usage(action, details = {}) + Rails.logger.info do + "#{self.class.name}: #{action} for assistant #{@assistant&.id} - #{details.inspect}" + end + end +end diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb new file mode 100644 index 000000000..8fc75f601 --- /dev/null +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -0,0 +1,48 @@ +class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool + description 'Update the priority of a conversation' + param :priority, type: 'string', desc: 'The priority level: low, medium, high, urgent, or nil to remove priority' + + def perform(tool_context, priority:) + @conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless @conversation + + @normalized_priority = normalize_priority(priority) + return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority) + + log_tool_usage('update_priority', { conversation_id: @conversation.id, priority: priority }) + + execute_priority_update + end + + private + + def execute_priority_update + update_conversation_priority(@conversation, @normalized_priority) + priority_text = @normalized_priority || 'none' + "Priority updated to '#{priority_text}' for conversation ##{@conversation.display_id}" + end + + def normalize_priority(priority) + priority == 'nil' || priority.blank? ? nil : priority + end + + def valid_priority?(priority) + valid_priorities.include?(priority) + end + + def valid_priorities + @valid_priorities ||= [nil] + Conversation.priorities.keys + end + + def valid_priority_options + (valid_priorities.compact + ['nil']).join(', ') + end + + def update_conversation_priority(conversation, priority) + conversation.update!(priority: priority) + end + + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] + end +end diff --git a/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb new file mode 100644 index 000000000..c087242dc --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddContactNoteTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ contact: { id: contact.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a note to a contact profile') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:note) + expect(tool.parameters[:note].name).to eq(:note) + expect(tool.parameters[:note].type).to eq('string') + expect(tool.parameters[:note].description).to eq('The note content to add to the contact') + end + end + + describe '#perform' do + context 'when contact exists' do + context 'with valid note content' do + it 'creates a contact note and returns success message' do + note_content = 'This is a contact note' + + expect do + result = tool.perform(tool_context, note: note_content) + expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})") + end.to change(Note, :count).by(1) + + created_note = Note.last + expect(created_note.content).to eq(note_content) + expect(created_note.account).to eq(account) + expect(created_note.contact).to eq(contact) + expect(created_note.user).to eq(assistant.account.users.first) + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'add_contact_note', + { contact_id: contact.id, note_length: 19 } + ) + + tool.perform(tool_context, note: 'This is a test note') + end + end + + context 'with blank note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: '') + expect(result).to eq('Note content is required') + end + + it 'does not create a note' do + expect do + tool.perform(tool_context, note: '') + end.not_to change(Note, :count) + end + end + + context 'with nil note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: nil) + expect(result).to eq('Note content is required') + end + end + end + + context 'when contact does not exist' do + let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + + it 'does not create a note' do + expect do + tool.perform(tool_context, note: 'Some note') + end.not_to change(Note, :count) + end + end + + context 'when contact state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + end + + context 'when contact id is nil' do + let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Contact not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb new file mode 100644 index 000000000..38e4dc7c6 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddLabelToConversationTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:label) { create(:label, account: account, title: 'urgent') } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a label to a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:label_name) + expect(tool.parameters[:label_name].name).to eq(:label_name) + expect(tool.parameters[:label_name].type).to eq('string') + expect(tool.parameters[:label_name].description).to eq('The name of the label to add') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid label that exists' do + before { label } + + it 'adds label to conversation and returns success message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + + expect(conversation.reload.label_list).to include('urgent') + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'added_label', + { conversation_id: conversation.id, label: 'urgent' } + ) + + tool.perform(tool_context, label_name: 'urgent') + end + + it 'handles case insensitive label names' do + result = tool.perform(tool_context, label_name: 'URGENT') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + end + + it 'strips whitespace from label names' do + result = tool.perform(tool_context, label_name: ' urgent ') + expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}") + end + end + + context 'with label that does not exist' do + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'nonexistent') + expect(result).to eq('Label not found') + end + + it 'does not add any labels to conversation' do + expect do + tool.perform(tool_context, label_name: 'nonexistent') + end.not_to(change { conversation.reload.labels.count }) + end + end + + context 'with blank label name' do + it 'returns error message for empty string' do + result = tool.perform(tool_context, label_name: '') + expect(result).to eq('Label name is required') + end + + it 'returns error message for nil' do + result = tool.perform(tool_context, label_name: nil) + expect(result).to eq('Label name is required') + end + + it 'returns error message for whitespace only' do + result = tool.perform(tool_context, label_name: ' ') + expect(result).to eq('Label name is required') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, label_name: 'urgent') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb new file mode 100644 index 000000000..cfce1a7d1 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb @@ -0,0 +1,124 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Add a private note to a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:note) + expect(tool.parameters[:note].name).to eq(:note) + expect(tool.parameters[:note].type).to eq('string') + expect(tool.parameters[:note].description).to eq('The private note content') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid note content' do + it 'creates a private note and returns success message' do + note_content = 'This is a private note' + + expect do + result = tool.perform(tool_context, note: note_content) + expect(result).to eq('Private note added successfully') + end.to change(Message, :count).by(1) + end + + it 'creates a private note with correct attributes' do + note_content = 'This is a private note' + + tool.perform(tool_context, note: note_content) + + created_message = Message.last + expect(created_message.content).to eq(note_content) + expect(created_message.message_type).to eq('outgoing') + expect(created_message.private).to be true + expect(created_message.account).to eq(account) + expect(created_message.inbox).to eq(inbox) + expect(created_message.conversation).to eq(conversation) + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'add_private_note', + { conversation_id: conversation.id, note_length: 19 } + ) + + tool.perform(tool_context, note: 'This is a test note') + end + end + + context 'with blank note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: '') + expect(result).to eq('Note content is required') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, note: '') + end.not_to change(Message, :count) + end + end + + context 'with nil note content' do + it 'returns error message' do + result = tool.perform(tool_context, note: nil) + expect(result).to eq('Note content is required') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, note: 'Some note') + end.not_to change(Message, :count) + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, note: 'Some note') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb new file mode 100644 index 000000000..9aa858593 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb @@ -0,0 +1,117 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::UpdatePriorityTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Update the priority of a conversation') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:priority) + expect(tool.parameters[:priority].name).to eq(:priority) + expect(tool.parameters[:priority].type).to eq('string') + expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority') + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with valid priority levels' do + %w[low medium high urgent].each do |priority| + it "updates conversation priority to #{priority}" do + result = tool.perform(tool_context, priority: priority) + expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to eq(priority) + end + end + + it 'removes priority when set to nil' do + conversation.update!(priority: 'high') + + result = tool.perform(tool_context, priority: 'nil') + expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to be_nil + end + + it 'removes priority when set to empty string' do + conversation.update!(priority: 'high') + + result = tool.perform(tool_context, priority: '') + expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}") + + expect(conversation.reload.priority).to be_nil + end + + it 'logs tool usage' do + expect(tool).to receive(:log_tool_usage).with( + 'update_priority', + { conversation_id: conversation.id, priority: 'high' } + ) + + tool.perform(tool_context, priority: 'high') + end + end + + context 'with invalid priority levels' do + it 'returns error message for invalid priority' do + result = tool.perform(tool_context, priority: 'invalid') + expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil') + end + + it 'does not update conversation priority' do + original_priority = conversation.priority + + tool.perform(tool_context, priority: 'invalid') + + expect(conversation.reload.priority).to eq(original_priority) + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, priority: 'high') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 1f13a362b..7a39559c3 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -33,15 +33,119 @@ RSpec.describe Captain::Scenario, type: :model do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } - describe 'before_save :populate_tools' do - it 'calls populate_tools before saving' do + describe 'before_save :resolve_tool_references' do + it 'calls resolve_tool_references before saving' do scenario = build(:captain_scenario, assistant: assistant, account: account) - expect(scenario).to receive(:populate_tools) + expect(scenario).to receive(:resolve_tool_references) scenario.save end end end + describe 'tool validation and population' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + # Mock available tools + allow(described_class).to receive(:available_tool_ids).and_return(%w[ + add_contact_note add_private_note update_priority + ]) + end + + describe 'validate_instruction_tools' do + it 'is valid with valid tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note) to document') + + expect(scenario).to be_valid + end + + it 'is invalid with invalid tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Invalid Tool](tool://invalid_tool) to process') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool') + end + + it 'is invalid with multiple invalid tools' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Invalid Tool](tool://invalid_tool) and [@Another Invalid](tool://another_invalid)') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool, another_invalid') + end + + it 'is valid with no tool references' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Just respond politely to the customer') + + expect(scenario).to be_valid + end + + it 'is valid with blank instruction' do + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: '') + + # Will be invalid due to presence validation, not tool validation + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) + end + end + + describe 'resolve_tool_references' do + it 'populates tools array with referenced tool IDs' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)') + + expect(scenario.tools).to eq(%w[add_contact_note update_priority]) + end + + it 'sets tools to nil when no tools are referenced' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Just respond politely to the customer') + + expect(scenario.tools).to be_nil + end + + it 'handles duplicate tool references' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note) and [@Add Contact Note](tool://add_contact_note) again') + + expect(scenario.tools).to eq(['add_contact_note']) + end + + it 'updates tools when instruction changes' do + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Contact Note](tool://add_contact_note)') + + expect(scenario.tools).to eq(['add_contact_note']) + + scenario.update!(instruction: 'Use [@Update Priority](tool://update_priority) instead') + expect(scenario.tools).to eq(['update_priority']) + end + end + end + describe 'factory' do it 'creates a valid scenario with associations' do account = create(:account) diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb new file mode 100644 index 000000000..afe482385 --- /dev/null +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -0,0 +1,180 @@ +require 'rails_helper' + +RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do + # Create a test class that includes the concern + let(:test_class) do + Class.new do + include Concerns::CaptainToolsHelpers + + def self.name + 'TestClass' + end + end + end + + let(:test_instance) { test_class.new } + + describe 'TOOL_REFERENCE_REGEX' do + it 'matches tool references in text' do + text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)' + matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX) + + expect(matches.flatten).to eq(%w[add_contact_note update_priority]) + end + + it 'does not match invalid formats' do + invalid_formats = [ + '', + 'tool://invalid', + '(tool:invalid)', + '(tool://)', + '(tool://with/slash)', + '(tool://add_contact_note)', + '[@Tool](tool://)', + '[Tool](tool://with/slash)', + '[](tool://valid)' + ] + + invalid_formats.each do |format| + matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX) + expect(matches).to be_empty, "Should not match: #{format}" + end + end + end + + describe '.available_agent_tools' do + before do + # Mock the YAML file loading + allow(YAML).to receive(:load_file).and_return([ + { + 'id' => 'add_contact_note', + 'title' => 'Add Contact Note', + 'description' => 'Add a note to a contact', + 'icon' => 'note-add' + }, + { + 'id' => 'invalid_tool', + 'title' => 'Invalid Tool', + 'description' => 'This tool does not exist', + 'icon' => 'invalid' + } + ]) + + # Mock class resolution - only add_contact_note exists + allow(test_class).to receive(:resolve_tool_class) do |tool_id| + case tool_id + when 'add_contact_note' + Captain::Tools::AddContactNoteTool + end + end + end + + it 'returns only resolvable tools' do + tools = test_class.available_agent_tools + + expect(tools.length).to eq(1) + expect(tools.first).to eq({ + id: 'add_contact_note', + title: 'Add Contact Note', + description: 'Add a note to a contact', + icon: 'note-add' + }) + end + + it 'logs warnings for unresolvable tools' do + expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool') + + test_class.available_agent_tools + end + + it 'memoizes the result' do + expect(YAML).to receive(:load_file).once.and_return([]) + + 2.times { test_class.available_agent_tools } + end + end + + describe '.resolve_tool_class' do + it 'resolves valid tool classes' do + # Mock the constantize to return a class + stub_const('Captain::Tools::AddContactNoteTool', Class.new) + + result = test_class.resolve_tool_class('add_contact_note') + expect(result).to eq(Captain::Tools::AddContactNoteTool) + end + + it 'returns nil for invalid tool classes' do + result = test_class.resolve_tool_class('invalid_tool') + expect(result).to be_nil + end + + it 'converts snake_case to PascalCase' do + stub_const('Captain::Tools::AddPrivateNoteTool', Class.new) + + result = test_class.resolve_tool_class('add_private_note') + expect(result).to eq(Captain::Tools::AddPrivateNoteTool) + end + end + + describe '.available_tool_ids' do + before do + allow(test_class).to receive(:available_agent_tools).and_return([ + { id: 'add_contact_note', title: 'Add Contact Note', description: '...', + icon: 'note' }, + { id: 'update_priority', title: 'Update Priority', description: '...', + icon: 'priority' } + ]) + end + + it 'returns array of tool IDs' do + ids = test_class.available_tool_ids + expect(ids).to eq(%w[add_contact_note update_priority]) + end + + it 'memoizes the result' do + expect(test_class).to receive(:available_agent_tools).once.and_return([]) + + 2.times { test_class.available_tool_ids } + end + end + + describe '#extract_tool_ids_from_text' do + it 'extracts tool IDs from text' do + text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq(%w[add_contact_note update_priority]) + end + + it 'returns unique tool IDs' do + text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq(['add_contact_note']) + end + + it 'returns empty array for blank text' do + expect(test_instance.extract_tool_ids_from_text('')).to eq([]) + expect(test_instance.extract_tool_ids_from_text(nil)).to eq([]) + expect(test_instance.extract_tool_ids_from_text(' ')).to eq([]) + end + + it 'returns empty array when no tools found' do + text = 'This text has no tool references' + result = test_instance.extract_tool_ids_from_text(text) + + expect(result).to eq([]) + end + + it 'handles complex text with multiple tools' do + text = <<~TEXT + Start with [@Add Contact Note](tool://add_contact_note) to document. + Then use [@Update Priority](tool://update_priority) if needed. + Finally [@Add Private Note](tool://add_private_note) for internal notes. + TEXT + + result = test_instance.extract_tool_ids_from_text(text) + expect(result).to eq(%w[add_contact_note update_priority add_private_note]) + end + end +end From dc49ae2515d325f026ffddfadaf915bea9e76313 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 22 Jul 2025 10:24:53 +0530 Subject: [PATCH 02/74] feat: Exclude account settings page from upgrade paywall (#11998) This pull request includes a small change to the `Dashboard.vue` file. The change adds `'general_settings_index'` to the list of route names checked for inclusion. --- app/javascript/dashboard/routes/dashboard/Dashboard.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue index ae54e9a7c..3d2610727 100644 --- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue +++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue @@ -56,6 +56,7 @@ export default { return [ 'billing_settings_index', 'settings_inbox_list', + 'general_settings_index', 'agent_list', ].includes(this.$route.name); }, From c4b076dbb2e741d5da7544835c2d14d70b1d0d65 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 22 Jul 2025 14:24:25 +0530 Subject: [PATCH 03/74] feat: allow setting up only web/worker deployments for linux (#12004) ## cwctl - allow setting up only web/worker deployments for linux - allow upgrades to a custom branch - experimental ``` Usage Examples: # Convert existing full deployment to web-only (for web ASG) cwctl --convert web # Convert existing full deployment to worker-only (for worker ASG) cwctl --convert worker # Convert specialized deployment back to full cwctl --convert full # Fresh installs still work as before cwctl -i --web-only # New web-only install cwctl -i --worker-only # New worker-only instal ``` - Upgrading to custom branches is risky, as other dependencies like node or db might have changed ``` cwctl --upgrade # Upgrade to master branch - default cwctl -U v4.3.0 # Upgrade to specific release branch cwctl --upgrade feat/new-feature # Upgrade to feature branch ``` --- VERSION_CW | 2 +- VERSION_CWCTL | 2 +- deployment/chatwoot-web.target | 6 + deployment/chatwoot-worker.target | 6 + deployment/setup_20.04.sh | 248 ++++++++++++++++++++++++++---- 5 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 deployment/chatwoot-web.target create mode 100644 deployment/chatwoot-worker.target diff --git a/VERSION_CW b/VERSION_CW index 4eba2a62e..fdc669880 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -3.13.0 +4.4.0 diff --git a/VERSION_CWCTL b/VERSION_CWCTL index 944880fa1..15a279981 100644 --- a/VERSION_CWCTL +++ b/VERSION_CWCTL @@ -1 +1 @@ -3.2.0 +3.3.0 diff --git a/deployment/chatwoot-web.target b/deployment/chatwoot-web.target new file mode 100644 index 000000000..759c7c443 --- /dev/null +++ b/deployment/chatwoot-web.target @@ -0,0 +1,6 @@ +[Unit] +Description=Chatwoot Web Server +Wants=chatwoot-web.1.service + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deployment/chatwoot-worker.target b/deployment/chatwoot-worker.target new file mode 100644 index 000000000..265cb1156 --- /dev/null +++ b/deployment/chatwoot-worker.target @@ -0,0 +1,6 @@ +[Unit] +Description=Chatwoot Background Worker +Wants=chatwoot-worker.1.service + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deployment/setup_20.04.sh b/deployment/setup_20.04.sh index 75320bbd3..d1091da3c 100644 --- a/deployment/setup_20.04.sh +++ b/deployment/setup_20.04.sh @@ -17,9 +17,9 @@ fi # Global variables # option --output/-o requires 1 argument -LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,webserver,version -OPTIONS=cdhiI:l:rsuwv -CWCTL_VERSION="3.2.0" +LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,Upgrade:,webserver,version,web-only,worker-only,convert: +OPTIONS=cdhiI:l:rsuU:wvWK +CWCTL_VERSION="3.3.0" pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '') CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events" @@ -42,7 +42,7 @@ fi # read getopt’s output this way to handle the quoting right: eval set -- "$PARSED" -c=n d=n h=n i=n I=n l=n r=n s=n u=n w=n v=n BRANCH=master SERVICE=web +c=n d=n h=n i=n I=n l=n r=n s=n u=n U=n w=n v=n W=n K=n C=n BRANCH=master SERVICE=web DEPLOYMENT_TYPE=full CONVERT_TO="" # Iterate options in order and nicely split until we see -- while true; do case "$1" in @@ -83,6 +83,12 @@ while true; do ;; -u|--upgrade) u=y + BRANCH="master" + break + ;; + -U|--Upgrade) + U=y + BRANCH="$2" break ;; -w|--webserver) @@ -93,6 +99,36 @@ while true; do v=y shift ;; + -W|--web-only) + W=y + DEPLOYMENT_TYPE=web + shift + ;; + -K|--worker-only) + K=y + DEPLOYMENT_TYPE=worker + shift + ;; + --convert) + C=y + CONVERT_TO="$2" + case "$CONVERT_TO" in + web) + DEPLOYMENT_TYPE=web + ;; + worker) + DEPLOYMENT_TYPE=worker + ;; + full) + DEPLOYMENT_TYPE=full + ;; + *) + echo "Invalid conversion type. Use: web, worker, or full" + exit 3 + ;; + esac + shift 2 + ;; --) shift break @@ -107,7 +143,7 @@ done # log if debug flag set if [ "$d" == "y" ]; then echo "console: $c, debug: $d, help: $h, install: $i, Install: $I, BRANCH: $BRANCH, \ - logs: $l, SERVICE: $SERVICE, ssl: $s, upgrade: $u, webserver: $w" + logs: $l, SERVICE: $SERVICE, ssl: $s, upgrade: $u, Upgrade: $U, webserver: $w, web-only: $W, worker-only: $K, convert: $C, convert-to: $CONVERT_TO, deployment-type: $DEPLOYMENT_TYPE" fi # exit if script is not run as root @@ -379,23 +415,84 @@ EOF ############################################################################## # Setup Chatwoot systemd services and cwctl CLI # Globals: -# None +# DEPLOYMENT_TYPE # Arguments: # None # Outputs: # None ############################################################################## function configure_systemd_services() { - cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service - cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service - cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target + # Check if this is a conversion from existing deployment + local existing_full_deployment=false + if [ -f "/etc/systemd/system/chatwoot.target" ]; then + existing_full_deployment=true + fi + + if [ "$DEPLOYMENT_TYPE" == "web" ]; then + echo "Setting up web-only deployment" + + # Stop and disable existing services if converting + if [ "$existing_full_deployment" = true ]; then + echo "Converting from full deployment to web-only" + systemctl stop chatwoot.target || true + systemctl disable chatwoot.target || true + systemctl stop chatwoot-worker.1.service || true + systemctl disable chatwoot-worker.1.service || true + fi + + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.target /etc/systemd/system/chatwoot-web.target + + systemctl daemon-reload + systemctl enable chatwoot-web.target + systemctl start chatwoot-web.target + + elif [ "$DEPLOYMENT_TYPE" == "worker" ]; then + echo "Setting up worker-only deployment" + + # Stop and disable existing services if converting + if [ "$existing_full_deployment" = true ]; then + echo "Converting from full deployment to worker-only" + systemctl stop chatwoot.target || true + systemctl disable chatwoot.target || true + systemctl stop chatwoot-web.1.service || true + systemctl disable chatwoot-web.1.service || true + fi + + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.target /etc/systemd/system/chatwoot-worker.target + + systemctl daemon-reload + systemctl enable chatwoot-worker.target + systemctl start chatwoot-worker.target + + else + echo "Setting up full deployment (web + worker)" + + # Stop existing specialized deployments if converting back to full + if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then + echo "Converting from web-only to full deployment" + systemctl stop chatwoot-web.target || true + systemctl disable chatwoot-web.target || true + fi + if [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then + echo "Converting from worker-only to full deployment" + systemctl stop chatwoot-worker.target || true + systemctl disable chatwoot-worker.target || true + fi + + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target + + systemctl daemon-reload + systemctl enable chatwoot.target + systemctl start chatwoot.target + fi cp /home/chatwoot/chatwoot/deployment/chatwoot /etc/sudoers.d/chatwoot cp /home/chatwoot/chatwoot/deployment/setup_20.04.sh /usr/local/bin/cwctl chmod +x /usr/local/bin/cwctl - - systemctl enable chatwoot.target - systemctl start chatwoot.target } ############################################################################## @@ -427,7 +524,15 @@ function setup_ssl() { cd chatwoot sed -i "s/http:\/\/0.0.0.0:3000/https:\/\/$domain_name/g" .env EOF - systemctl restart chatwoot.target + + # Restart the appropriate chatwoot target + if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then + systemctl restart chatwoot-web.target + elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then + systemctl restart chatwoot-worker.target + else + systemctl restart chatwoot.target + fi } ############################################################################## @@ -624,17 +729,27 @@ Usage: cwctl [OPTION]... Install and manage your Chatwoot installation. Example: cwctl -i master +Example: cwctl -i --web-only (for web server ASG) +Example: cwctl -i --worker-only (for worker ASG) +Example: cwctl --convert web (convert existing to web-only) +Example: cwctl --convert worker (convert existing to worker-only) +Example: cwctl --convert full (convert back to full deployment) +Example: cwctl --upgrade (upgrade to latest master) +Example: cwctl -U develop (upgrade to develop branch) Example: cwctl -l web Example: cwctl --logs worker -Example: cwctl --upgrade Example: cwctl -c Installation/Upgrade: -i, --install Install the latest stable version of Chatwoot - -I Install Chatwoot from a git branch + -I BRANCH Install Chatwoot from a git branch -u, --upgrade Upgrade Chatwoot to the latest stable version + -U BRANCH Upgrade Chatwoot from a git branch (EXPERIMENTAL) -s, --ssl Fetch and install SSL certificates using LetsEncrypt -w, --webserver Install and configure Nginx webserver with SSL + -W, --web-only Install only the web server (for ASG deployment) + -K, --worker-only Install only the background worker (for ASG deployment) + --convert TYPE Convert existing deployment (TYPE: web, worker, full) Management: -c, --console Open ruby console @@ -831,7 +946,31 @@ EOF function upgrade() { cwctl_upgrade_check get_cw_version - echo "Upgrading Chatwoot to v$CW_VERSION" + echo "Upgrading Chatwoot to v$CW_VERSION (branch: $BRANCH)" + + # Warning for non-master branch upgrades + if [ "$BRANCH" != "master" ]; then + cat << EOF + +⚠️ WARNING: Branch-specific upgrades are EXPERIMENTAL +⚠️ Switching between different versions/branches may cause: + - Database migration conflicts + - Asset compilation errors + - Configuration incompatibilities + - Data corruption or loss + +⚠️ This is NOT recommended for production environments. +⚠️ Always backup your database before proceeding. + +EOF + read -p "Do you understand the risks and want to continue? [y/N]: " user_input + user_input=${user_input:-N} + if [[ ! "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then + echo "Upgrade cancelled." + exit 1 + fi + fi + sleep 3 # Check if CW_VERSION is 4.0 or above @@ -857,8 +996,9 @@ function upgrade() { # Navigate to the Chatwoot directory cd chatwoot - # Pull the latest version of the master branch - git checkout master && git pull + # Pull the latest version of the specified branch + git fetch + git checkout "$BRANCH" && git pull # Ensure the ruby version is upto date # Parse the latest ruby version @@ -878,18 +1018,35 @@ function upgrade() { EOF - # Copy the updated targets - cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service - cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service - cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target + # Copy the updated services and targets based on existing deployment + if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then + echo "Updating web-only deployment" + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.target /etc/systemd/system/chatwoot-web.target + elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then + echo "Updating worker-only deployment" + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.target /etc/systemd/system/chatwoot-worker.target + else + echo "Updating full deployment" + cp /home/chatwoot/chatwoot/deployment/chatwoot-web.1.service /etc/systemd/system/chatwoot-web.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot-worker.1.service /etc/systemd/system/chatwoot-worker.1.service + cp /home/chatwoot/chatwoot/deployment/chatwoot.target /etc/systemd/system/chatwoot.target + fi cp /home/chatwoot/chatwoot/deployment/chatwoot /etc/sudoers.d/chatwoot # TODO:(@vn) handle cwctl updates systemctl daemon-reload - # Restart the chatwoot server - systemctl restart chatwoot.target + # Restart the appropriate chatwoot target + if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then + systemctl restart chatwoot-web.target + elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then + systemctl restart chatwoot-worker.target + else + systemctl restart chatwoot.target + fi } @@ -903,8 +1060,40 @@ EOF # None ############################################################################## function restart() { - systemctl restart chatwoot.target - systemctl status chatwoot.target + if [ -f "/etc/systemd/system/chatwoot-web.target" ]; then + systemctl restart chatwoot-web.target + systemctl status chatwoot-web.target + elif [ -f "/etc/systemd/system/chatwoot-worker.target" ]; then + systemctl restart chatwoot-worker.target + systemctl status chatwoot-worker.target + else + systemctl restart chatwoot.target + systemctl status chatwoot.target + fi +} + +############################################################################## +# Convert existing Chatwoot deployment to different type (--convert) +# Globals: +# DEPLOYMENT_TYPE +# Arguments: +# None +# Outputs: +# None +############################################################################## +function convert_deployment() { + echo "Converting Chatwoot deployment to: $DEPLOYMENT_TYPE" + + # Check if Chatwoot is installed + if [ ! -d "/home/chatwoot/chatwoot" ]; then + echo "Chatwoot installation not found. Use --install first." + exit 1 + fi + + # Run the systemd service configuration which handles conversion logic + configure_systemd_services + + echo "Deployment converted successfully to: $DEPLOYMENT_TYPE" } ############################################################################## @@ -1107,7 +1296,7 @@ function main() { ssl fi - if [ "$u" == "y" ]; then + if [ "$u" == "y" ] || [ "$U" == "y" ]; then report_event "cwctl" "upgrade" > /dev/null 2>&1 upgrade fi @@ -1122,6 +1311,11 @@ function main() { version fi + if [ "$C" == "y" ]; then + report_event "cwctl" "convert" > /dev/null 2>&1 + convert_deployment + fi + } main "$@" From 69ad953ae6801c6fd418d52d7fd511658f66d4ad Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 22 Jul 2025 14:26:55 +0530 Subject: [PATCH 04/74] fix: bubble color for outgoing email (#12003) --- .../components-next/message/bubbles/Email/Index.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue index 2e03e97af..63b5f8eef 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue @@ -119,7 +119,13 @@ const handleSeeOriginal = () => { >