Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,48 @@
class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
description 'Search FAQ responses using semantic similarity to find relevant answers'
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
def perform(_tool_context, query:)
log_tool_usage('searching', { query: query })
# Use existing vector search on approved responses
responses = @assistant.responses.approved.search(query).to_a
if responses.empty?
log_tool_usage('no_results', { query: query })
"No relevant FAQs found for: #{query}"
else
log_tool_usage('found_results', { query: query, count: responses.size })
format_responses(responses)
end
end
private
def format_responses(responses)
responses.map { |response| format_response(response) }.join
end
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if should_show_source?(response)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
def should_show_source?(response)
return false if response.documentable.blank?
return false unless response.documentable.try(:external_link)
# Don't show source if it's a PDF placeholder
external_link = response.documentable.external_link
!external_link.start_with?('PDF:')
end
end

View File

@@ -0,0 +1,63 @@
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
description 'Hand off the conversation to a human agent when unable to assist further'
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
def perform(tool_context, reason: nil)
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
# Log the handoff with reason
log_tool_usage('tool_handoff', {
conversation_id: conversation.id,
reason: reason || 'Agent requested handoff'
})
# Use existing handoff mechanism from ResponseBuilderJob
trigger_handoff(conversation, reason)
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
'Failed to handoff conversation'
end
private
def trigger_handoff(conversation, reason)
# post the reason as a private note
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: @assistant,
account: conversation.account,
inbox: conversation.inbox,
content: reason
)
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability
# This tool could be enhanced to:
# 1. Accept team_id parameter for routing to specific teams
# 2. Set conversation priority based on handoff reason
# 3. Add metadata for intelligent agent assignment
# 4. Support escalation levels (L1 -> L2 -> L3)
#
# Example future signature:
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
end

View File

@@ -0,0 +1,112 @@
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, tool_context)
@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, tool_context)
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)
apply_metadata_headers(request, tool_context)
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
def apply_metadata_headers(request, tool_context)
state = tool_context&.state || {}
metadata_headers = @custom_tool.build_metadata_headers(state)
metadata_headers.each { |key, value| request[key] = value }
end
end

View File

@@ -0,0 +1,27 @@
class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
description 'Resolve a conversation when the issue has been addressed or the conversation should be closed'
param :reason, type: 'string', desc: 'Brief reason for resolving the conversation', required: true
def perform(tool_context, reason:)
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
Current.captain_resolve_reason = reason
begin
conversation.resolved!
ensure
Current.captain_resolve_reason = nil
end
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
end
private
def permissions
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
end
end

View File

@@ -0,0 +1,50 @@
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)
return nil if priority == 'nil' || priority.blank?
priority.downcase
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