feat: Implementa e aprimora funcionalidades relacionadas a Captain e Jasmine, incluindo ferramentas, serviços LLM, integrações WhatsApp e ajustes de configuração.

This commit is contained in:
Rodrigo Borba 2026-01-25 09:26:30 -03:00
parent a392d81f06
commit 2672d21136
123 changed files with 6435 additions and 6377 deletions

View File

@ -1,3 +1,9 @@
inherit_from: .rubocop_todo.yml
inherit_mode:
merge:
- Exclude
plugins:
- rubocop-performance
- rubocop-rails
@ -23,6 +29,7 @@ Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
RSpec/ExampleLength:
Max: 50
@ -230,6 +237,7 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
- 'reference_chatwoot/**/*'
FactoryBot/SyntaxMethods:
Enabled: false

331
.rubocop_todo.yml Normal file
View File

@ -0,0 +1,331 @@
# This configuration was generated by
# `rubocop --auto-gen-config --exclude-limit 10000`
# on 2026-01-25 12:20:43 UTC using RuboCop version 1.75.6.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 2
Chatwoot/AttachmentDownload:
Exclude:
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'debug_media.rb'
# Offense count: 32
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Exclude:
- 'app/jobs/conversations/cluster_job.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'enterprise/app/jobs/captain/conversation/debounce_response_job.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
- 'enterprise/app/services/enterprise/message_templates/hook_execution_service.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
- 'scripts/debug_faq_search.rb'
- 'spec/factories/contacts.rb'
# Offense count: 4
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
Lint/DuplicateBranch:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
# Offense count: 2
Lint/DuplicateMethods:
Exclude:
- 'app/services/jasmine/semantic_search_service.rb'
# Offense count: 1
# Configuration parameters: MaximumRangeSize.
Lint/MissingCopEnableDirective:
Exclude:
- 'db/migrate/20260114100000_create_captain_inbox_automations.rb'
# Offense count: 1
Lint/NonLocalExitFromIterator:
Exclude:
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
# Offense count: 1
Lint/ShadowedException:
Exclude:
- 'enterprise/app/services/captain/llm/paginated_faq_generator_service.rb'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
# NotImplementedExceptions: NotImplementedError
Lint/UnusedMethodArgument:
Exclude:
- 'enterprise/app/services/llm/base_ai_service.rb'
# Offense count: 63
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
Metrics/AbcSize:
Exclude:
- 'app/jobs/conversations/auto_label_job.rb'
- 'app/jobs/conversations/resolution_job.rb'
- 'app/jobs/webhooks/whatsapp_events_job.rb'
- 'app/listeners/jasmine_listener.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/models/message.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/jasmine/brain_service.rb'
- 'app/services/jasmine/semantic_search_service.rb'
- 'app/services/jasmine/tool_runner.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'config/initializers/fix_null_message_crash.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'debug_token.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/tools_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/models/captain/assistant.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/inter/auth_service.rb'
- 'enterprise/app/services/captain/inter/cob_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/reminders/create_service.rb'
- 'enterprise/app/services/captain/reservations/create_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
- 'enterprise/lib/captain/tools/http_tool.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
# Offense count: 6
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
# AllowedMethods: refine
Metrics/BlockLength:
Exclude:
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/models/concerns/captain_tools_helpers.rb'
- 'script/test_auto_resolve_inbox.rb'
- 'seed_captain_tools.rb'
- 'seed_jasmine_hotel_v2.rb'
# Offense count: 11
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
- 'app/models/channel/whatsapp.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/tool_runner.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
# Offense count: 67
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Max: 39
# Offense count: 78
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/jobs/conversations/auto_label_job.rb'
- 'app/jobs/conversations/cluster_job.rb'
- 'app/jobs/jasmine/response_job.rb'
- 'app/listeners/jasmine_listener.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/captain/inter_service.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/jasmine/brain_service.rb'
- 'app/services/jasmine/embedding_service.rb'
- 'app/services/jasmine/semantic_search_service.rb'
- 'app/services/jasmine/tool_runner.rb'
- 'app/services/jasmine/vision_service.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20260104150000_add_crm_insights_history_fields.rb'
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'db/migrate/20260114090200_create_captain_reminders.rb'
- 'debug_token.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/jobs/captain/intent_classification_job.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
- 'enterprise/app/services/captain/reminders/create_service.rb'
- 'enterprise/app/services/captain/reservations/create_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
- 'enterprise/app/services/captain/tools/reminder_tool.rb'
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
- 'enterprise/app/services/captain/webhook_sender_service.rb'
- 'enterprise/app/services/captain/whatsapp_notification_service.rb'
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
- 'scripts/force_create_captain_tables.rb'
# Offense count: 2
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 132
# Offense count: 4
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Max: 7
# Offense count: 46
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Max: 40
# Offense count: 2
Naming/AccessorMethodName:
Exclude:
- 'app/services/captain/inter_service.rb'
- 'app/services/jasmine/vision_service.rb'
# Offense count: 7
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
Naming/MethodParameterName:
Exclude:
- 'app/services/whatsapp/decryption_service.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
# Offense count: 2
# Configuration parameters: MinSize.
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
# Offense count: 1
Rails/AfterCommitOverride:
Exclude:
- 'app/models/channel/whatsapp.rb'
# Offense count: 5
# Configuration parameters: Include.
# Include: **/app/models/**/*.rb
Rails/HasManyOrHasOneDependent:
Exclude:
- 'enterprise/app/models/captain/brand.rb'
- 'enterprise/app/models/captain/unit.rb'
# Offense count: 17
# Configuration parameters: IgnoreScopes, Include.
# Include: **/app/models/**/*.rb
Rails/InverseOf:
Exclude:
- 'app/models/captain/unit.rb'
- 'app/models/captain_assistant.rb'
- 'enterprise/app/models/captain/brand.rb'
- 'enterprise/app/models/captain/pricing.rb'
- 'enterprise/app/models/captain/pricing_inbox.rb'
- 'enterprise/app/models/captain/reservation.rb'
- 'enterprise/app/models/captain/unit.rb'
- 'enterprise/app/models/captain_inbox.rb'
# Offense count: 9
# Configuration parameters: ForbiddenMethods, AllowedMethods.
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
Rails/SkipsModelValidations:
Exclude:
- 'app/services/jasmine/tool_runner.rb'
- 'enterprise/app/jobs/captain/documents/response_builder_job.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'promote_super_admin.rb'
- 'script/test_auto_resolve_inbox.rb'
# Offense count: 8
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20240523120000_create_captain_configurations.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20251227201733_create_captain_tool_configs.rb'
- 'db/migrate/20260114101004_create_captain_extras.rb'
- 'db/migrate/20260120141736_add_message_signature_enabled_to_inboxes.rb'
- 'db/migrate/20260121165034_add_plug_play_to_captain_units.rb'
# Offense count: 2
# Configuration parameters: TransactionMethods.
Rails/TransactionExitStatement:
Exclude:
- 'app/services/jasmine/embedding_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: **/app/models/**/*.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/jasmine/inbox_collection.rb'
# Offense count: 3
# Configuration parameters: MinBranchesCount.
Style/HashLikeCase:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
# Offense count: 4
Style/OneClassPerFile:
Exclude:
- 'app/services/jasmine/brain_service.rb'
# Offense count: 8
UseFromEmail:
Exclude:
- 'app/services/captain/reservations/sync_service.rb'
- 'clear_chat_history.rb'
- 'debug_auth.rb'
- 'debug_token.rb'
- 'force_reset.rb'
- 'promote_super_admin.rb'
- 'setup_docker_env.rb'

View File

@ -26,18 +26,10 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
status = status_data['status'] || status_data['state'] || status_data
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
return
end
return if already_connected?(status)
qr_data = client.get_qr_code(user_token)
Rails.logger.info "Wuzapi QR Data Response keys: #{begin
qr_data.keys
rescue StandardError
'nil'
end}"
log_qr_data_keys(qr_data)
render json: qr_data
rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi QR Error: #{e.message}"
@ -118,8 +110,24 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
raise 'Token Wuzapi ausente; reprovisionar usuário'
else
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..-1]}"
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..]}"
end
token
end
def already_connected?(status)
if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
true
else
false
end
end
def log_qr_data_keys(qr_data)
Rails.logger.info "Wuzapi QR Data Response keys: #{qr_data.keys}"
rescue StandardError
Rails.logger.info 'Wuzapi QR Data Response keys: nil'
end
end

View File

@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::
Rails.logger.error(
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}"
)
hook.update_columns(settings: settings)
hook.update_columns(settings: settings) # rubocop:disable Rails/SkipsModelValidations
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
def index
@ -43,9 +39,4 @@ module Api
def collection_params
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Jasmine
class DocumentsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def index
@ -35,8 +31,4 @@ module Api
def document_params
params.require(:document).permit(:title, :content)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Jasmine
class InboxCollectionsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Jasmine::InboxCollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
@ -36,8 +32,4 @@ module Api
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Jasmine
class InboxConfigsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def show
@ -29,8 +25,4 @@ module Api
def config_params
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Public
module Api
module V1
module Captain
class PaymentsController < ApplicationController
class Public::Api::V1::Captain::PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
@ -18,8 +14,4 @@ module Public
# @charge is available for the view
# It should be a Captain::PixCharge model
end
end
end
end
end
end

View File

@ -37,9 +37,22 @@ class Webhooks::WhatsappController < ActionController::API
# This prevents ANY binary data from leaking into JSON serialization
def sanitize_payload_for_sidekiq
raw = params.to_unsafe_hash
clean_payload = build_base_payload(raw)
# Build a completely NEW payload with only safe fields
clean_payload = {
clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash)
if raw['whatsapp'].is_a?(Hash)
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
)
end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
deep_force_utf8(clean_payload)
end
def build_base_payload(raw)
{
'type' => raw['type'],
'state' => raw['state'],
'instanceName' => raw['instanceName'],
@ -48,14 +61,13 @@ class Webhooks::WhatsappController < ActionController::API
'action' => raw['action'],
'phone_number' => raw['phone_number']
}
end
# Only copy safe event fields
if raw['event'].is_a?(Hash)
def build_clean_event(raw_event)
clean_event = {}
# Info fields (all safe strings/ids)
if raw['event']['Info'].is_a?(Hash)
clean_event['Info'] = raw['event']['Info'].slice(
if raw_event['Info'].is_a?(Hash)
clean_event['Info'] = raw_event['Info'].slice(
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
'Timestamp', 'PushName', 'MessageSource'
)
@ -64,98 +76,76 @@ class Webhooks::WhatsappController < ActionController::API
# Safe event metadata
%w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner
BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key|
clean_event[key] = raw['event'][key] if raw['event'].key?(key)
clean_event[key] = raw_event[key] if raw_event.key?(key)
end
# Message content - WHITELIST only safe fields
if raw['event']['Message'].is_a?(Hash)
msg = raw['event']['Message']
clean_msg = {}
if raw_event['Message'].is_a?(Hash)
clean_msg = build_clean_message(raw_event['Message'])
clean_event['Message'] = clean_msg unless clean_msg.empty?
end
# Text messages
clean_event
end
def build_clean_message(msg)
clean_msg = {}
clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String)
if msg['extendedTextMessage'].is_a?(Hash)
clean_msg['extendedTextMessage'] = {
'text' => msg['extendedTextMessage']['text']
}
# Only copy contextInfo if it doesn't have quotedMessage with binaries
# Only copy contextInfo if it doesn't have quotedMessage with binaries
if msg['extendedTextMessage']['contextInfo'].is_a?(Hash)
ctx = msg['extendedTextMessage']['contextInfo']
clean_msg['extendedTextMessage']['contextInfo'] = {
clean_msg.merge!(clean_extended_text_message(msg['extendedTextMessage']))
clean_msg.merge!(clean_media_message(msg, 'imageMessage'))
clean_msg.merge!(clean_media_message(msg, 'videoMessage'))
clean_msg.merge!(clean_media_message(msg, 'audioMessage'))
clean_msg.merge!(clean_document_message(msg['documentMessage']))
clean_msg
end
def clean_extended_text_message(ext_msg)
return {} unless ext_msg.is_a?(Hash)
result = { 'extendedTextMessage' => { 'text' => ext_msg['text'] } }
result['extendedTextMessage']['contextInfo'] = clean_context_info(ext_msg['contextInfo']) if ext_msg['contextInfo'].is_a?(Hash)
result
end
def clean_context_info(ctx)
{
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
'participant' => ctx['participant']
}.compact
end
def clean_media_message(msg, type)
media = msg[type]
return {} unless media.is_a?(Hash)
clean_data = {
'URL' => media['URL'] || media['url'],
'directPath' => media['directPath'],
'mediaKey' => media['mediaKey'],
'fileEncSha256' => media['fileEncSha256'] || media['fileEncSHA256'],
'fileSha256' => media['fileSha256'] || media['fileSHA256'],
'fileLength' => media['fileLength'],
'mimetype' => media['mimetype'],
'seconds' => media['seconds'],
'caption' => media['caption'],
'ptt' => media['ptt'],
'width' => media['width'],
'height' => media['height']
}
clean_data['contextInfo'] = clean_context_info(media['contextInfo']) if media['contextInfo'].is_a?(Hash)
{ type => clean_data.compact }
end
# Image messages - ONLY safe metadata, NO binaries
if msg['imageMessage'].is_a?(Hash)
img = msg['imageMessage']
clean_msg['imageMessage'] = {
'URL' => img['URL'] || img['url'],
'directPath' => img['directPath'],
'mediaKey' => img['mediaKey'],
'fileEncSha256' => img['fileEncSha256'] || img['fileEncSHA256'],
'fileSha256' => img['fileSha256'] || img['fileSHA256'],
'fileLength' => img['fileLength'],
'mimetype' => img['mimetype'],
'width' => img['width'],
'height' => img['height'],
'caption' => img['caption'],
'contextInfo' => {
'stanzaID' => img.dig('contextInfo', 'stanzaID') || img.dig('contextInfo', 'stanzaId'),
'participant' => img.dig('contextInfo', 'participant')
}.compact
}.compact
# EXPLICITLY NO: JPEGThumbnail, scansSidecar, firstScanSidecar, etc
end
def clean_document_message(doc)
return {} unless doc.is_a?(Hash)
# Video messages - ONLY safe metadata
if msg['videoMessage'].is_a?(Hash)
vid = msg['videoMessage']
clean_msg['videoMessage'] = {
'URL' => vid['URL'] || vid['url'],
'directPath' => vid['directPath'],
'mediaKey' => vid['mediaKey'],
'fileEncSha256' => vid['fileEncSha256'] || vid['fileEncSHA256'],
'fileSha256' => vid['fileSha256'] || vid['fileSHA256'],
'fileLength' => vid['fileLength'],
'mimetype' => vid['mimetype'],
'seconds' => vid['seconds'],
'caption' => vid['caption'],
'contextInfo' => {
'stanzaID' => vid.dig('contextInfo', 'stanzaID') || vid.dig('contextInfo', 'stanzaId'),
'participant' => vid.dig('contextInfo', 'participant')
}.compact
}.compact
end
# Audio messages
if msg['audioMessage'].is_a?(Hash)
aud = msg['audioMessage']
clean_msg['audioMessage'] = {
'URL' => aud['URL'] || aud['url'],
'directPath' => aud['directPath'],
'mediaKey' => aud['mediaKey'],
'fileEncSha256' => aud['fileEncSha256'] || aud['fileEncSHA256'],
'fileSha256' => aud['fileSha256'] || aud['fileSHA256'],
'fileLength' => aud['fileLength'],
'mimetype' => aud['mimetype'],
'seconds' => aud['seconds'],
'ptt' => aud['ptt'],
'contextInfo' => {
'stanzaID' => aud.dig('contextInfo', 'stanzaID') || aud.dig('contextInfo', 'stanzaId'),
'participant' => aud.dig('contextInfo', 'participant')
}.compact
}.compact
end
# Document messages
if msg['documentMessage'].is_a?(Hash)
doc = msg['documentMessage']
clean_msg['documentMessage'] = {
{
'documentMessage' => {
'URL' => doc['URL'] || doc['url'],
'directPath' => doc['directPath'],
'mediaKey' => doc['mediaKey'],
@ -166,24 +156,7 @@ class Webhooks::WhatsappController < ActionController::API
'fileName' => doc['fileName'],
'title' => doc['title']
}.compact
end
clean_event['Message'] = clean_msg unless clean_msg.empty?
end
clean_payload['event'] = clean_event
end
# Also copy whatsapp key if present (but sanitize it too)
if raw['whatsapp'].is_a?(Hash)
# Just reference the same clean event structure
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
)
end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
deep_force_utf8(clean_payload)
}
end
def deep_force_utf8(obj)

View File

@ -1,4 +1,4 @@
class Webhooks::WuzapiController < ActionController::Base
class Webhooks::WuzapiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :fetch_inbox
before_action :verify_secret

View File

@ -1,5 +1,4 @@
module Conversations
class AutoLabelJob < ApplicationJob
class Conversations::AutoLabelJob < ApplicationJob
queue_as :low_priority
TAXONOMY = {
@ -17,7 +16,7 @@ module Conversations
def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
return unless conversation.messages.count > 0
return unless conversation.messages.count.positive?
# Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any?
@ -130,5 +129,4 @@ module Conversations
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
end
end
end

View File

@ -1,5 +1,4 @@
module Conversations
class ClusterJob < ApplicationJob
class Conversations::ClusterJob < ApplicationJob
queue_as :low_priority
def perform(account_id, days_back = 7)
@ -9,7 +8,7 @@ module Conversations
start_date = days_back.days.ago.beginning_of_day
# Labels que queremos agrupar (todas da taxonomia)
Conversations::AutoLabelJob::TAXONOMY.keys.each do |label|
Conversations::AutoLabelJob::TAXONOMY.each_key do |label|
# Busca perguntas dessa categoria
# Note: estamos queryng o campo JSONB additional_attributes
account.conversations
@ -82,5 +81,4 @@ module Conversations
)
end
end
end
end

View File

@ -1,5 +1,4 @@
module CrmInsights
class UpdateJob < ApplicationJob
class CrmInsights::UpdateJob < ApplicationJob
queue_as :low
def perform(conversation_id, reason: nil)
@ -13,5 +12,4 @@ module CrmInsights
UpdateService.new(conversation: conversation, reason: reason).call
end
end
end

View File

@ -1,5 +1,4 @@
module Jasmine
class ResponseJob < ApplicationJob
class Jasmine::ResponseJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :polynomially_longer, attempts: 2
@ -57,5 +56,4 @@ module Jasmine
rescue StandardError => e
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
end
end
end

View File

@ -25,10 +25,8 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id)
#
module Captain
class Pricing < ApplicationRecord
class Captain::Pricing < ApplicationRecord
belongs_to :account
belongs_to :captain_brand, optional: true
belongs_to :inbox, optional: true
end
end

View File

@ -48,8 +48,7 @@
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Captain
class Reservation < ApplicationRecord
class Captain::Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
belongs_to :account
@ -82,5 +81,4 @@ module Captain
scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
}
end
end

View File

@ -38,8 +38,7 @@
# fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Captain
class Unit < ApplicationRecord
class Captain::Unit < ApplicationRecord
self.table_name = 'captain_units'
belongs_to :account
@ -56,5 +55,4 @@ module Captain
# Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true
end
end

View File

@ -197,7 +197,7 @@ class Channel::Whatsapp < ApplicationRecord
provider_config.delete('wuzapi_user_token')
end
return unless provider_config['wuzapi_admin_token'].present?
return if provider_config['wuzapi_admin_token'].blank?
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
provider_config.delete('wuzapi_admin_token')
@ -209,13 +209,13 @@ class Channel::Whatsapp < ApplicationRecord
def perform_webhook_setup
if provider == 'wuzapi'
return unless inbox.present?
return if inbox.blank?
base_url = provider_config['wuzapi_base_url']
# Use encrypted token
user_token = wuzapi_user_token
return unless user_token.present?
return if user_token.blank?
# Construct Chatwoot Webhook URL
# Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix
@ -286,7 +286,7 @@ class Channel::Whatsapp < ApplicationRecord
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
self.wuzapi_user_token = result[:wuzapi_user_token]
masked_token = result[:wuzapi_user_token].to_s[-4..-1]
masked_token = result[:wuzapi_user_token].to_s[-4..]
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
end
end

View File

@ -24,15 +24,14 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (owner_inbox_id => inboxes.id)
#
module Jasmine
class Collection < ApplicationRecord
class Jasmine::Collection < ApplicationRecord
self.table_name = 'jasmine_collections'
belongs_to :account
belongs_to :owner_inbox, class_name: 'Inbox', optional: true
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', foreign_key: :collection_id, dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, dependent: :destroy
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', dependent: :destroy
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
@ -43,9 +42,8 @@ module Jasmine
private
def validate_owner_if_private
if visibility_private? && owner_inbox_id.nil?
return unless visibility_private? && owner_inbox_id.nil?
errors.add(:owner_inbox_id, 'must be present for private collections')
end
end
end
end

View File

@ -25,13 +25,12 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (collection_id => jasmine_collections.id)
#
module Jasmine
class Document < ApplicationRecord
class Jasmine::Document < ApplicationRecord
self.table_name = 'jasmine_documents'
belongs_to :account
belongs_to :collection, class_name: 'Jasmine::Collection'
has_many :chunks, class_name: 'Jasmine::DocumentChunk', foreign_key: :document_id, dependent: :delete_all
has_many :chunks, class_name: 'Jasmine::DocumentChunk', dependent: :delete_all
enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
@ -46,11 +45,11 @@ module Jasmine
def validate_account_consistency
return if collection.nil?
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
def enqueue_embed_job
Jasmine::EmbedDocumentJob.perform_later(id)
end
end
end

View File

@ -26,8 +26,7 @@
# fk_rails_... (collection_id => jasmine_collections.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class InboxCollection < ApplicationRecord
class Jasmine::InboxCollection < ApplicationRecord
self.table_name = 'jasmine_inbox_collections'
belongs_to :account
@ -53,9 +52,8 @@ module Jasmine
def validate_visibility_rules
return if collection.nil? || inbox.nil?
if collection.visibility_private? && collection.owner_inbox_id != inbox_id
return unless collection.visibility_private? && collection.owner_inbox_id != inbox_id
errors.add(:base, 'Private collections can only be linked to their owner inbox')
end
end
end
end

View File

@ -29,8 +29,7 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class InboxConfig < ApplicationRecord
class Jasmine::InboxConfig < ApplicationRecord
self.table_name = 'jasmine_inbox_settings'
belongs_to :account
@ -44,7 +43,7 @@ module Jasmine
def validate_account_consistency
return if inbox.nil?
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
end
end
end

View File

@ -28,8 +28,7 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class ToolConfig < ApplicationRecord
class Jasmine::ToolConfig < ApplicationRecord
self.table_name = 'jasmine_tool_configs'
belongs_to :account
@ -67,5 +66,4 @@ module Jasmine
def self.definitions
DEFINITIONS
end
end
end

View File

@ -1,5 +1,4 @@
module Captain
module Assistant
# Base module to fix NameError
module Captain::Assistant
# Base module to fix NameError
end
end

View File

@ -4,8 +4,7 @@ require 'json'
require 'openssl'
require 'base64'
module Captain
class InterService
class Captain::InterService
# Constants for API URLs
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
@ -117,5 +116,4 @@ module Captain
http.request(req)
end
end
end

View File

@ -1,7 +1,5 @@
module Captain
module Reservations
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
class Captain::Reservations::SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
def initialize(unit)
@unit = unit
@ -170,6 +168,4 @@ module Captain
:scheduled
end
end
end
end
end

View File

@ -1,5 +1,4 @@
module CrmInsights
class ContactSessionCounter
class CrmInsights::ContactSessionCounter
WINDOW = 24.hours
def initialize(conversation)
@ -27,5 +26,4 @@ module CrmInsights
last_contact_at: last_session_start
}
end
end
end

View File

@ -1,6 +1,5 @@
module CrmInsights
class GenerateService < Llm::BaseAiService
DEFAULT_MODEL = 'gpt-4o-mini'
class CrmInsights::GenerateService < Llm::BaseAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
super()
@ -168,5 +167,4 @@ module CrmInsights
"#{@from_message_id} ate #{@to_message_id}"
end
end
end

View File

@ -1,5 +1,4 @@
module CrmInsights
class UpdateService
class CrmInsights::UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
@ -355,5 +354,4 @@ module CrmInsights
error_message: error_message
}
end
end
end

View File

@ -1,5 +1,4 @@
module Jasmine
class BrainService
class Jasmine::BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
@ -282,5 +281,4 @@ module Jasmine
conversation.update!(custom_attributes: attrs)
end
end
end
end

View File

@ -1,5 +1,4 @@
module Jasmine
class EmbeddingService
class Jasmine::EmbeddingService
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
@ -91,7 +90,7 @@ module Jasmine
else
# Fallback: Generate deterministic hash-based embedding for testing
# This won't provide semantic search but allows the system to function
Rails.logger.warn "OpenAI not configured, using fallback embedding for Jasmine"
Rails.logger.warn 'OpenAI not configured, using fallback embedding for Jasmine'
generate_fallback_embedding(text)
end
end
@ -124,6 +123,4 @@ module Jasmine
def embedding_model
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
end
end
end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true
module Jasmine
class MediaAnalyzerService
class Jasmine::MediaAnalyzerService
attr_reader :message
def initialize(message:)
@ -16,7 +15,7 @@ module Jasmine
private
def analyze_audio
message.attachments.where(file_type: :audio).each do |attachment|
message.attachments.where(file_type: :audio).find_each do |attachment|
next if attachment.meta&.dig('transcribed_text').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
@ -33,7 +32,7 @@ module Jasmine
end
def analyze_images
message.attachments.where(file_type: :image).each do |attachment|
message.attachments.where(file_type: :image).find_each do |attachment|
next if attachment.meta&.dig('description').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
@ -49,5 +48,4 @@ module Jasmine
end
end
end
end
end

View File

@ -1,5 +1,4 @@
module Jasmine
class SemanticSearchService
class Jasmine::SemanticSearchService
CANDIDATES_PER_PRIORITY = 50
TOP_K_PER_PRIORITY = 10
MAX_CHUNKS_PER_DOC = 2
@ -174,5 +173,4 @@ module Jasmine
# PGVector accepts JSON array string e.g. "[1.0, 2.0]"
vector.to_s
end
end
end

View File

@ -1,7 +1,6 @@
require 'rest-client'
module Jasmine
class ToolRunner
class Jasmine::ToolRunner
attr_reader :inbox, :tool_key, :config
def initialize(inbox, tool_key)
@ -13,7 +12,7 @@ module Jasmine
def run
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
raise "Tool not found definition: #{@tool_key}" unless definition
raise "Tool not configured or disabled" unless config&.is_enabled?
raise 'Tool not configured or disabled' unless config&.is_enabled?
start_time = Time.current
@ -21,7 +20,7 @@ module Jasmine
response = make_request(definition)
duration = (Time.current - start_time) * 1000
success = (200..299).include?(response.code)
success = (200..299).cover?(response.code)
body = response.body.to_s
# Save stats
@ -38,7 +37,11 @@ module Jasmine
error_msg = e.message
# Try to parse body from error response if available
error_body = e.response&.body rescue nil
error_body = begin
e.response&.body
rescue StandardError
nil
end
error_msg = "#{error_msg} - #{error_body}" if error_body
sanitized_error = sanitize(error_msg)
@ -97,8 +100,8 @@ module Jasmine
end
def sanitize(text)
return text unless @config&.plug_play_token.present?
return text if @config&.plug_play_token.blank?
text.to_s.gsub(@config.plug_play_token, '****')
end
end
end

View File

@ -3,8 +3,7 @@
require 'openai'
require 'base64'
module Jasmine
class VisionService
class Jasmine::VisionService
attr_reader :attachment
def initialize(attachment:)
@ -65,5 +64,4 @@ module Jasmine
Base64.strict_encode64(file.read)
end
end
end
end

View File

@ -1,7 +1,5 @@
module Llm
class BaseAiService
class Llm::BaseAiService
def initialize
# Base initialization logic if needed
end
end
end

View File

@ -15,7 +15,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
!@raw_message[:key][:fromMe]
end
def jid_type # rubocop:disable Metrics/CyclomaticComplexity
def jid_type
jid = @raw_message[:key][:remoteJid]
server = jid.split('@').last
@ -39,7 +39,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
end
end
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength,Metrics/AbcSize
def message_type # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
msg = unwrap_ephemeral_message(@raw_message[:message])
if msg.key?(:conversation) || msg.dig(:extendedTextMessage, :text).present?
'text'
@ -69,7 +69,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
end
end
def message_content # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
def message_content # rubocop:disable Metrics/MethodLength
msg = unwrap_ephemeral_message(@raw_message[:message])
case message_type
when 'text'
@ -96,7 +96,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
end
end
def reply_to_message_id # rubocop:disable Metrics/CyclomaticComplexity
def reply_to_message_id
msg = unwrap_ephemeral_message(@raw_message[:message])
message_key = case message_type
when 'text' then :extendedTextMessage

View File

@ -1,4 +1,4 @@
module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength
module Whatsapp::BaileysHandlers::MessagesUpsert
include Whatsapp::BaileysHandlers::Helpers
include BaileysHelper

View File

@ -1,5 +1,4 @@
module Whatsapp
class DecryptionService
class Whatsapp::DecryptionService
require 'openssl'
require 'base64'
require 'net/http'
@ -143,5 +142,4 @@ module Whatsapp
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
nil
end
end
end

View File

@ -137,7 +137,7 @@ module Whatsapp::IncomingMessageServiceHelpers
# 2. Media Messages (Image, Video, Audio, Document, Sticker)
[:imageMessage, :videoMessage, :audioMessage, :documentMessage, :stickerMessage].each do |media_key|
next unless message.dig(media_key.to_s, 'contextInfo').present?
next if message.dig(media_key.to_s, 'contextInfo').blank?
ctx = message[media_key.to_s]['contextInfo']
@in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId']

View File

@ -1,5 +1,4 @@
module Whatsapp
class IncomingMessageWuzapiService < IncomingMessageBaseService
class Whatsapp::IncomingMessageWuzapiService < IncomingMessageBaseService
def perform
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
@ -246,5 +245,4 @@ module Whatsapp
else :file
end
end
end
end

View File

@ -1,7 +1,4 @@
module Whatsapp
module Providers
module Wuzapi
class PayloadParser
class Whatsapp::Providers::Wuzapi::PayloadParser
attr_reader :params
def initialize(params)
@ -82,7 +79,7 @@ module Whatsapp
# 2. Media Types direct contextInfo
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
ctx = msg.dig(key, :contextInfo)
next unless ctx.present?
next if ctx.blank?
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId]
@ -99,7 +96,7 @@ module Whatsapp
end
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage)
if msg[:conversation].present? && msg.dig(:contextInfo).present?
if msg[:conversation].present? && msg[:contextInfo].present?
ctx = msg[:contextInfo]
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId]
@ -170,7 +167,7 @@ module Whatsapp
return Time.current if timestamp_val.blank?
begin
Time.parse(timestamp_val.to_s)
Time.zone.parse(timestamp_val.to_s)
rescue ArgumentError
Time.current
end
@ -209,7 +206,4 @@ module Whatsapp
end
end
end
end
end
end
end

View File

@ -1,5 +1,4 @@
module Whatsapp::Providers
class WuzapiService < BaseService
class Whatsapp::Providers::WuzapiService < BaseService
attr_reader :whatsapp_channel
def initialize(whatsapp_channel:)
@ -170,9 +169,8 @@ module Whatsapp::Providers
target_message = message.conversation.messages.find_by(source_id: message.in_reply_to_external_id)
end
return false unless target_message.present?
return false if target_message.blank?
target_message.outgoing? || target_message.template?
end
end
end

View File

@ -39,7 +39,7 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
!@raw_message.key?(:notification)
end
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
def message_type
return 'text' if @raw_message.key?(:text)
return 'reaction' if @raw_message.key?(:reaction)
return 'audio' if @raw_message.key?(:audio)

View File

@ -1,5 +1,4 @@
module Wuzapi
class ProvisioningService
class Wuzapi::ProvisioningService
def initialize(base_url, admin_token)
@base_url = base_url
@admin_token = admin_token
@ -21,7 +20,8 @@ module Wuzapi
}
end
def setup_webhook(user_token, inbox_id, _webhook_secret) # [INTENTIONAL] reserved for signed webhooks
# [INTENTIONAL] reserved for signed webhooks
def setup_webhook(user_token, inbox_id, _webhook_secret)
# Host logic needs to come from GlobalConfig or Rails.application.routes
# Ideally passed in or resolved.
base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
@ -31,5 +31,4 @@ module Wuzapi
@client.set_webhook(user_token, webhook_url)
end
end
end

View File

@ -1,5 +1,7 @@
Rails.application.configure do
config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY', 'test_primary_key_must_be_32_bytes_long!!')
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', 'test_deterministic_key_must_be_32_bytes!!')
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', 'test_salt_must_be_unique_and_random')
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY',
'test_deterministic_key_must_be_32_bytes!!')
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT',
'test_salt_must_be_unique_and_random')
end

View File

@ -15,7 +15,7 @@ Rails.application.config.after_initialize do
Rails.logger.error "[RubyLLM] Init failed: #{e.class} #{e.message}"
end
Rails.logger.info "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..."
puts "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." # Log to stdout for rails runner visibility
Rails.logger.debug { "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." } # Log to stdout for rails runner visibility
else
Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment'
end

0
configure_captain_webhook.rb Normal file → Executable file
View File

View File

@ -8,4 +8,3 @@ class AddBrainFieldsToJasmineInboxSettings < ActiveRecord::Migration[7.1]
add_column :jasmine_inbox_settings, :intent_keywords, :jsonb, default: {}
end
end

View File

@ -6,7 +6,7 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1]
tools = Captain::CustomTool.where('endpoint_url ILIKE ? OR title ILIKE ?', '%/api/PlugPlay/api/SuitesStatus%', '%Status Suites%')
tools.each do |tool|
puts "Processing tool: #{tool.title} (ID: #{tool.id})"
Rails.logger.debug { "Processing tool: #{tool.title} (ID: #{tool.id})" }
updated = false
new_auth_config = tool.auth_config || {}
@ -20,7 +20,7 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1]
%w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].each do |header_key|
next unless query_params.key?(header_key)
puts " Found #{header_key} in URL query params. Moving to headers."
Rails.logger.debug { " Found #{header_key} in URL query params. Moving to headers." }
new_auth_config['headers'][header_key] = query_params[header_key]
query_params.delete(header_key)
updated = true
@ -36,16 +36,16 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1]
if tool.param_schema.is_a?(Array)
original_size = tool.param_schema.size
tool.param_schema.reject! { |p| %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].include?(p['name']) }
puts ' Removed params from param_schema.' if tool.param_schema.size < original_size
Rails.logger.debug ' Removed params from param_schema.' if tool.param_schema.size < original_size
end
tool.save!
puts ' Tool updated successfully.'
Rails.logger.debug ' Tool updated successfully.'
else
puts ' No keys found in URL query params. Manual update might be required for values.'
Rails.logger.debug ' No keys found in URL query params. Manual update might be required for values.'
end
rescue URI::InvalidURIError # [INTENTIONAL] keep for future logging
puts " Skipping invalid URI: #{tool.endpoint_url}"
Rails.logger.debug { " Skipping invalid URI: #{tool.endpoint_url}" }
end
end
end

View File

@ -6,7 +6,7 @@ if account
features_config = YAML.safe_load(Rails.root.join('config/features.yml').read)
# Select features that should be enabled by default
default_features = features_config.select { |f| f['enabled'] == true }.map { |f| f['name'] }
default_features = features_config.select { |f| f['enabled'] == true }.pluck('name')
puts "Enabling default features: #{default_features.join(', ')}"
@ -16,5 +16,5 @@ if account
puts "New feature flags: #{account.feature_flags}"
puts "Inbox Management Enabled? #{account.feature_enabled?('inbox_management')}"
else
puts "Account not found!"
puts 'Account not found!'
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class BrandsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::BrandsController < Api::V1::Accounts::BaseController
before_action :fetch_brand, only: [:show, :update, :destroy]
def index
@ -45,8 +41,4 @@ module Api
def brand_params
params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {})
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class ConfigurationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::ConfigurationsController < Api::V1::Accounts::BaseController
before_action :fetch_config
def show
@ -27,8 +23,4 @@ module Api
def config_params
params.require(:configuration).permit(:title, :subtitle, :primary_color, :secondary_color, :active, :logo_url, :phone_number)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class ExtrasController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::ExtrasController < Api::V1::Accounts::BaseController
before_action :fetch_extra, only: [:show, :update, :destroy]
def index
@ -45,8 +41,4 @@ module Api
def extra_params
params.require(:extra).permit(:title, :description, :price, :category)
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class PricingsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
before_action :fetch_pricing, only: [:show, :update, :destroy]
def index
@ -60,8 +56,4 @@ module Api
inboxes = Current.account.inboxes.where(id: ids)
pricing.inboxes = inboxes
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Api
module V1
module Accounts
module Captain
class UnitsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(::Captain::Assistant) }
@ -47,8 +43,4 @@ module Api
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
)
end
end
end
end
end
end

View File

@ -1,9 +1,4 @@
module Api
module V1
module Accounts
module Inboxes
module Captain
class ReminderSettingsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Inboxes::Captain::ReminderSettingsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_settings
@ -41,9 +36,4 @@ module Api
:feedback_delay_minutes
)
end
end
end
end
end
end
end

View File

@ -1,16 +1,8 @@
module Public
module Api
module V1
module Captain
class BookingAppController < ActionController::Base
class Public::Api::V1::Captain::BookingAppController < ApplicationController
layout false
def index
# This action will serve the React App container
render :index
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Public
module Api
module V1
module Captain
class MasterDataController < ActionController::API
class Public::Api::V1::Captain::MasterDataController < ActionController::API
def show
# Assuming account_id is passed or derived from domain
account = Account.find(params[:account_id])
@ -26,8 +22,4 @@ module Public
suites: suites.as_json
}
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Public
module Api
module V1
module Captain
class ReservationsController < ActionController::API
class Public::Api::V1::Captain::ReservationsController < ActionController::API
# Use basic rescue_from for generic error handling to capture 'Inbox not found' or similar if they bubble up
rescue_from StandardError, with: :handle_standard_error
@ -165,8 +161,4 @@ module Public
}
}, status: :ok
end
end
end
end
end
end

View File

@ -1,8 +1,4 @@
module Public
module Api
module V1
module Captain
class WebhooksController < ActionController::API
class Public::Api::V1::Captain::WebhooksController < ActionController::API
def inter_pix
Rails.logger.info "[Captain::InterWebhook] 🔔 Received Webhook: #{params.to_unsafe_h}"
@ -47,8 +43,4 @@ module Public
render json: { status: 'received' }, status: :ok
end
end
end
end
end
end

View File

@ -98,7 +98,8 @@ module Captain::ChatHelper
end
def api_key
raw_key = @assistant&.api_key.presence || @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY', nil) || ENV.fetch('GEMINI_API_KEY', nil)
raw_key = @assistant&.api_key.presence || @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY',
nil) || ENV.fetch('GEMINI_API_KEY', nil)
return nil if raw_key.blank?
# Sanitize: Remove common accidental suffixes like image names or whitespace

View File

@ -18,7 +18,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
trigger_media_analysis
Rails.logger.info "[ResponseBuilderJob] Captain V2 Enabled? #{captain_v2_enabled?}"
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" }
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" }
if captain_v2_enabled?
generate_response_with_v2
@ -209,7 +209,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
elapsed_time = Time.zone.now - @start_time
remaining_delay = target_delay - elapsed_time
sleep(remaining_delay) if remaining_delay > 0
sleep(remaining_delay) if remaining_delay.positive?
end
def fetch_new_incoming_messages
@ -217,11 +217,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
all_messages = @conversation.messages.order(:created_at)
# Find the last message sent by the assistant (outgoing)
last_outgoing_index = all_messages.rindex { |m| m.outgoing? }
last_outgoing_index = all_messages.rindex(&:outgoing?)
potential_messages = if last_outgoing_index
# Get all messages after the last outgoing one
all_messages[(last_outgoing_index + 1)..-1] || []
all_messages[(last_outgoing_index + 1)..] || []
else
# If no outgoing messages, use all messages
all_messages

View File

@ -1,5 +1,4 @@
module Captain
class IntentClassificationJob < ApplicationJob
class Captain::IntentClassificationJob < ApplicationJob
queue_as :default
CATEGORIES = %w[
@ -83,5 +82,4 @@ module Captain
content = response.dig('choices', 0, 'message', 'content')
content&.strip&.downcase
end
end
end

View File

@ -108,7 +108,7 @@ class Captain::Assistant < ApplicationRecord
next unless tool_class
# Avoid duplicates if tool is already added (e.g. hardcoded ones)
next if tools.any? { |t| t.is_a?(tool_class) }
next if tools.any?(tool_class)
tools << tool_class.new(self, conversation: conversation, user: user)
end

View File

@ -1,10 +1,8 @@
module Captain
class Configuration < ApplicationRecord
class Captain::Configuration < ApplicationRecord
self.table_name = 'captain_configurations'
belongs_to :account
validates :account_id, presence: true
validates :title, presence: true
end
end

View File

@ -1,5 +1,4 @@
module Captain
class Reminder < ApplicationRecord
class Captain::Reminder < ApplicationRecord
self.table_name = 'captain_reminders'
belongs_to :account
@ -17,5 +16,4 @@ module Captain
scope :pending, -> { where(status: :pending) }
scope :due, -> { pending.where('scheduled_at <= ?', Time.current) }
end
end

View File

@ -1,5 +1,4 @@
module Captain
class Reservation < ApplicationRecord
class Captain::Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
belongs_to :account
@ -33,5 +32,4 @@ module Captain
validates :check_out_at, presence: true
delegate :name, :email, :phone_number, to: :contact, prefix: true
end
end

View File

@ -1,9 +1,7 @@
module Captain
class Suite < ApplicationRecord
class Captain::Suite < ApplicationRecord
self.table_name = 'captain_suites'
belongs_to :account
validates :name, presence: true
validates :category, presence: true
end
end

View File

@ -1,5 +1,4 @@
module Captain
class ToolConfig < ApplicationRecord
class Captain::ToolConfig < ApplicationRecord
self.table_name = 'captain_tool_configs'
belongs_to :account
belongs_to :inbox, optional: true
@ -11,5 +10,4 @@ module Captain
validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :inbox_id] }, if: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? }
end
end

View File

@ -32,7 +32,7 @@ class Captain::Assistant::AgentRunnerService
# [FEATURE] Short-circuit for thank you/emoji messages to ensure reaction tool usage
Rails.logger.info "[Captain V2] Checking for reaction. Message: #{message_to_process.inspect}"
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] AgentRunnerService: checking reaction for #{message_to_process.inspect}" }
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] AgentRunnerService: checking reaction for #{message_to_process.inspect}" }
reaction_response = check_and_react_to_message(message_to_process)
return reaction_response if reaction_response
@ -41,7 +41,7 @@ class Captain::Assistant::AgentRunnerService
runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
puts "[DEBUG V2] Running with agents: #{agents.map(&:name).join(', ')}"
Rails.logger.debug { "[DEBUG V2] Running with agents: #{agents.map(&:name).join(', ')}" }
# Use assistant's API key if present, otherwise fallback to global config
result = with_assistant_api_key do
@ -89,7 +89,7 @@ class Captain::Assistant::AgentRunnerService
end
Rails.logger.info "[Captain V2] Reaction Pre-Check: Text='#{text}' Category=#{matched_category}"
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] AgentRunnerService: Text='#{text}' Category=#{matched_category}" }
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] AgentRunnerService: Text='#{text}' Category=#{matched_category}" }
if matched_category
Rails.logger.info "[Captain V2] Detected #{matched_category}. Executing ReactToMessageTool directly."
@ -140,7 +140,7 @@ class Captain::Assistant::AgentRunnerService
# Remove the last user message from history because it will be passed as the main message to the runner
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' || msg[:role] == :user }
filtered_history = if last_user_index
message_history[0...last_user_index] + message_history[(last_user_index + 1)..-1]
message_history[0...last_user_index] + message_history[(last_user_index + 1)..]
else
message_history
end
@ -328,7 +328,7 @@ class Captain::Assistant::AgentRunnerService
def sanitize_global_api_key
# Force sanitization of the global gem config just in case it's dirty
raw_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || ENV.fetch('OPENAI_API_KEY', nil)
return unless raw_key.present?
return if raw_key.blank?
sanitized_key = raw_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
Agents.configure { |config| config.openai_api_key = sanitized_key }

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true
module Captain
class HandoffWebhookService
class Captain::HandoffWebhookService
def initialize(conversation:, assistant:, handoff_context: {})
@conversation = conversation
@assistant = assistant
@ -85,7 +84,7 @@ module Captain
end
def should_retry?
(@assistant.handoff_webhook_config['retry_attempts'] || 0) > 0
(@assistant.handoff_webhook_config['retry_attempts'] || 0).positive?
end
def retry_if_needed(error)
@ -101,5 +100,4 @@ module Captain
Rails.logger.error "[HandoffWebhook] Failed for conversation #{@conversation.id}: #{error.message}"
ChatwootExceptionTracker.new(error, account: @conversation.account).capture_exception
end
end
end

View File

@ -1,8 +1,6 @@
require 'faraday'
module Captain
module Inter
class AuthService
class Captain::Inter::AuthService
API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
TOKEN_URL = '/oauth/v2/token'.freeze
@ -56,6 +54,4 @@ module Captain
conn.adapter Faraday.default_adapter
end
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Inter
class CobService
class Captain::Inter::CobService
API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
def initialize(reservation)
@ -9,7 +7,7 @@ module Captain
end
def call
raise 'Unit not configured for Pix' unless @unit.inter_pix_key.present?
raise 'Unit not configured for Pix' if @unit.inter_pix_key.blank?
token = AuthService.new(@unit).token
@ -32,7 +30,7 @@ module Captain
# [CRITICAL DEBUG] Log the ENTIRE response to see why it's being cut
Rails.logger.info "[BANCO INTER] FULL RESPONSE: #{safe_body}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] BANCO INTER RAW BODY: #{safe_body}"
f.puts "[#{Time.zone.now}] BANCO INTER RAW BODY: #{safe_body}"
end
persist_charge(data)
@ -85,6 +83,4 @@ module Captain
conn.adapter Faraday.default_adapter
end
end
end
end
end

View File

@ -43,7 +43,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
# 3. Handle Tool Strategy
if brain_decision.strategy == :execute_tool
File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] BRAIN DECIDED: #{brain_decision.tool_key}" }
File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] BRAIN DECIDED: #{brain_decision.tool_key}" }
inbox = @conversation.inbox
@ -57,7 +57,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
additional_data: { message: additional_message, tool_input: brain_decision.tool_input }
)
File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RUNNER RESULT: #{runner_result.inspect}" }
File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RUNNER RESULT: #{runner_result.inspect}" }
return { 'response' => runner_result[:body][:message] } if runner_result[:fallback] && runner_result.dig(:body, :message).present?

View File

@ -59,6 +59,6 @@ class Captain::Llm::ContactIdentityService
def normalize_name(name)
parts = name.split
parts.shift if parts.first&.downcase.in?(%w[a o])
parts.map { |part| part.capitalize }.join(' ')
parts.map(&:capitalize).join(' ')
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Llm
class JasmineBrain
class Captain::Llm::JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
def self.decide(assistant:, conversation:, message:, history:)
@ -378,6 +376,4 @@ module Captain
params
end
end
end
end
end

View File

@ -86,7 +86,6 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
<<~CITATION_TEXT
@ -149,7 +148,6 @@ class Captain::Llm::SystemPromptsService
#{available_tools}
SYSTEM_PROMPT_MESSAGE
end
# rubocop:enable Metrics/MethodLength
def assistant_response_generator(assistant_name, product_name, config = {})
json_instruction = <<~JSON_INSTRUCTION
@ -258,7 +256,7 @@ class Captain::Llm::SystemPromptsService
end
def assistant_prompt_from_blocks(blocks)
Array(blocks).map do |block|
Array(blocks).filter_map do |block|
title = block['title'] || block[:title]
content = block['content'] || block[:content]
next if title.to_s.strip.empty? && content.to_s.strip.empty?
@ -268,7 +266,7 @@ class Captain::Llm::SystemPromptsService
else
"[#{title}]\n#{content}".strip
end
end.compact.join("\n\n")
end.join("\n\n")
end
def paginated_faq_generator(start_page, end_page, language = 'english')

View File

@ -19,8 +19,8 @@ class Captain::Reminders::CreateService
inbox = conversation&.inbox || find_inbox
contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox)
raise ArgumentError, 'Inbox not found' unless inbox.present?
raise ArgumentError, 'Contact not found' unless contact_inbox&.contact.present?
raise ArgumentError, 'Inbox not found' if inbox.blank?
raise ArgumentError, 'Contact not found' if contact_inbox&.contact.blank?
schedule_time = parse_time(params[:scheduled_at])
raise ArgumentError, 'Scheduled time is required' unless schedule_time

View File

@ -32,7 +32,7 @@ class Captain::Reminders::Processor
def suite_available?
assistant = inbox.captain_assistant
return false unless assistant.present?
return false if assistant.blank?
tool = Captain::Tools::StatusSuitesTool.new(assistant, conversation: conversation)
result = tool.execute
@ -49,8 +49,8 @@ class Captain::Reminders::Processor
raise ArgumentError, 'Message content is required' if content.blank?
target_conversation = conversation || find_or_create_conversation
raise ArgumentError, 'Conversation not found' unless target_conversation.present?
raise ArgumentError, 'Assistant not configured for inbox' unless assistant.present?
raise ArgumentError, 'Conversation not found' if target_conversation.blank?
raise ArgumentError, 'Assistant not configured for inbox' if assistant.blank?
Current.executed_by = assistant
Current.account = @reminder.account

View File

@ -32,8 +32,8 @@ class Captain::Reservations::CreateService
inbox = conversation&.inbox || find_inbox
contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox)
raise ArgumentError, 'Inbox not found' unless inbox.present?
raise ArgumentError, 'Contact not found' unless contact_inbox&.contact.present?
raise ArgumentError, 'Inbox not found' if inbox.blank?
raise ArgumentError, 'Contact not found' if contact_inbox&.contact.blank?
check_in_at = parse_time(params[:check_in_at])
raise ArgumentError, 'Check-in time is required' unless check_in_at
@ -149,13 +149,13 @@ class Captain::Reservations::CreateService
end
def find_brand(brand_id)
return unless brand_id.present?
return if brand_id.blank?
Captain::Brand.find_by(id: brand_id, account_id: account.id)
end
def find_unit(unit_id)
return unless unit_id.present?
return if unit_id.blank?
Captain::Unit.find_by(id: unit_id, account_id: account.id)
end

View File

@ -16,7 +16,7 @@ class Captain::Tools::BaseTool < RubyLLM::Tool
RubyLLM.logger.debug "Tool #{name} returned: #{result.inspect}"
return result if result.is_a?(RubyLLM::Tool::Halt)
return result unless fallback_message.present?
return result if fallback_message.blank?
return result unless errorish_result?(result)
fallback_message

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class CheckAvailabilityTool < BaseTool
class Captain::Tools::CheckAvailabilityTool < BaseTool
def name
'check_availability'
end
@ -127,11 +125,10 @@ module Captain
if available_options.present?
msg = "Disponível! Para a suíte #{final_suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere."
Rails.logger.info "[CheckAvailabilityTool] MENU MODE: #{msg}"
return msg
else
msg = "Não encontrei tarifas para a suíte #{final_suite_category} nesta data. Confirme o nome da suíte."
return msg
end
return msg
end
pricing = pick_pricing_for_duration(pricing_scope, requested_duration)
@ -143,7 +140,6 @@ module Captain
)} (#{pricing.day_range})."
persist_last_availability(final_suite_category, requested_duration, pricing, target_date)
Rails.logger.info "[CheckAvailabilityTool] SUCCESS: #{msg}"
return msg
else
available_options = pricing_scope.map do |p|
"#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
@ -155,9 +151,9 @@ module Captain
msg = "Não encontrei tarifas cadastradas para a suíte #{final_suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto."
end
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
return msg
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
end
return msg
rescue StandardError => e
Rails.logger.error "[CheckAvailabilityTool] CRITICAL ERROR: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
raise e
@ -389,7 +385,7 @@ module Captain
return (start_idx..6).to_a + (0..end_idx).to_a
end
normalized.split(',').map(&:strip).map { |name| mapping[name] }.compact
normalized.split(',').map(&:strip).filter_map { |name| mapping[name] }
end
def parse_date_from_text(text)
@ -460,6 +456,4 @@ module Captain
nil
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class CreateReservationIntentTool < BaseTool
class Captain::Tools::CreateReservationIntentTool < BaseTool
def name
'create_reservation_intent'
end
@ -33,7 +31,7 @@ module Captain
def execute(*args, **params)
actual_params = resolve_params(args, params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] STARTING CreateReservationIntentTool with params: #{actual_params}"
f.puts "[#{Time.zone.now}] STARTING CreateReservationIntentTool with params: #{actual_params}"
end
suite_category = actual_params[:suite]
@ -64,7 +62,7 @@ module Captain
all_incoming = @conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || []
last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..-1] : all_incoming
relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..] : all_incoming
user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase
user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '')
@ -88,12 +86,12 @@ module Captain
end
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] INTENT BARRIER SCAN: Looking for #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}"
f.puts "[#{Time.zone.now}] INTENT BARRIER SCAN: Looking for #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}"
end
if suite_category.present? && !match_found
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] INTENT BLOCKED: Suite '#{suite_category}' (or aliases) not found after reset."
f.puts "[#{Time.zone.now}] INTENT BLOCKED: Suite '#{suite_category}' (or aliases) not found after reset."
end
return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'."
end
@ -101,40 +99,41 @@ module Captain
# Global Price Lock: If the price the agent wants to charge
# is different from the VERY LAST availability check, block it.
# EXCEPTION: If explicit deposit_value is provided, we trust the agent knows what it's doing (e.g. promo/custom time)
if price > 0 && last_availability.present? && !(deposit_override && deposit_override > 0) && price_mismatch?(price, last_availability[:price])
if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
last_availability[:price])
msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
price)}) diverge da última cotação (R$ #{format('%.2f',
last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}"
f.puts "[#{Time.zone.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}"
end
return msg
end
if suite_category.blank?
msg = "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja."
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" }
return msg
end
if price <= 0
msg = 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.'
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" }
return msg
end
ensure_conversation_context!
unless @conversation && @conversation.inbox
unless @conversation&.inbox
msg = "Erro Crítico: Contexto de conversa não disponível (Conversation/Inbox nil). Params: #{actual_params}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
return msg
end
unit = infer_unit
unless unit
msg = 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.'
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" }
return msg
end
@ -149,7 +148,7 @@ module Captain
.first
# Determine Final Charge Amount
deposit_amount = if deposit_override && deposit_override > 0
deposit_amount = if deposit_override&.positive?
deposit_override
else
price / 2.0
@ -157,7 +156,7 @@ module Captain
if recent_draft && (recent_draft.total_amount.to_f - deposit_amount).abs < 0.1
msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar."
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" }
return msg
end
@ -189,11 +188,11 @@ module Captain
msg = "Reserva iniciada com sucesso! O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount,
unit: 'R$ ', separator: ',', delimiter: '.')}. INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta 'generate_pix' para entregar o código de pagamento."
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] SUCCESS: #{msg}" }
return msg
rescue StandardError => e
error_msg = "ERRO FATAL NA CRIACAO: #{e.message} | #{e.backtrace.first}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] EXCEPTION: #{error_msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] EXCEPTION: #{error_msg}" }
return "Erro técnico ao criar reserva: #{e.message}"
end
end
@ -307,7 +306,7 @@ module Captain
end
def infer_from_history
return {} unless @conversation.present?
return {} if @conversation.blank?
suite_candidates = available_suite_categories
@ -359,6 +358,4 @@ module Captain
match[1].tr('.', '').tr(',', '.').to_f
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class Definitions
class Captain::Tools::Definitions
ALL = {
'status_suites' => {
type: :http,
@ -53,6 +51,4 @@ module Captain
always_on: true
}
}.freeze
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class GeneratePixTool < BaseTool
class Captain::Tools::GeneratePixTool < BaseTool
def name
'generate_pix'
end
@ -24,7 +22,7 @@ module Captain
def execute(*args, **params)
actual_params = resolve_params(args, params)
input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f > 0
override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f.positive?
# ... (Validation Logic) ...
# 1. Validate Contact Info
@ -143,6 +141,4 @@ module Captain
success: true
}
end
end
end
end

View File

@ -1,10 +1,11 @@
module Captain
module Tools
module Parsers
class StatusSuitesParser
class Captain::Tools::Parsers::StatusSuitesParser
def self.parse(response_body)
# Assuming response_body is already a Hash or needs parsing
data = response_body.is_a?(String) ? JSON.parse(response_body) : response_body rescue {}
data = begin
response_body.is_a?(String) ? JSON.parse(response_body) : response_body
rescue StandardError
{}
end
# Example normalization logic based on prompt
{
@ -23,7 +24,4 @@ module Captain
}
end
end
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class ReactToMessageTool < BaseTool
class Captain::Tools::ReactToMessageTool < BaseTool
def name
'react_to_message'
end
@ -29,7 +27,7 @@ module Captain
def execute(*args, **params)
actual_params = resolve_params(args, params)
emoji = actual_params[:emoji]
return error_response('Conversation not found') unless @conversation.present?
return error_response('Conversation not found') if @conversation.blank?
return error_response('Emoji is required') if emoji.blank?
# Get the last incoming message from the customer
@ -75,6 +73,4 @@ module Captain
def error_response(message)
{ success: false, error: message }.to_json
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class ReminderTool < BaseTool
class Captain::Tools::ReminderTool < BaseTool
def self.name
'reminder'
end
@ -21,7 +19,7 @@ module Captain
message = actual_params[:message]
scheduled_at = actual_params[:scheduled_at]
minutes_from_now = actual_params[:minutes_from_now]
return error_response('Conversation not found') unless @conversation.present?
return error_response('Conversation not found') if @conversation.blank?
return error_response('Message is required') if message.blank?
schedule_time = parse_schedule_time(scheduled_at, minutes_from_now)
@ -56,6 +54,4 @@ module Captain
def error_response(message)
{ success: false, error: message }.to_json
end
end
end
end

View File

@ -45,7 +45,7 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
end
def low_confidence?(response)
return false unless response&.respond_to?(:neighbor_distance)
return false unless response.respond_to?(:neighbor_distance)
response.neighbor_distance.to_f > LOW_CONFIDENCE_DISTANCE
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class StatusSuitesTool < BaseTool
class Captain::Tools::StatusSuitesTool < BaseTool
def self.name
'status_suites'
end
@ -56,6 +54,4 @@ module Captain
nil
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class SuiteWatchdogTool < BaseTool
class Captain::Tools::SuiteWatchdogTool < BaseTool
def self.name
'suite_watchdog'
end
@ -21,7 +19,7 @@ module Captain
suite_identifier = actual_params[:suite_identifier]
interval_minutes = actual_params[:interval_minutes] || 10
message = actual_params[:message]
return error_response('Conversation not found') unless @conversation.present?
return error_response('Conversation not found') if @conversation.blank?
return error_response('Suite identifier is required') if suite_identifier.blank?
reminder = Captain::Reminders::CreateService.new(
@ -54,6 +52,4 @@ module Captain
def error_response(message)
{ success: false, error: message }.to_json
end
end
end
end

View File

@ -1,9 +1,7 @@
require 'net/http'
require 'uri'
module Captain
module Tools
class ToolRunner
class Captain::Tools::ToolRunner
def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {})
new(assistant, tool_key, inbox, conversation, additional_data).run
end
@ -243,12 +241,10 @@ module Captain
# Specific payloads
if @tool_key == 'maria_fotos'
base.merge!(
suite_category: extract_suite_category || 'Indefinida',
message: 'User requested photos'
)
base[:suite_category] = extract_suite_category || 'Indefinida'
base[:message] = 'User requested photos'
elsif @tool_key == 'escalar_humano'
base.merge!(reason: 'User requested human agent')
base[:reason] = 'User requested human agent'
end
base
@ -306,11 +302,9 @@ module Captain
end
def fallback_configured?
return false unless @config&.fallback_message.present?
return false if @config&.fallback_message.blank?
return false if %w[faq_lookup react_to_message].include?(@tool_key)
true
end
end
end
end

View File

@ -1,6 +1,4 @@
module Captain
module Tools
class UpdateContactTool < BaseTool
class Captain::Tools::UpdateContactTool < BaseTool
def name
'update_contact'
end
@ -29,7 +27,7 @@ module Captain
def execute(*args, **params)
actual_params = resolve_params(args, params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] STARTING UpdateContactTool with params: #{actual_params}"
f.puts "[#{Time.zone.now}] STARTING UpdateContactTool with params: #{actual_params}"
end
name = actual_params[:nome] || actual_params[:name]
@ -39,9 +37,9 @@ module Captain
ensure_conversation_context!
unless @conversation && @conversation.contact
unless @conversation&.contact
msg = "Erro Crítico: Contexto de conversa ou contato não disponível. Params: #{actual_params}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
return msg
end
@ -53,13 +51,12 @@ module Captain
if contact.save
update_sticky_state(name: contact.name, cpf: contact.custom_attributes['cpf'])
msg = "Dados atualizados com sucesso. Nome: #{contact.name}, CPF: #{contact.custom_attributes['cpf']}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" }
msg
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] SUCCESS: #{msg}" }
else
msg = "Erro ao salvar dados: #{contact.errors.full_messages.join(', ')}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" }
msg
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
end
msg
end
private
@ -88,6 +85,4 @@ module Captain
rescue StandardError => e
Rails.logger.warn "[UpdateContactTool] Failed to update sticky state: #{e.message}"
end
end
end
end

View File

@ -1,12 +1,11 @@
module Captain
class WebhookSenderService
class Captain::WebhookSenderService
def initialize(reservation)
@reservation = reservation
@unit = reservation.unit
end
def perform
return unless @unit&.webhook_url.present?
return if @unit&.webhook_url.blank?
payload = build_payload
send_webhook(payload)
@ -63,5 +62,4 @@ module Captain
Rails.logger.error "[Captain::WebhookSender] Failed to send: #{e.message}"
end
end
end
end

View File

@ -1,5 +1,4 @@
module Captain
class WhatsappNotificationService
class Captain::WhatsappNotificationService
def initialize(reservation)
@reservation = reservation
@unit = reservation.unit
@ -94,5 +93,4 @@ module Captain
"✅ *Status:* Confirmado\n\n" \
'Estamos ansiosos para recebê-lo!'
end
end
end

View File

@ -47,7 +47,7 @@ module Enterprise::MessageTemplates::HookExecutionService
def send_typing_indicator
# Access phone number safely via contact association
phone = conversation.contact&.phone_number
return unless phone.present?
return if phone.blank?
# Assuming Wuzapi is the provider for Channel::Whatsapp in this context
# We need to find the Wuzapi client instance or create one.

View File

@ -14,7 +14,8 @@ class Llm::BaseAiService
setup_temperature
end
def chat(model: @model, temperature: @temperature, api_key: nil) # [INTENTIONAL] api_key reserved for per-request auth
# [INTENTIONAL] api_key reserved for per-request auth
def chat(model: @model, temperature: @temperature, api_key: nil)
client = RubyLLM.chat(model: model)
# client = client.with_api_key(api_key) if api_key.present?
client.with_temperature(temperature)

View File

@ -1,5 +1,5 @@
class Llm::ModelTestService
TEST_PROMPT = 'Reply with the word ok.'
TEST_PROMPT = 'Reply with the word ok.'.freeze
def initialize(provider:, model:, api_key:)
@provider = provider

Some files were not shown because too many files have changed in this diff Show More