mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	Merge branch 'develop' into feat/CW-5648
This commit is contained in:
		
							
								
								
									
										22
									
								
								db/migrate/20251003091242_create_captain_custom_tools.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								db/migrate/20251003091242_create_captain_custom_tools.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| class CreateCaptainCustomTools < ActiveRecord::Migration[7.1] | ||||
|   def change | ||||
|     create_table :captain_custom_tools do |t| | ||||
|       t.references :account, null: false, index: true | ||||
|       t.string :slug, null: false | ||||
|       t.string :title, null: false | ||||
|       t.text :description | ||||
|       t.string :http_method, null: false, default: 'GET' | ||||
|       t.text :endpoint_url, null: false | ||||
|       t.text :request_template | ||||
|       t.text :response_template | ||||
|       t.string :auth_type, default: 'none' | ||||
|       t.jsonb :auth_config, default: {} | ||||
|       t.jsonb :param_schema, default: [] | ||||
|       t.boolean :enabled, default: true, null: false | ||||
|  | ||||
|       t.timestamps | ||||
|     end | ||||
|  | ||||
|     add_index :captain_custom_tools, [:account_id, :slug], unique: true | ||||
|   end | ||||
| end | ||||
							
								
								
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do | ||||
| ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do | ||||
|   # These extensions should be enabled to support this database | ||||
|   enable_extension "pg_stat_statements" | ||||
|   enable_extension "pg_trgm" | ||||
| @@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do | ||||
|     t.index ["account_id"], name: "index_captain_assistants_on_account_id" | ||||
|   end | ||||
|  | ||||
|   create_table "captain_custom_tools", force: :cascade do |t| | ||||
|     t.bigint "account_id", null: false | ||||
|     t.string "slug", null: false | ||||
|     t.string "title", null: false | ||||
|     t.text "description" | ||||
|     t.string "http_method", default: "GET", null: false | ||||
|     t.text "endpoint_url", null: false | ||||
|     t.text "request_template" | ||||
|     t.text "response_template" | ||||
|     t.string "auth_type", default: "none" | ||||
|     t.jsonb "auth_config", default: {} | ||||
|     t.jsonb "param_schema", default: [] | ||||
|     t.boolean "enabled", default: true, null: false | ||||
|     t.datetime "created_at", null: false | ||||
|     t.datetime "updated_at", null: false | ||||
|     t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true | ||||
|     t.index ["account_id"], name: "index_captain_custom_tools_on_account_id" | ||||
|   end | ||||
|  | ||||
|   create_table "captain_documents", force: :cascade do |t| | ||||
|     t.string "name" | ||||
|     t.string "external_link", null: false | ||||
|   | ||||
| @@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base | ||||
|   end | ||||
|  | ||||
|   def tools | ||||
|     @tools = Captain::Assistant.available_agent_tools | ||||
|     assistant = Captain::Assistant.new(account: Current.account) | ||||
|     @tools = assistant.available_agent_tools | ||||
|   end | ||||
|  | ||||
|   private | ||||
|   | ||||
| @@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord | ||||
|     name | ||||
|   end | ||||
|  | ||||
|   def available_agent_tools | ||||
|     tools = self.class.built_in_agent_tools.dup | ||||
|  | ||||
|     custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata) | ||||
|     tools.concat(custom_tools) | ||||
|  | ||||
|     tools | ||||
|   end | ||||
|  | ||||
|   def available_tool_ids | ||||
|     available_agent_tools.pluck(:id) | ||||
|   end | ||||
|  | ||||
|   def push_event_data | ||||
|     { | ||||
|       id: id, | ||||
|   | ||||
							
								
								
									
										91
									
								
								enterprise/app/models/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								enterprise/app/models/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: captain_custom_tools | ||||
| # | ||||
| #  id                :bigint           not null, primary key | ||||
| #  auth_config       :jsonb | ||||
| #  auth_type         :string           default("none") | ||||
| #  description       :text | ||||
| #  enabled           :boolean          default(TRUE), not null | ||||
| #  endpoint_url      :text             not null | ||||
| #  http_method       :string           default("GET"), not null | ||||
| #  param_schema      :jsonb | ||||
| #  request_template  :text | ||||
| #  response_template :text | ||||
| #  slug              :string           not null | ||||
| #  title             :string           not null | ||||
| #  created_at        :datetime         not null | ||||
| #  updated_at        :datetime         not null | ||||
| #  account_id        :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_captain_custom_tools_on_account_id           (account_id) | ||||
| #  index_captain_custom_tools_on_account_id_and_slug  (account_id,slug) UNIQUE | ||||
| # | ||||
| class Captain::CustomTool < ApplicationRecord | ||||
|   include Concerns::Toolable | ||||
|   include Concerns::SafeEndpointValidatable | ||||
|  | ||||
|   self.table_name = 'captain_custom_tools' | ||||
|  | ||||
|   PARAM_SCHEMA_VALIDATION = { | ||||
|     'type': 'array', | ||||
|     'items': { | ||||
|       'type': 'object', | ||||
|       'properties': { | ||||
|         'name': { 'type': 'string' }, | ||||
|         'type': { 'type': 'string' }, | ||||
|         'description': { 'type': 'string' }, | ||||
|         'required': { 'type': 'boolean' } | ||||
|       }, | ||||
|       'required': %w[name type description], | ||||
|       'additionalProperties': false | ||||
|     } | ||||
|   }.to_json.freeze | ||||
|  | ||||
|   belongs_to :account | ||||
|  | ||||
|   enum :http_method, %w[GET POST].index_by(&:itself), validate: true | ||||
|   enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth | ||||
|  | ||||
|   before_validation :generate_slug | ||||
|  | ||||
|   validates :slug, presence: true, uniqueness: { scope: :account_id } | ||||
|   validates :title, presence: true | ||||
|   validates :endpoint_url, presence: true | ||||
|   validates_with JsonSchemaValidator, | ||||
|                  schema: PARAM_SCHEMA_VALIDATION, | ||||
|                  attribute_resolver: ->(record) { record.param_schema } | ||||
|  | ||||
|   scope :enabled, -> { where(enabled: true) } | ||||
|  | ||||
|   def to_tool_metadata | ||||
|     { | ||||
|       id: slug, | ||||
|       title: title, | ||||
|       description: description, | ||||
|       custom: true | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def generate_slug | ||||
|     return if slug.present? | ||||
|  | ||||
|     base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}" | ||||
|     self.slug = find_unique_slug(base_slug) | ||||
|   end | ||||
|  | ||||
|   def find_unique_slug(base_slug, counter = 0) | ||||
|     slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}" | ||||
|     return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate) | ||||
|  | ||||
|     slug_candidate | ||||
|   end | ||||
|  | ||||
|   def slug_exists?(candidate) | ||||
|     self.class.exists?(account_id: account_id, slug: candidate) | ||||
|   end | ||||
| end | ||||
| @@ -57,7 +57,7 @@ class Captain::Scenario < ApplicationRecord | ||||
|   end | ||||
|  | ||||
|   def agent_tools | ||||
|     resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) } | ||||
|     resolved_tools.map { |tool| resolve_tool_instance(tool) } | ||||
|   end | ||||
|  | ||||
|   def resolved_instructions | ||||
| @@ -69,12 +69,24 @@ class Captain::Scenario < ApplicationRecord | ||||
|   def resolved_tools | ||||
|     return [] if tools.blank? | ||||
|  | ||||
|     available_tools = self.class.available_agent_tools | ||||
|     available_tools = assistant.available_agent_tools | ||||
|     tools.filter_map do |tool_id| | ||||
|       available_tools.find { |tool| tool[:id] == tool_id } | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def resolve_tool_instance(tool_metadata) | ||||
|     tool_id = tool_metadata[:id] | ||||
|  | ||||
|     if tool_metadata[:custom] | ||||
|       custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true) | ||||
|       custom_tool&.tool(assistant) | ||||
|     else | ||||
|       tool_class = self.class.resolve_tool_class(tool_id) | ||||
|       tool_class&.new(assistant) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   # Validates that all tool references in the instruction are valid. | ||||
|   # Parses the instruction for tool references and checks if they exist | ||||
|   # in the available tools configuration. | ||||
| @@ -95,8 +107,8 @@ class Captain::Scenario < ApplicationRecord | ||||
|     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 | ||||
|     all_available_tool_ids = assistant.available_tool_ids | ||||
|     invalid_tools = tool_ids - all_available_tool_ids | ||||
|  | ||||
|     return unless invalid_tools.any? | ||||
|  | ||||
|   | ||||
| @@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers | ||||
|   TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} | ||||
|  | ||||
|   class_methods do | ||||
|     # Returns all available agent tools with their metadata. | ||||
|     # Returns all built-in agent tools with their metadata. | ||||
|     # Only includes tools that have corresponding class files and can be resolved. | ||||
|     # | ||||
|     # @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon | ||||
|     def available_agent_tools | ||||
|       @available_agent_tools ||= load_agent_tools | ||||
|     def built_in_agent_tools | ||||
|       @built_in_agent_tools ||= load_agent_tools | ||||
|     end | ||||
|  | ||||
|     # Resolves a tool class from a tool ID. | ||||
| @@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers | ||||
|       class_name.safe_constantize | ||||
|     end | ||||
|  | ||||
|     # Returns an array of all available tool IDs. | ||||
|     # Convenience method that extracts just the IDs from available_agent_tools. | ||||
|     # Returns an array of all built-in tool IDs. | ||||
|     # Convenience method that extracts just the IDs from built_in_agent_tools. | ||||
|     # | ||||
|     # @return [Array<String>] Array of available tool IDs | ||||
|     def available_tool_ids | ||||
|       @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] } | ||||
|     # @return [Array<String>] Array of built-in tool IDs | ||||
|     def built_in_tool_ids | ||||
|       @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] } | ||||
|     end | ||||
|  | ||||
|     private | ||||
|   | ||||
							
								
								
									
										84
									
								
								enterprise/app/models/concerns/safe_endpoint_validatable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								enterprise/app/models/concerns/safe_endpoint_validatable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| module Concerns::SafeEndpointValidatable | ||||
|   extend ActiveSupport::Concern | ||||
|  | ||||
|   FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze | ||||
|   DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze | ||||
|  | ||||
|   included do | ||||
|     validate :validate_safe_endpoint_url | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def validate_safe_endpoint_url | ||||
|     return if endpoint_url.blank? | ||||
|  | ||||
|     uri = parse_endpoint_uri | ||||
|     return errors.add(:endpoint_url, 'must be a valid URL') unless uri | ||||
|  | ||||
|     validate_endpoint_scheme(uri) | ||||
|     validate_endpoint_host(uri) | ||||
|     validate_not_ip_address(uri) | ||||
|     validate_no_unicode_chars(uri) | ||||
|   end | ||||
|  | ||||
|   def parse_endpoint_uri | ||||
|     # Strip Liquid template syntax for validation | ||||
|     # Replace {{ variable }} with a placeholder value | ||||
|     sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder') | ||||
|     URI.parse(sanitized_url) | ||||
|   rescue URI::InvalidURIError | ||||
|     nil | ||||
|   end | ||||
|  | ||||
|   def validate_endpoint_scheme(uri) | ||||
|     return if uri.scheme == 'https' | ||||
|  | ||||
|     errors.add(:endpoint_url, 'must use HTTPS protocol') | ||||
|   end | ||||
|  | ||||
|   def validate_endpoint_host(uri) | ||||
|     if uri.host.blank? | ||||
|       errors.add(:endpoint_url, 'must have a valid hostname') | ||||
|       return | ||||
|     end | ||||
|  | ||||
|     if uri.host == FRONTEND_HOST | ||||
|       errors.add(:endpoint_url, 'cannot point to the application itself') | ||||
|       return | ||||
|     end | ||||
|  | ||||
|     DISALLOWED_HOSTS.each do |pattern| | ||||
|       matched = if pattern.is_a?(Regexp) | ||||
|                   uri.host =~ pattern | ||||
|                 else | ||||
|                   uri.host.downcase == pattern | ||||
|                 end | ||||
|  | ||||
|       next unless matched | ||||
|  | ||||
|       errors.add(:endpoint_url, 'cannot use disallowed hostname') | ||||
|       break | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def validate_not_ip_address(uri) | ||||
|     # Check for IPv4 | ||||
|     if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host) | ||||
|       errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname') | ||||
|       return | ||||
|     end | ||||
|  | ||||
|     # Check for IPv6 | ||||
|     return unless uri.host.include?(':') | ||||
|  | ||||
|     errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname') | ||||
|   end | ||||
|  | ||||
|   def validate_no_unicode_chars(uri) | ||||
|     return unless uri.host | ||||
|     return if /\A[\x00-\x7F]+\z/.match?(uri.host) | ||||
|  | ||||
|     errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters') | ||||
|   end | ||||
| end | ||||
							
								
								
									
										78
									
								
								enterprise/app/models/concerns/toolable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								enterprise/app/models/concerns/toolable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| module Concerns::Toolable | ||||
|   extend ActiveSupport::Concern | ||||
|  | ||||
|   def tool(assistant) | ||||
|     custom_tool_record = self | ||||
|  | ||||
|     tool_class = Class.new(Captain::Tools::HttpTool) do | ||||
|       description custom_tool_record.description | ||||
|  | ||||
|       custom_tool_record.param_schema.each do |param_def| | ||||
|         param param_def['name'].to_sym, | ||||
|               type: param_def['type'], | ||||
|               desc: param_def['description'], | ||||
|               required: param_def.fetch('required', true) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     tool_class.new(assistant, self) | ||||
|   end | ||||
|  | ||||
|   def build_request_url(params) | ||||
|     return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{') | ||||
|  | ||||
|     render_template(endpoint_url, params) | ||||
|   end | ||||
|  | ||||
|   def build_request_body(params) | ||||
|     return nil if request_template.blank? | ||||
|  | ||||
|     render_template(request_template, params) | ||||
|   end | ||||
|  | ||||
|   def build_auth_headers | ||||
|     return {} if auth_none? | ||||
|  | ||||
|     case auth_type | ||||
|     when 'bearer' | ||||
|       { 'Authorization' => "Bearer #{auth_config['token']}" } | ||||
|     when 'api_key' | ||||
|       if auth_config['location'] == 'header' | ||||
|         { auth_config['name'] => auth_config['key'] } | ||||
|       else | ||||
|         {} | ||||
|       end | ||||
|     else | ||||
|       {} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def build_basic_auth_credentials | ||||
|     return nil unless auth_type == 'basic' | ||||
|  | ||||
|     [auth_config['username'], auth_config['password']] | ||||
|   end | ||||
|  | ||||
|   def format_response(raw_response_body) | ||||
|     return raw_response_body if response_template.blank? | ||||
|  | ||||
|     response_data = parse_response_body(raw_response_body) | ||||
|     render_template(response_template, { 'response' => response_data }) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def render_template(template, context) | ||||
|     liquid_template = Liquid::Template.parse(template, error_mode: :strict) | ||||
|     liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true) | ||||
|   rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e | ||||
|     Rails.logger.error("Liquid template error: #{e.message}") | ||||
|     raise "Template rendering failed: #{e.message}" | ||||
|   end | ||||
|  | ||||
|   def parse_response_body(body) | ||||
|     JSON.parse(body) | ||||
|   rescue JSON::ParserError | ||||
|     body | ||||
|   end | ||||
| end | ||||
| @@ -10,6 +10,7 @@ module Enterprise::Concerns::Account | ||||
|     has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' | ||||
|     has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' | ||||
|     has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' | ||||
|     has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' | ||||
|  | ||||
|     has_many :copilot_threads, dependent: :destroy_async | ||||
|     has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' | ||||
|   | ||||
							
								
								
									
										105
									
								
								enterprise/lib/captain/tools/http_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								enterprise/lib/captain/tools/http_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| require 'agents' | ||||
|  | ||||
| class Captain::Tools::HttpTool < Agents::Tool | ||||
|   def initialize(assistant, custom_tool) | ||||
|     @assistant = assistant | ||||
|     @custom_tool = custom_tool | ||||
|     super() | ||||
|   end | ||||
|  | ||||
|   def active? | ||||
|     @custom_tool.enabled? | ||||
|   end | ||||
|  | ||||
|   def perform(_tool_context, **params) | ||||
|     url = @custom_tool.build_request_url(params) | ||||
|     body = @custom_tool.build_request_body(params) | ||||
|  | ||||
|     response = execute_http_request(url, body) | ||||
|     @custom_tool.format_response(response.body) | ||||
|   rescue StandardError => e | ||||
|     Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") | ||||
|     'An error occurred while executing the request' | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   PRIVATE_IP_RANGES = [ | ||||
|     IPAddr.new('127.0.0.0/8'),    # IPv4 Loopback | ||||
|     IPAddr.new('10.0.0.0/8'),     # IPv4 Private network | ||||
|     IPAddr.new('172.16.0.0/12'),  # IPv4 Private network | ||||
|     IPAddr.new('192.168.0.0/16'), # IPv4 Private network | ||||
|     IPAddr.new('169.254.0.0/16'), # IPv4 Link-local | ||||
|     IPAddr.new('::1'),            # IPv6 Loopback | ||||
|     IPAddr.new('fc00::/7'),       # IPv6 Unique local addresses | ||||
|     IPAddr.new('fe80::/10')       # IPv6 Link-local | ||||
|   ].freeze | ||||
|  | ||||
|   # Limit response size to prevent memory exhaustion and match LLM token limits | ||||
|   # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows | ||||
|   MAX_RESPONSE_SIZE = 1.megabyte | ||||
|  | ||||
|   def execute_http_request(url, body) | ||||
|     uri = URI.parse(url) | ||||
|  | ||||
|     # Check if resolved IP is private | ||||
|     check_private_ip!(uri.host) | ||||
|  | ||||
|     http = Net::HTTP.new(uri.host, uri.port) | ||||
|     http.use_ssl = uri.scheme == 'https' | ||||
|     http.read_timeout = 30 | ||||
|     http.open_timeout = 10 | ||||
|     http.max_retries = 0 # Disable redirects | ||||
|  | ||||
|     request = build_http_request(uri, body) | ||||
|     apply_authentication(request) | ||||
|  | ||||
|     response = http.request(request) | ||||
|  | ||||
|     raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess) | ||||
|  | ||||
|     validate_response!(response) | ||||
|  | ||||
|     response | ||||
|   end | ||||
|  | ||||
|   def check_private_ip!(hostname) | ||||
|     ip_address = IPAddr.new(Resolv.getaddress(hostname)) | ||||
|  | ||||
|     raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) } | ||||
|   rescue Resolv::ResolvError, SocketError => e | ||||
|     raise "DNS resolution failed: #{e.message}" | ||||
|   end | ||||
|  | ||||
|   def validate_response!(response) | ||||
|     content_length = response['content-length']&.to_i | ||||
|     if content_length && content_length > MAX_RESPONSE_SIZE | ||||
|       raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" | ||||
|     end | ||||
|  | ||||
|     return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE | ||||
|  | ||||
|     raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" | ||||
|   end | ||||
|  | ||||
|   def build_http_request(uri, body) | ||||
|     if @custom_tool.http_method == 'POST' | ||||
|       request = Net::HTTP::Post.new(uri.request_uri) | ||||
|       if body | ||||
|         request.body = body | ||||
|         request['Content-Type'] = 'application/json' | ||||
|       end | ||||
|     else | ||||
|       request = Net::HTTP::Get.new(uri.request_uri) | ||||
|     end | ||||
|     request | ||||
|   end | ||||
|  | ||||
|   def apply_authentication(request) | ||||
|     headers = @custom_tool.build_auth_headers | ||||
|     headers.each { |key, value| request[key] = value } | ||||
|  | ||||
|     credentials = @custom_tool.build_basic_auth_credentials | ||||
|     request.basic_auth(*credentials) if credentials | ||||
|   end | ||||
| end | ||||
| @@ -118,7 +118,7 @@ class CaptainChatSession | ||||
|   end | ||||
|  | ||||
|   def show_available_tools | ||||
|     available_tools = Captain::Assistant.available_tool_ids | ||||
|     available_tools = @assistant.available_tool_ids | ||||
|     if available_tools.any? | ||||
|       puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}" | ||||
|     else | ||||
|   | ||||
							
								
								
									
										241
									
								
								spec/enterprise/lib/captain/tools/http_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								spec/enterprise/lib/captain/tools/http_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Captain::Tools::HttpTool, type: :model do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:assistant) { create(:captain_assistant, account: account) } | ||||
|   let(:custom_tool) { create(:captain_custom_tool, account: account) } | ||||
|   let(:tool) { described_class.new(assistant, custom_tool) } | ||||
|   let(:tool_context) { Struct.new(:state).new({}) } | ||||
|  | ||||
|   describe '#active?' do | ||||
|     it 'returns true when custom tool is enabled' do | ||||
|       custom_tool.update!(enabled: true) | ||||
|  | ||||
|       expect(tool.active?).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns false when custom tool is disabled' do | ||||
|       custom_tool.update!(enabled: false) | ||||
|  | ||||
|       expect(tool.active?).to be false | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#perform' do | ||||
|     context 'with GET request' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           http_method: 'GET', | ||||
|           endpoint_url: 'https://example.com/orders/123', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/orders/123') | ||||
|           .to_return(status: 200, body: '{"status": "success"}') | ||||
|       end | ||||
|  | ||||
|       it 'executes GET request and returns response body' do | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('{"status": "success"}') | ||||
|         expect(WebMock).to have_requested(:get, 'https://example.com/orders/123') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with POST request' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           http_method: 'POST', | ||||
|           endpoint_url: 'https://example.com/orders', | ||||
|           request_template: '{"order_id": "{{ order_id }}"}', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:post, 'https://example.com/orders') | ||||
|           .with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' }) | ||||
|           .to_return(status: 200, body: '{"created": true}') | ||||
|       end | ||||
|  | ||||
|       it 'executes POST request with rendered body' do | ||||
|         result = tool.perform(tool_context, order_id: '123') | ||||
|  | ||||
|         expect(result).to eq('{"created": true}') | ||||
|         expect(WebMock).to have_requested(:post, 'https://example.com/orders') | ||||
|           .with(body: '{"order_id": "123"}') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with template variables in URL' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           endpoint_url: 'https://example.com/orders/{{ order_id }}', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/orders/456') | ||||
|           .to_return(status: 200, body: '{"order_id": "456"}') | ||||
|       end | ||||
|  | ||||
|       it 'renders URL template with params' do | ||||
|         result = tool.perform(tool_context, order_id: '456') | ||||
|  | ||||
|         expect(result).to eq('{"order_id": "456"}') | ||||
|         expect(WebMock).to have_requested(:get, 'https://example.com/orders/456') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with bearer token authentication' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           auth_type: 'bearer', | ||||
|           auth_config: { 'token' => 'secret_bearer_token' }, | ||||
|           endpoint_url: 'https://example.com/data', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/data') | ||||
|           .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' }) | ||||
|           .to_return(status: 200, body: '{"authenticated": true}') | ||||
|       end | ||||
|  | ||||
|       it 'adds Authorization header with bearer token' do | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('{"authenticated": true}') | ||||
|         expect(WebMock).to have_requested(:get, 'https://example.com/data') | ||||
|           .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with basic authentication' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           auth_type: 'basic', | ||||
|           auth_config: { 'username' => 'user123', 'password' => 'pass456' }, | ||||
|           endpoint_url: 'https://example.com/data', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/data') | ||||
|           .with(basic_auth: %w[user123 pass456]) | ||||
|           .to_return(status: 200, body: '{"authenticated": true}') | ||||
|       end | ||||
|  | ||||
|       it 'adds basic auth credentials' do | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('{"authenticated": true}') | ||||
|         expect(WebMock).to have_requested(:get, 'https://example.com/data') | ||||
|           .with(basic_auth: %w[user123 pass456]) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with API key authentication' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           auth_type: 'api_key', | ||||
|           auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' }, | ||||
|           endpoint_url: 'https://example.com/data', | ||||
|           response_template: nil | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/data') | ||||
|           .with(headers: { 'X-API-Key' => 'api_key_123' }) | ||||
|           .to_return(status: 200, body: '{"authenticated": true}') | ||||
|       end | ||||
|  | ||||
|       it 'adds API key header' do | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('{"authenticated": true}') | ||||
|         expect(WebMock).to have_requested(:get, 'https://example.com/data') | ||||
|           .with(headers: { 'X-API-Key' => 'api_key_123' }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'with response template' do | ||||
|       before do | ||||
|         custom_tool.update!( | ||||
|           endpoint_url: 'https://example.com/orders/123', | ||||
|           response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}' | ||||
|         ) | ||||
|         stub_request(:get, 'https://example.com/orders/123') | ||||
|           .to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}') | ||||
|       end | ||||
|  | ||||
|       it 'formats response using template' do | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('Order status: shipped, ID: 123') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when handling errors' do | ||||
|       it 'returns generic error message on network failure' do | ||||
|         custom_tool.update!(endpoint_url: 'https://example.com/data') | ||||
|         stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect')) | ||||
|  | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('An error occurred while executing the request') | ||||
|       end | ||||
|  | ||||
|       it 'returns generic error message on timeout' do | ||||
|         custom_tool.update!(endpoint_url: 'https://example.com/data') | ||||
|         stub_request(:get, 'https://example.com/data').to_timeout | ||||
|  | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('An error occurred while executing the request') | ||||
|       end | ||||
|  | ||||
|       it 'returns generic error message on HTTP 404' do | ||||
|         custom_tool.update!(endpoint_url: 'https://example.com/data') | ||||
|         stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found') | ||||
|  | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('An error occurred while executing the request') | ||||
|       end | ||||
|  | ||||
|       it 'returns generic error message on HTTP 500' do | ||||
|         custom_tool.update!(endpoint_url: 'https://example.com/data') | ||||
|         stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error') | ||||
|  | ||||
|         result = tool.perform(tool_context) | ||||
|  | ||||
|         expect(result).to eq('An error occurred while executing the request') | ||||
|       end | ||||
|  | ||||
|       it 'logs error details' do | ||||
|         custom_tool.update!(endpoint_url: 'https://example.com/data') | ||||
|         stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error')) | ||||
|  | ||||
|         expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/) | ||||
|  | ||||
|         tool.perform(tool_context) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when integrating with Toolable methods' do | ||||
|       it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do | ||||
|         custom_tool.update!( | ||||
|           http_method: 'POST', | ||||
|           endpoint_url: 'https://example.com/users/{{ user_id }}/orders', | ||||
|           request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}', | ||||
|           auth_type: 'bearer', | ||||
|           auth_config: { 'token' => 'integration_token' }, | ||||
|           response_template: 'Created order #{{ response.order_number }} for {{ response.product }}' | ||||
|         ) | ||||
|  | ||||
|         stub_request(:post, 'https://example.com/users/42/orders') | ||||
|           .with( | ||||
|             body: '{"product": "Widget", "quantity": 5}', | ||||
|             headers: { | ||||
|               'Authorization' => 'Bearer integration_token', | ||||
|               'Content-Type' => 'application/json' | ||||
|             } | ||||
|           ) | ||||
|           .to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}') | ||||
|  | ||||
|         result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5) | ||||
|  | ||||
|         expect(result).to eq('Created order #ORD-789 for Widget') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										386
									
								
								spec/enterprise/models/captain/custom_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										386
									
								
								spec/enterprise/models/captain/custom_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,386 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Captain::CustomTool, type: :model do | ||||
|   describe 'associations' do | ||||
|     it { is_expected.to belong_to(:account) } | ||||
|   end | ||||
|  | ||||
|   describe 'validations' do | ||||
|     it { is_expected.to validate_presence_of(:title) } | ||||
|     it { is_expected.to validate_presence_of(:endpoint_url) } | ||||
|     it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) } | ||||
|  | ||||
|     it { | ||||
|       expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic', | ||||
|                                                                  'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth) | ||||
|     } | ||||
|  | ||||
|     describe 'slug uniqueness' do | ||||
|       let(:account) { create(:account) } | ||||
|  | ||||
|       it 'validates uniqueness of slug scoped to account' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|         duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|  | ||||
|         expect(duplicate).not_to be_valid | ||||
|         expect(duplicate.errors[:slug]).to include('has already been taken') | ||||
|       end | ||||
|  | ||||
|       it 'allows same slug across different accounts' do | ||||
|         account2 = create(:account) | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|         different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool') | ||||
|  | ||||
|         expect(different_account_tool).to be_valid | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'param_schema validation' do | ||||
|       let(:account) { create(:account) } | ||||
|  | ||||
|       it 'is valid with proper param_schema' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is valid with empty param_schema' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: []) | ||||
|  | ||||
|         expect(tool).to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is invalid when param_schema is missing name' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'type' => 'string', 'description' => 'Order ID' } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).not_to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is invalid when param_schema is missing type' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'name' => 'order_id', 'description' => 'Order ID' } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).not_to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is invalid when param_schema is missing description' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'name' => 'order_id', 'type' => 'string' } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).not_to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is invalid with additional properties in param_schema' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).not_to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is valid when required field is omitted (defaults to optional param)' do | ||||
|         tool = build(:captain_custom_tool, account: account, param_schema: [ | ||||
|                        { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' } | ||||
|                      ]) | ||||
|  | ||||
|         expect(tool).to be_valid | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'scopes' do | ||||
|     let(:account) { create(:account) } | ||||
|  | ||||
|     describe '.enabled' do | ||||
|       it 'returns only enabled custom tools' do | ||||
|         enabled_tool = create(:captain_custom_tool, account: account, enabled: true) | ||||
|         disabled_tool = create(:captain_custom_tool, account: account, enabled: false) | ||||
|  | ||||
|         expect(described_class.enabled).to include(enabled_tool) | ||||
|         expect(described_class.enabled).not_to include(disabled_tool) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'slug generation' do | ||||
|     let(:account) { create(:account) } | ||||
|  | ||||
|     it 'generates slug from title on creation' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_fetch-order-status') | ||||
|     end | ||||
|  | ||||
|     it 'adds custom_ prefix to generated slug' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'My Tool') | ||||
|  | ||||
|       expect(tool.slug).to start_with('custom_') | ||||
|     end | ||||
|  | ||||
|     it 'does not override manually set slug' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_manual-slug') | ||||
|     end | ||||
|  | ||||
|     it 'handles slug collisions by appending counter' do | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') | ||||
|       tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool') | ||||
|  | ||||
|       expect(tool2.slug).to eq('custom_test-tool-1') | ||||
|     end | ||||
|  | ||||
|     it 'handles multiple slug collisions' do | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1') | ||||
|       tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') | ||||
|  | ||||
|       expect(tool3.slug).to eq('custom_test-tool-2') | ||||
|     end | ||||
|  | ||||
|     it 'generates slug with UUID when title is blank' do | ||||
|       tool = build(:captain_custom_tool, account: account, title: nil) | ||||
|       tool.valid? | ||||
|  | ||||
|       expect(tool.slug).to match(/^custom_[0-9a-f-]+$/) | ||||
|     end | ||||
|  | ||||
|     it 'parameterizes title correctly' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_fetch-order-status-details') | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'factory' do | ||||
|     it 'creates a valid custom tool with default attributes' do | ||||
|       tool = create(:captain_custom_tool) | ||||
|  | ||||
|       expect(tool).to be_valid | ||||
|       expect(tool.title).to be_present | ||||
|       expect(tool.slug).to be_present | ||||
|       expect(tool.endpoint_url).to be_present | ||||
|       expect(tool.http_method).to eq('GET') | ||||
|       expect(tool.auth_type).to eq('none') | ||||
|       expect(tool.enabled).to be true | ||||
|     end | ||||
|  | ||||
|     it 'creates valid tool with POST trait' do | ||||
|       tool = create(:captain_custom_tool, :with_post) | ||||
|  | ||||
|       expect(tool.http_method).to eq('POST') | ||||
|       expect(tool.request_template).to be_present | ||||
|     end | ||||
|  | ||||
|     it 'creates valid tool with bearer auth trait' do | ||||
|       tool = create(:captain_custom_tool, :with_bearer_auth) | ||||
|  | ||||
|       expect(tool.auth_type).to eq('bearer') | ||||
|       expect(tool.auth_config['token']).to eq('test_bearer_token_123') | ||||
|     end | ||||
|  | ||||
|     it 'creates valid tool with basic auth trait' do | ||||
|       tool = create(:captain_custom_tool, :with_basic_auth) | ||||
|  | ||||
|       expect(tool.auth_type).to eq('basic') | ||||
|       expect(tool.auth_config['username']).to eq('test_user') | ||||
|       expect(tool.auth_config['password']).to eq('test_pass') | ||||
|     end | ||||
|  | ||||
|     it 'creates valid tool with api key trait' do | ||||
|       tool = create(:captain_custom_tool, :with_api_key) | ||||
|  | ||||
|       expect(tool.auth_type).to eq('api_key') | ||||
|       expect(tool.auth_config['key']).to eq('test_api_key') | ||||
|       expect(tool.auth_config['location']).to eq('header') | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'Toolable concern' do | ||||
|     let(:account) { create(:account) } | ||||
|  | ||||
|     describe '#build_request_url' do | ||||
|       it 'returns static URL when no template variables present' do | ||||
|         tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders') | ||||
|  | ||||
|         expect(tool.build_request_url({})).to eq('https://api.example.com/orders') | ||||
|       end | ||||
|  | ||||
|       it 'renders URL template with params' do | ||||
|         tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}') | ||||
|  | ||||
|         expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345') | ||||
|       end | ||||
|  | ||||
|       it 'handles multiple template variables' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}') | ||||
|  | ||||
|         result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' }) | ||||
|         expect(result).to eq('https://api.example.com/orders/123?details=true') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#build_request_body' do | ||||
|       it 'returns nil when request_template is blank' do | ||||
|         tool = create(:captain_custom_tool, account: account, request_template: nil) | ||||
|  | ||||
|         expect(tool.build_request_body({})).to be_nil | ||||
|       end | ||||
|  | ||||
|       it 'renders request body template with params' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }') | ||||
|  | ||||
|         result = tool.build_request_body({ order_id: '12345' }) | ||||
|         expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#build_auth_headers' do | ||||
|       it 'returns empty hash for none auth type' do | ||||
|         tool = create(:captain_custom_tool, account: account, auth_type: 'none') | ||||
|  | ||||
|         expect(tool.build_auth_headers).to eq({}) | ||||
|       end | ||||
|  | ||||
|       it 'returns bearer token header' do | ||||
|         tool = create(:captain_custom_tool, :with_bearer_auth, account: account) | ||||
|  | ||||
|         expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' }) | ||||
|       end | ||||
|  | ||||
|       it 'returns API key header when location is header' do | ||||
|         tool = create(:captain_custom_tool, :with_api_key, account: account) | ||||
|  | ||||
|         expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' }) | ||||
|       end | ||||
|  | ||||
|       it 'returns empty hash for API key when location is not header' do | ||||
|         tool = create(:captain_custom_tool, account: account, auth_type: 'api_key', | ||||
|                                             auth_config: { key: 'test_key', location: 'query', name: 'api_key' }) | ||||
|  | ||||
|         expect(tool.build_auth_headers).to eq({}) | ||||
|       end | ||||
|  | ||||
|       it 'returns empty hash for basic auth' do | ||||
|         tool = create(:captain_custom_tool, :with_basic_auth, account: account) | ||||
|  | ||||
|         expect(tool.build_auth_headers).to eq({}) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#build_basic_auth_credentials' do | ||||
|       it 'returns nil for non-basic auth types' do | ||||
|         tool = create(:captain_custom_tool, account: account, auth_type: 'none') | ||||
|  | ||||
|         expect(tool.build_basic_auth_credentials).to be_nil | ||||
|       end | ||||
|  | ||||
|       it 'returns username and password array for basic auth' do | ||||
|         tool = create(:captain_custom_tool, :with_basic_auth, account: account) | ||||
|  | ||||
|         expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass]) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#format_response' do | ||||
|       it 'returns raw response when no response_template' do | ||||
|         tool = create(:captain_custom_tool, account: account, response_template: nil) | ||||
|  | ||||
|         expect(tool.format_response('raw response')).to eq('raw response') | ||||
|       end | ||||
|  | ||||
|       it 'renders response template with JSON response' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             response_template: 'Order status: {{ response.status }}') | ||||
|         raw_response = '{"status": "shipped", "tracking": "123ABC"}' | ||||
|  | ||||
|         result = tool.format_response(raw_response) | ||||
|         expect(result).to eq('Order status: shipped') | ||||
|       end | ||||
|  | ||||
|       it 'handles response template with multiple fields' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}') | ||||
|         raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}' | ||||
|  | ||||
|         result = tool.format_response(raw_response) | ||||
|         expect(result).to eq('Order 12345 is delivered. Tracking: ABC123') | ||||
|       end | ||||
|  | ||||
|       it 'handles non-JSON response' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             response_template: 'Response: {{ response }}') | ||||
|         raw_response = 'plain text response' | ||||
|  | ||||
|         result = tool.format_response(raw_response) | ||||
|         expect(result).to eq('Response: plain text response') | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#to_tool_metadata' do | ||||
|       it 'returns tool metadata hash with custom flag' do | ||||
|         tool = create(:captain_custom_tool, account: account, | ||||
|                                             slug: 'custom_test-tool', | ||||
|                                             title: 'Test Tool', | ||||
|                                             description: 'A test tool') | ||||
|  | ||||
|         metadata = tool.to_tool_metadata | ||||
|         expect(metadata).to eq({ | ||||
|                                  id: 'custom_test-tool', | ||||
|                                  title: 'Test Tool', | ||||
|                                  description: 'A test tool', | ||||
|                                  custom: true | ||||
|                                }) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#tool' do | ||||
|       let(:assistant) { create(:captain_assistant, account: account) } | ||||
|  | ||||
|       it 'returns HttpTool instance' do | ||||
|         tool = create(:captain_custom_tool, account: account) | ||||
|  | ||||
|         tool_instance = tool.tool(assistant) | ||||
|         expect(tool_instance).to be_a(Captain::Tools::HttpTool) | ||||
|       end | ||||
|  | ||||
|       it 'sets description on the tool class' do | ||||
|         tool = create(:captain_custom_tool, account: account, description: 'Fetches order data') | ||||
|  | ||||
|         tool_instance = tool.tool(assistant) | ||||
|         expect(tool_instance.description).to eq('Fetches order data') | ||||
|       end | ||||
|  | ||||
|       it 'sets parameters on the tool class' do | ||||
|         tool = create(:captain_custom_tool, :with_params, account: account) | ||||
|  | ||||
|         tool_instance = tool.tool(assistant) | ||||
|         params = tool_instance.parameters | ||||
|  | ||||
|         expect(params.keys).to contain_exactly(:order_id, :include_details) | ||||
|         expect(params[:order_id].name).to eq(:order_id) | ||||
|         expect(params[:order_id].type).to eq('string') | ||||
|         expect(params[:order_id].description).to eq('The order ID') | ||||
|         expect(params[:order_id].required).to be true | ||||
|  | ||||
|         expect(params[:include_details].name).to eq(:include_details) | ||||
|         expect(params[:include_details].required).to be false | ||||
|       end | ||||
|  | ||||
|       it 'works with empty param_schema' do | ||||
|         tool = create(:captain_custom_tool, account: account, param_schema: []) | ||||
|  | ||||
|         tool_instance = tool.tool(assistant) | ||||
|         expect(tool_instance.parameters).to be_empty | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -48,7 +48,7 @@ RSpec.describe Captain::Scenario, type: :model do | ||||
|  | ||||
|     before do | ||||
|       # Mock available tools | ||||
|       allow(described_class).to receive(:available_tool_ids).and_return(%w[ | ||||
|       allow(described_class).to receive(:built_in_tool_ids).and_return(%w[ | ||||
|                                                                          add_contact_note add_private_note update_priority | ||||
|                                                                        ]) | ||||
|     end | ||||
| @@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do | ||||
|         expect(scenario).not_to be_valid | ||||
|         expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) | ||||
|       end | ||||
|  | ||||
|       it 'is valid with custom tool references' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = build(:captain_scenario, | ||||
|                          assistant: assistant, | ||||
|                          account: account, | ||||
|                          instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') | ||||
|  | ||||
|         expect(scenario).to be_valid | ||||
|       end | ||||
|  | ||||
|       it 'is invalid with custom tool from different account' do | ||||
|         other_account = create(:account) | ||||
|         create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order') | ||||
|         scenario = build(:captain_scenario, | ||||
|                          assistant: assistant, | ||||
|                          account: account, | ||||
|                          instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') | ||||
|  | ||||
|         expect(scenario).not_to be_valid | ||||
|         expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') | ||||
|       end | ||||
|  | ||||
|       it 'is invalid with disabled custom tool' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) | ||||
|         scenario = build(:captain_scenario, | ||||
|                          assistant: assistant, | ||||
|                          account: account, | ||||
|                          instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') | ||||
|  | ||||
|         expect(scenario).not_to be_valid | ||||
|         expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') | ||||
|       end | ||||
|  | ||||
|       it 'is valid with mixed static and custom tool references' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = build(:captain_scenario, | ||||
|                          assistant: assistant, | ||||
|                          account: account, | ||||
|                          instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         expect(scenario).to be_valid | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe 'resolve_tool_references' do | ||||
| @@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'custom tool integration' do | ||||
|     let(:account) { create(:account) } | ||||
|     let(:assistant) { create(:captain_assistant, account: account) } | ||||
|  | ||||
|     before do | ||||
|       allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note]) | ||||
|       allow(described_class).to receive(:built_in_agent_tools).and_return([ | ||||
|                                                                             { id: 'add_contact_note', title: 'Add Contact Note', | ||||
|                                                                               description: 'Add a note' } | ||||
|                                                                           ]) | ||||
|     end | ||||
|  | ||||
|     describe '#resolved_tools' do | ||||
|       it 'includes custom tool metadata' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', | ||||
|                                      title: 'Fetch Order', description: 'Gets order details') | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         resolved = scenario.send(:resolved_tools) | ||||
|         expect(resolved.length).to eq(1) | ||||
|         expect(resolved.first[:id]).to eq('custom_fetch-order') | ||||
|         expect(resolved.first[:title]).to eq('Fetch Order') | ||||
|         expect(resolved.first[:description]).to eq('Gets order details') | ||||
|       end | ||||
|  | ||||
|       it 'includes both static and custom tools' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         resolved = scenario.send(:resolved_tools) | ||||
|         expect(resolved.length).to eq(2) | ||||
|         expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order') | ||||
|       end | ||||
|  | ||||
|       it 'excludes disabled custom tools' do | ||||
|         custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         custom_tool.update!(enabled: false) | ||||
|  | ||||
|         resolved = scenario.send(:resolved_tools) | ||||
|         expect(resolved).to be_empty | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#resolve_tool_instance' do | ||||
|       it 'returns HttpTool instance for custom tools' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = create(:captain_scenario, assistant: assistant, account: account) | ||||
|  | ||||
|         tool_metadata = { id: 'custom_fetch-order', custom: true } | ||||
|         tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) | ||||
|         expect(tool_instance).to be_a(Captain::Tools::HttpTool) | ||||
|       end | ||||
|  | ||||
|       it 'returns nil for disabled custom tools' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) | ||||
|         scenario = create(:captain_scenario, assistant: assistant, account: account) | ||||
|  | ||||
|         tool_metadata = { id: 'custom_fetch-order', custom: true } | ||||
|         tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) | ||||
|         expect(tool_instance).to be_nil | ||||
|       end | ||||
|  | ||||
|       it 'returns static tool instance for non-custom tools' do | ||||
|         scenario = create(:captain_scenario, assistant: assistant, account: account) | ||||
|         allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( | ||||
|           Class.new do | ||||
|             def initialize(_assistant); end | ||||
|           end | ||||
|         ) | ||||
|  | ||||
|         tool_metadata = { id: 'add_contact_note' } | ||||
|         tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) | ||||
|         expect(tool_instance).not_to be_nil | ||||
|         expect(tool_instance).not_to be_a(Captain::Tools::HttpTool) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     describe '#agent_tools' do | ||||
|       it 'returns array of tool instances including custom tools' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         tools = scenario.send(:agent_tools) | ||||
|         expect(tools.length).to eq(1) | ||||
|         expect(tools.first).to be_a(Captain::Tools::HttpTool) | ||||
|       end | ||||
|  | ||||
|       it 'excludes disabled custom tools from execution' do | ||||
|         custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         custom_tool.update!(enabled: false) | ||||
|  | ||||
|         tools = scenario.send(:agent_tools) | ||||
|         expect(tools).to be_empty | ||||
|       end | ||||
|  | ||||
|       it 'returns mixed static and custom tool instances' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') | ||||
|         scenario = create(:captain_scenario, | ||||
|                           assistant: assistant, | ||||
|                           account: account, | ||||
|                           instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') | ||||
|  | ||||
|         allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( | ||||
|           Class.new do | ||||
|             def initialize(_assistant); end | ||||
|           end | ||||
|         ) | ||||
|  | ||||
|         tools = scenario.send(:agent_tools) | ||||
|         expect(tools.length).to eq(2) | ||||
|         expect(tools.last).to be_a(Captain::Tools::HttpTool) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'factory' do | ||||
|     it 'creates a valid scenario with associations' do | ||||
|       account = create(:account) | ||||
|   | ||||
| @@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do | ||||
|     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 | ||||
| @@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do | ||||
|     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)' | ||||
|   | ||||
							
								
								
									
										51
									
								
								spec/factories/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								spec/factories/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| FactoryBot.define do | ||||
|   factory :captain_custom_tool, class: 'Captain::CustomTool' do | ||||
|     sequence(:title) { |n| "Custom Tool #{n}" } | ||||
|     description { 'A custom HTTP tool for external API integration' } | ||||
|     endpoint_url { 'https://api.example.com/endpoint' } | ||||
|     http_method { 'GET' } | ||||
|     auth_type { 'none' } | ||||
|     auth_config { {} } | ||||
|     param_schema { [] } | ||||
|     enabled { true } | ||||
|     association :account | ||||
|  | ||||
|     trait :with_post do | ||||
|       http_method { 'POST' } | ||||
|       request_template { '{ "key": "{{ value }}" }' } | ||||
|     end | ||||
|  | ||||
|     trait :with_bearer_auth do | ||||
|       auth_type { 'bearer' } | ||||
|       auth_config { { token: 'test_bearer_token_123' } } | ||||
|     end | ||||
|  | ||||
|     trait :with_basic_auth do | ||||
|       auth_type { 'basic' } | ||||
|       auth_config { { username: 'test_user', password: 'test_pass' } } | ||||
|     end | ||||
|  | ||||
|     trait :with_api_key do | ||||
|       auth_type { 'api_key' } | ||||
|       auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } } | ||||
|     end | ||||
|  | ||||
|     trait :with_templates do | ||||
|       request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' } | ||||
|       response_template { 'Order status: {{ response.status }}' } | ||||
|     end | ||||
|  | ||||
|     trait :with_params do | ||||
|       param_schema do | ||||
|         [ | ||||
|           { 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true }, | ||||
|           { 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false } | ||||
|         ] | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     trait :disabled do | ||||
|       enabled { false } | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese