Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
112
research/chatwoot/enterprise/lib/captain/tools/http_tool.rb
Normal file
112
research/chatwoot/enterprise/lib/captain/tools/http_tool.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user