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: plugins:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
@ -23,6 +29,7 @@ Metrics/MethodLength:
Max: 19 Max: 19
Exclude: Exclude:
- 'enterprise/lib/captain/agent.rb' - 'enterprise/lib/captain/agent.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 50
@ -230,6 +237,7 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20230426130150_init_schema.rb'
- 'reference_chatwoot/**/*'
FactoryBot/SyntaxMethods: FactoryBot/SyntaxMethods:
Enabled: false 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 status = status_data['status'] || status_data['state'] || status_data
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}" Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
if %w[CONNECTED inChat success].include?(status) return if already_connected?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
return
end
qr_data = client.get_qr_code(user_token) qr_data = client.get_qr_code(user_token)
Rails.logger.info "Wuzapi QR Data Response keys: #{begin log_qr_data_keys(qr_data)
qr_data.keys
rescue StandardError
'nil'
end}"
render json: qr_data render json: qr_data
rescue Wuzapi::Client::Error => e rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi QR Error: #{e.message}" 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}" Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
raise 'Token Wuzapi ausente; reprovisionar usuário' raise 'Token Wuzapi ausente; reprovisionar usuário'
else 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 end
token token
end 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 end

View File

@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::
Rails.logger.error( Rails.logger.error(
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}" "[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
end end

View File

@ -1,51 +1,42 @@
module Api class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
module V1 before_action :find_collection, only: [:destroy]
module Accounts
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
def index def index
scope = Current.account.jasmine_collections scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility] scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope render json: scope
end end
def create def create
@collection = Current.account.jasmine_collections.new(collection_params) @collection = Current.account.jasmine_collections.new(collection_params)
if @collection.save if @collection.save
# Auto-link to inbox if owner_inbox_id provided # Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id) inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
inbox&.inbox_collections&.create(collection: @collection, priority: 10) inbox&.inbox_collections&.create(collection: @collection, priority: 10)
end
render json: @collection
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
if @collection.destroy
head :no_content
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def find_collection
@collection = Current.account.jasmine_collections.find(params[:id])
end
def collection_params
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
end
end
end end
render json: @collection
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end end
end end
end
def destroy
if @collection.destroy
head :no_content
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def find_collection
@collection = Current.account.jasmine_collections.find(params[:id])
end
def collection_params
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
end
end

View File

@ -1,42 +1,34 @@
module Api class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_collection
module Accounts
module Jasmine
class DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def index def index
render json: @collection.documents.order(created_at: :desc) render json: @collection.documents.order(created_at: :desc)
end end
def create def create
@document = @collection.documents.new(document_params) @document = @collection.documents.new(document_params)
@document.account = Current.account @document.account = Current.account
if @document.save if @document.save
render json: @document render json: @document
else else
render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
@document = @collection.documents.find(params[:id])
@document.destroy!
head :no_content
end
private
def fetch_collection
@collection = Current.account.jasmine_collections.find(params[:collection_id])
end
def document_params
params.require(:document).permit(:title, :content)
end
end
end
end end
end end
def destroy
@document = @collection.documents.find(params[:id])
@document.destroy!
head :no_content
end
private
def fetch_collection
@collection = Current.account.jasmine_collections.find(params[:collection_id])
end
def document_params
params.require(:document).permit(:title, :content)
end
end end

View File

@ -1,43 +1,35 @@
module Api class Api::V1::Accounts::Jasmine::InboxCollectionsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts
module Jasmine
class InboxCollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index def index
render json: @inbox.inbox_collections.includes(:collection) render json: @inbox.inbox_collections.includes(:collection)
end end
def create def create
collection = Current.account.jasmine_collections.find(params[:collection_id]) collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.new( link = @inbox.inbox_collections.new(
collection: collection, collection: collection,
account: Current.account, account: Current.account,
priority: params[:priority] || 0 priority: params[:priority] || 0
) )
if link.save if link.save
render json: link render json: link
else else
render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id])
link.destroy
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end end
end end
def destroy
link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id])
link.destroy
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end end

View File

@ -1,36 +1,28 @@
module Api class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts
module Jasmine
class InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def show def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config render json: config
end end
def update def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
if config.update(config_params) if config.update(config_params)
render json: config render json: config
else else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def config_params
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
end
end
end
end end
end end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def config_params
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
end
end end

View File

@ -1,25 +1,17 @@
module Public class Public::Api::V1::Captain::PaymentsController < ApplicationController
module Api layout false
module V1 skip_before_action :authenticate_user!, raise: false
module Captain skip_before_action :check_current_user_is_active, raise: false
class PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
def show def show
@charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment) @charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment)
return unless @charge.nil? return unless @charge.nil?
render plain: 'Link de pagamento inválido ou expirado.', status: :not_found render plain: 'Link de pagamento inválido ou expirado.', status: :not_found
return return
# @charge is available for the view # @charge is available for the view
# It should be a Captain::PixCharge model # It should be a Captain::PixCharge model
end
end
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 # This prevents ANY binary data from leaking into JSON serialization
def sanitize_payload_for_sidekiq def sanitize_payload_for_sidekiq
raw = params.to_unsafe_hash raw = params.to_unsafe_hash
clean_payload = build_base_payload(raw)
# Build a completely NEW payload with only safe fields clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash)
clean_payload = {
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'], 'type' => raw['type'],
'state' => raw['state'], 'state' => raw['state'],
'instanceName' => raw['instanceName'], 'instanceName' => raw['instanceName'],
@ -48,142 +61,102 @@ class Webhooks::WhatsappController < ActionController::API
'action' => raw['action'], 'action' => raw['action'],
'phone_number' => raw['phone_number'] 'phone_number' => raw['phone_number']
} }
end
# Only copy safe event fields def build_clean_event(raw_event)
if raw['event'].is_a?(Hash) clean_event = {}
clean_event = {}
# Info fields (all safe strings/ids) if raw_event['Info'].is_a?(Hash)
if raw['event']['Info'].is_a?(Hash) clean_event['Info'] = raw_event['Info'].slice(
clean_event['Info'] = raw['event']['Info'].slice( 'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup', 'Timestamp', 'PushName', 'MessageSource'
'Timestamp', 'PushName', 'MessageSource'
)
end
# 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)
end
# Message content - WHITELIST only safe fields
if raw['event']['Message'].is_a?(Hash)
msg = raw['event']['Message']
clean_msg = {}
# Text messages
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'] = {
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
'participant' => ctx['participant']
}.compact
end
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
# 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'] = {
'URL' => doc['URL'] || doc['url'],
'directPath' => doc['directPath'],
'mediaKey' => doc['mediaKey'],
'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'],
'fileSha256' => doc['fileSha256'] || doc['fileSHA256'],
'fileLength' => doc['fileLength'],
'mimetype' => doc['mimetype'],
'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 end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)' # Safe event metadata
deep_force_utf8(clean_payload) %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)
end
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
clean_event
end
def build_clean_message(msg)
clean_msg = {}
clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String)
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
def clean_document_message(doc)
return {} unless doc.is_a?(Hash)
{
'documentMessage' => {
'URL' => doc['URL'] || doc['url'],
'directPath' => doc['directPath'],
'mediaKey' => doc['mediaKey'],
'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'],
'fileSha256' => doc['fileSha256'] || doc['fileSHA256'],
'fileLength' => doc['fileLength'],
'mimetype' => doc['mimetype'],
'fileName' => doc['fileName'],
'title' => doc['title']
}.compact
}
end end
def deep_force_utf8(obj) 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 skip_before_action :verify_authenticity_token
before_action :fetch_inbox before_action :fetch_inbox
before_action :verify_secret before_action :verify_secret

View File

@ -1,134 +1,132 @@
module Conversations class Conversations::AutoLabelJob < ApplicationJob
class AutoLabelJob < ApplicationJob queue_as :low_priority
queue_as :low_priority
TAXONOMY = { TAXONOMY = {
'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.', 'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.',
'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.', 'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.',
'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.', 'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.', 'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.', 'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.',
'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.', 'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.', 'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.', 'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.' 'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze }.freeze
def perform(conversation_id) def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id) conversation = Conversation.find_by(id: conversation_id)
return unless conversation 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) # Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any? # return if (conversation.label_list & TAXONOMY.keys).any?
process_classification(conversation) process_classification(conversation)
rescue StandardError => e
Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}"
end
private
def process_classification(conversation)
messages_text = prepare_history(conversation)
return if messages_text.blank?
result = call_llm_classification(messages_text)
return unless result
apply_label(conversation, result)
end
def prepare_history(conversation)
# Pega últimas 20 mensagens para dar contexto suficiente
conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m|
sender = m.incoming? ? 'Cliente' : 'Atendente'
"#{sender}: #{m.content}"
end.join("\n")
end
def call_llm_classification(history)
prompt = <<~PROMPT
Você é um assistente classificador de conversas para um hotel.
Analise o histórico da conversa abaixo e identifique:
1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias).
2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?".
Categorias permitidas:
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")}
Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações:
{
"label": "codigo_da_categoria",
"question": "Resumo curto da dúvida"
}
Se não tiver certeza da categoria, use 'outros'.
--- INÍCIO DA CONVERSA ---
#{history}
--- FIM DA CONVERSA ---
PROMPT
# Lista de modelos para tentar (Configurado > Alternativas)
models_to_try = [
ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
'gemini-1.5-flash-001',
'gemini-pro',
'gpt-3.5-turbo'
].uniq.reject(&:blank?)
last_error = nil
models_to_try.each do |model|
# Tenta usar a infra existente
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
# Limpa markdown json se houver
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
parsed = JSON.parse(clean_response)
return parsed
rescue JSON::ParserError => e
Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}"
last_error = e
next
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}" Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}"
last_error = e
next
end end
private # Se chegou aqui, todos falharam
Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}"
nil
end
def process_classification(conversation) def apply_label(conversation, result)
messages_text = prepare_history(conversation) label_name = result['label']
return if messages_text.blank? question_summary = result['question']
result = call_llm_classification(messages_text) unless TAXONOMY.key?(label_name)
return unless result Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
apply_label(conversation, result)
end end
def prepare_history(conversation) # Garante que a label existe na conta para aparecer nos relatórios
# Pega últimas 20 mensagens para dar contexto suficiente conversation.account.labels.find_or_create_by!(title: label_name) do |l|
conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m| l.description = TAXONOMY[label_name]
sender = m.incoming? ? 'Cliente' : 'Atendente' l.color = '#7C3AED' # Roxo para indicar IA/Automático
"#{sender}: #{m.content}" l.show_on_sidebar = true
end.join("\n")
end end
def call_llm_classification(history) conversation.add_labels([label_name])
prompt = <<~PROMPT
Você é um assistente classificador de conversas para um hotel.
Analise o histórico da conversa abaixo e identifique:
1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias).
2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?".
Categorias permitidas: # Salva a dúvida canônica nos atributos adicionais
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")} conversation.additional_attributes ||= {}
conversation.additional_attributes['ai_canonical_question'] = question_summary
conversation.save!
Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações: Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
{
"label": "codigo_da_categoria",
"question": "Resumo curto da dúvida"
}
Se não tiver certeza da categoria, use 'outros'.
--- INÍCIO DA CONVERSA ---
#{history}
--- FIM DA CONVERSA ---
PROMPT
# Lista de modelos para tentar (Configurado > Alternativas)
models_to_try = [
ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
'gemini-1.5-flash-001',
'gemini-pro',
'gpt-3.5-turbo'
].uniq.reject(&:blank?)
last_error = nil
models_to_try.each do |model|
# Tenta usar a infra existente
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
# Limpa markdown json se houver
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
parsed = JSON.parse(clean_response)
return parsed
rescue JSON::ParserError => e
Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}"
last_error = e
next
rescue StandardError => e
Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}"
last_error = e
next
end
# Se chegou aqui, todos falharam
Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}"
nil
end
def apply_label(conversation, result)
label_name = result['label']
question_summary = result['question']
unless TAXONOMY.key?(label_name)
Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
end
# Garante que a label existe na conta para aparecer nos relatórios
conversation.account.labels.find_or_create_by!(title: label_name) do |l|
l.description = TAXONOMY[label_name]
l.color = '#7C3AED' # Roxo para indicar IA/Automático
l.show_on_sidebar = true
end
conversation.add_labels([label_name])
# Salva a dúvida canônica nos atributos adicionais
conversation.additional_attributes ||= {}
conversation.additional_attributes['ai_canonical_question'] = question_summary
conversation.save!
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
end
end end
end end

View File

@ -1,86 +1,84 @@
module Conversations class Conversations::ClusterJob < ApplicationJob
class ClusterJob < ApplicationJob queue_as :low_priority
queue_as :low_priority
def perform(account_id, days_back = 7) def perform(account_id, days_back = 7)
account = Account.find(account_id) account = Account.find(account_id)
# 1. Busca conversas recentes que já foram processadas pela IA # 1. Busca conversas recentes que já foram processadas pela IA
start_date = days_back.days.ago.beginning_of_day start_date = days_back.days.ago.beginning_of_day
# Labels que queremos agrupar (todas da taxonomia) # 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 # Busca perguntas dessa categoria
# Note: estamos queryng o campo JSONB additional_attributes # Note: estamos queryng o campo JSONB additional_attributes
account.conversations account.conversations
.where('created_at >= ?', start_date) .where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label) .tagged_with(label)
.pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'")) .pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'"))
# O pluck acima pode ser complexo dependendo do adapter, vamos simplificar: # O pluck acima pode ser complexo dependendo do adapter, vamos simplificar:
questions = account.conversations questions = account.conversations
.where('created_at >= ?', start_date) .where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label) .tagged_with(label)
.map { |c| c.additional_attributes['ai_canonical_question'] } .map { |c| c.additional_attributes['ai_canonical_question'] }
next if questions.empty? next if questions.empty?
# 2. Chama LLM para agrupar # 2. Chama LLM para agrupar
clusters = cluster_questions_with_llm(label, questions) clusters = cluster_questions_with_llm(label, questions)
# 3. Salva no banco # 3. Salva no banco
save_clusters(account, label, clusters, start_date.to_date) save_clusters(account, label, clusters, start_date.to_date)
end
end end
end
private private
def cluster_questions_with_llm(label, questions_list) def cluster_questions_with_llm(label, questions_list)
prompt = <<~PROMPT prompt = <<~PROMPT
Atue como um analista de dados especialista em atendimento ao cliente. Atue como um analista de dados especialista em atendimento ao cliente.
Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}". Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}".
Sua tarefa: Sua tarefa:
1. Agrupar dúvidas semânticamente idênticas. 1. Agrupar dúvidas semânticamente idênticas.
2. Criar uma "Pergunta Padrão" clara que represente o grupo. 2. Criar uma "Pergunta Padrão" clara que represente o grupo.
3. Contar quantas vezes cada dúvida apareceu. 3. Contar quantas vezes cada dúvida apareceu.
Lista de Dúvidas: Lista de Dúvidas:
#{questions_list.map { |q| "- #{q}" }.join("\n")} #{questions_list.map { |q| "- #{q}" }.join("\n")}
Retorne APENAS um JSON: Retorne APENAS um JSON:
[ [
{ "question": "Pergunta Padrão 1", "count": 10 }, { "question": "Pergunta Padrão 1", "count": 10 },
{ "question": "Pergunta Padrão 2", "count": 5 } { "question": "Pergunta Padrão 2", "count": 5 }
] ]
PROMPT PROMPT
model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini') model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini')
chat = RubyLLM.chat(model: model).with_temperature(0.0) chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt) response = chat.ask(prompt)
clean_response = response.content.gsub('```json', '').gsub('```', '').strip clean_response = response.content.gsub('```json', '').gsub('```', '').strip
JSON.parse(clean_response) JSON.parse(clean_response)
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}" Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}"
[] []
end end
def save_clusters(account, label, clusters, date) def save_clusters(account, label, clusters, date)
# Limpa clusters anteriores dessa data/label para reprocessamento # Limpa clusters anteriores dessa data/label para reprocessamento
FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all
clusters.each do |cluster| clusters.each do |cluster|
FrequentQuestion.create!( FrequentQuestion.create!(
account: account, account: account,
label: label, label: label,
question_text: cluster['question'], question_text: cluster['question'],
occurrence_count: cluster['count'], occurrence_count: cluster['count'],
cluster_date: date cluster_date: date
) )
end
end end
end end
end end

View File

@ -1,17 +1,15 @@
module CrmInsights class CrmInsights::UpdateJob < ApplicationJob
class UpdateJob < ApplicationJob queue_as :low
queue_as :low
def perform(conversation_id, reason: nil) def perform(conversation_id, reason: nil)
conversation = Conversation.find_by(id: conversation_id) conversation = Conversation.find_by(id: conversation_id)
return unless conversation return unless conversation
if reason == 'idle' if reason == 'idle'
last_activity_at = conversation.last_activity_at last_activity_at = conversation.last_activity_at
return if last_activity_at.present? && last_activity_at > 30.minutes.ago return if last_activity_at.present? && last_activity_at > 30.minutes.ago
end
UpdateService.new(conversation: conversation, reason: reason).call
end end
UpdateService.new(conversation: conversation, reason: reason).call
end end
end end

View File

@ -1,61 +1,59 @@
module Jasmine class Jasmine::ResponseJob < ApplicationJob
class ResponseJob < ApplicationJob queue_as :default
queue_as :default
retry_on StandardError, wait: :polynomially_longer, attempts: 2 retry_on StandardError, wait: :polynomially_longer, attempts: 2
def perform(message_id) def perform(message_id)
message = Message.find_by(id: message_id) message = Message.find_by(id: message_id)
return unless message return unless message
conversation = message.conversation conversation = message.conversation
inbox = message.inbox inbox = message.inbox
config = inbox.jasmine_inbox_config config = inbox.jasmine_inbox_config
# Double-check conditions (in case they changed since job was enqueued) # Double-check conditions (in case they changed since job was enqueued)
Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}" Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}"
return unless config&.is_enabled? return unless config&.is_enabled?
return if conversation.assignee.present? return if conversation.assignee.present?
# Send typing indicator # Send typing indicator
inbox.channel.toggle_typing_status('typing_on', conversation: conversation) inbox.channel.toggle_typing_status('typing_on', conversation: conversation)
begin begin
# Sleep for verification (optimized to 1.5s per recommendation) # Sleep for verification (optimized to 1.5s per recommendation)
sleep 1.5 sleep 1.5
# Get response from BrainService # Get response from BrainService
response_text = BrainService.new( response_text = BrainService.new(
inbox: inbox, inbox: inbox,
conversation: conversation, conversation: conversation,
message: message message: message
).respond ).respond
return if response_text.blank? return if response_text.blank?
# Send response as outgoing message # Send response as outgoing message
send_response(conversation, response_text) send_response(conversation, response_text)
ensure ensure
# Ensure typing is turned off even if errors occur or no response # Ensure typing is turned off even if errors occur or no response
# Wait a bit to ensure the message "send" signal propagates before sending "paused" # Wait a bit to ensure the message "send" signal propagates before sending "paused"
sleep 0.5 sleep 0.5
inbox.channel.toggle_typing_status('typing_off', conversation: conversation) inbox.channel.toggle_typing_status('typing_off', conversation: conversation)
end
end
private
def send_response(conversation, content)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content,
sender: nil, # No agent, it's from Jasmine
content_type: :text
)
rescue StandardError => e
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
end end
end end
private
def send_response(conversation, content)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content,
sender: nil, # No agent, it's from Jasmine
content_type: :text
)
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_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id) # fk_rails_... (captain_brand_id => captain_brands.id)
# #
module Captain class Captain::Pricing < ApplicationRecord
class Pricing < ApplicationRecord belongs_to :account
belongs_to :account belongs_to :captain_brand, optional: true
belongs_to :captain_brand, optional: true belongs_to :inbox, optional: true
belongs_to :inbox, optional: true
end
end end

View File

@ -48,39 +48,37 @@
# fk_rails_... (conversation_id => conversations.id) # fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #
module Captain class Captain::Reservation < ApplicationRecord
class Reservation < ApplicationRecord self.table_name = 'captain_reservations'
self.table_name = 'captain_reservations'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :conversation, optional: true belongs_to :conversation, optional: true
belongs_to :captain_brand, optional: true belongs_to :captain_brand, optional: true
belongs_to :captain_unit, optional: true belongs_to :captain_unit, optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
# Validations # Validations
validates :check_in_at, presence: true validates :check_in_at, presence: true
validates :check_out_at, presence: true validates :check_out_at, presence: true
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
enum status: { enum status: {
scheduled: 0, scheduled: 0,
active: 1, active: 1,
completed: 2, completed: 2,
cancelled: 3, cancelled: 3,
no_show: 4, no_show: 4,
pending_payment: 5, pending_payment: 5,
expired: 6, expired: 6,
payment_confirmed: 7, payment_confirmed: 7,
issues: 8, issues: 8,
awaiting_checkin: 9 awaiting_checkin: 9
} }
scope :active_in_date_range, lambda { |start_date, end_date| scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date) where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
} }
end
end end

View File

@ -38,23 +38,21 @@
# fk_rails_... (captain_brand_id => captain_brands.id) # fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #
module Captain class Captain::Unit < ApplicationRecord
class Unit < ApplicationRecord self.table_name = 'captain_units'
self.table_name = 'captain_units'
belongs_to :account belongs_to :account
belongs_to :captain_brand belongs_to :captain_brand
belongs_to :inbox, optional: true belongs_to :inbox, optional: true
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy
# Encrypted fields for PlugPlay Integration # Encrypted fields for PlugPlay Integration
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase. # Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase.
# Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise. # Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise.
# Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive. # Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive.
# For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`. # For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`.
# Let's check generally used pattern later, but for now defining relations is key. # Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true validates :name, presence: true
end
end end

View File

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

View File

@ -24,28 +24,26 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (owner_inbox_id => inboxes.id) # fk_rails_... (owner_inbox_id => inboxes.id)
# #
module Jasmine class Jasmine::Collection < ApplicationRecord
class Collection < ApplicationRecord self.table_name = 'jasmine_collections'
self.table_name = 'jasmine_collections'
belongs_to :account belongs_to :account
belongs_to :owner_inbox, class_name: 'Inbox', optional: true 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 :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, dependent: :destroy has_many :documents, class_name: 'Jasmine::Document', dependent: :destroy
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
validates :name, presence: true validates :name, presence: true
validates :account_id, presence: true validates :account_id, presence: true
validate :validate_owner_if_private validate :validate_owner_if_private
private private
def validate_owner_if_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 errors.add(:owner_inbox_id, 'must be present for private collections')
end
end end
end end

View File

@ -25,32 +25,31 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (collection_id => jasmine_collections.id) # fk_rails_... (collection_id => jasmine_collections.id)
# #
module Jasmine class Jasmine::Document < ApplicationRecord
class Document < ApplicationRecord self.table_name = 'jasmine_documents'
self.table_name = 'jasmine_documents'
belongs_to :account belongs_to :account
belongs_to :collection, class_name: 'Jasmine::Collection' 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 status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 } enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
validates :content, presence: true validates :content, presence: true
validate :validate_account_consistency validate :validate_account_consistency
# Async processing job # Async processing job
after_create_commit :enqueue_embed_job after_create_commit :enqueue_embed_job
private private
def validate_account_consistency def validate_account_consistency
return if collection.nil? return if collection.nil?
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
def enqueue_embed_job errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
Jasmine::EmbedDocumentJob.perform_later(id) end
end
def enqueue_embed_job
Jasmine::EmbedDocumentJob.perform_later(id)
end end
end end

View File

@ -26,36 +26,34 @@
# fk_rails_... (collection_id => jasmine_collections.id) # fk_rails_... (collection_id => jasmine_collections.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #
module Jasmine class Jasmine::InboxCollection < ApplicationRecord
class InboxCollection < ApplicationRecord self.table_name = 'jasmine_inbox_collections'
self.table_name = 'jasmine_inbox_collections'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
belongs_to :collection, class_name: 'Jasmine::Collection' belongs_to :collection, class_name: 'Jasmine::Collection'
validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :validate_account_consistency validate :validate_account_consistency
validate :validate_visibility_rules validate :validate_visibility_rules
validates :collection_id, uniqueness: { scope: :inbox_id } validates :collection_id, uniqueness: { scope: :inbox_id }
private private
def validate_account_consistency def validate_account_consistency
return if inbox.nil? || collection.nil? return if inbox.nil? || collection.nil?
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end end
def validate_visibility_rules def validate_visibility_rules
return if collection.nil? || inbox.nil? 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 errors.add(:base, 'Private collections can only be linked to their owner inbox')
end
end end
end end

View File

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

View File

@ -28,44 +28,42 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #
module Jasmine class Jasmine::ToolConfig < ApplicationRecord
class ToolConfig < ApplicationRecord self.table_name = 'jasmine_tool_configs'
self.table_name = 'jasmine_tool_configs'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
# Token encryption using Rails 7 native encryption # Token encryption using Rails 7 native encryption
encrypts :plug_play_token if Chatwoot.encryption_configured? encrypts :plug_play_token if Chatwoot.encryption_configured?
validates :tool_key, presence: true validates :tool_key, presence: true
validates :plug_play_id, presence: true, if: :is_enabled? validates :plug_play_id, presence: true, if: :is_enabled?
validates :plug_play_token, presence: true, if: :is_enabled? validates :plug_play_token, presence: true, if: :is_enabled?
# Fixed Tool Definitions # Fixed Tool Definitions
DEFINITIONS = { DEFINITIONS = {
'status_suites' => { 'status_suites' => {
name: 'Status das Suítes', name: 'Status das Suítes',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
description: 'Verifica o status atual das suítes.' description: 'Verifica o status atual das suítes.'
}, },
'listar_reservas' => { 'listar_reservas' => {
name: 'Listar Reservas', name: 'Listar Reservas',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1', url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1',
description: 'Lista as reservas ativas.' description: 'Lista as reservas ativas.'
}, },
'categoria_disponibilidade' => { 'categoria_disponibilidade' => {
name: 'Disponibilidade por Categoria', name: 'Disponibilidade por Categoria',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade', url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade',
description: 'Verifica disponibilidade de categorias.' description: 'Verifica disponibilidade de categorias.'
} }
}.freeze }.freeze
def self.definitions def self.definitions
DEFINITIONS DEFINITIONS
end
end end
end end

View File

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

View File

@ -4,118 +4,116 @@ require 'json'
require 'openssl' require 'openssl'
require 'base64' require 'base64'
module Captain class Captain::InterService
class InterService # Constants for API URLs
# Constants for API URLs AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
# initialize accepts credentials dynamically # initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil) def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id @client_id = client_id
@client_secret = client_secret @client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage. # If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path @cert_path = cert_path
@key_path = key_path @key_path = key_path
@pix_key = pix_key @pix_key = pix_key
@account_number = account_number @account_number = account_number
end end
def create_pix_charge(reservation) def create_pix_charge(reservation)
token = get_token token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token return { success: false, error: 'Failed to authenticate with Inter' } unless token
payload = { payload = {
calendario: { calendario: {
expiracao: 3600 # 1 hour expiracao: 3600 # 1 hour
}, },
devedor: { devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''), cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name] nome: reservation[:contact_name]
}, },
valor: { valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0) original: format('%.2f', reservation[:total_amount].to_f / 2.0)
}, },
chave: @pix_key, chave: @pix_key,
solicitacaoPagador: "Reserva #{reservation[:id]}" solicitacaoPagador: "Reserva #{reservation[:id]}"
}
response = request(:post, PIX_URL, payload, token)
if response.code.to_i == 201
data = JSON.parse(response.body)
{
success: true,
txid: data['txid'],
pix_copy_paste: data['pixCopiaECola'],
# Inter doesn't return a QR code image URL, just the text string.
# Frontend or another service must generate the image.
qr_code_url: data['location'] # Use location if needed
} }
else
response = request(:post, PIX_URL, payload, token) Rails.logger.error "Inter PIX Error: #{response.body}"
{ success: false, error: response.body }
if response.code.to_i == 201
data = JSON.parse(response.body)
{
success: true,
txid: data['txid'],
pix_copy_paste: data['pixCopiaECola'],
# Inter doesn't return a QR code image URL, just the text string.
# Frontend or another service must generate the image.
qr_code_url: data['location'] # Use location if needed
}
else
Rails.logger.error "Inter PIX Error: #{response.body}"
{ success: false, error: response.body }
end
rescue StandardError => e
Rails.logger.error "Inter Service Error: #{e.message}"
{ success: false, error: e.message }
end end
rescue StandardError => e
Rails.logger.error "Inter Service Error: #{e.message}"
{ success: false, error: e.message }
end
private private
def get_token def get_token
uri = URI(AUTH_URL) uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri) request = Net::HTTP::Post.new(uri)
request.set_form_data( request.set_form_data(
'client_id' => @client_id, 'client_id' => @client_id,
'client_secret' => @client_secret, 'client_secret' => @client_secret,
'grant_type' => 'client_credentials', 'grant_type' => 'client_credentials',
'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read' 'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read'
) )
response = send_request(uri, request) response = send_request(uri, request)
if response.code.to_i == 200 if response.code.to_i == 200
JSON.parse(response.body)['access_token'] JSON.parse(response.body)['access_token']
else else
Rails.logger.error "Inter Auth Error: #{response.body}" Rails.logger.error "Inter Auth Error: #{response.body}"
nil nil
end
end
def request(method, url, payload, token)
uri = URI(url)
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
req['Authorization'] = "Bearer #{token}"
req['Content-Type'] = 'application/json'
req.body = payload.to_json
send_request(uri, req)
end
def send_request(uri, req)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing
# Prepare SSL context with client certificate
if @cert_path.present? && @key_path.present?
begin
if File.exist?(@cert_path) && File.exist?(@key_path)
cert_content = File.read(@cert_path)
key_content = File.read(@key_path)
http.cert = OpenSSL::X509::Certificate.new(cert_content)
http.key = OpenSSL::PKey::RSA.new(key_content)
else
Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}"
# If configured but file missing, it will likely fail.
end
rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e
Rails.logger.error "Invalid Certificate/Key format: #{e.message}"
end
end
http.request(req)
end end
end end
def request(method, url, payload, token)
uri = URI(url)
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
req['Authorization'] = "Bearer #{token}"
req['Content-Type'] = 'application/json'
req.body = payload.to_json
send_request(uri, req)
end
def send_request(uri, req)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing
# Prepare SSL context with client certificate
if @cert_path.present? && @key_path.present?
begin
if File.exist?(@cert_path) && File.exist?(@key_path)
cert_content = File.read(@cert_path)
key_content = File.read(@key_path)
http.cert = OpenSSL::X509::Certificate.new(cert_content)
http.key = OpenSSL::PKey::RSA.new(key_content)
else
Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}"
# If configured but file missing, it will likely fail.
end
rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e
Rails.logger.error "Invalid Certificate/Key format: #{e.message}"
end
end
http.request(req)
end
end end

View File

@ -1,175 +1,171 @@
module Captain class Captain::Reservations::SyncService
module Reservations PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
def initialize(unit) def initialize(unit)
@unit = unit @unit = unit
@account = unit.account @account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback @inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
end
def perform
return unless @unit.reservations_sync_enabled?
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
page = 1
loop do
reservations_data = fetch_page(page)
break if reservations_data.empty?
reservations_data.each do |reservation_data|
process_reservation(reservation_data)
end end
def perform page += 1
return unless @unit.reservations_sync_enabled? # Safety break to avoid infinite loops in case of API issues
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present? break if page > 50
end
page = 1 @unit.update(last_synced_at: Time.current)
loop do end
reservations_data = fetch_page(page)
break if reservations_data.empty?
reservations_data.each do |reservation_data| private
process_reservation(reservation_data)
end
page += 1 def fetch_page(page)
# Safety break to avoid infinite loops in case of API issues url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
break if page > 50 response = HTTParty.get(url, headers: headers)
end
@unit.update(last_synced_at: Time.current) if response.success?
end begin
JSON.parse(response.body)
private
def fetch_page(page)
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
response = HTTParty.get(url, headers: headers)
if response.success?
begin
JSON.parse(response.body)
rescue StandardError
[]
end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def headers
{
'PLUG-PLAY-ID' => @unit.plug_play_id,
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
'Content-Type' => 'application/json'
}
end
def process_reservation(data)
external_id = data['id']
return if external_id.blank?
reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
# Resolve Contact
contact = find_or_create_contact(data)
# Map Attributes
reservation.account = @account
reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
reservation.contact = contact
reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
# If contact_inbox missing (new contact created without association to this inbox), create it
if reservation.contact_inbox.nil?
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
end
reservation.suite_identifier = data['suiteRef']
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
return
end
reservation.total_amount = data['totalAPagar']
# Status Mapping
reservation.status = map_status(data)
reservation.metadata ||= {}
reservation.metadata['raw_plug_play_data'] = data
reservation.metadata['guest_name'] = data['nome']
reservation.metadata['guest_email'] = data['email']
reservation.metadata['guest_phone'] = data['telefone']
reservation.metadata['notes'] = data['observacoes']
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
reservation.save!
rescue StandardError => e
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
else
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
end
end
def find_or_create_contact(data)
phone = normalize_phone_number(data['telefone'])
email = data['email']
name = data['nome']
contact = nil
# Try finding by phone
contact = @account.contacts.find_by(phone_number: phone) if phone.present?
# Try finding by email
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
# Create if not found
if contact.nil?
contact = @account.contacts.create!(
name: name,
email: email,
phone_number: phone
)
end
contact
end
def normalize_phone_number(raw_phone)
digits = raw_phone.to_s.gsub(/[^\d]/, '')
return nil if digits.blank?
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
return nil if digits.length < 10 || digits.length > 15
"+#{digits}"
end
def parse_date(date_string)
return nil if date_string.blank?
Time.zone.parse(date_string)
rescue StandardError rescue StandardError
nil []
end end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def map_status(data) def headers
# MVP Logic based on dates and 'cancelada' {
return :cancelled if data['cancelada'] == true 'PLUG-PLAY-ID' => @unit.plug_play_id,
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
'Content-Type' => 'application/json'
}
end
check_in = parse_date(data['dataInicio']) def process_reservation(data)
check_out = parse_date(data['saidaPrevistaOuNegociada']) external_id = data['id']
now = Time.current return if external_id.blank?
return :scheduled unless check_in && check_out reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
if now >= check_out # Resolve Contact
:completed contact = find_or_create_contact(data)
elsif now >= check_in && now < check_out
:active # Map Attributes
else reservation.account = @account
:scheduled reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
end reservation.contact = contact
end reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
# If contact_inbox missing (new contact created without association to this inbox), create it
if reservation.contact_inbox.nil?
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
end
reservation.suite_identifier = data['suiteRef']
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
return
end
reservation.total_amount = data['totalAPagar']
# Status Mapping
reservation.status = map_status(data)
reservation.metadata ||= {}
reservation.metadata['raw_plug_play_data'] = data
reservation.metadata['guest_name'] = data['nome']
reservation.metadata['guest_email'] = data['email']
reservation.metadata['guest_phone'] = data['telefone']
reservation.metadata['notes'] = data['observacoes']
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
reservation.save!
rescue StandardError => e
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
else
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
end
end
def find_or_create_contact(data)
phone = normalize_phone_number(data['telefone'])
email = data['email']
name = data['nome']
contact = nil
# Try finding by phone
contact = @account.contacts.find_by(phone_number: phone) if phone.present?
# Try finding by email
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
# Create if not found
if contact.nil?
contact = @account.contacts.create!(
name: name,
email: email,
phone_number: phone
)
end
contact
end
def normalize_phone_number(raw_phone)
digits = raw_phone.to_s.gsub(/[^\d]/, '')
return nil if digits.blank?
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
return nil if digits.length < 10 || digits.length > 15
"+#{digits}"
end
def parse_date(date_string)
return nil if date_string.blank?
Time.zone.parse(date_string)
rescue StandardError
nil
end
def map_status(data)
# MVP Logic based on dates and 'cancelada'
return :cancelled if data['cancelada'] == true
check_in = parse_date(data['dataInicio'])
check_out = parse_date(data['saidaPrevistaOuNegociada'])
now = Time.current
return :scheduled unless check_in && check_out
if now >= check_out
:completed
elsif now >= check_in && now < check_out
:active
else
:scheduled
end end
end end
end end

View File

@ -1,31 +1,29 @@
module CrmInsights class CrmInsights::ContactSessionCounter
class ContactSessionCounter WINDOW = 24.hours
WINDOW = 24.hours
def initialize(conversation) def initialize(conversation)
@conversation = conversation @conversation = conversation
end end
def call def call
inbound_times = @conversation.messages inbound_times = @conversation.messages
.where(message_type: :incoming, private: false) .where(message_type: :incoming, private: false)
.order(:created_at) .order(:created_at)
.pluck(:created_at) .pluck(:created_at)
count = 0 count = 0
last_session_start = nil last_session_start = nil
inbound_times.each do |timestamp| inbound_times.each do |timestamp|
if last_session_start.nil? || timestamp > last_session_start + WINDOW if last_session_start.nil? || timestamp > last_session_start + WINDOW
count += 1 count += 1
last_session_start = timestamp last_session_start = timestamp
end
end end
{
count: count,
last_contact_at: last_session_start
}
end end
{
count: count,
last_contact_at: last_session_start
}
end end
end end

View File

@ -1,172 +1,170 @@
module CrmInsights class CrmInsights::GenerateService < Llm::BaseAiService
class GenerateService < Llm::BaseAiService DEFAULT_MODEL = 'gpt-4o-mini'.freeze
DEFAULT_MODEL = 'gpt-4o-mini'
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil) def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
super() super()
@conversation = conversation @conversation = conversation
@insight = insight @insight = insight
@sessions_count = sessions_count @sessions_count = sessions_count
@last_contact_at = last_contact_at @last_contact_at = last_contact_at
@from_message_id = from_message_id @from_message_id = from_message_id
@to_message_id = to_message_id @to_message_id = to_message_id
@model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL) @model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL)
end end
def generate def generate
chat = RubyLLM.chat(model: @model) chat = RubyLLM.chat(model: @model)
.with_temperature(0.2) .with_temperature(0.2)
.with_params(response_format: { type: 'json_object' }) .with_params(response_format: { type: 'json_object' })
response = chat.ask(prompt) response = chat.ask(prompt)
parsed = parse_response(response) parsed = parse_response(response)
return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank? return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank?
{ data: parsed, error: nil } { data: parsed, error: nil }
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[CRM Insights] Generation failed: #{e.message}" Rails.logger.error "[CRM Insights] Generation failed: #{e.message}"
{ data: nil, error: e.message } { data: nil, error: e.message }
end end
private private
def prompt def prompt
<<~PROMPT <<~PROMPT
Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente. Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente.
Regras: Regras:
- Idioma: PT-BR sempre. - Idioma: PT-BR sempre.
- Nao resuma a conversa; gere um perfil do cliente. - Nao resuma a conversa; gere um perfil do cliente.
- Frases curtas, estilo CRM humano. - Frases curtas, estilo CRM humano.
- Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes. - Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes.
- Atualize o resumo existente sem perder informacoes relevantes. - Atualize o resumo existente sem perder informacoes relevantes.
- Priorize padroes recorrentes sobre eventos isolados. - Priorize padroes recorrentes sobre eventos isolados.
- Se dados forem insuficientes, diga que faltam sinais claros. - Se dados forem insuficientes, diga que faltam sinais claros.
- So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo. - So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo.
- Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio. - Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio.
- Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio. - Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio.
- Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio. - Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio.
- Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos. - Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos.
Saida OBRIGATORIA (JSON valido): Saida OBRIGATORIA (JSON valido):
{ {
"summary_text": "texto humano completo para UI", "summary_text": "texto humano completo para UI",
"structured_data": { "structured_data": {
"summary_text": "...", "summary_text": "...",
"preferences": [], "preferences": [],
"contact_pattern": { "time_range": "", "days": [] }, "contact_pattern": { "time_range": "", "days": [] },
"intent": "", "intent": "",
"price_sensitivity": "", "price_sensitivity": "",
"urgency": "", "urgency": "",
"frictions": [], "frictions": [],
"commercial_status": "", "commercial_status": "",
"customer_potential": "", "customer_potential": "",
"agent_tip": "", "agent_tip": "",
"funnel": { "funnel": {
"stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost "stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost
"confidence": 0.0, // float 0-1 "confidence": 0.0, // float 0-1
"reason": "justificativa curta", "reason": "justificativa curta",
"evidence_message_ids": [], // IDs das mensagens que justificam o estagio "evidence_message_ids": [], // IDs das mensagens que justificam o estagio
"updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior "updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior
}
} }
} }
}
REGRAS FUNIL DE VENDAS (CRITICO): REGRAS FUNIL DE VENDAS (CRITICO):
1. Analise APENAS o historico fornecido abaixo para definir o estagio. 1. Analise APENAS o historico fornecido abaixo para definir o estagio.
2. Estagios: 2. Estagios:
- info: pede informacoes gerais. (Confianca minima: qualquer) - info: pede informacoes gerais. (Confianca minima: qualquer)
- price: discute valores. (Confianca minima: 0.6) - price: discute valores. (Confianca minima: 0.6)
- availability: pergunta sobre datas/vagas. (Confianca minima: 0.6) - availability: pergunta sobre datas/vagas. (Confianca minima: 0.6)
- confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75) - confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75)
- closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85) - closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85)
- closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85) - closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85)
3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio. 3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio.
4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda. 4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda.
5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info". 5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info".
Contexto: Contexto:
- Canal: #{channel_name} - Canal: #{channel_name}
- Conversa ID: #{@conversation.id} - Conversa ID: #{@conversation.id}
- Contatos (24h): #{@sessions_count} - Contatos (24h): #{@sessions_count}
- Ultimo contato valido: #{format_time(@last_contact_at)} - Ultimo contato valido: #{format_time(@last_contact_at)}
- Intervalo de mensagens: #{message_range_label} - Intervalo de mensagens: #{message_range_label}
Resumo anterior (se existir): Resumo anterior (se existir):
#{@insight&.summary_text || 'Sem resumo anterior.'} #{@insight&.summary_text || 'Sem resumo anterior.'}
JSON anterior (se existir): JSON anterior (se existir):
#{(@insight&.structured_data || {}).to_json} #{(@insight&.structured_data || {}).to_json}
Historico recente (ate 50 mensagens): Historico recente (ate 50 mensagens):
#{history_block} #{history_block}
Formato do texto humano (exemplo de estilo): Formato do texto humano (exemplo de estilo):
Cliente recorrente. Cliente recorrente.
Demonstra preferencia por suites com hidro. Demonstra preferencia por suites com hidro.
Costuma entrar em contato a noite (principalmente entre 19h e 23h). Costuma entrar em contato a noite (principalmente entre 19h e 23h).
Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in. Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in.
Perfil objetivo, poucas mensagens. Perfil objetivo, poucas mensagens.
Intencao predominante: reserva rapida Intencao predominante: reserva rapida
Sensibilidade a preco: media Sensibilidade a preco: media
Urgencia: alta Urgencia: alta
Padrao de contato: Padrao de contato:
Horario: entre 19h e 23h Horario: entre 19h e 23h
Dias mais comuns: sexta e sabado Dias mais comuns: sexta e sabado
Pontos de atencao: Pontos de atencao:
Duvidas recorrentes sobre formas de pagamento Duvidas recorrentes sobre formas de pagamento
Questionamentos frequentes sobre horario de check-in Questionamentos frequentes sobre horario de check-in
Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade) Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade)
Potencial do cliente: Potencial do cliente:
Perfil recorrente Perfil recorrente
Compativel com suites premium Compativel com suites premium
Bom candidato a fidelizacao Bom candidato a fidelizacao
Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro. Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro.
PROMPT PROMPT
end end
def history_block def history_block
messages = @conversation.messages messages = @conversation.messages
.where(message_type: %i[incoming outgoing], private: false) .where(message_type: %i[incoming outgoing], private: false)
messages = messages.where('id >= ?', @from_message_id) if @from_message_id messages = messages.where('id >= ?', @from_message_id) if @from_message_id
messages = messages.where('id <= ?', @to_message_id) if @to_message_id messages = messages.where('id <= ?', @to_message_id) if @to_message_id
messages = messages.order(created_at: :desc).limit(50).reverse messages = messages.order(created_at: :desc).limit(50).reverse
messages.map do |message| messages.map do |message|
role = message.incoming? ? 'Cliente' : 'Atendente' role = message.incoming? ? 'Cliente' : 'Atendente'
time = message.created_at&.strftime('%d/%m/%Y %H:%M') time = message.created_at&.strftime('%d/%m/%Y %H:%M')
"#{time} - #{role}: #{message.content}" "#{time} - #{role}: #{message.content}"
end.join("\n") end.join("\n")
end end
def channel_name def channel_name
@conversation.inbox&.channel_type.to_s @conversation.inbox&.channel_type.to_s
end end
def format_time(value) def format_time(value)
return 'Desconhecido' if value.blank? return 'Desconhecido' if value.blank?
value.strftime('%d/%m/%Y %H:%M') value.strftime('%d/%m/%Y %H:%M')
end end
def parse_response(response) def parse_response(response)
content = response.respond_to?(:content) ? response.content : response.to_s content = response.respond_to?(:content) ? response.content : response.to_s
JSON.parse(content) JSON.parse(content)
rescue JSON::ParserError => e rescue JSON::ParserError => e
Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}" Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}"
nil nil
end end
def message_range_label def message_range_label
return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank? return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank?
return "A partir de #{@from_message_id}" if @to_message_id.blank? return "A partir de #{@from_message_id}" if @to_message_id.blank?
return "Ate #{@to_message_id}" if @from_message_id.blank? return "Ate #{@to_message_id}" if @from_message_id.blank?
"#{@from_message_id} ate #{@to_message_id}" "#{@from_message_id} ate #{@to_message_id}"
end
end end
end end

View File

@ -1,359 +1,357 @@
module CrmInsights class CrmInsights::UpdateService
class UpdateService def initialize(conversation:, reason: nil)
def initialize(conversation:, reason: nil) @conversation = conversation
@conversation = conversation @reason = reason
@reason = reason end
end
def call def call
session_stats = ContactSessionCounter.new(@conversation).call session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id) last_message_id = relevant_messages.maximum(:id)
return result_payload(last_success, 'no_messages') if last_message_id.blank? return result_payload(last_success, 'no_messages') if last_message_id.blank?
from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil
to_message_id = last_message_id to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
result = GenerateService.new( result = GenerateService.new(
conversation: @conversation, conversation: @conversation,
insight: last_success, insight: last_success,
sessions_count: session_stats[:count], sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at], last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id, from_message_id: from_message_id,
to_message_id: to_message_id to_message_id: to_message_id
).generate ).generate
if result[:data].blank? if result[:data].blank?
create_failed_insight( create_failed_insight(
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id,
error_message: result[:error] || 'Falha ao gerar resumo'
)
return result_payload(last_success, 'failed', result[:error])
end
range_messages = messages_for_range(from_message_id, to_message_id)
sanitized_result = sanitize_result(
result[:data],
range_messages,
last_success&.structured_data || {},
@conversation.contact
)
insight = create_success_insight(
result: sanitized_result,
session_stats: session_stats, session_stats: session_stats,
from_message_id: from_message_id, from_message_id: from_message_id,
to_message_id: to_message_id to_message_id: to_message_id,
error_message: result[:error] || 'Falha ao gerar resumo'
) )
result_payload(insight, 'success') return result_payload(last_success, 'failed', result[:error])
end end
private range_messages = messages_for_range(from_message_id, to_message_id)
sanitized_result = sanitize_result(
result[:data],
range_messages,
last_success&.structured_data || {},
@conversation.contact
)
def relevant_messages insight = create_success_insight(
@relevant_messages ||= @conversation.messages.where( result: sanitized_result,
message_type: %i[incoming outgoing], session_stats: session_stats,
private: false from_message_id: from_message_id,
) to_message_id: to_message_id
)
result_payload(insight, 'success')
end
private
def relevant_messages
@relevant_messages ||= @conversation.messages.where(
message_type: %i[incoming outgoing],
private: false
)
end
def messages_for_range(from_message_id, to_message_id)
scope = relevant_messages
scope = scope.where('id >= ?', from_message_id) if from_message_id
scope = scope.where('id <= ?', to_message_id) if to_message_id
scope
end
def sanitize_result(result, messages, prior_structured, contact)
structured_data = result['structured_data'] || {}
incoming_messages = messages.select(&:incoming?)
incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ')
inbound_count = messages.count(&:incoming?)
outbound_count = messages.count(&:outgoing?)
sanitized_structured = structured_data.deep_dup
return minimal_payload(incoming_messages, contact) if inbound_count < 3
sanitized_structured['frictions'] = sanitize_frictions(
structured_data['frictions'],
incoming_text,
prior_structured['frictions']
)
sanitized_structured['contact_pattern'] = sanitize_contact_pattern(
structured_data['contact_pattern'],
incoming_text,
inbound_count,
prior_structured['contact_pattern']
)
sanitized_structured['preferences'] = sanitize_preferences(
structured_data['preferences'],
incoming_text,
prior_structured['preferences']
)
if inbound_count < 3 && outbound_count < 3
sanitized_structured['intent'] = ''
sanitized_structured['urgency'] = ''
sanitized_structured['price_sensitivity'] = ''
sanitized_structured['commercial_status'] = ''
sanitized_structured['customer_potential'] = ''
end end
def messages_for_range(from_message_id, to_message_id) summary_text = result['summary_text'].to_s.strip
scope = relevant_messages summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.'
scope = scope.where('id >= ?', from_message_id) if from_message_id
scope = scope.where('id <= ?', to_message_id) if to_message_id sanitized_structured['summary_text'] = summary_text
scope sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0'
sanitized_structured['source'] = structured_data['source'] || 'ai'
sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601
sanitized_structured['evidence'] ||= {}
{
'summary_text' => summary_text,
'structured_data' => sanitized_structured
}
end
def sanitize_frictions(frictions, text, prior_frictions)
items = Array(frictions).map(&:to_s)
return Array(prior_frictions).map(&:to_s) if items.empty?
evidence = {
'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i,
'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i,
'preco' => /(pre[cç]o|valor|custo)/i
}
filtered = items.select do |item|
evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } ||
evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) }
end
return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present?
filtered
end
def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern)
pattern_hash = pattern.is_a?(Hash) ? pattern : {}
time_range = pattern_hash['time_range'].to_s
days = Array(pattern_hash['days']).map(&:to_s)
if inbound_count < 3
return prior_pattern if prior_pattern.present?
return { 'time_range' => '', 'days' => [] }
end end
def sanitize_result(result, messages, prior_structured, contact) time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
structured_data = result['structured_data'] || {} day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
incoming_messages = messages.select(&:incoming?)
incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ')
inbound_count = messages.count(&:incoming?)
outbound_count = messages.count(&:outgoing?)
sanitized_structured = structured_data.deep_dup time_range = '' unless time_evidence
days = [] unless day_evidence
return minimal_payload(incoming_messages, contact) if inbound_count < 3 if days.any?
normalized_text = text.downcase
sanitized_structured['frictions'] = sanitize_frictions( days = days.select do |day|
structured_data['frictions'], normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
incoming_text,
prior_structured['frictions']
)
sanitized_structured['contact_pattern'] = sanitize_contact_pattern(
structured_data['contact_pattern'],
incoming_text,
inbound_count,
prior_structured['contact_pattern']
)
sanitized_structured['preferences'] = sanitize_preferences(
structured_data['preferences'],
incoming_text,
prior_structured['preferences']
)
if inbound_count < 3 && outbound_count < 3
sanitized_structured['intent'] = ''
sanitized_structured['urgency'] = ''
sanitized_structured['price_sensitivity'] = ''
sanitized_structured['commercial_status'] = ''
sanitized_structured['customer_potential'] = ''
end end
summary_text = result['summary_text'].to_s.strip
summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.'
sanitized_structured['summary_text'] = summary_text
sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0'
sanitized_structured['source'] = structured_data['source'] || 'ai'
sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601
sanitized_structured['evidence'] ||= {}
{
'summary_text' => summary_text,
'structured_data' => sanitized_structured
}
end end
def sanitize_frictions(frictions, text, prior_frictions) {
items = Array(frictions).map(&:to_s) 'time_range' => time_range,
return Array(prior_frictions).map(&:to_s) if items.empty? 'days' => days
}
end
evidence = { def sanitize_preferences(preferences, text, prior_preferences)
'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i, return Array(prior_preferences).map(&:to_s) if preferences.blank?
'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i,
'preco' => /(pre[cç]o|valor|custo)/i
}
filtered = items.select do |item| tokens = if preferences.is_a?(Array)
evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } || preferences
evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) } elsif preferences.is_a?(Hash)
preferences.values.flatten
else
[preferences]
end
filtered = tokens.map(&:to_s).select do |item|
case item.downcase
when /hidro/
text.include?('hidro')
when /pix/
text.include?('pix')
when /check/
text.match?(/check-?in/)
else
parts = item.downcase.split(/[_\s]/).reject(&:blank?)
parts.any? { |part| text.include?(part) }
end end
return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present? end
return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present?
filtered filtered
end
def minimal_summary(text, preferences)
prefs = Array(preferences).map(&:to_s).reject(&:blank?)
parts = []
if prefs.any?
humanized = prefs.map { |item| item.tr('_', ' ') }
parts << "demonstrou interesse em #{humanized.join(', ')}"
end end
def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern) parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
pattern_hash = pattern.is_a?(Hash) ? pattern : {}
time_range = pattern_hash['time_range'].to_s
days = Array(pattern_hash['days']).map(&:to_s)
if inbound_count < 3 parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
return prior_pattern if prior_pattern.present?
return { 'time_range' => '', 'days' => [] } parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
end
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i) return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty?
day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
time_range = '' unless time_evidence "Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
days = [] unless day_evidence end
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
end
end
{ def minimal_payload(incoming_messages, contact)
'time_range' => time_range, incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
'days' => days normalized_text = normalize_text(incoming_text)
} evidence = {}
preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil)
if preferred_name.present?
name_ids = evidence_ids_for(preferred_name, incoming_messages)
evidence['preferred_name'] = name_ids if name_ids.any?
end end
def sanitize_preferences(preferences, text, prior_preferences) room_type = nil
return Array(prior_preferences).map(&:to_s) if preferences.blank? if normalized_text.include?('hidro')
room_type = 'suite_hidro'
tokens = if preferences.is_a?(Array) evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
preferences
elsif preferences.is_a?(Hash)
preferences.values.flatten
else
[preferences]
end
filtered = tokens.map(&:to_s).select do |item|
case item.downcase
when /hidro/
text.include?('hidro')
when /pix/
text.include?('pix')
when /check/
text.match?(/check-?in/)
else
parts = item.downcase.split(/[_\s]/).reject(&:blank?)
parts.any? { |part| text.include?(part) }
end
end
return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present?
filtered
end end
def minimal_summary(text, preferences) day_interest = []
prefs = Array(preferences).map(&:to_s).reject(&:blank?) day_map.each_key do |day|
parts = [] day_interest << day if normalized_text.match?(/\b#{day}\b/i)
end
if prefs.any? if day_interest.any?
humanized = prefs.map { |item| item.tr('_', ' ') } day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i })
parts << "demonstrou interesse em #{humanized.join(', ')}" evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages)
end
parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty?
"Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
end end
def minimal_payload(incoming_messages, contact) intent = nil
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ') if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/)
normalized_text = normalize_text(incoming_text) intent = 'reserva_rapida'
evidence = {} evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages)
preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil)
if preferred_name.present?
name_ids = evidence_ids_for(preferred_name, incoming_messages)
evidence['preferred_name'] = name_ids if name_ids.any?
end
room_type = nil
if normalized_text.include?('hidro')
room_type = 'suite_hidro'
evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
end
day_interest = []
day_map.each_key do |day|
day_interest << day if normalized_text.match?(/\b#{day}\b/i)
end
if day_interest.any?
day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i })
evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages)
end
intent = nil
if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/)
intent = 'reserva_rapida'
evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages)
end
summary_text = minimal_summary(normalized_text, room_type ? [room_type] : [])
summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present?
summary_text = summary_text.strip
structured_data = {
'schema_version' => '1.0',
'source' => 'ai',
'generated_at' => Time.current.iso8601,
'summary_text' => summary_text,
'customer_type' => nil,
'customer_potential' => nil,
'intent' => intent,
'urgency' => nil,
'price_sensitivity' => nil,
'confidence' => intent.present? ? 0.9 : nil,
'preferences' => {
'room_type' => room_type ? [room_type] : [],
'date_interest' => day_interest
},
'contact_pattern' => nil,
'frictions' => nil,
'commercial_status' => nil,
'nba' => if intent.present?
{
'action' => 'informar_disponibilidade_e_valor',
'priority' => 'media',
'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.'
}
end,
'suggested_labels' => [
(room_type ? 'hidro' : nil),
'primeiro_contato'
].compact,
'evidence' => evidence
}
{
'summary_text' => summary_text,
'structured_data' => structured_data
}
end end
def evidence_ids_for(pattern, messages) summary_text = minimal_summary(normalized_text, room_type ? [room_type] : [])
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present?
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id) summary_text = summary_text.strip
end
def normalize_text(value) structured_data = {
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc') 'schema_version' => '1.0',
end 'source' => 'ai',
'generated_at' => Time.current.iso8601,
'summary_text' => summary_text,
'customer_type' => nil,
'customer_potential' => nil,
'intent' => intent,
'urgency' => nil,
'price_sensitivity' => nil,
'confidence' => intent.present? ? 0.9 : nil,
'preferences' => {
'room_type' => room_type ? [room_type] : [],
'date_interest' => day_interest
},
'contact_pattern' => nil,
'frictions' => nil,
'commercial_status' => nil,
'nba' => if intent.present?
{
'action' => 'informar_disponibilidade_e_valor',
'priority' => 'media',
'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.'
}
end,
'suggested_labels' => [
(room_type ? 'hidro' : nil),
'primeiro_contato'
].compact,
'evidence' => evidence
}
def day_map {
{ 'summary_text' => summary_text,
'segunda' => 'segunda', 'structured_data' => structured_data
'terca' => 'terca', }
'quarta' => 'quarta', end
'quinta' => 'quinta',
'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:) def evidence_ids_for(pattern, messages)
structured_data = result['structured_data'] || {} regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL) messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
ConversationCrmInsight.create!( end
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: result['summary_text'],
structured_data: structured_data,
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'success',
schema_version: structured_data['schema_version'] || '1.0',
model: structured_data['model'] || model_name,
confidence: structured_data['confidence']
)
end
def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:) def normalize_text(value)
ConversationCrmInsight.create!( value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
conversation: @conversation, end
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: nil,
structured_data: {},
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'failed',
error_message: error_message
)
end
def result_payload(insight, status, error_message = nil) def day_map
{ {
insight: insight, 'segunda' => 'segunda',
status: status, 'terca' => 'terca',
error_message: error_message 'quarta' => 'quarta',
} 'quinta' => 'quinta',
end 'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:)
structured_data = result['structured_data'] || {}
model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: result['summary_text'],
structured_data: structured_data,
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'success',
schema_version: structured_data['schema_version'] || '1.0',
model: structured_data['model'] || model_name,
confidence: structured_data['confidence']
)
end
def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: nil,
structured_data: {},
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'failed',
error_message: error_message
)
end
def result_payload(insight, status, error_message = nil)
{
insight: insight,
status: status,
error_message: error_message
}
end end
end end

View File

@ -1,286 +1,284 @@
module Jasmine class Jasmine::BrainService
class BrainService # Default intent keywords for hotel/motel business
# Default intent keywords for hotel/motel business DEFAULT_INTENT_KEYWORDS = {
DEFAULT_INTENT_KEYWORDS = { price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária], info_request: %w[como funciona detalhe explica suíte quarto tipo],
info_request: %w[como funciona detalhe explica suíte quarto tipo], policy: %w[horário check-in check-out política regra pagamento cancelamento],
policy: %w[horário check-in check-out política regra pagamento cancelamento], greeting: %w[oi olá bom dia boa tarde boa noite],
greeting: %w[oi olá bom dia boa tarde boa noite], objection: %w[caro não sei preciso pensar depois conversar],
objection: %w[caro não sei preciso pensar depois conversar], closing: %w[reservar agendar confirmar fechar quero sim pode],
closing: %w[reservar agendar confirmar fechar quero sim pode], general: [] # fallback
general: [] # fallback }.freeze
}.freeze
# Strategies for handling intents # Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze RAG_PROHIBITED = %i[greeting closing].freeze
attr_reader :inbox, :conversation, :message, :config attr_reader :inbox, :conversation, :message, :config
def initialize(inbox:, conversation:, message:) def initialize(inbox:, conversation:, message:)
@inbox = inbox @inbox = inbox
@conversation = conversation @conversation = conversation
@message = message @message = message
@config = load_config @config = load_config
end
def respond
trigger_media_analysis if message.attachments.any?
llm_content = message.content_for_llm
intent = IntentDetector.new(llm_content, intent_keywords).detect
strategy = StrategyDecider.new(intent, jasmine_state).decide
rag_context = fetch_rag_if_needed(strategy, llm_content)
prompt = PromptAssembler.new(
config: config,
state: jasmine_state,
history: recent_history,
rag_context: rag_context,
current_message: llm_content
).assemble
response = call_llm(prompt)
StateUpdater.new(conversation, intent, rag_context.present?).update
log_decision(intent, strategy, rag_context)
response
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}"
'Desculpe, tive um problema. Pode repetir?'
end
private
def load_config
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new(
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f,
rag_max_results: 3,
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7
)
end
def intent_keywords
custom = config.intent_keywords.presence || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys)
end
def jasmine_state
conversation.custom_attributes&.dig('jasmine_state') || {}
end
def recent_history
msgs = conversation.messages
# Handle both ActiveRecord relations and arrays (for playground testing)
return [] unless msgs.respond_to?(:where)
msgs
.where(message_type: %w[incoming outgoing])
.order(created_at: :desc)
.limit(4) # 2 pairs of messages
.reverse
.map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } }
end
def fetch_rag_if_needed(strategy, query)
return nil if strategy == :no_rag
return nil if loop_protection_triggered?
results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results)
return nil if results.empty?
results.map { |r| r[:content] || r.content }.join("\n\n---\n\n")
end
def loop_protection_triggered?
(jasmine_state['rag_queries_count'] || 0) > 5
end
def fallback_response
nil # Will trigger "vou verificar com a equipe"
end
def call_llm(prompt)
chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature)
response = chat.ask(prompt)
response.content
end
def log_decision(intent, strategy, rag_context)
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
end
def trigger_media_analysis
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
Jasmine::MediaAnalyzerService.new(message: message).perform
message.attachments.reload # CRITICAL: Ensure we see the new metadata
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
end
# =========================================
# COMPONENT: Intent Detector
# =========================================
class IntentDetector
attr_reader :text, :keywords
def initialize(text, keywords)
@text = text.to_s.downcase.strip
@keywords = keywords
end end
def respond def detect
trigger_media_analysis if message.attachments.any? # Check each intent type for keyword matches
llm_content = message.content_for_llm keywords.each do |intent_type, words|
next if words.empty?
return intent_type if words.any? { |word| text.include?(word.downcase) }
end
intent = IntentDetector.new(llm_content, intent_keywords).detect :general # fallback
strategy = StrategyDecider.new(intent, jasmine_state).decide end
end
rag_context = fetch_rag_if_needed(strategy, llm_content) # =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
prompt = PromptAssembler.new( def initialize(intent, state)
config: config, @intent = intent
state: jasmine_state, @state = state
history: recent_history, end
rag_context: rag_context,
current_message: llm_content
).assemble
response = call_llm(prompt) def decide
return :no_rag if RAG_PROHIBITED.include?(intent)
return :rag_required if RAG_MANDATORY.include?(intent)
StateUpdater.new(conversation, intent, rag_context.present?).update :rag_optional
end
end
log_decision(intent, strategy, rag_context) # =========================================
# COMPONENT: Prompt Assembler
# =========================================
class PromptAssembler
MAX_HISTORY_MESSAGES = 4 # 2 pairs
response attr_reader :config, :state, :history, :rag_context, :current_message
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}" def initialize(config:, state:, history:, rag_context:, current_message:)
'Desculpe, tive um problema. Pode repetir?' @config = config
@state = state
@history = history.last(MAX_HISTORY_MESSAGES)
@rag_context = rag_context
@current_message = current_message
end
def assemble
parts = []
# System Prompt (identity, tone, rules)
parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present?
# Playbook SDR (sales script)
parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present?
# Lead State
if state.present?
state_text = format_state(state)
parts << "[ESTADO DO LEAD]\n#{state_text}"
end
# RAG Context (SOURCE OF TRUTH)
if rag_context.present?
parts << <<~RAG
[CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE]
Use EXCLUSIVAMENTE as informações abaixo para responder.
NÃO invente ou complemente com conhecimento externo.
#{rag_context}
RAG
end
# History (limited)
if history.present?
history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n")
parts << "[HISTÓRICO RECENTE]\n#{history_text}"
end
# Current message
parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}"
parts.join("\n\n")
end end
private private
def load_config def format_state(state)
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new( lines = []
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f, lines << "Etapa: #{state['stage']}" if state['stage']
rag_max_results: 3, lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7 if state['collected_info'].present?
state['collected_info'].each do |key, value|
lines << "#{key.capitalize}: #{value}"
end
end
lines.join("\n")
end
end
# =========================================
# COMPONENT: State Updater
# =========================================
class StateUpdater
MAX_STATE_SIZE = 4096 # 4KB
attr_reader :conversation, :intent, :rag_used
def initialize(conversation, intent, rag_used)
@conversation = conversation
@intent = intent
@rag_used = rag_used
end
def update
current_state = conversation.custom_attributes&.dig('jasmine_state') || {}
new_state = current_state.merge(
'last_intent' => intent.to_s,
'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0),
'updated_at' => Time.current.iso8601
)
# Size protection
if new_state.to_json.bytesize > MAX_STATE_SIZE
Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up'
new_state = {
'last_intent' => intent.to_s,
'rag_queries_count' => 0,
'updated_at' => Time.current.iso8601
}
end
conversation.update!(
custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state)
) )
end end
def intent_keywords def self.cleanup(conversation)
custom = config.intent_keywords.presence || {} attrs = conversation.custom_attributes || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys) attrs.delete('jasmine_state')
end conversation.update!(custom_attributes: attrs)
def jasmine_state
conversation.custom_attributes&.dig('jasmine_state') || {}
end
def recent_history
msgs = conversation.messages
# Handle both ActiveRecord relations and arrays (for playground testing)
return [] unless msgs.respond_to?(:where)
msgs
.where(message_type: %w[incoming outgoing])
.order(created_at: :desc)
.limit(4) # 2 pairs of messages
.reverse
.map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } }
end
def fetch_rag_if_needed(strategy, query)
return nil if strategy == :no_rag
return nil if loop_protection_triggered?
results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results)
return nil if results.empty?
results.map { |r| r[:content] || r.content }.join("\n\n---\n\n")
end
def loop_protection_triggered?
(jasmine_state['rag_queries_count'] || 0) > 5
end
def fallback_response
nil # Will trigger "vou verificar com a equipe"
end
def call_llm(prompt)
chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature)
response = chat.ask(prompt)
response.content
end
def log_decision(intent, strategy, rag_context)
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
end
def trigger_media_analysis
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
Jasmine::MediaAnalyzerService.new(message: message).perform
message.attachments.reload # CRITICAL: Ensure we see the new metadata
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
end
# =========================================
# COMPONENT: Intent Detector
# =========================================
class IntentDetector
attr_reader :text, :keywords
def initialize(text, keywords)
@text = text.to_s.downcase.strip
@keywords = keywords
end
def detect
# Check each intent type for keyword matches
keywords.each do |intent_type, words|
next if words.empty?
return intent_type if words.any? { |word| text.include?(word.downcase) }
end
:general # fallback
end
end
# =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
def initialize(intent, state)
@intent = intent
@state = state
end
def decide
return :no_rag if RAG_PROHIBITED.include?(intent)
return :rag_required if RAG_MANDATORY.include?(intent)
:rag_optional
end
end
# =========================================
# COMPONENT: Prompt Assembler
# =========================================
class PromptAssembler
MAX_HISTORY_MESSAGES = 4 # 2 pairs
attr_reader :config, :state, :history, :rag_context, :current_message
def initialize(config:, state:, history:, rag_context:, current_message:)
@config = config
@state = state
@history = history.last(MAX_HISTORY_MESSAGES)
@rag_context = rag_context
@current_message = current_message
end
def assemble
parts = []
# System Prompt (identity, tone, rules)
parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present?
# Playbook SDR (sales script)
parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present?
# Lead State
if state.present?
state_text = format_state(state)
parts << "[ESTADO DO LEAD]\n#{state_text}"
end
# RAG Context (SOURCE OF TRUTH)
if rag_context.present?
parts << <<~RAG
[CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE]
Use EXCLUSIVAMENTE as informações abaixo para responder.
NÃO invente ou complemente com conhecimento externo.
#{rag_context}
RAG
end
# History (limited)
if history.present?
history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n")
parts << "[HISTÓRICO RECENTE]\n#{history_text}"
end
# Current message
parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}"
parts.join("\n\n")
end
private
def format_state(state)
lines = []
lines << "Etapa: #{state['stage']}" if state['stage']
lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
if state['collected_info'].present?
state['collected_info'].each do |key, value|
lines << "#{key.capitalize}: #{value}"
end
end
lines.join("\n")
end
end
# =========================================
# COMPONENT: State Updater
# =========================================
class StateUpdater
MAX_STATE_SIZE = 4096 # 4KB
attr_reader :conversation, :intent, :rag_used
def initialize(conversation, intent, rag_used)
@conversation = conversation
@intent = intent
@rag_used = rag_used
end
def update
current_state = conversation.custom_attributes&.dig('jasmine_state') || {}
new_state = current_state.merge(
'last_intent' => intent.to_s,
'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0),
'updated_at' => Time.current.iso8601
)
# Size protection
if new_state.to_json.bytesize > MAX_STATE_SIZE
Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up'
new_state = {
'last_intent' => intent.to_s,
'rag_queries_count' => 0,
'updated_at' => Time.current.iso8601
}
end
conversation.update!(
custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state)
)
end
def self.cleanup(conversation)
attrs = conversation.custom_attributes || {}
attrs.delete('jasmine_state')
conversation.update!(custom_attributes: attrs)
end
end end
end end
end end

View File

@ -1,129 +1,126 @@
module Jasmine class Jasmine::EmbeddingService
class EmbeddingService CHUNK_SIZE = 1000
CHUNK_SIZE = 1000 CHUNK_OVERLAP = 200
CHUNK_OVERLAP = 200 EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
def initialize(document) def initialize(document)
@document = document @document = document
end
def process
@document.with_lock do
return if @document.indexed?
@document.update!(status: :processing)
@document.chunks.delete_all
chunks = chunk_content(@document.content)
create_chunks(chunks)
@document.update!(status: :indexed)
end end
rescue StandardError => e
@document.update!(status: :failed, error_message: e.message)
Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}"
end
def process private
@document.with_lock do
return if @document.indexed?
@document.update!(status: :processing) def chunk_content(content)
@document.chunks.delete_all chunks = []
return chunks if content.blank?
chunks = chunk_content(@document.content) start_index = 0
create_chunks(chunks) chunk_index = 0
@document.update!(status: :indexed) while start_index < content.length
end end_index = [start_index + CHUNK_SIZE, content.length].min
rescue StandardError => e
@document.update!(status: :failed, error_message: e.message)
Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}"
end
private if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
def chunk_content(content) end_index = start_index + last_space if last_space
chunks = []
return chunks if content.blank?
start_index = 0
chunk_index = 0
while start_index < content.length
end_index = [start_index + CHUNK_SIZE, content.length].min
if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
end_index = start_index + last_space if last_space
end
chunk_text = content[start_index...end_index].strip
if chunk_text.present?
chunks << {
content: chunk_text,
index: chunk_index,
char_start: start_index,
char_end: end_index
}
chunk_index += 1
end
break if end_index >= content.length
start_index = end_index - CHUNK_OVERLAP
start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE)
end end
chunks chunk_text = content[start_index...end_index].strip
end
def create_chunks(chunks) if chunk_text.present?
return if chunks.empty? chunks << {
content: chunk_text,
chunks.each do |chunk_data| index: chunk_index,
embedding = generate_embedding(chunk_data[:content]) char_start: start_index,
char_end: end_index
Jasmine::DocumentChunk.create!( }
account: @document.account, chunk_index += 1
collection: @document.collection,
document: @document,
content: chunk_data[:content],
metadata: {
chunk_index: chunk_data[:index],
char_start: chunk_data[:char_start],
char_end: chunk_data[:char_end],
model: embedding_model
},
embedding: embedding
)
end end
break if end_index >= content.length
start_index = end_index - CHUNK_OVERLAP
start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE)
end end
def generate_embedding(text) chunks
if openai_configured? end
generate_openai_embedding(text)
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"
generate_fallback_embedding(text)
end
end
def openai_configured? def create_chunks(chunks)
ENV['OPENAI_API_KEY'].present? return if chunks.empty?
end
def generate_openai_embedding(text) chunks.each do |chunk_data|
response = RubyLLM.embed(text, model: embedding_model) embedding = generate_embedding(chunk_data[:content])
response.vectors.first
end
def generate_fallback_embedding(text) Jasmine::DocumentChunk.create!(
# Generate a deterministic pseudo-random vector based on text content account: @document.account,
# Uses SHA256 hash to seed random number generator for reproducibility collection: @document.collection,
require 'digest' document: @document,
content: chunk_data[:content],
seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32) metadata: {
rng = Random.new(seed) chunk_index: chunk_data[:index],
char_start: chunk_data[:char_start],
# Generate normalized vector with EMBEDDING_DIMENSIONS dimensions char_end: chunk_data[:char_end],
vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) } model: embedding_model
},
# Normalize to unit length embedding: embedding
magnitude = Math.sqrt(vector.sum { |v| v**2 }) )
vector.map { |v| v / magnitude }
end
def embedding_model
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
end end
end end
end
def generate_embedding(text)
if openai_configured?
generate_openai_embedding(text)
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'
generate_fallback_embedding(text)
end
end
def openai_configured?
ENV['OPENAI_API_KEY'].present?
end
def generate_openai_embedding(text)
response = RubyLLM.embed(text, model: embedding_model)
response.vectors.first
end
def generate_fallback_embedding(text)
# Generate a deterministic pseudo-random vector based on text content
# Uses SHA256 hash to seed random number generator for reproducibility
require 'digest'
seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32)
rng = Random.new(seed)
# Generate normalized vector with EMBEDDING_DIMENSIONS dimensions
vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) }
# Normalize to unit length
magnitude = Math.sqrt(vector.sum { |v| v**2 })
vector.map { |v| v / magnitude }
end
def embedding_model
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
end
end

View File

@ -1,52 +1,50 @@
# frozen_string_literal: true # frozen_string_literal: true
module Jasmine class Jasmine::MediaAnalyzerService
class MediaAnalyzerService attr_reader :message
attr_reader :message
def initialize(message:) def initialize(message:)
@message = message @message = message
end end
def perform def perform
analyze_audio analyze_audio
analyze_images analyze_images
end end
private private
def analyze_audio 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? next if attachment.meta&.dig('transcribed_text').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}" Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
# Try to use the standard AudioTranscriptionService (usually in enterprise folder) # Try to use the standard AudioTranscriptionService (usually in enterprise folder)
begin begin
if defined?(Messages::AudioTranscriptionService) if defined?(Messages::AudioTranscriptionService)
# This service updates the attachment meta internally # This service updates the attachment meta internally
Messages::AudioTranscriptionService.new(attachment).perform Messages::AudioTranscriptionService.new(attachment).perform
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
end end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
end end
end end
end
def analyze_images 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? next if attachment.meta&.dig('description').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}" Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
begin begin
description = Jasmine::VisionService.new(attachment: attachment).perform description = Jasmine::VisionService.new(attachment: attachment).perform
if description.present? if description.present?
new_meta = (attachment.meta || {}).merge('description' => description) new_meta = (attachment.meta || {}).merge('description' => description)
attachment.update!(meta: new_meta) attachment.update!(meta: new_meta)
Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}" Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}"
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}"
end end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}"
end end
end end
end end

View File

@ -1,178 +1,176 @@
module Jasmine class Jasmine::SemanticSearchService
class SemanticSearchService CANDIDATES_PER_PRIORITY = 50
CANDIDATES_PER_PRIORITY = 50 TOP_K_PER_PRIORITY = 10
TOP_K_PER_PRIORITY = 10 MAX_CHUNKS_PER_DOC = 2
MAX_CHUNKS_PER_DOC = 2
def initialize(inbox) def initialize(inbox)
@inbox = inbox @inbox = inbox
@account_id = inbox.account_id @account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f @threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end end
def search(query, limit: 10) def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC # 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections enabled_links = @inbox.inbox_collections
.where(is_enabled: true) .where(is_enabled: true)
.order(priority: :desc) .order(priority: :desc)
.includes(:collection) .includes(:collection)
return [] if enabled_links.empty? return [] if enabled_links.empty?
# Group by exact priority # Group by exact priority
priority_groups = enabled_links.group_by(&:priority) priority_groups = enabled_links.group_by(&:priority)
# Prepare query embedding # Prepare query embedding
query_embedding = generate_embedding(query) query_embedding = generate_embedding(query)
final_results = [] final_results = []
processed_chunk_ids = Set.new processed_chunk_ids = Set.new
# 2. Iterate Priority Groups (Waterfall) # 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority| priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id) collection_ids = priority_groups[priority].map(&:collection_id)
# Step 1: ANN/HNSW Candidate Retrieval # Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group # Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator # Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids) candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 2: Rerank, Filter (Threshold), and Dedupe # Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates) group_results = process_candidates(candidates)
# Waterfall Logic # Waterfall Logic
group_results.each do |result| group_results.each do |result|
next if processed_chunk_ids.include?(result.id) next if processed_chunk_ids.include?(result.id)
final_results << result final_results << result
processed_chunk_ids.add(result.id) processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
break if final_results.size >= limit break if final_results.size >= limit
end end
final_results break if final_results.size >= limit
end end
private final_results
end
def retrieve_candidates(query_embedding, collection_ids) private
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>) def retrieve_candidates(query_embedding, collection_ids)
Jasmine::DocumentChunk # Step 1: Broad search for candidates using HNSW index
.where(collection_id: collection_ids) # We order by cosine distance (<=>)
.order(Arel.sql("embedding <=> '#{query_embedding}'")) Jasmine::DocumentChunk
.limit(CANDIDATES_PER_PRIORITY) .where(collection_id: collection_ids)
.order(Arel.sql("embedding <=> '#{query_embedding}'"))
.limit(CANDIDATES_PER_PRIORITY)
end
def process_candidates(candidates)
# Step 2: Deterministic Reranking and Filtering
# Note: 'nearest_neighbors' from neighbor gem already does distance calc,
# but we did it manually in retrieve_candidates to ensure we control the operator.
# We need to manually calculate distance for thresholding if the db didn't return it explicit as a column,
# or trust the order.
# Better approach: Select distance in the query.
# [FUTURE] Placeholder until distance select is wired into filtering.
# Enhanced query with distance
candidates.select(
"jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance"
)
# Filter by Threshold
# We need to re-query or calculate.
# Let's refine retrieve_candidates to include distance.
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation.
# We'll map them to objects and filters.
# [FUTURE] Reserved for threshold filtering output.
Hash.new(0)
# Calculate distances locally or re-fetch.
# Since we ordered by distance in DB, we rely on that order.
# But we need the value for threshold.
# Let's fix retrieve_candidates to return distance
# Re-doing retrieval with select
# Correct approach:
# Iterate, check threshold, check Max Chunks per Doc
candidates.each do |chunk|
# [FUTURE] Distance will gate threshold checks once wired up.
chunk.neighbor_distance(:embedding, @embedding_vector)
rescue StandardError
nil
# NOTE: neighbor gem might not expose distance easily without using its scopes.
# Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value.
# Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order.
# To strictly follow plan: "Re-rank exact cosine distance".
# We can implement a simple ruby cosine distance if vector is loaded,
# or use the SQL value.
# Optimization: Let's assume the SQL order is correct (it is).
# We just need to stop if distance > threshold.
# Since we can't easily get the distance value without select, let's use neighbor gem scope correctly.
end end
def process_candidates(candidates) # Better Implementation using Neighbor Gem capabilities which handles this
# Step 2: Deterministic Reranking and Filtering # But filtering by priority group AND threshold AND limit is complex chain.
# Note: 'nearest_neighbors' from neighbor gem already does distance calc,
# but we did it manually in retrieve_candidates to ensure we control the operator.
# We need to manually calculate distance for thresholding if the db didn't return it explicit as a column,
# or trust the order.
# Better approach: Select distance in the query.
# [FUTURE] Placeholder until distance select is wired into filtering. # Let's use Raw SQL for the whole Step 1 + Distance Select
# Enhanced query with distance # This is safer.
candidates.select(
"jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance"
)
# Filter by Threshold return [] if candidates.empty?
# We need to re-query or calculate. end
# Let's refine retrieve_candidates to include distance.
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation. # Simplified re-implementation of retrieve + process
# We'll map them to objects and filters. def retrieve_candidates(query_embedding, collection_ids)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance")
.order('distance ASC')
.limit(CANDIDATES_PER_PRIORITY)
end
# [FUTURE] Reserved for threshold filtering output. # Overwrite process_candidates with the list from above
Hash.new(0) def process_candidates(candidates)
filtered = []
doc_counts = Hash.new(0)
# Calculate distances locally or re-fetch. candidates.each do |chunk|
# Since we ordered by distance in DB, we rely on that order. # 1. Threshold Check
# But we need the value for threshold. # distance is a string/float from SQL
dist = chunk[:distance].to_f
next if dist > @threshold
# Let's fix retrieve_candidates to return distance # 2. Doc Dedupe
# Re-doing retrieval with select limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
# Correct approach: doc_counts[chunk.document_id] += 1
# Iterate, check threshold, check Max Chunks per Doc filtered << chunk
candidates.each do |chunk|
# [FUTURE] Distance will gate threshold checks once wired up.
chunk.neighbor_distance(:embedding, @embedding_vector)
rescue StandardError
nil
# NOTE: neighbor gem might not expose distance easily without using its scopes.
# Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value.
# Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order.
# To strictly follow plan: "Re-rank exact cosine distance".
# We can implement a simple ruby cosine distance if vector is loaded,
# or use the SQL value.
# Optimization: Let's assume the SQL order is correct (it is).
# We just need to stop if distance > threshold.
# Since we can't easily get the distance value without select, let's use neighbor gem scope correctly.
end
# Better Implementation using Neighbor Gem capabilities which handles this
# But filtering by priority group AND threshold AND limit is complex chain.
# Let's use Raw SQL for the whole Step 1 + Distance Select
# This is safer.
return [] if candidates.empty?
end end
# Simplified re-implementation of retrieve + process # 3. Top K per Priority
def retrieve_candidates(query_embedding, collection_ids) filtered.first(TOP_K_PER_PRIORITY)
Jasmine::DocumentChunk end
.where(collection_id: collection_ids)
.select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance")
.order('distance ASC')
.limit(CANDIDATES_PER_PRIORITY)
end
# Overwrite process_candidates with the list from above def generate_embedding(text)
def process_candidates(candidates) # Using shared logic or direct call.
filtered = [] # Duplication for now to keep service independent or use embedding service class func
doc_counts = Hash.new(0) model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
RubyLLM.embed(text, model: model).vectors.first
end
candidates.each do |chunk| def to_pg_vector(vector)
# 1. Threshold Check # Ensure vector is an array of floats
# distance is a string/float from SQL # PGVector accepts JSON array string e.g. "[1.0, 2.0]"
dist = chunk[:distance].to_f vector.to_s
next if dist > @threshold
# 2. Doc Dedupe
limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
doc_counts[chunk.document_id] += 1
filtered << chunk
end
# 3. Top K per Priority
filtered.first(TOP_K_PER_PRIORITY)
end
def generate_embedding(text)
# Using shared logic or direct call.
# Duplication for now to keep service independent or use embedding service class func
model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
RubyLLM.embed(text, model: model).vectors.first
end
def to_pg_vector(vector)
# Ensure vector is an array of floats
# PGVector accepts JSON array string e.g. "[1.0, 2.0]"
vector.to_s
end
end end
end end

View File

@ -1,104 +1,107 @@
require 'rest-client' require 'rest-client'
module Jasmine class Jasmine::ToolRunner
class ToolRunner attr_reader :inbox, :tool_key, :config
attr_reader :inbox, :tool_key, :config
def initialize(inbox, tool_key) def initialize(inbox, tool_key)
@inbox = inbox @inbox = inbox
@tool_key = tool_key.to_s @tool_key = tool_key.to_s
@config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key) @config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key)
end end
def run def run
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key] definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
raise "Tool not found definition: #{@tool_key}" unless definition 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 start_time = Time.current
begin begin
response = make_request(definition) response = make_request(definition)
duration = (Time.current - start_time) * 1000 duration = (Time.current - start_time) * 1000
success = (200..299).include?(response.code) success = (200..299).cover?(response.code)
body = response.body.to_s body = response.body.to_s
# Save stats # Save stats
update_stats(response.code, nil, duration) update_stats(response.code, nil, duration)
{ {
success: success, success: success,
status: response.code, status: response.code,
body: body.first(2000), # Preview limited body: body.first(2000), # Preview limited
duration_ms: duration.to_i duration_ms: duration.to_i
}
rescue RestClient::ExceptionWithResponse => e
duration = (Time.current - start_time) * 1000
error_msg = e.message
# Try to parse body from error response if available
error_body = e.response&.body rescue nil
error_msg = "#{error_msg} - #{error_body}" if error_body
sanitized_error = sanitize(error_msg)
update_stats(e.http_code, sanitized_error, duration)
{
success: false,
status: e.http_code,
error: sanitized_error,
duration_ms: duration.to_i
}
rescue StandardError => e
duration = (Time.current - start_time) * 1000
sanitized_error = sanitize(e.message)
update_stats(0, sanitized_error, duration)
Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}"
{
success: false,
status: 0,
error: sanitized_error,
duration_ms: duration.to_i
}
end
end
private
def make_request(definition)
url = definition[:url]
method = definition[:method]
headers = {
'PLUG-PLAY-ID' => config.plug_play_id,
'PLUG-PLAY-TOKEN' => config.plug_play_token,
'User-Agent' => 'Chatwoot/Jasmine-Tools'
} }
rescue RestClient::ExceptionWithResponse => e
duration = (Time.current - start_time) * 1000
error_msg = e.message
RestClient::Request.execute( # Try to parse body from error response if available
method: method, error_body = begin
url: url, e.response&.body
headers: headers, rescue StandardError
open_timeout: 2, nil
read_timeout: 8 end
) error_msg = "#{error_msg} - #{error_body}" if error_body
end
def update_stats(status, error, duration) sanitized_error = sanitize(error_msg)
config.update_columns( update_stats(e.http_code, sanitized_error, duration)
last_tested_at: Time.current,
last_test_status: status,
last_test_error: error,
last_test_duration_ms: duration.to_i
)
end
def sanitize(text) {
return text unless @config&.plug_play_token.present? success: false,
text.to_s.gsub(@config.plug_play_token, '****') status: e.http_code,
error: sanitized_error,
duration_ms: duration.to_i
}
rescue StandardError => e
duration = (Time.current - start_time) * 1000
sanitized_error = sanitize(e.message)
update_stats(0, sanitized_error, duration)
Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}"
{
success: false,
status: 0,
error: sanitized_error,
duration_ms: duration.to_i
}
end end
end end
private
def make_request(definition)
url = definition[:url]
method = definition[:method]
headers = {
'PLUG-PLAY-ID' => config.plug_play_id,
'PLUG-PLAY-TOKEN' => config.plug_play_token,
'User-Agent' => 'Chatwoot/Jasmine-Tools'
}
RestClient::Request.execute(
method: method,
url: url,
headers: headers,
open_timeout: 2,
read_timeout: 8
)
end
def update_stats(status, error, duration)
config.update_columns(
last_tested_at: Time.current,
last_test_status: status,
last_test_error: error,
last_test_duration_ms: duration.to_i
)
end
def sanitize(text)
return text if @config&.plug_play_token.blank?
text.to_s.gsub(@config.plug_play_token, '****')
end
end end

View File

@ -3,67 +3,65 @@
require 'openai' require 'openai'
require 'base64' require 'base64'
module Jasmine class Jasmine::VisionService
class VisionService attr_reader :attachment
attr_reader :attachment
def initialize(attachment:) def initialize(attachment:)
@attachment = attachment @attachment = attachment
end end
def perform def perform
return nil unless attachment.image? return nil unless attachment.image?
api_key = ENV.fetch('OPENAI_API_KEY', nil) api_key = ENV.fetch('OPENAI_API_KEY', nil)
return nil if api_key.blank? return nil if api_key.blank?
client = OpenAI::Client.new(access_token: api_key) client = OpenAI::Client.new(access_token: api_key)
image_data = get_image_data image_data = get_image_data
return nil if image_data.blank? return nil if image_data.blank?
response = client.chat( response = client.chat(
parameters: { parameters: {
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
messages: [ messages: [
{ {
role: 'user', role: 'user',
content: [ content: [
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' }, { type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
image_data image_data
] ]
} }
], ],
max_tokens: 300 max_tokens: 300
}
)
response.dig('choices', 0, 'message', 'content')
rescue StandardError => e
Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}"
nil
end
private
def get_image_data
# Always return base64 for better compatibility (OpenAI can reach it even if local)
# and it avoids issues with signed URL expiration or private buckets.
{
type: 'image_url',
image_url: {
url: "data:#{attachment.file.content_type};base64,#{encode_image}"
}
} }
rescue StandardError => e )
Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}"
nil
end
def encode_image response.dig('choices', 0, 'message', 'content')
attachment.file.blob.open do |file| rescue StandardError => e
Base64.strict_encode64(file.read) Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}"
end nil
end
private
def get_image_data
# Always return base64 for better compatibility (OpenAI can reach it even if local)
# and it avoids issues with signed URL expiration or private buckets.
{
type: 'image_url',
image_url: {
url: "data:#{attachment.file.content_type};base64,#{encode_image}"
}
}
rescue StandardError => e
Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}"
nil
end
def encode_image
attachment.file.blob.open do |file|
Base64.strict_encode64(file.read)
end end
end end
end end

View File

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

View File

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

View File

@ -1,147 +1,145 @@
module Whatsapp class Whatsapp::DecryptionService
class DecryptionService require 'openssl'
require 'openssl' require 'base64'
require 'base64' require 'net/http'
require 'net/http'
# HKDF Info strings for different media types (WhatsApp protocol) # HKDF Info strings for different media types (WhatsApp protocol)
INFO_STRINGS = { INFO_STRINGS = {
image: 'WhatsApp Image Keys', image: 'WhatsApp Image Keys',
video: 'WhatsApp Video Keys', video: 'WhatsApp Video Keys',
audio: 'WhatsApp Audio Keys', audio: 'WhatsApp Audio Keys',
document: 'WhatsApp Document Keys', document: 'WhatsApp Document Keys',
sticker: 'WhatsApp Image Keys' sticker: 'WhatsApp Image Keys'
}.freeze }.freeze
def initialize(media_url, media_key, media_type) def initialize(media_url, media_key, media_type)
@media_url = media_url @media_url = media_url
@media_key = Base64.decode64(media_key) @media_key = Base64.decode64(media_key)
@media_type = media_type.to_sym @media_type = media_type.to_sym
@info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document] @info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document]
end end
def decrypt def decrypt
return nil unless @media_url && @media_key return nil unless @media_url && @media_key
# 1. Download encrypted bytes # 1. Download encrypted bytes
encrypted_bytes = download_content encrypted_bytes = download_content
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10 return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes" Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes"
# 2. Derive keys using HKDF SHA-256 (112 bytes total) # 2. Derive keys using HKDF SHA-256 (112 bytes total)
expanded_key = OpenSSL::KDF.hkdf( expanded_key = OpenSSL::KDF.hkdf(
@media_key, @media_key,
salt: ''.b, # Empty binary string salt: ''.b, # Empty binary string
info: @info, info: @info,
length: 112, length: 112,
hash: 'sha256' hash: 'sha256'
) )
# 3. Split derived key # 3. Split derived key
iv = expanded_key[0...16] iv = expanded_key[0...16]
cipher_key = expanded_key[16...48] cipher_key = expanded_key[16...48]
# mac_key = expanded_key[48...80] # For verification (optional) # mac_key = expanded_key[48...80] # For verification (optional)
# ref_key = expanded_key[80...112] # Not used # ref_key = expanded_key[80...112] # Not used
# 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)] # 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
# Remove the last 10 bytes (MAC) # Remove the last 10 bytes (MAC)
cipher_text = encrypted_bytes[0...-10] cipher_text = encrypted_bytes[0...-10]
# 5. Try AES-256-CBC first (older WhatsApp versions) # 5. Try AES-256-CBC first (older WhatsApp versions)
decrypted = try_aes_cbc(cipher_key, iv, cipher_text) decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
# 6. If CBC fails, try CTR mode (some implementations use this) # 6. If CBC fails, try CTR mode (some implementations use this)
decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text) decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text)
return nil unless decrypted return nil unless decrypted
# 7. Validate that we got a valid image (check magic bytes) # 7. Validate that we got a valid image (check magic bytes)
if valid_media?(decrypted) if valid_media?(decrypted)
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected' Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
StringIO.new(decrypted) StringIO.new(decrypted)
else else
Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format' Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format'
nil
end
rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
nil nil
end end
rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
nil
end
private private
def try_aes_cbc(key, iv, data) def try_aes_cbc(key, iv, data)
decipher = OpenSSL::Cipher.new('AES-256-CBC') decipher = OpenSSL::Cipher.new('AES-256-CBC')
decipher.decrypt decipher.decrypt
decipher.key = key decipher.key = key
decipher.iv = iv decipher.iv = iv
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
decipher.update(data) + decipher.final decipher.update(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CBC failed: #{e.message}" } Rails.logger.debug { "AES-CBC failed: #{e.message}" }
nil nil
end end
def try_aes_ctr(key, iv, data) def try_aes_ctr(key, iv, data)
decipher = OpenSSL::Cipher.new('AES-256-CTR') decipher = OpenSSL::Cipher.new('AES-256-CTR')
decipher.decrypt decipher.decrypt
decipher.key = key decipher.key = key
decipher.iv = iv decipher.iv = iv
decipher.update(data) + decipher.final decipher.update(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CTR failed: #{e.message}" } Rails.logger.debug { "AES-CTR failed: #{e.message}" }
nil nil
end end
def valid_media?(data) def valid_media?(data)
return false if data.nil? || data.bytesize < 4 return false if data.nil? || data.bytesize < 4
bytes = data.bytes[0..7] bytes = data.bytes[0..7]
# JPEG: FF D8 FF # JPEG: FF D8 FF
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] return true if bytes[0..2] == [0xFF, 0xD8, 0xFF]
# PNG: 89 50 4E 47 # PNG: 89 50 4E 47
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47]
# WebP: RIFF....WEBP # WebP: RIFF....WEBP
return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP' return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
# MP4/MOV: ftyp # MP4/MOV: ftyp
return true if data[4..7] == 'ftyp' return true if data[4..7] == 'ftyp'
# MP3: ID3 or FF FB/FF FA # MP3: ID3 or FF FB/FF FA
return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA] return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA]
# OGG: OggS # OGG: OggS
return true if data[0..3] == 'OggS' return true if data[0..3] == 'OggS'
# PDF: %PDF # PDF: %PDF
return true if data[0..3] == '%PDF' return true if data[0..3] == '%PDF'
false false
end end
def download_content def download_content
uri = URI.parse(@media_url) uri = URI.parse(@media_url)
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https') http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_NONE http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.open_timeout = 10 http.open_timeout = 10
http.read_timeout = 30 http.read_timeout = 30
request = Net::HTTP::Get.new(uri.request_uri) request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request) response = http.request(request)
response.is_a?(Net::HTTPSuccess) ? response.body.b : nil response.is_a?(Net::HTTPSuccess) ? response.body.b : nil
rescue StandardError => e rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}" Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
nil nil
end
end end
end end

View File

@ -137,7 +137,7 @@ module Whatsapp::IncomingMessageServiceHelpers
# 2. Media Messages (Image, Video, Audio, Document, Sticker) # 2. Media Messages (Image, Video, Audio, Document, Sticker)
[:imageMessage, :videoMessage, :audioMessage, :documentMessage, :stickerMessage].each do |media_key| [: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'] ctx = message[media_key.to_s]['contextInfo']
@in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId'] @in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId']

View File

@ -1,250 +1,248 @@
module Whatsapp class Whatsapp::IncomingMessageWuzapiService < IncomingMessageBaseService
class IncomingMessageWuzapiService < IncomingMessageBaseService def perform
def perform parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params) Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
# 1. Message Type Check # 1. Message Type Check
if parser.message_type == :ignore if parser.message_type == :ignore
Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)' Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)'
return return
end
allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence]
unless allowed_types.include?(parser.message_type)
Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}"
return
end
# 2. V1 Scope: Ignore Groups
if parser.group_message?
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
return
end
# 3. Strong Dedupe (Using WAID prefix)
clean_source_id = "WAID:#{parser.external_id}"
if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
return
end
if parser.sender_phone_number.blank?
Rails.logger.warn "WuzAPI: Skipping processing for event with no valid phone (Type: #{parser.message_type})"
return
end
# 4. Process
Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})"
ActiveRecord::Base.transaction do
@contact = find_or_create_contact(parser)
@conversation = find_or_create_conversation(@contact)
if parser.message_type == :chat_presence
status = parser.presence_state == 'composing' ? 'on' : 'off'
@conversation.toggle_typing_status(status)
return
end
# Create Message First (Clean source_id) - Build, not save yet
@message = build_message(parser, @conversation, clean_source_id)
# Attach media BEFORE saving (Chatwoot pattern)
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
# Now save with attachments
@message.save!
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
end
rescue StandardError => e
Rails.logger.error "WuzAPI Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise e
end end
private allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence]
unless allowed_types.include?(parser.message_type)
def find_or_create_contact(parser) Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}"
phone = parser.sender_phone_number return
normalized_phone = "+#{phone}"
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: phone,
inbox: inbox,
contact_attributes: {
name: parser.push_name || phone,
phone_number: normalized_phone
}
).perform
contact_inbox.contact
end end
def find_or_create_conversation(contact) # 2. V1 Scope: Ignore Groups
conversation = inbox.conversations.where(contact_id: contact.id) if parser.group_message?
.where.not(status: :resolved) Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
.last return
conversation || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)&.id
)
end end
def build_message(parser, conversation, clean_source_id) # 3. Strong Dedupe (Using WAID prefix)
is_outgoing = parser.from_me? clean_source_id = "WAID:#{parser.external_id}"
if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
return
end
msg_params = { if parser.sender_phone_number.blank?
content: parser.text_content, Rails.logger.warn "WuzAPI: Skipping processing for event with no valid phone (Type: #{parser.message_type})"
account_id: inbox.account_id, return
inbox_id: inbox.id, end
message_type: is_outgoing ? :outgoing : :incoming,
sender: is_outgoing ? nil : @contact, # 4. Process
source_id: clean_source_id, Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})"
created_at: parser.timestamp
ActiveRecord::Base.transaction do
@contact = find_or_create_contact(parser)
@conversation = find_or_create_conversation(@contact)
if parser.message_type == :chat_presence
status = parser.presence_state == 'composing' ? 'on' : 'off'
@conversation.toggle_typing_status(status)
return
end
# Create Message First (Clean source_id) - Build, not save yet
@message = build_message(parser, @conversation, clean_source_id)
# Attach media BEFORE saving (Chatwoot pattern)
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
# Now save with attachments
@message.save!
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
end
rescue StandardError => e
Rails.logger.error "WuzAPI Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise e
end
private
def find_or_create_contact(parser)
phone = parser.sender_phone_number
normalized_phone = "+#{phone}"
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: phone,
inbox: inbox,
contact_attributes: {
name: parser.push_name || phone,
phone_number: normalized_phone
} }
).perform
# Handle Replies contact_inbox.contact
# Handle Reply Logic (Aligned with Reference) end
if (reply_id = parser.in_reply_to_external_id).present?
clean_reply_id = "WAID:#{reply_id}"
# Strict lookup within conversation to prevent cross-inbox leaks def find_or_create_conversation(contact)
original_message = conversation.messages.find_by(source_id: clean_reply_id) conversation = inbox.conversations.where(contact_id: contact.id)
.where.not(status: :resolved)
.last
if original_message conversation || ::Conversation.create!(
msg_params[:in_reply_to_id] = original_message.id account_id: inbox.account_id,
else inbox_id: inbox.id,
# Fallback: Store ID for UI "Replying to..." display even if not linked contact_id: contact.id,
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id } contact_inbox_id: ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)&.id
end )
end end
# Use .build so we can attach files before .save! def build_message(parser, conversation, clean_source_id)
conversation.messages.build(msg_params) is_outgoing = parser.from_me?
end
def attach_files(parser) msg_params = {
attachment_data = parser.attachment_params content: parser.text_content,
return if attachment_data.blank? || attachment_data[:external_url].blank? account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: is_outgoing ? :outgoing : :incoming,
sender: is_outgoing ? nil : @contact,
source_id: clean_source_id,
created_at: parser.timestamp
}
begin # Handle Replies
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})" # Handle Reply Logic (Aligned with Reference)
if (reply_id = parser.in_reply_to_external_id).present?
clean_reply_id = "WAID:#{reply_id}"
# 1. Download/Decrypt to get a file # Strict lookup within conversation to prevent cross-inbox leaks
file_io = download_or_decrypt_media(attachment_data, parser.message_type) original_message = conversation.messages.find_by(source_id: clean_reply_id)
return if file_io.blank?
# 2. Determine filename if original_message
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}" msg_params[:in_reply_to_id] = original_message.id
extension = File.extname(original_filename) else
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank? # Fallback: Store ID for UI "Replying to..." display even if not linked
final_filename = "#{File.basename(original_filename, '.*')}#{extension}" msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
# 3. Attach using Chatwoot's standard pattern
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(parser.message_type),
file: {
io: file_io,
filename: final_filename,
content_type: attachment_data[:mimetype] || 'application/octet-stream'
}
)
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
rescue StandardError => e
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
Rails.logger.error e.backtrace.first(10).join("\n")
end end
end end
def download_or_decrypt_media(attachment_data, message_type) # Use .build so we can attach files before .save!
media_url = attachment_data[:external_url] conversation.messages.build(msg_params)
end
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media) def attach_files(parser)
# This is the equivalent of Cloud API's media download attachment_data = parser.attachment_params
begin return if attachment_data.blank? || attachment_data[:external_url].blank?
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present? begin
# WuzAPI returns base64 in 'data' field Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
image_data = wuzapi_response['data']
# Strip data URI prefix if present
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
decoded = Base64.decode64(image_data) # 1. Download/Decrypt to get a file
if decoded.bytesize > 1000 # Valid image should be > 1KB file_io = download_or_decrypt_media(attachment_data, parser.message_type)
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS' return if file_io.blank?
return StringIO.new(decoded)
end
end
rescue StandardError => e
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
end
# METHOD 2: Try local decryption if we have mediaKey # 2. Determine filename
if attachment_data[:media_key].present? original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...' extension = File.extname(original_filename)
decrypted = Whatsapp::DecryptionService.new( extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
media_url, final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
attachment_data[:media_key],
file_content_type(message_type)
).decrypt
return decrypted if decrypted # 3. Attach using Chatwoot's standard pattern
@message.attachments.new(
Rails.logger.warn 'WuzAPI: Local decryption failed...' account_id: @message.account_id,
end file_type: file_content_type(parser.message_type),
file: {
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs) io: file_io,
Rails.logger.info "WuzAPI: Direct download from #{media_url}" filename: final_filename,
Down.download( content_type: attachment_data[:mimetype] || 'application/octet-stream'
media_url, }
open_timeout: 10,
read_timeout: 30,
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
) )
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
rescue StandardError => e rescue StandardError => e
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}" Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
nil Rails.logger.error e.backtrace.first(10).join("\n")
end end
end
def wuzapi_client def download_or_decrypt_media(attachment_data, message_type)
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url']) media_url = attachment_data[:external_url]
end
def wuzapi_token # METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
@inbox.channel.wuzapi_user_token # This is the equivalent of Cloud API's media download
end begin
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
def detect_extension(mimetype, message_type) if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
return '.jpg' if message_type == :image || message_type == :sticker # WuzAPI returns base64 in 'data' field
return '.mp3' if message_type == :audio image_data = wuzapi_response['data']
return '.mp4' if message_type == :video # Strip data URI prefix if present
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
case mimetype decoded = Base64.decode64(image_data)
when 'image/png' then '.png' if decoded.bytesize > 1000 # Valid image should be > 1KB
when 'image/webp' then '.webp' Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
when 'image/gif' then '.gif' return StringIO.new(decoded)
when 'audio/ogg' then '.ogg' end
when 'video/webm' then '.webm'
else '.bin'
end end
rescue StandardError => e
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
end end
def file_content_type(message_type) # METHOD 2: Try local decryption if we have mediaKey
case message_type if attachment_data[:media_key].present?
when :image, :sticker then :image Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
when :audio then :audio decrypted = Whatsapp::DecryptionService.new(
when :video then :video media_url,
else :file attachment_data[:media_key],
end file_content_type(message_type)
).decrypt
return decrypted if decrypted
Rails.logger.warn 'WuzAPI: Local decryption failed...'
end
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs)
Rails.logger.info "WuzAPI: Direct download from #{media_url}"
Down.download(
media_url,
open_timeout: 10,
read_timeout: 30,
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
)
rescue StandardError => e
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
nil
end
def wuzapi_client
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
end
def wuzapi_token
@inbox.channel.wuzapi_user_token
end
def detect_extension(mimetype, message_type)
return '.jpg' if message_type == :image || message_type == :sticker
return '.mp3' if message_type == :audio
return '.mp4' if message_type == :video
case mimetype
when 'image/png' then '.png'
when 'image/webp' then '.webp'
when 'image/gif' then '.gif'
when 'audio/ogg' then '.ogg'
when 'video/webm' then '.webm'
else '.bin'
end
end
def file_content_type(message_type)
case message_type
when :image, :sticker then :image
when :audio then :audio
when :video then :video
else :file
end end
end end
end end

View File

@ -1,214 +1,208 @@
module Whatsapp class Whatsapp::Providers::Wuzapi::PayloadParser
module Providers attr_reader :params
module Wuzapi
class PayloadParser
attr_reader :params
def initialize(params) def initialize(params)
@params = params.with_indifferent_access @params = params.with_indifferent_access
end end
def external_id def external_id
params.dig(:event, :Info, :ID) params.dig(:event, :Info, :ID)
end end
def from_me? def from_me?
is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe) is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe)
return false unless is_api_from_me return false unless is_api_from_me
instance_phone = params['phone_number'] instance_phone = params['phone_number']
sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
if instance_phone.present? && sender_jid.present? if instance_phone.present? && sender_jid.present?
sender_phone = sender_jid.split('@').first sender_phone = sender_jid.split('@').first
return false if sender_phone != instance_phone return false if sender_phone != instance_phone
end end
true true
end end
def message_type def message_type
return :chat_presence if params['type'] == 'ChatPresence' return :chat_presence if params['type'] == 'ChatPresence'
# Info: Type contains the general classification (text, image, etc) # Info: Type contains the general classification (text, image, etc)
type = params.dig(:event, :Info, :Type) type = params.dig(:event, :Info, :Type)
media_type = params.dig(:event, :Info, :MediaType) media_type = params.dig(:event, :Info, :MediaType)
# WuzAPI sometimes sends 'media' in Type and the actual type in MediaType # WuzAPI sometimes sends 'media' in Type and the actual type in MediaType
type = media_type if type == 'media' && media_type.present? type = media_type if type == 'media' && media_type.present?
case type case type
when 'text' then :text when 'text' then :text
when 'image' then :image when 'image' then :image
when 'audio' then :audio when 'audio' then :audio
when 'video' then :video when 'video' then :video
when 'document' then :document when 'document' then :document
when 'sticker' then :sticker when 'sticker' then :sticker
when 'ReadReceipt' then :ignore when 'ReadReceipt' then :ignore
else else
# Fallback: Detect type from Message content keys # Fallback: Detect type from Message content keys
msg = params.dig(:event, :Message) msg = params.dig(:event, :Message)
if msg.is_a?(Hash) if msg.is_a?(Hash)
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? return :text if msg[:conversation].present? || msg[:extendedTextMessage].present?
return :image if msg[:imageMessage].present? return :image if msg[:imageMessage].present?
return :audio if msg[:audioMessage].present? return :audio if msg[:audioMessage].present?
return :video if msg[:videoMessage].present? return :video if msg[:videoMessage].present?
return :document if msg[:documentMessage].present? return :document if msg[:documentMessage].present?
return :sticker if msg[:stickerMessage].present? return :sticker if msg[:stickerMessage].present?
end end
:unknown :unknown
end end
end end
def presence_state def presence_state
params.dig(:event, :State) params.dig(:event, :State)
end end
def in_reply_to_external_id def in_reply_to_external_id
msg = unwrap_ephemeral_message(params.dig(:event, :Message)) msg = unwrap_ephemeral_message(params.dig(:event, :Message))
return nil unless msg.is_a?(Hash) return nil unless msg.is_a?(Hash)
# DEBUG: Log the message structure to understand reply context # DEBUG: Log the message structure to understand reply context
Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}" Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}"
# 1. Extended text # 1. Extended text
ctx = msg.dig(:extendedTextMessage, :contextInfo) ctx = msg.dig(:extendedTextMessage, :contextInfo)
if ctx.present? if ctx.present?
Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}" Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId] stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present? return stanza if stanza.present?
end end
# 2. Media Types direct contextInfo # 2. Media Types direct contextInfo
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key| [:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
ctx = msg.dig(key, :contextInfo) ctx = msg.dig(key, :contextInfo)
next unless ctx.present? next if ctx.blank?
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}" Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId] stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present? return stanza if stanza.present?
end end
# 3. Document With Caption # 3. Document With Caption
if msg.key?(:documentWithCaptionMessage) if msg.key?(:documentWithCaptionMessage)
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo) ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
if ctx.present? if ctx.present?
Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}" Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}"
return ctx[:stanzaID] || ctx[:stanzaId] return ctx[:stanzaID] || ctx[:stanzaId]
end end
end end
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage) # 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] ctx = msg[:contextInfo]
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}" Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId] stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present? return stanza if stanza.present?
end end
Rails.logger.info 'WuzAPI Reply Debug: No reply context found' Rails.logger.info 'WuzAPI Reply Debug: No reply context found'
nil nil
end end
def text_content def text_content
msg = unwrap_ephemeral_message(params.dig(:event, :Message)) msg = unwrap_ephemeral_message(params.dig(:event, :Message))
return nil unless msg.is_a?(Hash) return nil unless msg.is_a?(Hash)
# 1. Simple text # 1. Simple text
return msg[:conversation] if msg[:conversation].present? return msg[:conversation] if msg[:conversation].present?
# 2. Extended Text # 2. Extended Text
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present? return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
# 3. Media Captions (Image, Video, Document) # 3. Media Captions (Image, Video, Document)
[:imageMessage, :videoMessage, :documentMessage].each do |media_key| [:imageMessage, :videoMessage, :documentMessage].each do |media_key|
caption = msg.dig(media_key, :caption) caption = msg.dig(media_key, :caption)
return caption if caption.present? return caption if caption.present?
end end
# 4. Document With Caption # 4. Document With Caption
return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage) return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage)
nil nil
end end
def attachment_params def attachment_params
media_key = case message_type media_key = case message_type
when :image then :imageMessage when :image then :imageMessage
when :audio then :audioMessage when :audio then :audioMessage
when :video then :videoMessage when :video then :videoMessage
when :document then :documentMessage when :document then :documentMessage
when :sticker then :stickerMessage when :sticker then :stickerMessage
end end
return nil unless media_key return nil unless media_key
msg = unwrap_ephemeral_message(params.dig(:event, :Message)) msg = unwrap_ephemeral_message(params.dig(:event, :Message))
data = msg[media_key] data = msg[media_key]
return nil unless data.is_a?(Hash) return nil unless data.is_a?(Hash)
{ {
external_url: data['URL'], external_url: data['URL'],
file_name: data['fileName'] || "file_#{external_id}", file_name: data['fileName'] || "file_#{external_id}",
mimetype: data['mimetype'], mimetype: data['mimetype'],
thumbnail: data['JPEGThumbnail'], thumbnail: data['JPEGThumbnail'],
media_key: data['mediaKey'] media_key: data['mediaKey']
} }
end end
def sender_phone_number def sender_phone_number
jid = extract_jid jid = extract_jid
# Reject LIDs as they aren't valid E164 phone numbers # Reject LIDs as they aren't valid E164 phone numbers
return nil if jid.blank? || jid.include?('@lid') return nil if jid.blank? || jid.include?('@lid')
# Format: 556182098580@s.whatsapp.net -> 556182098580 # Format: 556182098580@s.whatsapp.net -> 556182098580
jid.split('@').first jid.split('@').first
end end
def timestamp def timestamp
timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp) timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp)
return Time.current if timestamp_val.blank? return Time.current if timestamp_val.blank?
begin begin
Time.parse(timestamp_val.to_s) Time.zone.parse(timestamp_val.to_s)
rescue ArgumentError rescue ArgumentError
Time.current Time.current
end end
end end
def push_name def push_name
params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName) params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName)
end end
def group_message? def group_message?
params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup) params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup)
end end
private private
def unwrap_ephemeral_message(msg) def unwrap_ephemeral_message(msg)
return {} unless msg return {} unless msg
msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg
end end
def extract_jid def extract_jid
if from_me? if from_me?
params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat) params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
else else
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt) sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
# Prefer @s.whatsapp.net over @lid # Prefer @s.whatsapp.net over @lid
if sender&.include?('@s.whatsapp.net') if sender&.include?('@s.whatsapp.net')
sender sender
elsif sender_alt&.include?('@s.whatsapp.net') elsif sender_alt&.include?('@s.whatsapp.net')
sender_alt sender_alt
else else
sender sender
end
end
end
end end
end end
end end

View File

@ -1,178 +1,176 @@
module Whatsapp::Providers class Whatsapp::Providers::WuzapiService < BaseService
class WuzapiService < BaseService attr_reader :whatsapp_channel
attr_reader :whatsapp_channel
def initialize(whatsapp_channel:) def initialize(whatsapp_channel:)
super(whatsapp_channel: whatsapp_channel) super(whatsapp_channel: whatsapp_channel)
@base_url = whatsapp_channel.provider_config['wuzapi_base_url'] @base_url = whatsapp_channel.provider_config['wuzapi_base_url']
end end
def send_message(phone_number, message) def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, ) # Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction] return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
response = if message.attachments.present? response = if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message) send_attachment_message(user_token, normalized_phone, message)
else else
params = {} params = {}
# Extract and clean reply ID (remove WAID: prefix if stored) # Extract and clean reply ID (remove WAID: prefix if stored)
if (reply_id = message.content_attributes['in_reply_to_external_id']).present? if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '') params['MessageId'] = reply_id.gsub(/^WAID:/, '')
elsif (reply_id = message.in_reply_to_external_id).present? elsif (reply_id = message.in_reply_to_external_id).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '') params['MessageId'] = reply_id.gsub(/^WAID:/, '')
end
client.send_text(user_token, normalized_phone, message.content, **params)
end end
# Extract message ID from WuzAPI response and format as WAID:xxx client.send_text(user_token, normalized_phone, message.content, **params)
extract_message_id(response) end
end
def send_attachment_message(user_token, phone_number, message) # Extract message ID from WuzAPI response and format as WAID:xxx
attachment = message.attachments.first extract_message_id(response)
base64_data = Base64.strict_encode64(attachment.file.download) end
mime_type = attachment.file.content_type
data_uri = "data:#{mime_type};base64,#{base64_data}"
if mime_type.start_with?('image/') def send_attachment_message(user_token, phone_number, message)
client.send_image(user_token, phone_number, data_uri, message.content) attachment = message.attachments.first
else base64_data = Base64.strict_encode64(attachment.file.download)
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s) mime_type = attachment.file.content_type
end data_uri = "data:#{mime_type};base64,#{base64_data}"
end
def send_reaction_message(phone_number, message) if mime_type.start_with?('image/')
user_token = whatsapp_channel.wuzapi_user_token client.send_image(user_token, phone_number, data_uri, message.content)
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') else
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
# Assuming message content is the emoji
reaction_emoji = message.content
# Resolve the correct external message ID
message_id = message.content_attributes['in_reply_to_external_id']
# Fallback to internal ID resolution if external is missing
if message_id.blank? && message.content_attributes['in_reply_to'].present?
target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to'])
message_id = target_msg&.source_id
end
# Strip WAID prefix if present
message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
use_me_prefix = reaction_to_own_message?(message)
if use_me_prefix
normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:')
message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:')
else
# Enforce JID format for customer numbers
clean_number = normalized_phone.split('@').first
normalized_phone = "#{clean_number}@s.whatsapp.net"
end
Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}"
if message_id.present?
# Wuzapi client needs to implement send_reaction
# This assumes the client wrapper has this method. If not, we might need to add it or use raw request.
# Based on typical Wuzapi forks, it might be /send-reaction-message
# We'll assume the client wrapper will have a send_reaction method.
# If not visible in the existing codebase, we might need to add it to the client class too.
# checking...
response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji)
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
response
else
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
end
end
def send_template(_phone_number, _template_info)
# Placeholder for template support if Wuzapi supports it.
# For now, just logging or no-op as per initial text-focused plan.
Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.'
end
def sync_templates
# No-op for Wuzapi as it doesn't insist on syncing templates like Cloud API
end
def validate_provider_config?
# Validate if we can connect to session status
user_token = whatsapp_channel.wuzapi_user_token
return false if user_token.blank?
begin
client.session_status(user_token)
true
rescue Wuzapi::Client::Error
false
end
end
def toggle_typing_status(typing_status, recipient_id: nil, **_kwargs)
# typing_status: 'typing_on', 'typing_off'
# Wuzapi expects: 'composing', 'paused'
state = %w[typing_on on].include?(typing_status) ? 'composing' : 'paused'
user_token = whatsapp_channel.wuzapi_user_token
phone_number = recipient_id || whatsapp_channel.phone_number
# Clean phone number (digits only)
normalized_phone = phone_number.to_s.gsub(/[\+\s\-\(\)]/, '')
# Enforce JID format: 5561...@s.whatsapp.net
# Strip any existing suffix (like @lid or even @s.whatsapp.net to be safe) and append standard one.
clean_number = normalized_phone.split('@').first
jid = "#{clean_number}@s.whatsapp.net"
Rails.logger.info "[WuzapiService] toggle_typing_status: Sending presence to #{jid} (raw: #{normalized_phone}), state: #{state}, token_present: #{user_token.present?}"
begin
# Use JID in the 'Phone' field as confirmed by manual tests (Test C)
response = client.send_chat_presence(user_token, jid, state)
Rails.logger.info "[WuzapiService] toggle_typing_status response: #{response}"
rescue StandardError => e
Rails.logger.warn "Wuzapi: Failed to send typing status: #{e.message}"
end
end
private
def client
@client ||= ::Wuzapi::Client.new(@base_url)
end
# Extract message ID from WuzAPI response and format it as WAID:xxx
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true}
def extract_message_id(response)
return nil unless response.is_a?(Hash)
message_id = response.dig('data', 'Id') || response.dig(:data, :Id)
return nil if message_id.blank?
"WAID:#{message_id}"
end
def reaction_to_own_message?(message)
# If we can resolve the target message, check if it was sent by us.
target_message = nil
if message.in_reply_to.present?
target_message = message.conversation.messages.find_by(id: message.in_reply_to)
target_message ||= message.conversation.messages.find_by(source_id: message.in_reply_to)
elsif message.in_reply_to_external_id.present?
target_message = message.conversation.messages.find_by(source_id: message.in_reply_to_external_id)
end
return false unless target_message.present?
target_message.outgoing? || target_message.template?
end end
end end
def send_reaction_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
# Assuming message content is the emoji
reaction_emoji = message.content
# Resolve the correct external message ID
message_id = message.content_attributes['in_reply_to_external_id']
# Fallback to internal ID resolution if external is missing
if message_id.blank? && message.content_attributes['in_reply_to'].present?
target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to'])
message_id = target_msg&.source_id
end
# Strip WAID prefix if present
message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
use_me_prefix = reaction_to_own_message?(message)
if use_me_prefix
normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:')
message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:')
else
# Enforce JID format for customer numbers
clean_number = normalized_phone.split('@').first
normalized_phone = "#{clean_number}@s.whatsapp.net"
end
Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}"
if message_id.present?
# Wuzapi client needs to implement send_reaction
# This assumes the client wrapper has this method. If not, we might need to add it or use raw request.
# Based on typical Wuzapi forks, it might be /send-reaction-message
# We'll assume the client wrapper will have a send_reaction method.
# If not visible in the existing codebase, we might need to add it to the client class too.
# checking...
response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji)
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
response
else
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
end
end
def send_template(_phone_number, _template_info)
# Placeholder for template support if Wuzapi supports it.
# For now, just logging or no-op as per initial text-focused plan.
Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.'
end
def sync_templates
# No-op for Wuzapi as it doesn't insist on syncing templates like Cloud API
end
def validate_provider_config?
# Validate if we can connect to session status
user_token = whatsapp_channel.wuzapi_user_token
return false if user_token.blank?
begin
client.session_status(user_token)
true
rescue Wuzapi::Client::Error
false
end
end
def toggle_typing_status(typing_status, recipient_id: nil, **_kwargs)
# typing_status: 'typing_on', 'typing_off'
# Wuzapi expects: 'composing', 'paused'
state = %w[typing_on on].include?(typing_status) ? 'composing' : 'paused'
user_token = whatsapp_channel.wuzapi_user_token
phone_number = recipient_id || whatsapp_channel.phone_number
# Clean phone number (digits only)
normalized_phone = phone_number.to_s.gsub(/[\+\s\-\(\)]/, '')
# Enforce JID format: 5561...@s.whatsapp.net
# Strip any existing suffix (like @lid or even @s.whatsapp.net to be safe) and append standard one.
clean_number = normalized_phone.split('@').first
jid = "#{clean_number}@s.whatsapp.net"
Rails.logger.info "[WuzapiService] toggle_typing_status: Sending presence to #{jid} (raw: #{normalized_phone}), state: #{state}, token_present: #{user_token.present?}"
begin
# Use JID in the 'Phone' field as confirmed by manual tests (Test C)
response = client.send_chat_presence(user_token, jid, state)
Rails.logger.info "[WuzapiService] toggle_typing_status response: #{response}"
rescue StandardError => e
Rails.logger.warn "Wuzapi: Failed to send typing status: #{e.message}"
end
end
private
def client
@client ||= ::Wuzapi::Client.new(@base_url)
end
# Extract message ID from WuzAPI response and format it as WAID:xxx
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true}
def extract_message_id(response)
return nil unless response.is_a?(Hash)
message_id = response.dig('data', 'Id') || response.dig(:data, :Id)
return nil if message_id.blank?
"WAID:#{message_id}"
end
def reaction_to_own_message?(message)
# If we can resolve the target message, check if it was sent by us.
target_message = nil
if message.in_reply_to.present?
target_message = message.conversation.messages.find_by(id: message.in_reply_to)
target_message ||= message.conversation.messages.find_by(source_id: message.in_reply_to)
elsif message.in_reply_to_external_id.present?
target_message = message.conversation.messages.find_by(source_id: message.in_reply_to_external_id)
end
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) !@raw_message.key?(:notification)
end end
def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity def message_type
return 'text' if @raw_message.key?(:text) return 'text' if @raw_message.key?(:text)
return 'reaction' if @raw_message.key?(:reaction) return 'reaction' if @raw_message.key?(:reaction)
return 'audio' if @raw_message.key?(:audio) return 'audio' if @raw_message.key?(:audio)

View File

@ -1,35 +1,34 @@
module Wuzapi class Wuzapi::ProvisioningService
class ProvisioningService def initialize(base_url, admin_token)
def initialize(base_url, admin_token) @base_url = base_url
@base_url = base_url @admin_token = admin_token
@admin_token = admin_token @client = Wuzapi::Client.new(base_url)
@client = Wuzapi::Client.new(base_url) end
end
def provision(name) def provision(name)
user_token = SecureRandom.hex(32) user_token = SecureRandom.hex(32)
response = @client.create_user(@admin_token, name, user_token) response = @client.create_user(@admin_token, name, user_token)
# Wuzapi returns the user object, or we assume success if no error raised. # Wuzapi returns the user object, or we assume success if no error raised.
# The response structure depends on Wuzapi. Assuming it returns { "ID": "...", ... } or similar. # The response structure depends on Wuzapi. Assuming it returns { "ID": "...", ... } or similar.
# Based on plan, we just need to know it succeeded. # Based on plan, we just need to know it succeeded.
# We return the generated data to be saved. # We return the generated data to be saved.
{ {
wuzapi_user_id: response['ID'] || response['id'], # Adjust based on actual response if known, strictly fallback wuzapi_user_id: response['ID'] || response['id'], # Adjust based on actual response if known, strictly fallback
wuzapi_user_token: user_token wuzapi_user_token: user_token
} }
end end
def setup_webhook(user_token, inbox_id, _webhook_secret) # [INTENTIONAL] reserved for signed webhooks # [INTENTIONAL] reserved for signed webhooks
# Host logic needs to come from GlobalConfig or Rails.application.routes def setup_webhook(user_token, inbox_id, _webhook_secret)
# Ideally passed in or resolved. # Host logic needs to come from GlobalConfig or Rails.application.routes
base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') # Ideally passed in or resolved.
inbox = Inbox.find(inbox_id) base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
phone_number = inbox.channel.phone_number.delete('+') inbox = Inbox.find(inbox_id)
webhook_url = "#{base_host}/webhooks/whatsapp/#{phone_number}" phone_number = inbox.channel.phone_number.delete('+')
webhook_url = "#{base_host}/webhooks/whatsapp/#{phone_number}"
@client.set_webhook(user_token, webhook_url) @client.set_webhook(user_token, webhook_url)
end
end end
end end

View File

@ -1,5 +1,7 @@
Rails.application.configure do 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.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.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY',
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', 'test_salt_must_be_unique_and_random') '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 end

View File

@ -15,7 +15,7 @@ Rails.application.config.after_initialize do
Rails.logger.error "[RubyLLM] Init failed: #{e.class} #{e.message}" Rails.logger.error "[RubyLLM] Init failed: #{e.class} #{e.message}"
end end
Rails.logger.info "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." 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 else
Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment' Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment'
end 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: {} add_column :jasmine_inbox_settings, :intent_keywords, :jsonb, default: {}
end end
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 = Captain::CustomTool.where('endpoint_url ILIKE ? OR title ILIKE ?', '%/api/PlugPlay/api/SuitesStatus%', '%Status Suites%')
tools.each do |tool| tools.each do |tool|
puts "Processing tool: #{tool.title} (ID: #{tool.id})" Rails.logger.debug { "Processing tool: #{tool.title} (ID: #{tool.id})" }
updated = false updated = false
new_auth_config = tool.auth_config || {} 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| %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].each do |header_key|
next unless query_params.key?(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] new_auth_config['headers'][header_key] = query_params[header_key]
query_params.delete(header_key) query_params.delete(header_key)
updated = true updated = true
@ -36,16 +36,16 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1]
if tool.param_schema.is_a?(Array) if tool.param_schema.is_a?(Array)
original_size = tool.param_schema.size original_size = tool.param_schema.size
tool.param_schema.reject! { |p| %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].include?(p['name']) } 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 end
tool.save! tool.save!
puts ' Tool updated successfully.' Rails.logger.debug ' Tool updated successfully.'
else 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 end
rescue URI::InvalidURIError # [INTENTIONAL] keep for future logging 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 end
end end

View File

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

View File

@ -1,52 +1,44 @@
module Api class Api::V1::Accounts::Captain::BrandsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_brand, only: [:show, :update, :destroy]
module Accounts
module Captain
class BrandsController < Api::V1::Accounts::BaseController
before_action :fetch_brand, only: [:show, :update, :destroy]
def index def index
@brands = Current.account.captain_brands @brands = Current.account.captain_brands
end end
def show; end def show; end
def create def create
@brand = Current.account.captain_brands.new(brand_params) @brand = Current.account.captain_brands.new(brand_params)
if @brand.save if @brand.save
render :show, status: :created render :show, status: :created
else else
render_error_response(@brand) render_error_response(@brand)
end
end
def update
if @brand.update(brand_params)
render :show
else
render_error_response(@brand)
end
end
def destroy
if @brand.destroy
head :no_content
else
render_error_response(@brand)
end
end
private
def fetch_brand
@brand = Current.account.captain_brands.find(params[:id])
end
def brand_params
params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {})
end
end
end
end end
end end
def update
if @brand.update(brand_params)
render :show
else
render_error_response(@brand)
end
end
def destroy
if @brand.destroy
head :no_content
else
render_error_response(@brand)
end
end
private
def fetch_brand
@brand = Current.account.captain_brands.find(params[:id])
end
def brand_params
params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {})
end
end end

View File

@ -1,34 +1,26 @@
module Api class Api::V1::Accounts::Captain::ConfigurationsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_config
module Accounts
module Captain
class ConfigurationsController < Api::V1::Accounts::BaseController
before_action :fetch_config
def show def show
render json: @config render json: @config
end end
def update def update
if @config.update(config_params) if @config.update(config_params)
render json: @config render json: @config
else else
render_error_response(@config) render_error_response(@config)
end
end
private
def fetch_config
# Ensure a config exists for the account
@config = current_account.captain_configuration || current_account.create_captain_configuration!
end
def config_params
params.require(:configuration).permit(:title, :subtitle, :primary_color, :secondary_color, :active, :logo_url, :phone_number)
end
end
end
end end
end end
private
def fetch_config
# Ensure a config exists for the account
@config = current_account.captain_configuration || current_account.create_captain_configuration!
end
def config_params
params.require(:configuration).permit(:title, :subtitle, :primary_color, :secondary_color, :active, :logo_url, :phone_number)
end
end end

View File

@ -1,52 +1,44 @@
module Api class Api::V1::Accounts::Captain::ExtrasController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_extra, only: [:show, :update, :destroy]
module Accounts
module Captain
class ExtrasController < Api::V1::Accounts::BaseController
before_action :fetch_extra, only: [:show, :update, :destroy]
def index def index
@extras = Current.account.captain_extras @extras = Current.account.captain_extras
end end
def show; end def show; end
def create def create
@extra = Current.account.captain_extras.new(extra_params) @extra = Current.account.captain_extras.new(extra_params)
if @extra.save if @extra.save
render :show, status: :created render :show, status: :created
else else
render_error_response(@extra) render_error_response(@extra)
end
end
def update
if @extra.update(extra_params)
render :show
else
render_error_response(@extra)
end
end
def destroy
if @extra.destroy
head :no_content
else
render_error_response(@extra)
end
end
private
def fetch_extra
@extra = Current.account.captain_extras.find(params[:id])
end
def extra_params
params.require(:extra).permit(:title, :description, :price, :category)
end
end
end
end end
end end
def update
if @extra.update(extra_params)
render :show
else
render_error_response(@extra)
end
end
def destroy
if @extra.destroy
head :no_content
else
render_error_response(@extra)
end
end
private
def fetch_extra
@extra = Current.account.captain_extras.find(params[:id])
end
def extra_params
params.require(:extra).permit(:title, :description, :price, :category)
end
end end

View File

@ -1,67 +1,59 @@
module Api class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_pricing, only: [:show, :update, :destroy]
module Accounts
module Captain
class PricingsController < Api::V1::Accounts::BaseController
before_action :fetch_pricing, only: [:show, :update, :destroy]
def index def index
@pricings = Current.account.captain_pricings.includes(:brand, :inbox) @pricings = Current.account.captain_pricings.includes(:brand, :inbox)
end end
def show; end def show; end
def create def create
@pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids)) @pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids))
@pricing.save! @pricing.save!
sync_inboxes(@pricing, pricing_params[:inbox_ids]) sync_inboxes(@pricing, pricing_params[:inbox_ids])
render :show, status: :created render :show, status: :created
end end
def update def update
@pricing.update!(pricing_params.except(:inbox_ids)) @pricing.update!(pricing_params.except(:inbox_ids))
sync_inboxes(@pricing, pricing_params[:inbox_ids]) sync_inboxes(@pricing, pricing_params[:inbox_ids])
render :show render :show
end end
def destroy def destroy
@pricing.destroy! @pricing.destroy!
head :no_content head :no_content
end end
private private
def fetch_pricing def fetch_pricing
@pricing = Current.account.captain_pricings.find(params[:id]) @pricing = Current.account.captain_pricings.find(params[:id])
end end
def pricing_params def pricing_params
params.require(:pricing).permit( params.require(:pricing).permit(
:captain_brand_id, :captain_brand_id,
:inbox_id, :inbox_id,
:day_range, :day_range,
:suite_category, :suite_category,
:duration, :duration,
:price, :price,
:keywords, :keywords,
inbox_ids: [] inbox_ids: []
) )
end end
def sync_inboxes(pricing, inbox_ids) def sync_inboxes(pricing, inbox_ids)
return if inbox_ids.nil? return if inbox_ids.nil?
ids = Array(inbox_ids).reject(&:blank?).map(&:to_i) ids = Array(inbox_ids).reject(&:blank?).map(&:to_i)
if ids.empty? if ids.empty?
pricing.inboxes.clear pricing.inboxes.clear
return return
end
inboxes = Current.account.inboxes.where(id: ids)
pricing.inboxes = inboxes
end
end
end
end end
inboxes = Current.account.inboxes.where(id: ids)
pricing.inboxes = inboxes
end end
end end

View File

@ -1,54 +1,46 @@
module Api class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
module V1 before_action :current_account
module Accounts before_action -> { check_authorization(::Captain::Assistant) }
module Captain
class UnitsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(::Captain::Assistant) }
def index def index
@units = if params[:all] @units = if params[:all]
Current.account.captain_units Current.account.captain_units
else else
Current.account.captain_units.active.select(:id, :name) Current.account.captain_units.active.select(:id, :name)
end end
render json: @units render json: @units
end end
def create def create
@unit = Current.account.captain_units.new(unit_params) @unit = Current.account.captain_units.new(unit_params)
if @unit.save if @unit.save
render json: @unit render json: @unit
else else
render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def update
@unit = Current.account.captain_units.find(params[:id])
if @unit.update(unit_params)
render json: @unit
else
render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def unit_params
params.require(:unit).permit(
:name, :status, :captain_brand_id,
:reservations_sync_enabled,
:plug_play_id, :plug_play_token,
:inter_client_id, :inter_client_secret,
:inter_pix_key, :inter_cert_path,
:inter_key_path, :inter_account_number,
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
)
end
end
end
end end
end end
def update
@unit = Current.account.captain_units.find(params[:id])
if @unit.update(unit_params)
render json: @unit
else
render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def unit_params
params.require(:unit).permit(
:name, :status, :captain_brand_id,
:reservations_sync_enabled,
:plug_play_id, :plug_play_token,
:inter_client_id, :inter_client_secret,
:inter_pix_key, :inter_cert_path,
:inter_key_path, :inter_account_number,
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
)
end
end end

View File

@ -1,49 +1,39 @@
module Api class Api::V1::Accounts::Inboxes::Captain::ReminderSettingsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts before_action :fetch_or_initialize_settings
module Inboxes
module Captain
class ReminderSettingsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_settings
def show def show
render json: @settings render json: @settings
end end
def update def update
if @settings.update(settings_params) if @settings.update(settings_params)
render json: @settings render json: @settings
else else
render json: { error: @settings.errors.full_messages.join(', ') }, status: :unprocessable_entity render json: { error: @settings.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_settings
@settings = ::Captain::InboxReminderSetting.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def settings_params
params.permit(
:enabled,
:menu_message,
:menu_delay_minutes,
:feedback_message,
:feedback_delay_minutes
)
end
end
end
end
end end
end end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_settings
@settings = ::Captain::InboxReminderSetting.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def settings_params
params.permit(
:enabled,
:menu_message,
:menu_delay_minutes,
:feedback_message,
:feedback_delay_minutes
)
end
end end

View File

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

View File

@ -1,33 +1,25 @@
module Public class Public::Api::V1::Captain::MasterDataController < ActionController::API
module Api def show
module V1 # Assuming account_id is passed or derived from domain
module Captain account = Account.find(params[:account_id])
class MasterDataController < ActionController::API
def show
# Assuming account_id is passed or derived from domain
account = Account.find(params[:account_id])
brands = account.captain_brands.includes(:units, :pricings) brands = account.captain_brands.includes(:units, :pricings)
extras = account.captain_extras.where(active: true).order(:order) extras = account.captain_extras.where(active: true).order(:order)
suites = account.captain_suites.all suites = account.captain_suites.all
begin begin
config = account.captain_configuration || account.create_captain_configuration! config = account.captain_configuration || account.create_captain_configuration!
rescue StandardError => e rescue StandardError => e
Rails.logger.error "Failed to create Captain Configuration: #{e.message}" Rails.logger.error "Failed to create Captain Configuration: #{e.message}"
config = { title: 'Reserva Rápida', subtitle: 'Agende sua visita', primary_color: '#1E90FF', secondary_color: '#1B3B5F' } config = { title: 'Reserva Rápida', subtitle: 'Agende sua visita', primary_color: '#1E90FF', secondary_color: '#1B3B5F' }
end
render json: {
app_config: config,
brands: brands.as_json(include: :units),
pricings: account.captain_pricings.as_json,
extras: extras.as_json,
suites: suites.as_json
}
end
end
end
end end
render json: {
app_config: config,
brands: brands.as_json(include: :units),
pricings: account.captain_pricings.as_json,
extras: extras.as_json,
suites: suites.as_json
}
end end
end end

View File

@ -1,172 +1,164 @@
module Public class Public::Api::V1::Captain::ReservationsController < ActionController::API
module Api # Use basic rescue_from for generic error handling to capture 'Inbox not found' or similar if they bubble up
module V1 rescue_from StandardError, with: :handle_standard_error
module Captain
class 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
def create def create
Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation' Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation'
Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}" Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}"
# 1. Parse Params # 1. Parse Params
# 3. Persistence Logic # 3. Persistence Logic
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
# A. Find or Create Contact (Simple Logic for now) # A. Find or Create Contact (Simple Logic for now)
contact = find_or_create_contact(reservation_params) contact = find_or_create_contact(reservation_params)
# A.1 Find or Create Conversation # A.1 Find or Create Conversation
# We need an inbox to create a conversation. # We need an inbox to create a conversation.
inbox_id = unit.account.inboxes.first&.id inbox_id = unit.account.inboxes.first&.id
raise 'Inbox not found' unless inbox_id raise 'Inbox not found' unless inbox_id
conversation = ::Conversation.create!( conversation = ::Conversation.create!(
account_id: unit.account_id, account_id: unit.account_id,
inbox_id: inbox_id, inbox_id: inbox_id,
contact_id: contact.id, contact_id: contact.id,
contact_inbox_id: ::ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox_id)&.id, contact_inbox_id: ::ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox_id)&.id,
status: :open status: :open
) )
# B. Create Reservation # B. Create Reservation
@reservation = ::Captain::Reservation.create!( @reservation = ::Captain::Reservation.create!(
account_id: unit.account_id, account_id: unit.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
contact_id: contact.id, contact_id: contact.id,
contact_inbox_id: conversation.contact_inbox_id, contact_inbox_id: conversation.contact_inbox_id,
conversation_id: conversation.id, conversation_id: conversation.id,
captain_brand_id: unit.captain_brand_id, captain_brand_id: unit.captain_brand_id,
captain_unit_id: unit.id, captain_unit_id: unit.id,
suite_identifier: params[:selected_category] || 'Standard', suite_identifier: params[:selected_category] || 'Standard',
check_in_at: params[:check_in_at], check_in_at: params[:check_in_at],
check_out_at: params[:check_out_at], check_out_at: params[:check_out_at],
total_amount: params[:total_value] || params[:total_amount], total_amount: params[:total_value] || params[:total_amount],
status: :pending_payment, status: :pending_payment,
payment_status: :pending, payment_status: :pending,
metadata: { metadata: {
observacao: params[:observacao] observacao: params[:observacao]
} }
) )
# C. Generate PIX (Real or Mock) # C. Generate PIX (Real or Mock)
pix_result = generate_pix(unit, reservation_params) pix_result = generate_pix(unit, reservation_params)
raise "Pix Generation Failed: #{pix_result[:error]}" unless pix_result[:success] raise "Pix Generation Failed: #{pix_result[:error]}" unless pix_result[:success]
# D. Save Pix Charge # D. Save Pix Charge
::Captain::PixCharge.create!( ::Captain::PixCharge.create!(
reservation_id: @reservation.id, reservation_id: @reservation.id,
unit_id: unit.id, unit_id: unit.id,
txid: pix_result[:txid], txid: pix_result[:txid],
pix_copia_e_cola: pix_result[:pix_copy_paste], pix_copia_e_cola: pix_result[:pix_copy_paste],
status: 'active', # Means created/waiting status: 'active', # Means created/waiting
raw_webhook_payload: {}, # Will be filled by webhook later raw_webhook_payload: {}, # Will be filled by webhook later
paid_at: nil paid_at: nil
) )
Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted." Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted."
render_success(pix_result, @reservation.id) render_success(pix_result, @reservation.id)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.error "❌ Failed to create reservation: #{e.message}" Rails.logger.error "❌ Failed to create reservation: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
def status def status
reservation = ::Captain::Reservation.find_by(id: params[:id]) reservation = ::Captain::Reservation.find_by(id: params[:id])
if reservation if reservation
render json: { render json: {
id: reservation.id, id: reservation.id,
status: reservation.status, status: reservation.status,
payment_status: reservation.payment_status payment_status: reservation.payment_status
}, status: :ok }, status: :ok
else else
render json: { error: 'Not Found' }, status: :not_found render json: { error: 'Not Found' }, status: :not_found
end
end
private
def find_or_create_contact(params)
# Logic to reuse contact by email or phone
# For this context, we assume an Account Context is known (unit.account_id)
# Simplified for brevity. In a real scenario, use ContactBuilder.
account_id = ::Captain::Unit.find(params[:unit_id]).account_id
contact = ::Contact.find_by(email: params[:email], account_id: account_id)
contact ||= ::Contact.create!(
name: params[:contact_name] || params[:name],
email: params[:email],
phone_number: params[:phone],
account_id: account_id
)
# Ensure ContactInbox exists (required for Reservation model validation)
inbox = ::Inbox.where(account_id: account_id).first
if inbox && !::ContactInbox.exists?(contact_id: contact.id, inbox_id: inbox.id)
::ContactInbox.create!(contact: contact, inbox: inbox, source_id: contact.id)
end
contact
end
def generate_pix(unit, params)
if unit.inter_client_id.present?
service = ::Captain::InterService.new(
client_id: unit.inter_client_id,
client_secret: unit.inter_client_secret,
cert_path: unit.inter_cert_path,
key_path: unit.inter_key_path,
pix_key: unit.inter_pix_key,
account_number: unit.inter_account_number
)
service.create_pix_charge(params)
else
# Mock Return
{
success: true,
pix_copy_paste: 'MOCK_PIX_CODE_123',
qr_code_url: 'https://dummyimage.com/300x300/000/fff&text=MOCK+QR',
txid: "MOCK-#{SecureRandom.hex(10)}"
}
end
end
def unit
@unit ||= ::Captain::Unit.find(params[:unit_id])
end
def reservation_params
params.permit(
:brand_id, :unit_id, :contact_name, :phone_number, :email, :cpf,
:check_in_at, :check_out_at, :total_amount, :duration_minutes,
metadata: {}
)
end
def handle_standard_error(e)
Rails.logger.error '[Captain::Booking::Error] 🔥🔥 CRITICAL ERROR in ReservationsController 🔥🔥'
Rails.logger.error "[Captain::Booking::Error] Message: #{e.message}"
Rails.logger.error "[Captain::Booking::Error] Backtrace:\n#{e.backtrace.first(15).join("\n")}"
render json: { error: "Internal Server Error: #{e.message}", details: 'Check logs for Captain::Booking::Error' },
status: :internal_server_error
end
def render_success(data, reservation_id)
render json: {
success: true,
message: 'Reserva iniciada com sucesso.',
reservation_id: reservation_id,
metadata: {
pix: {
copyPasteCode: data[:pix_copy_paste],
qrCodeValue: data[:qr_code_url] || 'https://dummyimage.com/300x300/000/fff&text=QR+Code+Inter'
}
}
}, status: :ok
end
end
end
end end
end end
private
def find_or_create_contact(params)
# Logic to reuse contact by email or phone
# For this context, we assume an Account Context is known (unit.account_id)
# Simplified for brevity. In a real scenario, use ContactBuilder.
account_id = ::Captain::Unit.find(params[:unit_id]).account_id
contact = ::Contact.find_by(email: params[:email], account_id: account_id)
contact ||= ::Contact.create!(
name: params[:contact_name] || params[:name],
email: params[:email],
phone_number: params[:phone],
account_id: account_id
)
# Ensure ContactInbox exists (required for Reservation model validation)
inbox = ::Inbox.where(account_id: account_id).first
if inbox && !::ContactInbox.exists?(contact_id: contact.id, inbox_id: inbox.id)
::ContactInbox.create!(contact: contact, inbox: inbox, source_id: contact.id)
end
contact
end
def generate_pix(unit, params)
if unit.inter_client_id.present?
service = ::Captain::InterService.new(
client_id: unit.inter_client_id,
client_secret: unit.inter_client_secret,
cert_path: unit.inter_cert_path,
key_path: unit.inter_key_path,
pix_key: unit.inter_pix_key,
account_number: unit.inter_account_number
)
service.create_pix_charge(params)
else
# Mock Return
{
success: true,
pix_copy_paste: 'MOCK_PIX_CODE_123',
qr_code_url: 'https://dummyimage.com/300x300/000/fff&text=MOCK+QR',
txid: "MOCK-#{SecureRandom.hex(10)}"
}
end
end
def unit
@unit ||= ::Captain::Unit.find(params[:unit_id])
end
def reservation_params
params.permit(
:brand_id, :unit_id, :contact_name, :phone_number, :email, :cpf,
:check_in_at, :check_out_at, :total_amount, :duration_minutes,
metadata: {}
)
end
def handle_standard_error(e)
Rails.logger.error '[Captain::Booking::Error] 🔥🔥 CRITICAL ERROR in ReservationsController 🔥🔥'
Rails.logger.error "[Captain::Booking::Error] Message: #{e.message}"
Rails.logger.error "[Captain::Booking::Error] Backtrace:\n#{e.backtrace.first(15).join("\n")}"
render json: { error: "Internal Server Error: #{e.message}", details: 'Check logs for Captain::Booking::Error' },
status: :internal_server_error
end
def render_success(data, reservation_id)
render json: {
success: true,
message: 'Reserva iniciada com sucesso.',
reservation_id: reservation_id,
metadata: {
pix: {
copyPasteCode: data[:pix_copy_paste],
qrCodeValue: data[:qr_code_url] || 'https://dummyimage.com/300x300/000/fff&text=QR+Code+Inter'
}
}
}, status: :ok
end
end end

View File

@ -1,54 +1,46 @@
module Public class Public::Api::V1::Captain::WebhooksController < ActionController::API
module Api def inter_pix
module V1 Rails.logger.info "[Captain::InterWebhook] 🔔 Received Webhook: #{params.to_unsafe_h}"
module Captain
class WebhooksController < ActionController::API
def inter_pix
Rails.logger.info "[Captain::InterWebhook] 🔔 Received Webhook: #{params.to_unsafe_h}"
# Inter sends an array of "pix" objects # Inter sends an array of "pix" objects
pix_events = params[:pix] || [] pix_events = params[:pix] || []
pix_events.each do |event| pix_events.each do |event|
txid = event[:txid] txid = event[:txid]
next unless txid next unless txid
charge = ::Captain::PixCharge.find_by(txid: txid) charge = ::Captain::PixCharge.find_by(txid: txid)
if charge if charge
Rails.logger.info "[Captain::InterWebhook] ✅ Found Charge for TXID: #{txid}" Rails.logger.info "[Captain::InterWebhook] ✅ Found Charge for TXID: #{txid}"
# Update Charge # Update Charge
charge.update!( charge.update!(
status: 'paid', status: 'paid',
paid_at: Time.current, paid_at: Time.current,
raw_webhook_payload: event.to_json raw_webhook_payload: event.to_json
) )
# Update Reservation # Update Reservation
reservation = charge.reservation reservation = charge.reservation
if reservation if reservation
reservation.update!( reservation.update!(
status: :active, status: :active,
payment_status: :paid payment_status: :paid
) )
Rails.logger.info "[Captain::InterWebhook] 🏨 Reservation ##{reservation.id} Confirmed!" Rails.logger.info "[Captain::InterWebhook] 🏨 Reservation ##{reservation.id} Confirmed!"
# Trigger N8n Webhook # Trigger N8n Webhook
::Captain::WebhookSenderService.new(reservation).perform ::Captain::WebhookSenderService.new(reservation).perform
# Send WhatsApp Confirmation # Send WhatsApp Confirmation
::Captain::WhatsappNotificationService.new(reservation).perform ::Captain::WhatsappNotificationService.new(reservation).perform
end
else
Rails.logger.warn "[Captain::InterWebhook] ⚠️ Charge NOT found for TXID: #{txid}"
end
end
render json: { status: 'received' }, status: :ok
end
end end
else
Rails.logger.warn "[Captain::InterWebhook] ⚠️ Charge NOT found for TXID: #{txid}"
end end
end end
render json: { status: 'received' }, status: :ok
end end
end end

View File

@ -98,7 +98,8 @@ module Captain::ChatHelper
end end
def api_key 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? return nil if raw_key.blank?
# Sanitize: Remove common accidental suffixes like image names or whitespace # Sanitize: Remove common accidental suffixes like image names or whitespace

View File

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

View File

@ -1,87 +1,85 @@
module Captain class Captain::IntentClassificationJob < ApplicationJob
class IntentClassificationJob < ApplicationJob queue_as :default
queue_as :default
CATEGORIES = %w[ CATEGORIES = %w[
valores valores
disponibilidade disponibilidade
localizacao localizacao
checkin_checkout checkin_checkout
pet_friendly pet_friendly
cancelamento cancelamento
cafe_da_manha cafe_da_manha
estacionamento estacionamento
pagamento pagamento
outros outros
].freeze ].freeze
def perform(conversation_id, message_content) def perform(conversation_id, message_content)
conversation = Conversation.find_by(id: conversation_id) conversation = Conversation.find_by(id: conversation_id)
return unless conversation return unless conversation
# Prevent labeling if already labeled recently (optional optimization, skipping for MVP to ensure accuracy) # Prevent labeling if already labeled recently (optional optimization, skipping for MVP to ensure accuracy)
# For MVP, we classify every user message to capture the flow, or we could limit to the first few. # For MVP, we classify every user message to capture the flow, or we could limit to the first few.
# Let's classify every message that is substantial enough. # Let's classify every message that is substantial enough.
return if message_content.to_s.strip.length < 5 return if message_content.to_s.strip.length < 5
intent = classify_intent(message_content) intent = classify_intent(message_content)
return unless intent.present? && CATEGORIES.include?(intent) return unless intent.present? && CATEGORIES.include?(intent)
label_name = "duvida:#{intent}" label_name = "duvida:#{intent}"
# Add label if not present # Add label if not present
unless conversation.labels.exists?(name: label_name) unless conversation.labels.exists?(name: label_name)
conversation.labels << Label.find_or_create_by(title: label_name, account_id: conversation.account_id) conversation.labels << Label.find_or_create_by(title: label_name, account_id: conversation.account_id)
Rails.logger.info "[IntentClassification] Applied label '#{label_name}' to conversation #{conversation.id}" Rails.logger.info "[IntentClassification] Applied label '#{label_name}' to conversation #{conversation.id}"
end
rescue StandardError => e
Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}"
end end
rescue StandardError => e
Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}"
end
private private
def classify_intent(text) def classify_intent(text)
# We use a simple prompt for the LLM # We use a simple prompt for the LLM
prompt = <<~PROMPT prompt = <<~PROMPT
Classifique a mensagem do usuário em UMA das seguintes categorias: Classifique a mensagem do usuário em UMA das seguintes categorias:
#{CATEGORIES.join(', ')} #{CATEGORIES.join(', ')}
Se não se encaixar claramente, responda 'outros'. Se não se encaixar claramente, responda 'outros'.
Responda APENAS com o nome da categoria. Responda APENAS com o nome da categoria.
Mensagem: "#{text}" Mensagem: "#{text}"
PROMPT PROMPT
# Using the existing LLM infrastructure # Using the existing LLM infrastructure
# We create a temporary safe agent config or just use direct LLM call if possible. # We create a temporary safe agent config or just use direct LLM call if possible.
# Since we are inside Captain, we can try to use RubyLLM direct client if configured, # Since we are inside Captain, we can try to use RubyLLM direct client if configured,
# or fallback to the conversation's assistant if available. # or fallback to the conversation's assistant if available.
# For simplicity and robustness in this specific codebase context, let's use the OpenAI client wrapper directly # For simplicity and robustness in this specific codebase context, let's use the OpenAI client wrapper directly
# if available via the Agents gem or RubyLLM configuration already set up. # if available via the Agents gem or RubyLLM configuration already set up.
messages = [{ role: 'user', content: prompt }] messages = [{ role: 'user', content: prompt }]
# Robust API Key fetching for background jobs # Robust API Key fetching for background jobs
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
api_key ||= ENV.fetch('OPENAI_API_KEY', nil) api_key ||= ENV.fetch('OPENAI_API_KEY', nil)
# Strip eventual image suffixes if present (reuse sanitization logic) # Strip eventual image suffixes if present (reuse sanitization logic)
api_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip api_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
client = OpenAI::Client.new(access_token: api_key) client = OpenAI::Client.new(access_token: api_key)
response = client.chat( response = client.chat(
parameters: { parameters: {
model: 'gpt-4o-mini', # Cost effective model: 'gpt-4o-mini', # Cost effective
messages: messages, messages: messages,
temperature: 0.0, temperature: 0.0,
max_tokens: 10 max_tokens: 10
} }
) )
content = response.dig('choices', 0, 'message', 'content') content = response.dig('choices', 0, 'message', 'content')
content&.strip&.downcase content&.strip&.downcase
end
end end
end end

View File

@ -108,7 +108,7 @@ class Captain::Assistant < ApplicationRecord
next unless tool_class next unless tool_class
# Avoid duplicates if tool is already added (e.g. hardcoded ones) # 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) tools << tool_class.new(self, conversation: conversation, user: user)
end end

View File

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

View File

@ -1,21 +1,19 @@
module Captain class Captain::Reminder < ApplicationRecord
class Reminder < ApplicationRecord self.table_name = 'captain_reminders'
self.table_name = 'captain_reminders'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :conversation, optional: true belongs_to :conversation, optional: true
belongs_to :source, polymorphic: true, optional: true belongs_to :source, polymorphic: true, optional: true
enum status: { pending: 0, sent: 1, failed: 2, cancelled: 3 } enum status: { pending: 0, sent: 1, failed: 2, cancelled: 3 }
enum reminder_type: { manual: 0, automation: 1 } enum reminder_type: { manual: 0, automation: 1 }
validates :scheduled_at, presence: true validates :scheduled_at, presence: true
validates :message, presence: true validates :message, presence: true
scope :pending, -> { where(status: :pending) } scope :pending, -> { where(status: :pending) }
scope :due, -> { pending.where('scheduled_at <= ?', Time.current) } scope :due, -> { pending.where('scheduled_at <= ?', Time.current) }
end
end end

View File

@ -1,37 +1,35 @@
module Captain class Captain::Reservation < ApplicationRecord
class Reservation < ApplicationRecord self.table_name = 'captain_reservations'
self.table_name = 'captain_reservations'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :conversation, class_name: '::Conversation', optional: true belongs_to :conversation, class_name: '::Conversation', optional: true
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
has_many :reminders, class_name: 'Captain::Reminder', as: :source, dependent: :destroy has_many :reminders, class_name: 'Captain::Reminder', as: :source, dependent: :destroy
enum status: { scheduled: 0, active: 1, completed: 2, cancelled: 3, pending_payment: 4, draft: 5 } enum status: { scheduled: 0, active: 1, completed: 2, cancelled: 3, pending_payment: 4, draft: 5 }
enum payment_status: { pending: 'pending', paid: 'paid', failed: 'failed' }, _prefix: :payment enum payment_status: { pending: 'pending', paid: 'paid', failed: 'failed' }, _prefix: :payment
scope :filter_by_unit, ->(unit_id) { where(captain_unit_id: unit_id) if unit_id.present? } scope :filter_by_unit, ->(unit_id) { where(captain_unit_id: unit_id) if unit_id.present? }
scope :filter_by_status, ->(status) { where(status: status) if status.present? && status != 'all' } scope :filter_by_status, ->(status) { where(status: status) if status.present? && status != 'all' }
scope :filter_by_date_range, lambda { |from, to| scope :filter_by_date_range, lambda { |from, to|
if from.present? && to.present? if from.present? && to.present?
where(check_in_at: from..to) where(check_in_at: from..to)
elsif from.present? elsif from.present?
where('check_in_at >= ?', from) where('check_in_at >= ?', from)
elsif to.present? elsif to.present?
where('check_in_at <= ?', to) where('check_in_at <= ?', to)
end end
} }
validates :suite_identifier, presence: true validates :suite_identifier, presence: true
validates :check_in_at, presence: true validates :check_in_at, presence: true
validates :check_out_at, presence: true validates :check_out_at, presence: true
delegate :name, :email, :phone_number, to: :contact, prefix: true delegate :name, :email, :phone_number, to: :contact, prefix: true
end
end end

View File

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

View File

@ -1,15 +1,13 @@
module Captain class Captain::ToolConfig < ApplicationRecord
class ToolConfig < ApplicationRecord self.table_name = 'captain_tool_configs'
self.table_name = 'captain_tool_configs' belongs_to :account
belongs_to :account belongs_to :inbox, optional: true
belongs_to :inbox, optional: true belongs_to :captain_assistant, class_name: 'Captain::Assistant', optional: true
belongs_to :captain_assistant, class_name: 'Captain::Assistant', optional: true
validates :tool_key, presence: true validates :tool_key, presence: true
# Ensure tool config is either linked to an inbox or an assistant # Ensure tool config is either linked to an inbox or an assistant
validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? } 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, :inbox_id] }, if: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? } validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? }
end
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 # [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}" 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) reaction_response = check_and_react_to_message(message_to_process)
return reaction_response if reaction_response return reaction_response if reaction_response
@ -41,7 +41,7 @@ class Captain::Assistant::AgentRunnerService
runner = Agents::Runner.with_agents(*agents) runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any? 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 # Use assistant's API key if present, otherwise fallback to global config
result = with_assistant_api_key do result = with_assistant_api_key do
@ -89,7 +89,7 @@ class Captain::Assistant::AgentRunnerService
end end
Rails.logger.info "[Captain V2] Reaction Pre-Check: Text='#{text}' Category=#{matched_category}" 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 if matched_category
Rails.logger.info "[Captain V2] Detected #{matched_category}. Executing ReactToMessageTool directly." 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 # 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 } last_user_index = message_history.rindex { |msg| msg[:role] == 'user' || msg[:role] == :user }
filtered_history = if last_user_index 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 else
message_history message_history
end end
@ -328,7 +328,7 @@ class Captain::Assistant::AgentRunnerService
def sanitize_global_api_key def sanitize_global_api_key
# Force sanitization of the global gem config just in case it's dirty # 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) 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 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 } Agents.configure { |config| config.openai_api_key = sanitized_key }

View File

@ -1,105 +1,103 @@
# frozen_string_literal: true # frozen_string_literal: true
module Captain class Captain::HandoffWebhookService
class HandoffWebhookService def initialize(conversation:, assistant:, handoff_context: {})
def initialize(conversation:, assistant:, handoff_context: {}) @conversation = conversation
@conversation = conversation @assistant = assistant
@assistant = assistant @handoff_context = handoff_context
@handoff_context = handoff_context end
def deliver
return unless webhook_enabled?
webhook_config = @assistant.handoff_webhook_config
url = webhook_config['url']
headers = build_headers(webhook_config['headers'] || {})
payload = build_payload
Rails.logger.info "[HandoffWebhook] Sending to #{url} for conversation #{@conversation.id}"
response = Faraday.post(url, payload.to_json, headers) do |req|
req.options.timeout = webhook_config['timeout_seconds'] || 5
end end
def deliver log_success(response)
return unless webhook_enabled? rescue StandardError => e
log_failure(e)
# Enqueue retry if configured
retry_if_needed(e) if should_retry?
end
webhook_config = @assistant.handoff_webhook_config private
url = webhook_config['url']
headers = build_headers(webhook_config['headers'] || {})
payload = build_payload def webhook_enabled?
config = @assistant.handoff_webhook_config
config.present? && config['enabled'] == true && config['url'].present?
end
Rails.logger.info "[HandoffWebhook] Sending to #{url} for conversation #{@conversation.id}" def build_payload
{
event: 'conversation.handoff',
timestamp: Time.zone.now.iso8601,
conversation: conversation_data,
contact: contact_data,
handoff_context: @handoff_context,
assistant: assistant_data
}
end
response = Faraday.post(url, payload.to_json, headers) do |req| def conversation_data
req.options.timeout = webhook_config['timeout_seconds'] || 5 {
end id: @conversation.id,
display_id: @conversation.display_id,
status: @conversation.status,
inbox_id: @conversation.inbox_id,
assignee_id: @conversation.assignee_id,
team_id: @conversation.team_id
}
end
log_success(response) def contact_data
rescue StandardError => e contact = @conversation.contact
log_failure(e) {
# Enqueue retry if configured id: contact.id,
retry_if_needed(e) if should_retry? name: contact.name,
end phone_number: contact.phone_number,
email: contact.email,
additional_attributes: contact.additional_attributes
}
end
private def assistant_data
{
id: @assistant.id,
name: @assistant.name
}
end
def webhook_enabled? def build_headers(custom_headers)
config = @assistant.handoff_webhook_config {
config.present? && config['enabled'] == true && config['url'].present? 'Content-Type' => 'application/json',
end 'User-Agent' => 'Chatwoot-Captain/1.0'
}.merge(custom_headers)
end
def build_payload def should_retry?
{ (@assistant.handoff_webhook_config['retry_attempts'] || 0).positive?
event: 'conversation.handoff', end
timestamp: Time.zone.now.iso8601,
conversation: conversation_data,
contact: contact_data,
handoff_context: @handoff_context,
assistant: assistant_data
}
end
def conversation_data def retry_if_needed(error)
{ # TODO: Implement retry logic with Sidekiq in future PR
id: @conversation.id, Rails.logger.warn "[HandoffWebhook] Retry needed but not implemented yet: #{error.message}"
display_id: @conversation.display_id, end
status: @conversation.status,
inbox_id: @conversation.inbox_id,
assignee_id: @conversation.assignee_id,
team_id: @conversation.team_id
}
end
def contact_data def log_success(response)
contact = @conversation.contact Rails.logger.info "[HandoffWebhook] Success: #{response.status} for conversation #{@conversation.id}"
{ end
id: contact.id,
name: contact.name,
phone_number: contact.phone_number,
email: contact.email,
additional_attributes: contact.additional_attributes
}
end
def assistant_data def log_failure(error)
{ Rails.logger.error "[HandoffWebhook] Failed for conversation #{@conversation.id}: #{error.message}"
id: @assistant.id, ChatwootExceptionTracker.new(error, account: @conversation.account).capture_exception
name: @assistant.name
}
end
def build_headers(custom_headers)
{
'Content-Type' => 'application/json',
'User-Agent' => 'Chatwoot-Captain/1.0'
}.merge(custom_headers)
end
def should_retry?
(@assistant.handoff_webhook_config['retry_attempts'] || 0) > 0
end
def retry_if_needed(error)
# TODO: Implement retry logic with Sidekiq in future PR
Rails.logger.warn "[HandoffWebhook] Retry needed but not implemented yet: #{error.message}"
end
def log_success(response)
Rails.logger.info "[HandoffWebhook] Success: #{response.status} for conversation #{@conversation.id}"
end
def log_failure(error)
Rails.logger.error "[HandoffWebhook] Failed for conversation #{@conversation.id}: #{error.message}"
ChatwootExceptionTracker.new(error, account: @conversation.account).capture_exception
end
end end
end end

View File

@ -1,61 +1,57 @@
require 'faraday' require 'faraday'
module Captain class Captain::Inter::AuthService
module Inter API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
class AuthService TOKEN_URL = '/oauth/v2/token'.freeze
API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
TOKEN_URL = '/oauth/v2/token'.freeze
def initialize(unit) def initialize(unit)
@unit = unit @unit = unit
end end
def token def token
cached_token = Redis::Alfred.get(cache_key) cached_token = Redis::Alfred.get(cache_key)
return cached_token if cached_token.present? return cached_token if cached_token.present?
fetch_new_token fetch_new_token
end end
private private
def cache_key def cache_key
"inter_token:unit_#{@unit.id}" "inter_token:unit_#{@unit.id}"
end end
def fetch_new_token def fetch_new_token
raise "Unit #{@unit.name} is inactive" unless @unit.active? raise "Unit #{@unit.name} is inactive" unless @unit.active?
response = connection.post(TOKEN_URL) do |req| response = connection.post(TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded' req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
# Inter requires Basic Auth with ClientID:ClientSecret # Inter requires Basic Auth with ClientID:ClientSecret
req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@unit.inter_client_id}:#{@unit.inter_client_secret}")}" req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@unit.inter_client_id}:#{@unit.inter_client_secret}")}"
req.body = URI.encode_www_form({ req.body = URI.encode_www_form({
grant_type: 'client_credentials', grant_type: 'client_credentials',
scope: 'cob.write cob.read pix.write pix.read webhook.read webhook.write' scope: 'cob.write cob.read pix.write pix.read webhook.read webhook.write'
}) })
end end
raise "Auth Failed: #{response.body}" unless response.success? raise "Auth Failed: #{response.body}" unless response.success?
data = JSON.parse(response.body) data = JSON.parse(response.body)
access_token = data['access_token'] access_token = data['access_token']
expires_in = data['expires_in'].to_i expires_in = data['expires_in'].to_i
# Cache with safety margin (5 min less than expiry) # Cache with safety margin (5 min less than expiry)
Redis::Alfred.setex(cache_key, access_token, expires_in - 300) Redis::Alfred.setex(cache_key, access_token, expires_in - 300)
access_token access_token
end end
def connection def connection
@connection ||= Faraday.new(url: API_BASE_URL) do |conn| @connection ||= Faraday.new(url: API_BASE_URL) do |conn|
conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path))
conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path))
conn.adapter Faraday.default_adapter conn.adapter Faraday.default_adapter
end
end
end end
end end
end end

View File

@ -1,90 +1,86 @@
module Captain class Captain::Inter::CobService
module Inter API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
class CobService
API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze
def initialize(reservation) def initialize(reservation)
@reservation = reservation @reservation = reservation
@unit = reservation.unit @unit = reservation.unit
end end
def call 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 token = AuthService.new(@unit).token
# Determine TxId: Inter allows sending or they generate. # Determine TxId: Inter allows sending or they generate.
# Strategically, letting Inter generate is safer for avoidance of collision, # Strategically, letting Inter generate is safer for avoidance of collision,
# but we need to store it. # but we need to store it.
# If we want to send, it must be 26-35 characters. # If we want to send, it must be 26-35 characters.
payload = build_payload payload = build_payload
response = connection(token).post('/pix/v2/cob', payload.to_json) response = connection(token).post('/pix/v2/cob', payload.to_json)
raise "Pix Creation Failed: #{response.body}" unless response.success? raise "Pix Creation Failed: #{response.body}" unless response.success?
# Ensure safe encoding for logging # Ensure safe encoding for logging
safe_body = response.body.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') safe_body = response.body.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
data = JSON.parse(safe_body) data = JSON.parse(safe_body)
# [CRITICAL DEBUG] Log the ENTIRE response to see why it's being cut # [CRITICAL DEBUG] Log the ENTIRE response to see why it's being cut
Rails.logger.info "[BANCO INTER] FULL RESPONSE: #{safe_body}" Rails.logger.info "[BANCO INTER] FULL RESPONSE: #{safe_body}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| 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 end
persist_charge(data) persist_charge(data)
end end
private private
def build_payload def build_payload
amount = @reservation.total_amount.to_f.round(2) amount = @reservation.total_amount.to_f.round(2)
{ {
calendario: { expiracao: Captain::PixCharge::EXPIRATION_SECONDS }, # 1 hour calendario: { expiracao: Captain::PixCharge::EXPIRATION_SECONDS }, # 1 hour
devedor: { devedor: {
cpf: @reservation.contact.custom_attributes['cpf'] || '00000000000', # Fallback for dev/testing cpf: @reservation.contact.custom_attributes['cpf'] || '00000000000', # Fallback for dev/testing
nome: @reservation.contact.name || 'Cliente' nome: @reservation.contact.name || 'Cliente'
}, },
valor: { original: format('%.2f', amount) }, valor: { original: format('%.2f', amount) },
chave: @unit.inter_pix_key, chave: @unit.inter_pix_key,
solicitacaoPagador: "Reserva #{@reservation.id}" solicitacaoPagador: "Reserva #{@reservation.id}"
} }
end end
def persist_charge(data) def persist_charge(data)
# Try every possible field where Inter might hide the EMV code # Try every possible field where Inter might hide the EMV code
pix_code = data['pixCopiaECola'] || pix_code = data['pixCopiaECola'] ||
data.dig('pix', 'copiaECola') || data.dig('pix', 'copiaECola') ||
data['qrcode'] || data['qrcode'] ||
data['textoImagemQRcode'] data['textoImagemQRcode']
charge = @unit.pix_charges.create!( charge = @unit.pix_charges.create!(
reservation: @reservation, reservation: @reservation,
txid: data['txid'], txid: data['txid'],
pix_copia_e_cola: pix_code, pix_copia_e_cola: pix_code,
status: 'active', status: 'active',
e2eid: nil, e2eid: nil,
raw_webhook_payload: data.to_json raw_webhook_payload: data.to_json
) )
@reservation.update!(current_pix_charge_id: charge.id) @reservation.update!(current_pix_charge_id: charge.id)
charge charge
end end
def connection(token) def connection(token)
Faraday.new(url: API_BASE_URL) do |conn| Faraday.new(url: API_BASE_URL) do |conn|
conn.headers['Authorization'] = "Bearer #{token}" conn.headers['Authorization'] = "Bearer #{token}"
conn.headers['Content-Type'] = 'application/json' conn.headers['Content-Type'] = 'application/json'
conn.headers['x-conta-corrente'] = @unit.inter_account_number conn.headers['x-conta-corrente'] = @unit.inter_account_number
conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path))
conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path))
conn.adapter Faraday.default_adapter conn.adapter Faraday.default_adapter
end
end
end end
end end
end end

View File

@ -43,7 +43,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
# 3. Handle Tool Strategy # 3. Handle Tool Strategy
if brain_decision.strategy == :execute_tool 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 inbox = @conversation.inbox
@ -57,7 +57,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
additional_data: { message: additional_message, tool_input: brain_decision.tool_input } 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? 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) def normalize_name(name)
parts = name.split parts = name.split
parts.shift if parts.first&.downcase.in?(%w[a o]) parts.shift if parts.first&.downcase.in?(%w[a o])
parts.map { |part| part.capitalize }.join(' ') parts.map(&:capitalize).join(' ')
end end
end end

View File

@ -1,383 +1,379 @@
module Captain class Captain::Llm::JasmineBrain
module Llm Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
class JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
def self.decide(assistant:, conversation:, message:, history:) def self.decide(assistant:, conversation:, message:, history:)
new(assistant, conversation, message, history).decide new(assistant, conversation, message, history).decide
end
def initialize(assistant, conversation, message, history)
@assistant = assistant
@conversation = conversation
@message = message.to_s
@history = history
@contact = conversation.contact
end
def decide
Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}"
# 1. Gate: Check if AI is disabled for this contact
return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label?
# [FEATURE] React to thank you messages and emojis FIRST
# This takes priority over sticky scenarios
reaction_decision = check_thank_you_or_emoji
if reaction_decision
Rails.logger.info "[JasmineBrain] Short-circuiting to REACTION: #{reaction_decision.inspect}"
return reaction_decision
end
sticky_decision = sticky_decision_for_message
if sticky_decision
Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}"
return sticky_decision
end
# [FEATURE] Dynamic Routing (Self-Service)
# Verifica se algum Agente (Scenario) tem palavras-chave (Gatilhos) que batem com a mensagem.
# Se sim, faz o roteamento imediato (Short-Circuit) sem consultar o LLM.
dynamic_decision = check_dynamic_triggers
return dynamic_decision if dynamic_decision
llm_decision = ask_brain_for_classification
if llm_decision
decision = Decision.new(
strategy: llm_decision['strategy'].to_s.downcase.to_sym,
tool_key: llm_decision['tool_key'],
reasoning: llm_decision['reasoning'],
tool_input: llm_decision['tool_input']
)
log_decision(decision)
return decision if decision.strategy == :execute_tool && decision.tool_key.present?
end
if feature_faq_enabled? && question_like?(@message) && !reservation_like?(@message)
log_decision(Decision.new(strategy: :execute_tool, tool_key: 'faq_lookup', reasoning: 'FAQ fallback', tool_input: { query: @message }))
return Decision.new(
strategy: :execute_tool,
tool_key: 'faq_lookup',
reasoning: 'Fallback FAQ lookup for general question with feature_faq enabled.',
tool_input: { query: @message }
)
end
decision = Decision.new(strategy: :direct, reasoning: llm_decision ? 'Direct fallback' : 'LLM Classification Failed')
log_decision(decision)
decision
rescue StandardError => e
Rails.logger.error "[JasmineBrain] Error in decision: #{e.message}"
Decision.new(strategy: :direct, reasoning: "Error: #{e.message}")
end
private
def contact_has_disabled_label?
@conversation.labels.exists?(name: 'desligar_ia')
rescue StandardError
false
end
def sticky_scenario_active?
return false unless @conversation.respond_to?(:active_scenario_key)
expires_at = @conversation.active_scenario_expires_at
if expires_at.present? && expires_at < Time.current
clear_sticky_session
return false
end
@conversation.active_scenario_key.present?
end
def exit_keyword?(message)
text = message.to_s.downcase
return false if text.blank?
exit_patterns = [
/\b(cancelar|sair|parar|desistir|reiniciar|comecar de novo|resetar)\b/i,
/\b(falar com (humano|atendente|pessoa))\b/i,
/\b(tchau|adeus|ate logo)\b/i
]
exit_patterns.any? { |pattern| text.match?(pattern) }
end
def clear_sticky_session
@conversation.update!(
active_scenario_key: nil,
active_scenario_expires_at: nil,
active_scenario_state: {}
)
# [DEEP CLEAN] Wipe any lingering Jasmine state or cached tool results
if @conversation.custom_attributes.present?
new_attrs = @conversation.custom_attributes.except('jasmine_state', 'last_availability')
@conversation.update!(custom_attributes: new_attrs)
end
Rails.logger.info "[JasmineBrain] Session DEEP CLEANED for Conversation ##{@conversation.id}"
rescue StandardError => e
Rails.logger.warn "[JasmineBrain] Failed to clear sticky session: #{e.message}"
end
def log_decision(decision)
payload = {
service: 'JasmineBrain',
conversation_id: @conversation&.id,
account_id: @conversation&.account_id,
decision_strategy: decision.strategy,
tool_key: decision.tool_key,
active_scenario_key: @conversation&.active_scenario_key,
scenario_stage: @conversation&.active_scenario_state&.dig('stage'),
message_length: @message.to_s.length,
timestamp: Time.current.iso8601
}
Rails.logger.info payload.to_json
rescue StandardError => e
Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}"
end
def ask_brain_for_classification
system_prompt = build_classification_prompt
model = @assistant.try(:llm_model).presence || 'gpt-4o-mini'
chat = RubyLLM.chat(model: model)
chat = chat.with_params(
response_format: { type: 'json_object' },
temperature: 0.1
)
chat.add_message({ role: 'system', content: system_prompt })
if @history.is_a?(Array)
@history.each do |msg|
content = msg[:content]
next if content.blank?
chat.add_message({ role: msg[:role], content: content })
end end
end
def initialize(assistant, conversation, message, history) raw_response = chat.ask(@message)
@assistant = assistant parse_json(raw_response)
@conversation = conversation end
@message = message.to_s
@history = history
@contact = conversation.contact
end
def decide def build_classification_prompt
Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}" # Carregamos as ferramentas e cenários dinamicamente do assistente
# Incluímos as ferramentas básicas e os "Cenários" (que são ScenarioDelegatorTool)
available_tools = @assistant.agent_tools(conversation: @conversation, user: nil)
# 1. Gate: Check if AI is disabled for this contact tools_list = available_tools.map do |tool|
return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label? tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
tool_desc = tool.respond_to?(:description) ? tool.description : ''
tool_params = tool_parameters_schema_for_prompt(tool)
params_text = tool_params.present? ? "\n params_schema: #{tool_params.to_json}" : ''
"- #{tool_name}: #{tool_desc}#{params_text}"
end.join("\n")
# [FEATURE] React to thank you messages and emojis FIRST <<~PROMPT
# This takes priority over sticky scenarios You are Jasmine, the Brain of the operation.
reaction_decision = check_thank_you_or_emoji Your goal is to classify the user's intent based on their latest message and decide the action strategy.
if reaction_decision
Rails.logger.info "[JasmineBrain] Short-circuiting to REACTION: #{reaction_decision.inspect}"
return reaction_decision
end
sticky_decision = sticky_decision_for_message AVAILABLE INTENTS (TOOLS):
if sticky_decision #{tools_list}
Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}" - direct: For general conversation, doubts unrelated to specific tools, or if unsure.
return sticky_decision
end
# [FEATURE] Dynamic Routing (Self-Service) IMPORTANT:
# Verifica se algum Agente (Scenario) tem palavras-chave (Gatilhos) que batem com a mensagem. - If the user greets (oi/ola/bom dia/boa tarde/boa noite) and no other request exists, ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "😀"}.
# Se sim, faz o roteamento imediato (Short-Circuit) sem consultar o LLM. - If the user asks to keep an eye on something (acompanhar, ficar de olho, monitorar), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"}.
dynamic_decision = check_dynamic_triggers - If the user asks about reservations or availability (reserva, reservar, disponibilidade), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement.
return dynamic_decision if dynamic_decision - If the user asks about research/search (pesquisa, pesquisar, buscar, procura), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement.
- If the user sends a THANK YOU message ("obrigado", "obrigada", "valeu", "agradeço", "muito obrigado", "agradecido") -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user sends ONLY an emoji (🙏, 👍, , etc) -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user wants to check availability or make a reservation but did NOT mention a specific suite name (Stilo, Alexa, Hidro, Master), you MUST use "direct" strategy and ask: "Qual suíte você prefere?" or "Para qual suíte?". Do NOT guess the suite.
- If the user's request matches one of the specialized departments (scenarios) above, use that tool.
- Do NOT trigger "escalar_humano" for greeting messages or simple questions.
- Only use "escalar_humano" if the user is explicitly requesting a human or is angry.
- If the list of AVAILABLE INTENTS (TOOLS) above is empty, ALWAYS use "direct".
- Only choose "execute_tool" when you can fill the required tool_input fields from the user's message or recent context.
- If required fields are missing, use "direct" and ask for the missing info.
- For scenario tools (consultar_*), set tool_input to {"pergunta_interna": "<resumo do pedido do cliente>"}.
- For faq_lookup, set tool_input to {"query": "<pergunta do cliente>"}.
llm_decision = ask_brain_for_classification Output MUST be a valid JSON object with:
if llm_decision {
decision = Decision.new( "strategy": "execute_tool" OR "direct",
strategy: llm_decision['strategy'].to_s.downcase.to_sym, "tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL",
tool_key: llm_decision['tool_key'], "tool_input": { "key": "value" } OR null,
reasoning: llm_decision['reasoning'], "reasoning": "A brief explanation of why you chose this intent."
tool_input: llm_decision['tool_input'] }
)
log_decision(decision) Example:
User: "Tem vaga agora?"
JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "tool_input": null, "reasoning": "User asked about vacancy."}
return decision if decision.strategy == :execute_tool && decision.tool_key.present? Example:
end User: "Muito obrigado!"
JSON: {"strategy": "execute_tool", "tool_key": "react_to_message", "tool_input": {"emoji": "❤️"}, "reasoning": "User sent a thank you message."}
PROMPT
end
if feature_faq_enabled? && question_like?(@message) && !reservation_like?(@message) def parse_json(response_obj)
log_decision(Decision.new(strategy: :execute_tool, tool_key: 'faq_lookup', reasoning: 'FAQ fallback', tool_input: { query: @message })) content = response_obj.respond_to?(:content) ? response_obj.content : response_obj.to_s
return Decision.new( # Attempt to clean code blocks if present (common with some LLMs)
strategy: :execute_tool, clean_content = content.gsub(/^```json\s*|```$/, '').strip
tool_key: 'faq_lookup', JSON.parse(clean_content)
reasoning: 'Fallback FAQ lookup for general question with feature_faq enabled.', rescue JSON::ParserError
tool_input: { query: @message } Rails.logger.warn "[JasmineBrain] Failed to parse JSON: #{content}"
) nil
end end
decision = Decision.new(strategy: :direct, reasoning: llm_decision ? 'Direct fallback' : 'LLM Classification Failed') def sticky_decision_for_message
log_decision(decision) return nil unless sticky_scenario_active?
decision
rescue StandardError => e
Rails.logger.error "[JasmineBrain] Error in decision: #{e.message}"
Decision.new(strategy: :direct, reasoning: "Error: #{e.message}")
end
private if exit_keyword?(@message)
Rails.logger.info '[JasmineBrain] EXIT KEYWORD DETECTED. Clearing session.'
clear_sticky_session
return Decision.new(
strategy: :direct,
reasoning: 'User requested reset/exit',
tool_input: nil
)
end
def contact_has_disabled_label? Decision.new(
@conversation.labels.exists?(name: 'desligar_ia') strategy: :execute_tool,
rescue StandardError tool_key: @conversation.active_scenario_key,
false reasoning: 'Sticky scenario active',
end tool_input: { pergunta_interna: @message }
)
end
def sticky_scenario_active? def feature_faq_enabled?
return false unless @conversation.respond_to?(:active_scenario_key) @assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true
end
expires_at = @conversation.active_scenario_expires_at def check_thank_you_or_emoji
if expires_at.present? && expires_at < Time.current raw_msg = @message
clear_sticky_session raw_msg = raw_msg.content if raw_msg.respond_to?(:content)
return false text = raw_msg.to_s.strip.downcase
end
@conversation.active_scenario_key.present? # Patterns for thank you messages
end thank_you_patterns = [
/\b(obrigad[oa]|obrigad[oa]s)\b/i,
/\b(valeu)\b/i,
/\b(agradeço|agradecid[oa])\b/i,
/\b(muito obrigad[oa])\b/i,
/\b(brigadão|brigadao|brigadinha)\b/i,
/\b(grat[oa]|gratidão|gratidao)\b/i
]
def exit_keyword?(message) # Check if message is ONLY emoji(s)
text = message.to_s.downcase only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u)
return false if text.blank?
exit_patterns = [ if thank_you_patterns.any? { |pattern| text.match?(pattern) } || only_emoji
/\b(cancelar|sair|parar|desistir|reiniciar|comecar de novo|resetar)\b/i, Rails.logger.info '[JasmineBrain] Detected thank you or emoji, triggering react_to_message'
/\b(falar com (humano|atendente|pessoa))\b/i, return Decision.new(
/\b(tchau|adeus|ate logo)\b/i strategy: :execute_tool,
] tool_key: 'react_to_message',
reasoning: 'Thank you or emoji detected - short-circuit to react_to_message',
tool_input: { emoji: '❤️' }
)
end
exit_patterns.any? { |pattern| text.match?(pattern) } nil
end end
def clear_sticky_session def question_like?(message)
@conversation.update!( text = message.to_s.strip.downcase
active_scenario_key: nil, return false if text.empty?
active_scenario_expires_at: nil,
active_scenario_state: {}
)
# [DEEP CLEAN] Wipe any lingering Jasmine state or cached tool results text.end_with?('?') ||
if @conversation.custom_attributes.present? text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/)
new_attrs = @conversation.custom_attributes.except('jasmine_state', 'last_availability') end
@conversation.update!(custom_attributes: new_attrs)
end
Rails.logger.info "[JasmineBrain] Session DEEP CLEANED for Conversation ##{@conversation.id}" def reservation_like?(message)
rescue StandardError => e text = message.to_s.downcase
Rails.logger.warn "[JasmineBrain] Failed to clear sticky session: #{e.message}" keywords = %w[
end reserv agendar agendamento suite pernoite diaria
check-in checkin entrada saida horario amanha hoje
]
def log_decision(decision) keywords.any? { |keyword| text.include?(keyword) }
payload = { end
service: 'JasmineBrain',
conversation_id: @conversation&.id,
account_id: @conversation&.account_id,
decision_strategy: decision.strategy,
tool_key: decision.tool_key,
active_scenario_key: @conversation&.active_scenario_key,
scenario_stage: @conversation&.active_scenario_state&.dig('stage'),
message_length: @message.to_s.length,
timestamp: Time.current.iso8601
}
Rails.logger.info payload.to_json def strong_reservation_intent?(message)
rescue StandardError => e text = message.to_s.downcase
Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}" return false if text.blank?
end
def ask_brain_for_classification # Padroes que indicam vontade explicita de reservar, nao apenas duvida
system_prompt = build_classification_prompt patterns = [
model = @assistant.try(:llm_model).presence || 'gpt-4o-mini' /quero reservar/i,
/gostaria de reservar/i,
/fazer (uma )?reserva/i,
/para o dia \d+/i,
/pro dia \d+/i,
/reservar para/i,
/tem vaga (para|pra)/i
]
chat = RubyLLM.chat(model: model) patterns.any? { |pattern| text.match?(pattern) }
chat = chat.with_params( end
response_format: { type: 'json_object' },
temperature: 0.1
)
chat.add_message({ role: 'system', content: system_prompt }) def check_dynamic_triggers
return nil if @message.blank?
if @history.is_a?(Array) # Carrega cenarios ativos que tenham palavras-chave definidas
@history.each do |msg| scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, ''])
content = msg[:content]
next if content.blank?
chat.add_message({ role: msg[:role], content: content }) text = @message.downcase.strip
end
end
raw_response = chat.ask(@message) scenarios.each do |scenario|
parse_json(raw_response) # trigger_keywords eh text no banco, separado por virgula
end keywords = scenario.trigger_keywords.to_s.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
def build_classification_prompt # Verifica se alguma palavra-chave esta contida na mensagem
# Carregamos as ferramentas e cenários dinamicamente do assistente match = keywords.find { |kw| text.include?(kw) }
# Incluímos as ferramentas básicas e os "Cenários" (que são ScenarioDelegatorTool)
available_tools = @assistant.agent_tools(conversation: @conversation, user: nil)
tools_list = available_tools.map do |tool| next unless match
tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
tool_desc = tool.respond_to?(:description) ? tool.description : ''
tool_params = tool_parameters_schema_for_prompt(tool)
params_text = tool_params.present? ? "\n params_schema: #{tool_params.to_json}" : ''
"- #{tool_name}: #{tool_desc}#{params_text}"
end.join("\n")
<<~PROMPT tool_key = "consultar_#{scenario.title.parameterize.underscore}"
You are Jasmine, the Brain of the operation. Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})"
Your goal is to classify the user's intent based on their latest message and decide the action strategy.
AVAILABLE INTENTS (TOOLS): return Decision.new(
#{tools_list} strategy: :execute_tool,
- direct: For general conversation, doubts unrelated to specific tools, or if unsure. tool_key: tool_key,
reasoning: "Dynamic Trigger matched keyword: '#{match}'",
tool_input: { pergunta_interna: @message }
)
end
IMPORTANT: nil
- If the user greets (oi/ola/bom dia/boa tarde/boa noite) and no other request exists, ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "😀"}. end
- If the user asks to keep an eye on something (acompanhar, ficar de olho, monitorar), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"}.
- If the user asks about reservations or availability (reserva, reservar, disponibilidade), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement.
- If the user asks about research/search (pesquisa, pesquisar, buscar, procura), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement.
- If the user sends a THANK YOU message ("obrigado", "obrigada", "valeu", "agradeço", "muito obrigado", "agradecido") -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user sends ONLY an emoji (🙏, 👍, , etc) -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}.
- If the user wants to check availability or make a reservation but did NOT mention a specific suite name (Stilo, Alexa, Hidro, Master), you MUST use "direct" strategy and ask: "Qual suíte você prefere?" or "Para qual suíte?". Do NOT guess the suite.
- If the user's request matches one of the specialized departments (scenarios) above, use that tool.
- Do NOT trigger "escalar_humano" for greeting messages or simple questions.
- Only use "escalar_humano" if the user is explicitly requesting a human or is angry.
- If the list of AVAILABLE INTENTS (TOOLS) above is empty, ALWAYS use "direct".
- Only choose "execute_tool" when you can fill the required tool_input fields from the user's message or recent context.
- If required fields are missing, use "direct" and ask for the missing info.
- For scenario tools (consultar_*), set tool_input to {"pergunta_interna": "<resumo do pedido do cliente>"}.
- For faq_lookup, set tool_input to {"query": "<pergunta do cliente>"}.
Output MUST be a valid JSON object with: def tool_parameters_schema_for_prompt(tool)
return tool.tool_parameters_schema if tool.respond_to?(:tool_parameters_schema)
return nil unless tool.respond_to?(:parameters)
params = tool.parameters
return nil unless params.is_a?(Hash)
if params.values.all? { |param| param.respond_to?(:type) }
{
type: 'object',
properties: params.transform_values do |param|
{ {
"strategy": "execute_tool" OR "direct", type: param.type,
"tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL", description: param.description
"tool_input": { "key": "value" } OR null, }.compact
"reasoning": "A brief explanation of why you chose this intent." end,
} required: params.select { |_, param| param.required }.keys
}
Example: else
User: "Tem vaga agora?" params
JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "tool_input": null, "reasoning": "User asked about vacancy."}
Example:
User: "Muito obrigado!"
JSON: {"strategy": "execute_tool", "tool_key": "react_to_message", "tool_input": {"emoji": "❤️"}, "reasoning": "User sent a thank you message."}
PROMPT
end
def parse_json(response_obj)
content = response_obj.respond_to?(:content) ? response_obj.content : response_obj.to_s
# Attempt to clean code blocks if present (common with some LLMs)
clean_content = content.gsub(/^```json\s*|```$/, '').strip
JSON.parse(clean_content)
rescue JSON::ParserError
Rails.logger.warn "[JasmineBrain] Failed to parse JSON: #{content}"
nil
end
def sticky_decision_for_message
return nil unless sticky_scenario_active?
if exit_keyword?(@message)
Rails.logger.info '[JasmineBrain] EXIT KEYWORD DETECTED. Clearing session.'
clear_sticky_session
return Decision.new(
strategy: :direct,
reasoning: 'User requested reset/exit',
tool_input: nil
)
end
Decision.new(
strategy: :execute_tool,
tool_key: @conversation.active_scenario_key,
reasoning: 'Sticky scenario active',
tool_input: { pergunta_interna: @message }
)
end
def feature_faq_enabled?
@assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true
end
def check_thank_you_or_emoji
raw_msg = @message
raw_msg = raw_msg.content if raw_msg.respond_to?(:content)
text = raw_msg.to_s.strip.downcase
# Patterns for thank you messages
thank_you_patterns = [
/\b(obrigad[oa]|obrigad[oa]s)\b/i,
/\b(valeu)\b/i,
/\b(agradeço|agradecid[oa])\b/i,
/\b(muito obrigad[oa])\b/i,
/\b(brigadão|brigadao|brigadinha)\b/i,
/\b(grat[oa]|gratidão|gratidao)\b/i
]
# Check if message is ONLY emoji(s)
only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u)
if thank_you_patterns.any? { |pattern| text.match?(pattern) } || only_emoji
Rails.logger.info '[JasmineBrain] Detected thank you or emoji, triggering react_to_message'
return Decision.new(
strategy: :execute_tool,
tool_key: 'react_to_message',
reasoning: 'Thank you or emoji detected - short-circuit to react_to_message',
tool_input: { emoji: '❤️' }
)
end
nil
end
def question_like?(message)
text = message.to_s.strip.downcase
return false if text.empty?
text.end_with?('?') ||
text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/)
end
def reservation_like?(message)
text = message.to_s.downcase
keywords = %w[
reserv agendar agendamento suite pernoite diaria
check-in checkin entrada saida horario amanha hoje
]
keywords.any? { |keyword| text.include?(keyword) }
end
def strong_reservation_intent?(message)
text = message.to_s.downcase
return false if text.blank?
# Padroes que indicam vontade explicita de reservar, nao apenas duvida
patterns = [
/quero reservar/i,
/gostaria de reservar/i,
/fazer (uma )?reserva/i,
/para o dia \d+/i,
/pro dia \d+/i,
/reservar para/i,
/tem vaga (para|pra)/i
]
patterns.any? { |pattern| text.match?(pattern) }
end
def check_dynamic_triggers
return nil if @message.blank?
# Carrega cenarios ativos que tenham palavras-chave definidas
scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, ''])
text = @message.downcase.strip
scenarios.each do |scenario|
# trigger_keywords eh text no banco, separado por virgula
keywords = scenario.trigger_keywords.to_s.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
# Verifica se alguma palavra-chave esta contida na mensagem
match = keywords.find { |kw| text.include?(kw) }
next unless match
tool_key = "consultar_#{scenario.title.parameterize.underscore}"
Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})"
return Decision.new(
strategy: :execute_tool,
tool_key: tool_key,
reasoning: "Dynamic Trigger matched keyword: '#{match}'",
tool_input: { pergunta_interna: @message }
)
end
nil
end
def tool_parameters_schema_for_prompt(tool)
return tool.tool_parameters_schema if tool.respond_to?(:tool_parameters_schema)
return nil unless tool.respond_to?(:parameters)
params = tool.parameters
return nil unless params.is_a?(Hash)
if params.values.all? { |param| param.respond_to?(:type) }
{
type: 'object',
properties: params.transform_values do |param|
{
type: param.type,
description: param.description
}.compact
end,
required: params.select { |_, param| param.required }.keys
}
else
params
end
end
end end
end end
end end

View File

@ -86,7 +86,6 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {}) def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation'] citation_guidelines = if config['feature_citation']
<<~CITATION_TEXT <<~CITATION_TEXT
@ -149,7 +148,6 @@ class Captain::Llm::SystemPromptsService
#{available_tools} #{available_tools}
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end
# rubocop:enable Metrics/MethodLength
def assistant_response_generator(assistant_name, product_name, config = {}) def assistant_response_generator(assistant_name, product_name, config = {})
json_instruction = <<~JSON_INSTRUCTION json_instruction = <<~JSON_INSTRUCTION
@ -258,7 +256,7 @@ class Captain::Llm::SystemPromptsService
end end
def assistant_prompt_from_blocks(blocks) def assistant_prompt_from_blocks(blocks)
Array(blocks).map do |block| Array(blocks).filter_map do |block|
title = block['title'] || block[:title] title = block['title'] || block[:title]
content = block['content'] || block[:content] content = block['content'] || block[:content]
next if title.to_s.strip.empty? && content.to_s.strip.empty? next if title.to_s.strip.empty? && content.to_s.strip.empty?
@ -268,7 +266,7 @@ class Captain::Llm::SystemPromptsService
else else
"[#{title}]\n#{content}".strip "[#{title}]\n#{content}".strip
end end
end.compact.join("\n\n") end.join("\n\n")
end end
def paginated_faq_generator(start_page, end_page, language = 'english') 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 inbox = conversation&.inbox || find_inbox
contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox) contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox)
raise ArgumentError, 'Inbox not found' unless inbox.present? raise ArgumentError, 'Inbox not found' if inbox.blank?
raise ArgumentError, 'Contact not found' unless contact_inbox&.contact.present? raise ArgumentError, 'Contact not found' if contact_inbox&.contact.blank?
schedule_time = parse_time(params[:scheduled_at]) schedule_time = parse_time(params[:scheduled_at])
raise ArgumentError, 'Scheduled time is required' unless schedule_time raise ArgumentError, 'Scheduled time is required' unless schedule_time

View File

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

View File

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

View File

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

View File

@ -1,465 +1,459 @@
module Captain class Captain::Tools::CheckAvailabilityTool < BaseTool
module Tools def name
class CheckAvailabilityTool < BaseTool 'check_availability'
def name end
'check_availability'
end
def description def description
'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.' 'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.'
end end
def tool_parameters_schema def tool_parameters_schema
{ {
type: 'object', type: 'object',
properties: { properties: {
suite: { suite: {
type: 'string', type: 'string',
description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)' description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)'
}, },
duration: { duration: {
type: 'integer', type: 'integer',
description: 'Duração em horas (padrão: 1)' description: 'Duração em horas (padrão: 1)'
}, },
date: { date: {
type: 'string', type: 'string',
description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)' description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)'
}, },
check_in_at: { check_in_at: {
type: 'string', type: 'string',
description: 'Data/hora desejada para o check-in (ISO ou texto livre)' description: 'Data/hora desejada para o check-in (ISO ou texto livre)'
}
},
required: %w[suite]
} }
},
required: %w[suite]
}
end
def execute(*args, **params)
actual_params = resolve_params(args, params)
account_id = @conversation&.account_id || @assistant&.account_id
Rails.logger.info "[CheckAvailabilityTool] STARTING with params: #{actual_params}"
Rails.logger.debug { "[CheckAvailabilityTool] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}" }
Rails.logger.debug { "[CheckAvailabilityTool] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}" }
suite_category = actual_params[:suite]
requested_duration = actual_params[:duration].presence # Don't default yet
if suite_category.blank?
return "Por favor, pergunte ao cliente: 'Qual suíte você prefere (Stilo, Alexa ou Hidro) e por quanto tempo gostaria de ficar?'."
end
ensure_conversation_context!
# ... (Anti-Hallucination logic remains same) ...
# [DATE RESOLUTION]
target_date = resolve_target_date(actual_params)
Rails.logger.info "[CheckAvailabilityTool] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}"
# [DEBUG] Log the context
current_inbox_id = @conversation&.inbox_id
# [KEYWORD SEARCH]
# 1. First, find if the term matches any Brand suite_keywords or suite_categories
account_brands = Captain::Brand.where(account_id: account_id)
# Try to find a category that matches the input directly (case insensitive)
matched_category = nil
normalized_input = suite_category.to_s.strip.downcase
# Iterate over brands to find a match in categories or keywords
account_brands.find_each do |brand|
# Check direct category name match
found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input }
if found_cat
matched_category = found_cat
break
end end
def execute(*args, **params) # Check keywords match
actual_params = resolve_params(args, params) # suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" }
account_id = @conversation&.account_id || @assistant&.account_id brand.suite_keywords&.each do |cat_name, keywords_str|
next if keywords_str.blank?
Rails.logger.info "[CheckAvailabilityTool] STARTING with params: #{actual_params}" keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip)
Rails.logger.debug { "[CheckAvailabilityTool] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}" } if keywords_list.any? { |kw| normalized_input.include?(kw) }
Rails.logger.debug { "[CheckAvailabilityTool] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}" } matched_category = cat_name
break
suite_category = actual_params[:suite]
requested_duration = actual_params[:duration].presence # Don't default yet
if suite_category.blank?
return "Por favor, pergunte ao cliente: 'Qual suíte você prefere (Stilo, Alexa ou Hidro) e por quanto tempo gostaria de ficar?'."
end end
end
break if matched_category
end
ensure_conversation_context! # Use the matched category if found, otherwise stick to the original input (fallback)
final_suite_category = matched_category || suite_category
# ... (Anti-Hallucination logic remains same) ... Rails.logger.info "[CheckAvailabilityTool] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'"
# [DATE RESOLUTION] pricing_scope = Captain::Pricing.where(account_id: account_id)
target_date = resolve_target_date(actual_params) .where('suite_category ILIKE ?', final_suite_category)
Rails.logger.info "[CheckAvailabilityTool] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}"
# [DEBUG] Log the context # [INBOX PRIORITY] Filter by Current Inbox > Global
current_inbox_id = @conversation&.inbox_id current_inbox_id = @conversation&.inbox_id
# [KEYWORD SEARCH] pricing_scope = if current_inbox_id.present?
# 1. First, find if the term matches any Brand suite_keywords or suite_categories # STRICT MODE: Only fetch prices for THIS specific inbox.
account_brands = Captain::Brand.where(account_id: account_id) # Supports both legacy (inbox_id column) and new (has_many through join table)
pricing_scope.left_joins(:inboxes)
.where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id)
.distinct
else
# No Context (Playground/Test): Global Only
pricing_scope.where(inbox_id: nil)
end
# Try to find a category that matches the input directly (case insensitive) # Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil)
matched_category = nil # This implements the "Override" behavior.
pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 }
normalized_input = suite_category.to_s.strip.downcase pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date
# Iterate over brands to find a match in categories or keywords # [AUTO-MENU MODE] If duration is missing, return all options
account_brands.find_each do |brand| if requested_duration.blank?
# Check direct category name match available_options = pricing_scope.map do |p|
found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input } "#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
if found_cat end.join(', ')
matched_category = found_cat
break
end
# Check keywords match if available_options.present?
# suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" } 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."
brand.suite_keywords&.each do |cat_name, keywords_str| Rails.logger.info "[CheckAvailabilityTool] MENU MODE: #{msg}"
next if keywords_str.blank? else
msg = "Não encontrei tarifas para a suíte #{final_suite_category} nesta data. Confirme o nome da suíte."
end
return msg
end
keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip) pricing = pick_pricing_for_duration(pricing_scope, requested_duration)
if keywords_list.any? { |kw| normalized_input.include?(kw) }
matched_category = cat_name
break
end
end
break if matched_category
end
# Use the matched category if found, otherwise stick to the original input (fallback) if pricing
final_suite_category = matched_category || suite_category final_price = pricing.price.to_f
msg = "Disponível! A Suíte #{final_suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency(
final_price, unit: 'R$ ', separator: ',', delimiter: '.'
)} (#{pricing.day_range})."
persist_last_availability(final_suite_category, requested_duration, pricing, target_date)
Rails.logger.info "[CheckAvailabilityTool] SUCCESS: #{msg}"
else
available_options = pricing_scope.map do |p|
"#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
end.join(', ')
Rails.logger.info "[CheckAvailabilityTool] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'" if available_options.present?
msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{final_suite_category}: #{available_options}. Pergunte qual ele prefere."
pricing_scope = Captain::Pricing.where(account_id: account_id) else
.where('suite_category ILIKE ?', final_suite_category) 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."
# [INBOX PRIORITY] Filter by Current Inbox > Global
current_inbox_id = @conversation&.inbox_id
pricing_scope = if current_inbox_id.present?
# STRICT MODE: Only fetch prices for THIS specific inbox.
# Supports both legacy (inbox_id column) and new (has_many through join table)
pricing_scope.left_joins(:inboxes)
.where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id)
.distinct
else
# No Context (Playground/Test): Global Only
pricing_scope.where(inbox_id: nil)
end
# Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil)
# This implements the "Override" behavior.
pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 }
pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date
# [AUTO-MENU MODE] If duration is missing, return all options
if requested_duration.blank?
available_options = pricing_scope.map do |p|
"#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}"
end.join(', ')
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
end
pricing = pick_pricing_for_duration(pricing_scope, requested_duration)
if pricing
final_price = pricing.price.to_f
msg = "Disponível! A Suíte #{final_suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency(
final_price, unit: 'R$ ', separator: ',', delimiter: '.'
)} (#{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: '.')}"
end.join(', ')
if available_options.present?
msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{final_suite_category}: #{available_options}. Pergunte qual ele prefere."
else
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
end
rescue StandardError => e
Rails.logger.error "[CheckAvailabilityTool] CRITICAL ERROR: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
raise e
end end
private 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
end
# Helper to ensure we have a conversation object private
def ensure_conversation_context!
return if @conversation.present? # Helper to ensure we have a conversation object
def ensure_conversation_context!
return if @conversation.present?
end
def resolve_account_id(conversation, assistant)
conversation&.account_id || assistant&.account_id
end
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def persist_last_availability(suite_category, duration, pricing, target_date)
return unless @conversation
@conversation.custom_attributes ||= {}
price_value = pricing&.price&.to_f
@conversation.custom_attributes['last_availability'] = {
suite: suite_category,
duration: duration,
price: price_value,
day_range: pricing.day_range,
date: target_date&.iso8601,
captured_at: Time.zone.now.iso8601
}
@conversation.save!
update_sticky_state(suite_category, duration, pricing, target_date)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}"
end
def update_sticky_state(suite_category, duration, pricing, target_date)
return unless @conversation.respond_to?(:active_scenario_state)
state = @conversation.active_scenario_state || {}
price_value = pricing&.price&.to_f
collected = (state['collected'] || {}).merge(
'suite' => suite_category,
'duration' => duration,
'date' => target_date&.iso8601
).compact
last_tool_results = (state['last_tool_results'] || {}).merge(
'check_availability' => {
'suite' => suite_category,
'duration' => duration,
'price' => price_value,
'day_range' => pricing.day_range,
'date' => target_date&.iso8601,
'captured_at' => Time.zone.now.iso8601
}
)
@conversation.update!(
active_scenario_state: state.merge(
'stage' => 'availability_checked',
'collected' => collected,
'last_tool_results' => last_tool_results,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to update sticky state: #{e.message}"
end
def pick_pricing_for_duration(scope, requested_duration)
pricings = scope.to_a
return pricings.first if requested_duration.blank? # Se não pediu duração, qualquer uma serve
return nil if pricings.empty?
# Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3)
normalized_request = normalize_duration_input(requested_duration)
# 1. Tenta match exato pelo número normalizado
if normalized_request.is_a?(Integer)
matched = pricings.find do |pricing|
extract_duration_number(pricing.duration) == normalized_request
end end
return matched if matched
end
def resolve_account_id(conversation, assistant) # 2. Tenta match pelo texto normalizado (ex: "pernoite")
conversation&.account_id || assistant&.account_id requested_text = requested_duration.to_s.strip.downcase
end matched = pricings.find do |pricing|
p_dur = pricing.duration.to_s.strip.downcase
p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request
end
def infer_unit return matched if matched
@conversation&.inbox&.captain_inbox&.unit
end
def persist_last_availability(suite_category, duration, pricing, target_date) # [FIX] Strict Mode com Log:
return unless @conversation Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}"
nil
end
@conversation.custom_attributes ||= {} def normalize_duration_input(input)
price_value = pricing&.price&.to_f text = input.to_s.downcase.strip
@conversation.custom_attributes['last_availability'] = {
suite: suite_category,
duration: duration,
price: price_value,
day_range: pricing.day_range,
date: target_date&.iso8601,
captured_at: Time.zone.now.iso8601
}
@conversation.save!
update_sticky_state(suite_category, duration, pricing, target_date) # Mapa de extenso para números
rescue StandardError => e word_to_num = {
Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}" 'um' => 1, 'uma' => 1,
end 'dois' => 2, 'duas' => 2,
'tres' => 3, 'três' => 3,
'quatro' => 4,
'cinco' => 5,
'seis' => 6,
'doze' => 12
}
def update_sticky_state(suite_category, duration, pricing, target_date) # Verifica palavras por extenso
return unless @conversation.respond_to?(:active_scenario_state) word_to_num.each do |word, num|
return num if text.include?(word)
end
state = @conversation.active_scenario_state || {} # Verifica dígitos
price_value = pricing&.price&.to_f match = text.match(/(\d+)/)
collected = (state['collected'] || {}).merge( return match[1].to_i if match
'suite' => suite_category,
'duration' => duration,
'date' => target_date&.iso8601
).compact
last_tool_results = (state['last_tool_results'] || {}).merge( text # Retorna o texto original se não for número (ex: "pernoite")
'check_availability' => { end
'suite' => suite_category,
'duration' => duration,
'price' => price_value,
'day_range' => pricing.day_range,
'date' => target_date&.iso8601,
'captured_at' => Time.zone.now.iso8601
}
)
@conversation.update!( def extract_duration_number(value)
active_scenario_state: state.merge( return nil if value.blank?
'stage' => 'availability_checked',
'collected' => collected,
'last_tool_results' => last_tool_results,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to update sticky state: #{e.message}"
end
def pick_pricing_for_duration(scope, requested_duration) text = value.to_s.downcase
pricings = scope.to_a match = text.match(/(\d+)/)
return pricings.first if requested_duration.blank? # Se não pediu duração, qualquer uma serve match ? match[1].to_i : nil
return nil if pricings.empty? end
# Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3) def resolve_target_date(actual_params)
normalized_request = normalize_duration_input(requested_duration) date_text = actual_params[:date].presence || actual_params[:data].presence
check_in_at = actual_params[:check_in_at].presence
# 1. Tenta match exato pelo número normalizado # 1. Try to get date from param or history FIRST
if normalized_request.is_a?(Integer) base_date = parse_date_from_text(date_text) if date_text.present?
matched = pricings.find do |pricing| base_date ||= infer_date_from_history
extract_duration_number(pricing.duration) == normalized_request
end
return matched if matched
end
# 2. Tenta match pelo texto normalizado (ex: "pernoite") # 2. If we have a check_in_at time
requested_text = requested_duration.to_s.strip.downcase if check_in_at.present?
matched = pricings.find do |pricing| parsed_time = begin
p_dur = pricing.duration.to_s.strip.downcase Time.zone.parse(check_in_at.to_s)
p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request rescue StandardError
end
return matched if matched
# [FIX] Strict Mode com Log:
Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}"
nil nil
end end
if parsed_time
# If check_in_at is just a time (e.g. "21:00"), combine it with base_date
return base_date if base_date && check_in_at.to_s.length <= 5 # Likely just HH:MM
def normalize_duration_input(input) return parsed_time.to_date
text = input.to_s.downcase.strip
# Mapa de extenso para números
word_to_num = {
'um' => 1, 'uma' => 1,
'dois' => 2, 'duas' => 2,
'tres' => 3, 'três' => 3,
'quatro' => 4,
'cinco' => 5,
'seis' => 6,
'doze' => 12
}
# Verifica palavras por extenso
word_to_num.each do |word, num|
return num if text.include?(word)
end
# Verifica dígitos
match = text.match(/(\d+)/)
return match[1].to_i if match
text # Retorna o texto original se não for número (ex: "pernoite")
end
def extract_duration_number(value)
return nil if value.blank?
text = value.to_s.downcase
match = text.match(/(\d+)/)
match ? match[1].to_i : nil
end
def resolve_target_date(actual_params)
date_text = actual_params[:date].presence || actual_params[:data].presence
check_in_at = actual_params[:check_in_at].presence
# 1. Try to get date from param or history FIRST
base_date = parse_date_from_text(date_text) if date_text.present?
base_date ||= infer_date_from_history
# 2. If we have a check_in_at time
if check_in_at.present?
parsed_time = begin
Time.zone.parse(check_in_at.to_s)
rescue StandardError
nil
end
if parsed_time
# If check_in_at is just a time (e.g. "21:00"), combine it with base_date
return base_date if base_date && check_in_at.to_s.length <= 5 # Likely just HH:MM
return parsed_time.to_date
end
end
base_date || Time.zone.today
end
def infer_date_from_history
return nil unless @conversation
messages = @conversation.messages.incoming.order(created_at: :desc).limit(12).to_a
# [CRITICAL RESET FIX] If there is a reset in history, stop looking further back
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
if reset_msg
# Keep only messages after reset
messages = messages.take_while { |m| m.id != reset_msg.id }
end
messages.each do |message|
text = message.content.to_s
next if text.blank?
date = parse_date_from_text(text)
return date if date.present?
end
nil
end
def filter_pricings_by_day_range(scope, target_date)
return scope if target_date.blank?
target_wday = target_date.wday
pricings = scope.to_a
matched = pricings.select do |pricing|
day_range_matches_wday?(pricing.day_range, target_wday)
end
matched.any? ? matched : scope
end
def day_range_matches_wday?(day_range, wday)
return false if day_range.blank?
days = normalize_day_range(day_range)
days.include?(wday)
end
def normalize_day_range(day_range)
normalized = ActiveSupport::Inflector.transliterate(day_range.to_s).upcase
normalized = normalized.gsub(/\s+/, ' ').strip
mapping = {
'SEGUNDA' => 1,
'TERCA' => 2,
'QUARTA' => 3,
'QUINTA' => 4,
'SEXTA' => 5,
'SABADO' => 6,
'DOMINGO' => 0
}
if normalized.include?(' A ')
start_name, end_name = normalized.split(' A ').map(&:strip)
start_idx = mapping[start_name]
end_idx = mapping[end_name]
return [] if start_idx.nil? || end_idx.nil?
return (start_idx..end_idx).to_a if start_idx <= end_idx
return (start_idx..6).to_a + (0..end_idx).to_a
end
normalized.split(',').map(&:strip).map { |name| mapping[name] }.compact
end
def parse_date_from_text(text)
normalized = text.to_s.downcase
ascii = ActiveSupport::Inflector.transliterate(normalized)
return Time.zone.today + 2.days if normalized.include?('depois de amanha')
return Time.zone.today + 1.day if normalized.include?('amanha')
return Time.zone.today if normalized.include?('hoje')
if (match = normalized.match(%r{\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b}))
day = match[1].to_i
month = match[2].to_i
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
return Date.new(year, month, day)
end
months = {
'jan' => 1, 'janeiro' => 1,
'fev' => 2, 'fevereiro' => 2,
'mar' => 3, 'marco' => 3,
'abr' => 4, 'abril' => 4,
'mai' => 5, 'maio' => 5,
'jun' => 6, 'junho' => 6,
'jul' => 7, 'julho' => 7,
'ago' => 8, 'agosto' => 8,
'set' => 9, 'setembro' => 9,
'out' => 10, 'outubro' => 10,
'nov' => 11, 'novembro' => 11,
'dez' => 12, 'dezembro' => 12
}
month_pattern = months.keys.join('|')
if (match = ascii.match(/\b(?:dia\s*)?(\d{1,2})\s*(?:de\s*)?(#{month_pattern})(?:\s*(?:de\s*)?(\d{2,4}))?\b/))
day = match[1].to_i
month = months[match[2]]
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
date = Date.new(year, month, day)
date = date.next_year if match[3].blank? && date < Time.zone.today
return date
end
weekdays = {
'segunda' => 1,
'terca' => 2,
'terça' => 2,
'quarta' => 3,
'quinta' => 4,
'sexta' => 5,
'sabado' => 6,
'sábado' => 6,
'domingo' => 0
}
weekdays.each do |name, wday|
next unless normalized.include?(name)
today = Time.zone.today
days_ahead = (wday - today.wday) % 7
days_ahead = 7 if days_ahead.zero?
date = today + days_ahead.days
date += 7.days if normalized.include?('que vem')
return date
end
nil
end end
end end
base_date || Time.zone.today
end
def infer_date_from_history
return nil unless @conversation
messages = @conversation.messages.incoming.order(created_at: :desc).limit(12).to_a
# [CRITICAL RESET FIX] If there is a reset in history, stop looking further back
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
if reset_msg
# Keep only messages after reset
messages = messages.take_while { |m| m.id != reset_msg.id }
end
messages.each do |message|
text = message.content.to_s
next if text.blank?
date = parse_date_from_text(text)
return date if date.present?
end
nil
end
def filter_pricings_by_day_range(scope, target_date)
return scope if target_date.blank?
target_wday = target_date.wday
pricings = scope.to_a
matched = pricings.select do |pricing|
day_range_matches_wday?(pricing.day_range, target_wday)
end
matched.any? ? matched : scope
end
def day_range_matches_wday?(day_range, wday)
return false if day_range.blank?
days = normalize_day_range(day_range)
days.include?(wday)
end
def normalize_day_range(day_range)
normalized = ActiveSupport::Inflector.transliterate(day_range.to_s).upcase
normalized = normalized.gsub(/\s+/, ' ').strip
mapping = {
'SEGUNDA' => 1,
'TERCA' => 2,
'QUARTA' => 3,
'QUINTA' => 4,
'SEXTA' => 5,
'SABADO' => 6,
'DOMINGO' => 0
}
if normalized.include?(' A ')
start_name, end_name = normalized.split(' A ').map(&:strip)
start_idx = mapping[start_name]
end_idx = mapping[end_name]
return [] if start_idx.nil? || end_idx.nil?
return (start_idx..end_idx).to_a if start_idx <= end_idx
return (start_idx..6).to_a + (0..end_idx).to_a
end
normalized.split(',').map(&:strip).filter_map { |name| mapping[name] }
end
def parse_date_from_text(text)
normalized = text.to_s.downcase
ascii = ActiveSupport::Inflector.transliterate(normalized)
return Time.zone.today + 2.days if normalized.include?('depois de amanha')
return Time.zone.today + 1.day if normalized.include?('amanha')
return Time.zone.today if normalized.include?('hoje')
if (match = normalized.match(%r{\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b}))
day = match[1].to_i
month = match[2].to_i
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
return Date.new(year, month, day)
end
months = {
'jan' => 1, 'janeiro' => 1,
'fev' => 2, 'fevereiro' => 2,
'mar' => 3, 'marco' => 3,
'abr' => 4, 'abril' => 4,
'mai' => 5, 'maio' => 5,
'jun' => 6, 'junho' => 6,
'jul' => 7, 'julho' => 7,
'ago' => 8, 'agosto' => 8,
'set' => 9, 'setembro' => 9,
'out' => 10, 'outubro' => 10,
'nov' => 11, 'novembro' => 11,
'dez' => 12, 'dezembro' => 12
}
month_pattern = months.keys.join('|')
if (match = ascii.match(/\b(?:dia\s*)?(\d{1,2})\s*(?:de\s*)?(#{month_pattern})(?:\s*(?:de\s*)?(\d{2,4}))?\b/))
day = match[1].to_i
month = months[match[2]]
year = match[3].to_i
year += 2000 if year.positive? && year < 100
year = Time.zone.today.year if year.zero?
date = Date.new(year, month, day)
date = date.next_year if match[3].blank? && date < Time.zone.today
return date
end
weekdays = {
'segunda' => 1,
'terca' => 2,
'terça' => 2,
'quarta' => 3,
'quinta' => 4,
'sexta' => 5,
'sabado' => 6,
'sábado' => 6,
'domingo' => 0
}
weekdays.each do |name, wday|
next unless normalized.include?(name)
today = Time.zone.today
days_ahead = (wday - today.wday) % 7
days_ahead = 7 if days_ahead.zero?
date = today + days_ahead.days
date += 7.days if normalized.include?('que vem')
return date
end
nil
end end
end end

View File

@ -1,364 +1,361 @@
module Captain class Captain::Tools::CreateReservationIntentTool < BaseTool
module Tools def name
class CreateReservationIntentTool < BaseTool 'create_reservation_intent'
def name end
'create_reservation_intent'
end
def description def description
'Creates a draft reservation intent. Use this when the user agrees to a price/suite. Requires "suite" and "price" (decimal). Saves the intent so payment can be generated later.' 'Creates a draft reservation intent. Use this when the user agrees to a price/suite. Requires "suite" and "price" (decimal). Saves the intent so payment can be generated later.'
end end
def tool_parameters_schema def tool_parameters_schema
{ {
type: 'object', type: 'object',
properties: { properties: {
suite: { suite: {
type: 'string', type: 'string',
description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)' description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
}, },
price: { price: {
type: 'number', type: 'number',
description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0' description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
}, },
deposit_value: { deposit_value: {
type: 'number', type: 'number',
description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50' description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50'
}
},
required: %w[suite price]
} }
},
required: %w[suite price]
}
end
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.zone.now}] STARTING CreateReservationIntentTool with params: #{actual_params}"
end
suite_category = actual_params[:suite]
# Remove currency symbols and parse
price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
price = price_raw.to_f
deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
deposit_override = deposit_input.to_f if deposit_input.present?
last_availability = fetch_last_availability
if suite_category.blank? || price <= 0
inferred = infer_from_history
suite_category ||= inferred[:suite]
price = inferred[:price].to_f if price <= 0 && inferred[:price].present?
end
if (suite_category.blank? || price <= 0) && last_availability.present?
suite_category ||= last_availability[:suite]
price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present?
end
# ... (Validation Logic kept effectively same, just moved down) ...
# [ANTI-HALLUCINATION - REINICIAR BARRIER]
# Only scan messages AFTER the last 'reiniciar' command.
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)..] : 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]/, '')
# [ALIASES MAP]
aliases = {
'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem],
'stilo' => %w[stilo estilo],
'master' => %w[master],
'alexa' => %w[alexa]
}
suite_key = suite_category.to_s.downcase.strip
suite_key = ActiveSupport::Inflector.transliterate(suite_key)
valid_terms = aliases[suite_key] || [suite_key]
# Check if ANY of the valid terms is in the user text
match_found = valid_terms.any? do |term|
term_clean = ActiveSupport::Inflector.transliterate(term)
user_text_post_reset.include?(term_clean)
end
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
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.zone.now}] INTENT BLOCKED: Suite '#{suite_category}' (or aliases) not found after reset."
end 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
def execute(*args, **params) # Global Price Lock: If the price the agent wants to charge
actual_params = resolve_params(args, params) # is different from the VERY LAST availability check, block it.
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| # EXCEPTION: If explicit deposit_value is provided, we trust the agent knows what it's doing (e.g. promo/custom time)
f.puts "[#{Time.now}] STARTING CreateReservationIntentTool with params: #{actual_params}" if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
end last_availability[:price])
msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
suite_category = actual_params[:suite] price)}) diverge da última cotação (R$ #{format('%.2f',
# Remove currency symbols and parse last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.') File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
price = price_raw.to_f f.puts "[#{Time.zone.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}"
deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
deposit_override = deposit_input.to_f if deposit_input.present?
last_availability = fetch_last_availability
if suite_category.blank? || price <= 0
inferred = infer_from_history
suite_category ||= inferred[:suite]
price = inferred[:price].to_f if price <= 0 && inferred[:price].present?
end
if (suite_category.blank? || price <= 0) && last_availability.present?
suite_category ||= last_availability[:suite]
price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present?
end
# ... (Validation Logic kept effectively same, just moved down) ...
# [ANTI-HALLUCINATION - REINICIAR BARRIER]
# Only scan messages AFTER the last 'reiniciar' command.
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
user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase
user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '')
# [ALIASES MAP]
aliases = {
'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem],
'stilo' => %w[stilo estilo],
'master' => %w[master],
'alexa' => %w[alexa]
}
suite_key = suite_category.to_s.downcase.strip
suite_key = ActiveSupport::Inflector.transliterate(suite_key)
valid_terms = aliases[suite_key] || [suite_key]
# Check if ANY of the valid terms is in the user text
match_found = valid_terms.any? do |term|
term_clean = ActiveSupport::Inflector.transliterate(term)
user_text_post_reset.include?(term_clean)
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}"
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."
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
# 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])
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]}"
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}" }
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}" }
return msg
end
ensure_conversation_context!
unless @conversation && @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}" }
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}" }
return msg
end
check_in_at, check_out_at = resolve_check_in_and_out(actual_params)
# [IDEMPOTENCY CHECK]
# If a draft for this conversation, suite, and price was created < 5 mins ago, reuse it.
recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft)
.where('created_at > ?', 5.minutes.ago)
.where(suite_identifier: suite_category)
.order(created_at: :desc)
.first
# Determine Final Charge Amount
deposit_amount = if deposit_override && deposit_override > 0
deposit_override
else
price / 2.0
end
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}" }
return msg
end
# Cancel previous drafts to keep history clean (optional, but good for robust state)
Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled)
begin
Captain::Reservation.create!(
conversation_id: @conversation.id,
account: @conversation.account,
contact: @conversation.contact,
contact_inbox: @conversation.contact_inbox,
inbox: @conversation.inbox,
captain_unit_id: unit.id,
captain_brand_id: unit.captain_brand_id,
suite_identifier: suite_category,
status: :draft,
total_amount: deposit_amount, # Saving finalized amount
check_in_at: check_in_at,
check_out_at: check_out_at
)
update_sticky_state(
suite: suite_category,
price: deposit_amount,
check_in_at: check_in_at,
check_out_at: check_out_at
)
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}" }
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}" }
return "Erro técnico ao criar reserva: #{e.message}"
end
end end
return msg
end
private 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.zone.now}] RETURN: #{msg}" }
return msg
end
def same_suite?(s1, s2) if price <= 0
s1.to_s.downcase.strip == s2.to_s.downcase.strip msg = 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.'
end File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" }
return msg
end
def price_mismatch?(p1, p2) ensure_conversation_context!
(p1.to_f - p2.to_f).abs > 0.01
end
def resolve_check_in_and_out(params) unless @conversation&.inbox
c_in = params[:check_in_at] || params[:date] || params[:day] msg = "Erro Crítico: Contexto de conversa não disponível (Conversation/Inbox nil). Params: #{actual_params}"
c_out = params[:check_out_at] File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
return msg
end
# Try to parse check-in unit = infer_unit
begin unless unit
check_in = if c_in.present? msg = 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.'
Time.zone.parse(c_in.to_s) File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" }
return msg
end
check_in_at, check_out_at = resolve_check_in_and_out(actual_params)
# [IDEMPOTENCY CHECK]
# If a draft for this conversation, suite, and price was created < 5 mins ago, reuse it.
recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft)
.where('created_at > ?', 5.minutes.ago)
.where(suite_identifier: suite_category)
.order(created_at: :desc)
.first
# Determine Final Charge Amount
deposit_amount = if deposit_override&.positive?
deposit_override
else else
# Default to tomorrow if not specified price / 2.0
Time.zone.now.tomorrow.change(hour: 14)
end end
rescue StandardError
check_in = Time.zone.now.tomorrow.change(hour: 14)
end
# Try to parse check-out if recent_draft && (recent_draft.total_amount.to_f - deposit_amount).abs < 0.1
begin 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."
check_out = if c_out.present? File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" }
Time.zone.parse(c_out.to_s) return msg
else end
# Default to 12 hours after check-in if not specified
check_in + 12.hours
end
rescue StandardError
check_out = check_in + 12.hours
end
[check_in, check_out] # Cancel previous drafts to keep history clean (optional, but good for robust state)
end Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled)
def send_pix_message(pix_code) begin
return if pix_code.blank? Captain::Reservation.create!(
conversation_id: @conversation.id,
account: @conversation.account,
contact: @conversation.contact,
contact_inbox: @conversation.contact_inbox,
inbox: @conversation.inbox,
captain_unit_id: unit.id,
captain_brand_id: unit.captain_brand_id,
suite_identifier: suite_category,
status: :draft,
total_amount: deposit_amount, # Saving finalized amount
check_in_at: check_in_at,
check_out_at: check_out_at
)
@conversation.messages.create!( update_sticky_state(
content: pix_code, suite: suite_category,
message_type: :outgoing, price: deposit_amount,
account: @conversation.account, check_in_at: check_in_at,
inbox: @conversation.inbox, check_out_at: check_out_at
sender: @assistant )
)
end
# Helper to ensure we have a conversation object, even if passed differently msg = "Reserva iniciada com sucesso! O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount,
def ensure_conversation_context! 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."
# Se @conversation for nulo mas tivermos um ID nos params ou args, podemos tentar buscar File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] SUCCESS: #{msg}" }
return if @conversation.present? return msg
rescue StandardError => e
# Tentativa de fallback (ex: se o runner passar conversation_id via params) error_msg = "ERRO FATAL NA CRIACAO: #{e.message} | #{e.backtrace.first}"
# Implementação futura se necessário. Por enquanto, focamos em validar o que temos. File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] EXCEPTION: #{error_msg}" }
end return "Erro técnico ao criar reserva: #{e.message}"
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def update_sticky_state(suite:, price:, check_in_at:, check_out_at:)
return unless @conversation.respond_to?(:active_scenario_state)
state = @conversation.active_scenario_state || {}
collected = (state['collected'] || {}).merge(
'suite' => suite,
'price' => price,
'check_in_at' => check_in_at&.iso8601,
'check_out_at' => check_out_at&.iso8601
).compact
@conversation.update!(
active_scenario_state: state.merge(
'stage' => 'reservation_intent_created',
'collected' => collected,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}"
end
def fetch_last_availability
return nil unless @conversation
data = @conversation.custom_attributes&.fetch('last_availability', nil)
return nil unless data.is_a?(Hash)
# [FIX] Validade da Informação (TTL)
# Se a cotação tem mais de 4 horas, considere expirada.
# Isso força o agente a perguntar novamente em uma nova interação.
captured_at = data['captured_at']
return nil if captured_at.blank?
if Time.zone.parse(captured_at) < 4.hours.ago
Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)'
return nil
end
data.with_indifferent_access
end
def infer_from_history
return {} unless @conversation.present?
suite_candidates = available_suite_categories
# [FIX] Janela de Contexto Temporal
# Olha apenas as mensagens das últimas 4 horas.
# Se o cliente falou da suíte ontem, não assumimos que ele quer a mesma hoje.
messages = @conversation.messages
.where(private: false)
.where('created_at >= ?', 4.hours.ago)
.order(created_at: :desc)
.limit(20).to_a
# [CRITICAL RESET FIX] If there is a reset in history, stop looking further back
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
if reset_msg
# Keep only messages after reset
messages = messages.take_while { |m| m.id != reset_msg.id }
end
messages.each do |message|
content = message.content.to_s
suite = find_suite_in_text(content, suite_candidates)
price = extract_price_from_text(content)
return { suite: suite, price: price } if suite.present? || price.present?
end
{}
end
def available_suite_categories
unit = infer_unit
return %w[Stilo Master Hidromassagem] unless unit
Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq
end
def find_suite_in_text(content, suite_candidates)
return nil if content.blank?
suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) }
end
def extract_price_from_text(content)
return nil if content.blank?
match = content.match(/R\$\s*([\d\.]+,\d{2})/)
return nil unless match
match[1].tr('.', '').tr(',', '.').to_f
end
end end
end end
private
def same_suite?(s1, s2)
s1.to_s.downcase.strip == s2.to_s.downcase.strip
end
def price_mismatch?(p1, p2)
(p1.to_f - p2.to_f).abs > 0.01
end
def resolve_check_in_and_out(params)
c_in = params[:check_in_at] || params[:date] || params[:day]
c_out = params[:check_out_at]
# Try to parse check-in
begin
check_in = if c_in.present?
Time.zone.parse(c_in.to_s)
else
# Default to tomorrow if not specified
Time.zone.now.tomorrow.change(hour: 14)
end
rescue StandardError
check_in = Time.zone.now.tomorrow.change(hour: 14)
end
# Try to parse check-out
begin
check_out = if c_out.present?
Time.zone.parse(c_out.to_s)
else
# Default to 12 hours after check-in if not specified
check_in + 12.hours
end
rescue StandardError
check_out = check_in + 12.hours
end
[check_in, check_out]
end
def send_pix_message(pix_code)
return if pix_code.blank?
@conversation.messages.create!(
content: pix_code,
message_type: :outgoing,
account: @conversation.account,
inbox: @conversation.inbox,
sender: @assistant
)
end
# Helper to ensure we have a conversation object, even if passed differently
def ensure_conversation_context!
# Se @conversation for nulo mas tivermos um ID nos params ou args, podemos tentar buscar
return if @conversation.present?
# Tentativa de fallback (ex: se o runner passar conversation_id via params)
# Implementação futura se necessário. Por enquanto, focamos em validar o que temos.
end
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def update_sticky_state(suite:, price:, check_in_at:, check_out_at:)
return unless @conversation.respond_to?(:active_scenario_state)
state = @conversation.active_scenario_state || {}
collected = (state['collected'] || {}).merge(
'suite' => suite,
'price' => price,
'check_in_at' => check_in_at&.iso8601,
'check_out_at' => check_out_at&.iso8601
).compact
@conversation.update!(
active_scenario_state: state.merge(
'stage' => 'reservation_intent_created',
'collected' => collected,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}"
end
def fetch_last_availability
return nil unless @conversation
data = @conversation.custom_attributes&.fetch('last_availability', nil)
return nil unless data.is_a?(Hash)
# [FIX] Validade da Informação (TTL)
# Se a cotação tem mais de 4 horas, considere expirada.
# Isso força o agente a perguntar novamente em uma nova interação.
captured_at = data['captured_at']
return nil if captured_at.blank?
if Time.zone.parse(captured_at) < 4.hours.ago
Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)'
return nil
end
data.with_indifferent_access
end
def infer_from_history
return {} if @conversation.blank?
suite_candidates = available_suite_categories
# [FIX] Janela de Contexto Temporal
# Olha apenas as mensagens das últimas 4 horas.
# Se o cliente falou da suíte ontem, não assumimos que ele quer a mesma hoje.
messages = @conversation.messages
.where(private: false)
.where('created_at >= ?', 4.hours.ago)
.order(created_at: :desc)
.limit(20).to_a
# [CRITICAL RESET FIX] If there is a reset in history, stop looking further back
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
if reset_msg
# Keep only messages after reset
messages = messages.take_while { |m| m.id != reset_msg.id }
end
messages.each do |message|
content = message.content.to_s
suite = find_suite_in_text(content, suite_candidates)
price = extract_price_from_text(content)
return { suite: suite, price: price } if suite.present? || price.present?
end
{}
end
def available_suite_categories
unit = infer_unit
return %w[Stilo Master Hidromassagem] unless unit
Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq
end
def find_suite_in_text(content, suite_candidates)
return nil if content.blank?
suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) }
end
def extract_price_from_text(content)
return nil if content.blank?
match = content.match(/R\$\s*([\d\.]+,\d{2})/)
return nil unless match
match[1].tr('.', '').tr(',', '.').to_f
end
end end

View File

@ -1,58 +1,54 @@
module Captain class Captain::Tools::Definitions
module Tools ALL = {
class Definitions 'status_suites' => {
ALL = { type: :http,
'status_suites' => { method: :get,
type: :http, url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
method: :get, description: 'Check suite availability'
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', },
description: 'Check suite availability' 'maria_fotos' => {
}, type: :webhook,
'maria_fotos' => { description: 'Send photos via webhook'
type: :webhook, },
description: 'Send photos via webhook' 'escalar_humano' => {
}, type: :webhook,
'escalar_humano' => { description: 'Escalate to human agent'
type: :webhook, },
description: 'Escalate to human agent' 'react_to_message' => {
}, type: :internal,
'react_to_message' => { name: 'Reagir a Mensagens',
type: :internal, description: 'React to customer messages with emoji (👍, ❤️, 😊)'
name: 'Reagir a Mensagens', },
description: 'React to customer messages with emoji (👍, ❤️, 😊)' 'update_contact' => {
}, type: :internal,
'update_contact' => { name: 'Atualizar Contato',
type: :internal, description: 'Atualiza nome e CPF do contato atual'
name: 'Atualizar Contato', },
description: 'Atualiza nome e CPF do contato atual' 'check_availability' => {
}, type: :internal,
'check_availability' => { name: 'Consultar Disponibilidade',
type: :internal, description: 'Verifica preço e disponibilidade de suíte'
name: 'Consultar Disponibilidade', },
description: 'Verifica preço e disponibilidade de suíte' 'create_reservation_intent' => {
}, type: :internal,
'create_reservation_intent' => { name: 'Criar Intenção de Reserva',
type: :internal, description: 'Salva rascunho da reserva com valor acordado'
name: 'Criar Intenção de Reserva', },
description: 'Salva rascunho da reserva com valor acordado' 'generate_pix' => {
}, type: :internal,
'generate_pix' => { name: 'Gerar Pix (Finalizar)',
type: :internal, description: 'Gera Pix para a reserva em rascunho atual'
name: 'Gerar Pix (Finalizar)', },
description: 'Gera Pix para a reserva em rascunho atual' 'list_reservations' => {
}, type: :internal,
'list_reservations' => { name: 'Listar Reservas',
type: :internal, description: 'Lista as ultimas reservas do contato atual'
name: 'Listar Reservas', },
description: 'Lista as ultimas reservas do contato atual' 'faq_lookup' => {
}, type: :internal,
'faq_lookup' => { name: 'Pesquisar FAQ',
type: :internal, description: 'Busca respostas no FAQ por similaridade semantica',
name: 'Pesquisar FAQ', always_on: true
description: 'Busca respostas no FAQ por similaridade semantica', }
always_on: true }.freeze
}
}.freeze
end
end
end end

View File

@ -1,148 +1,144 @@
module Captain class Captain::Tools::GeneratePixTool < BaseTool
module Tools def name
class GeneratePixTool < BaseTool 'generate_pix'
def name end
'generate_pix'
end
def description def description
'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.' 'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.'
end end
def tool_parameters_schema def tool_parameters_schema
{ {
type: 'object', type: 'object',
properties: { properties: {
amount: { amount: {
type: 'number', type: 'number',
description: 'Opcional. Valor final exato para cobrar no Pix. Se informado, atualiza o valor da reserva antes de gerar.' description: 'Opcional. Valor final exato para cobrar no Pix. Se informado, atualiza o valor da reserva antes de gerar.'
}
}
} }
}
}
end
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.positive?
# ... (Validation Logic) ...
# 1. Validate Contact Info
contact = @conversation.contact
return error_response('Erro: CPF não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.custom_attributes['cpf'].blank?
return error_response('Erro: Nome não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.name.blank?
pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last
if pending
# [AMOUT CHECK]
# If an explicit amount was passed, but we found a pending charge with different amount,
# we should probably CANCEL/EXPIRE the old one and generate a new one.
if override_amount && (pending.total_amount.to_f - override_amount).abs > 0.1
Rails.logger.info "[GeneratePixTool] Montante mudou (#{pending.total_amount} -> #{override_amount}). Forçando novo Pix."
pending.update!(total_amount: override_amount)
# Expire old charges
Captain::PixCharge.where(reservation_id: pending.id).update_all(status: 'expired')
return generate_new_pix(pending, prefix: "Atualizei o valor para R$ #{format('%.2f', override_amount)}. Novo Pix abaixo:")
end end
def execute(*args, **params) charge = current_pix_charge_for(pending)
actual_params = resolve_params(args, params) if charge&.pix_copia_e_cola.present?
input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.') if charge.expired? || charge.expired_by_time?
override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f > 0 charge.update!(status: 'expired') unless charge.expired?
return generate_new_pix(pending, prefix: 'Pix expirado. Gerando um novo agora.')
# ... (Validation Logic) ...
# 1. Validate Contact Info
contact = @conversation.contact
return error_response('Erro: CPF não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.custom_attributes['cpf'].blank?
return error_response('Erro: Nome não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.name.blank?
pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last
if pending
# [AMOUT CHECK]
# If an explicit amount was passed, but we found a pending charge with different amount,
# we should probably CANCEL/EXPIRE the old one and generate a new one.
if override_amount && (pending.total_amount.to_f - override_amount).abs > 0.1
Rails.logger.info "[GeneratePixTool] Montante mudou (#{pending.total_amount} -> #{override_amount}). Forçando novo Pix."
pending.update!(total_amount: override_amount)
# Expire old charges
Captain::PixCharge.where(reservation_id: pending.id).update_all(status: 'expired')
return generate_new_pix(pending, prefix: "Atualizei o valor para R$ #{format('%.2f', override_amount)}. Novo Pix abaixo:")
end
charge = current_pix_charge_for(pending)
if charge&.pix_copia_e_cola.present?
if charge.expired? || charge.expired_by_time?
charge.update!(status: 'expired') unless charge.expired?
return generate_new_pix(pending, prefix: 'Pix expirado. Gerando um novo agora.')
end
return build_pix_response(charge, pending,
prefix: 'Pix ainda válido. Segue abaixo para pagamento:')
end
return generate_new_pix(pending, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.')
end end
reservation = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft') return build_pix_response(charge, pending,
.where('updated_at > ?', 2.hours.ago) prefix: 'Pix ainda válido. Segue abaixo para pagamento:')
.order(created_at: :desc)
.first
unless reservation
return error_response('Erro: Nenhuma intenção de reserva recente (últimas 2h) encontrada. Por favor, confirme a suíte e o valor novamente usando "Quero reservar".')
end
# [AMOUNT OVERRIDE]
if override_amount
Rails.logger.info "[GeneratePixTool] Atualizando valor da reserva #{reservation.id} de #{reservation.total_amount} para #{override_amount} antes de gerar Pix."
reservation.update!(total_amount: override_amount)
end
Rails.logger.info "[GeneratePixTool] Usando Reserva ID #{reservation.id} | Valor no Banco: #{reservation.total_amount}"
generate_new_pix(reservation)
end end
private return generate_new_pix(pending, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.')
def error_response(msg)
{ formatted_message: msg, success: false }
end
def current_pix_charge_for(reservation)
return nil unless reservation
return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge)
Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first
end
def generate_new_pix(reservation, prefix: nil)
service = Captain::Inter::CobService.new(reservation)
charge = service.call
# Force status update to make it visible in Admin Panel immediately
reservation.update!(status: 'pending_payment')
Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} movida para pending_payment"
final_prefix = prefix || 'Cobrança Pix gerada com sucesso.'
build_pix_response(charge, reservation, prefix: final_prefix)
rescue StandardError => e
Rails.logger.error "[GeneratePixTool] Falha ao gerar Pix: #{e.message}"
error_response('Erro técnico ao gerar o Pix. Por favor, tente novamente em alguns instantes.')
end
def build_pix_response(charge, reservation, prefix: nil)
intro = prefix.present? ? "#{prefix}\n\n" : ''
# [Architecture Update] One-Tap Copy Link (SGID)
# Fix: Ensure host is reachable (localhost instead of 0.0.0.0 for local dev) and instruct LLM to NOT use Markdown.
base_url = ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0')
token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s
link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url)
instructions = "Para facilitar, gerei um link de pagamento rápido com botão COPIAR.\n" \
"1. Clique no link abaixo\n" \
"2. Aperte 'COPIAR CÓDIGO PIX'\n" \
"3. Cole no app do seu banco e pague.\n\n" \
"Link Seguro: #{link}\n\n" \
'(IMPORTANTE PARA A I.A.: Envie este link EXATAMENTE como está acima. NÃO use formatação Markdown [texto](url) NEM coloque entre colchetes [url]. O WhatsApp não reconhece. Envie APENAS a URL pura, solta no texto.)'
# Fallback raw payload in case the user asks for it explicitly later (hidden from formatted msg by default now)
final_code = charge.pix_copia_e_cola.to_s.strip
if final_code.start_with?('/spi/')
header = '00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br'
final_code = "#{header}#{final_code}"
end
full_message = "#{intro}#{instructions}"
{
formatted_message: full_message,
raw_payload: final_code, # Kept for debugging/fallback
payment_link: link,
amount: reservation.total_amount.to_f,
reservation_id: reservation.id,
success: true
}
end
end end
reservation = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft')
.where('updated_at > ?', 2.hours.ago)
.order(created_at: :desc)
.first
unless reservation
return error_response('Erro: Nenhuma intenção de reserva recente (últimas 2h) encontrada. Por favor, confirme a suíte e o valor novamente usando "Quero reservar".')
end
# [AMOUNT OVERRIDE]
if override_amount
Rails.logger.info "[GeneratePixTool] Atualizando valor da reserva #{reservation.id} de #{reservation.total_amount} para #{override_amount} antes de gerar Pix."
reservation.update!(total_amount: override_amount)
end
Rails.logger.info "[GeneratePixTool] Usando Reserva ID #{reservation.id} | Valor no Banco: #{reservation.total_amount}"
generate_new_pix(reservation)
end
private
def error_response(msg)
{ formatted_message: msg, success: false }
end
def current_pix_charge_for(reservation)
return nil unless reservation
return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge)
Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first
end
def generate_new_pix(reservation, prefix: nil)
service = Captain::Inter::CobService.new(reservation)
charge = service.call
# Force status update to make it visible in Admin Panel immediately
reservation.update!(status: 'pending_payment')
Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} movida para pending_payment"
final_prefix = prefix || 'Cobrança Pix gerada com sucesso.'
build_pix_response(charge, reservation, prefix: final_prefix)
rescue StandardError => e
Rails.logger.error "[GeneratePixTool] Falha ao gerar Pix: #{e.message}"
error_response('Erro técnico ao gerar o Pix. Por favor, tente novamente em alguns instantes.')
end
def build_pix_response(charge, reservation, prefix: nil)
intro = prefix.present? ? "#{prefix}\n\n" : ''
# [Architecture Update] One-Tap Copy Link (SGID)
# Fix: Ensure host is reachable (localhost instead of 0.0.0.0 for local dev) and instruct LLM to NOT use Markdown.
base_url = ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0')
token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s
link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url)
instructions = "Para facilitar, gerei um link de pagamento rápido com botão COPIAR.\n" \
"1. Clique no link abaixo\n" \
"2. Aperte 'COPIAR CÓDIGO PIX'\n" \
"3. Cole no app do seu banco e pague.\n\n" \
"Link Seguro: #{link}\n\n" \
'(IMPORTANTE PARA A I.A.: Envie este link EXATAMENTE como está acima. NÃO use formatação Markdown [texto](url) NEM coloque entre colchetes [url]. O WhatsApp não reconhece. Envie APENAS a URL pura, solta no texto.)'
# Fallback raw payload in case the user asks for it explicitly later (hidden from formatted msg by default now)
final_code = charge.pix_copia_e_cola.to_s.strip
if final_code.start_with?('/spi/')
header = '00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br'
final_code = "#{header}#{final_code}"
end
full_message = "#{intro}#{instructions}"
{
formatted_message: full_message,
raw_payload: final_code, # Kept for debugging/fallback
payment_link: link,
amount: reservation.total_amount.to_f,
reservation_id: reservation.id,
success: true
}
end end
end end

View File

@ -1,29 +1,27 @@
module Captain class Captain::Tools::Parsers::StatusSuitesParser
module Tools def self.parse(response_body)
module Parsers # Assuming response_body is already a Hash or needs parsing
class StatusSuitesParser data = begin
def self.parse(response_body) response_body.is_a?(String) ? JSON.parse(response_body) : response_body
# Assuming response_body is already a Hash or needs parsing rescue StandardError
data = response_body.is_a?(String) ? JSON.parse(response_body) : response_body rescue {} {}
end
# Example normalization logic based on prompt # Example normalization logic based on prompt
{ {
tool_key: 'status_suites', tool_key: 'status_suites',
free: normalize_items(data['free'] || []), free: normalize_items(data['free'] || []),
occupied: normalize_items(data['occupied'] || []), occupied: normalize_items(data['occupied'] || []),
cleaning: normalize_items(data['cleaning'] || []) cleaning: normalize_items(data['cleaning'] || [])
} }
end end
def self.normalize_items(items) def self.normalize_items(items)
items.map do |item| items.map do |item|
{ {
suite: item['suite'] || item['id'], suite: item['suite'] || item['id'],
category: item['category'] || item['categoria'] || 'Standard' category: item['category'] || item['categoria'] || 'Standard'
} }
end
end
end
end end
end end
end end

View File

@ -1,80 +1,76 @@
module Captain class Captain::Tools::ReactToMessageTool < BaseTool
module Tools def name
class ReactToMessageTool < BaseTool 'react_to_message'
def name end
'react_to_message'
end
def description def description
'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.' 'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.'
end end
def tool_parameters_schema def tool_parameters_schema
{ {
type: 'object', type: 'object',
properties: { properties: {
emoji: { emoji: {
type: 'string', type: 'string',
description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.' description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.'
}
},
required: ['emoji']
} }
end },
required: ['emoji']
}
end
def initialize(assistant, user: nil, conversation: nil) def initialize(assistant, user: nil, conversation: nil)
super(assistant, user: user, conversation: conversation) super(assistant, user: user, conversation: conversation)
end end
def execute(*args, **params) def execute(*args, **params)
actual_params = resolve_params(args, params) actual_params = resolve_params(args, params)
emoji = actual_params[:emoji] 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? return error_response('Emoji is required') if emoji.blank?
# Get the last incoming message from the customer # Get the last incoming message from the customer
last_customer_message = @conversation.messages.incoming.last last_customer_message = @conversation.messages.incoming.last
if last_customer_message.blank? if last_customer_message.blank?
Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}" Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}"
return error_response('No customer message to react to') return error_response('No customer message to react to')
end
# Get the external message ID (source_id) - required for WhatsApp reactions
message_external_id = last_customer_message.source_id
if message_external_id.blank?
Rails.logger.warn "[ReactToMessageTool] Failure: Message #{last_customer_message.id} has no source_id"
return error_response('Message has no external ID for reaction')
end
Rails.logger.info "[ReactToMessageTool] Reacting to message #{last_customer_message.id} (source: #{message_external_id}) with #{emoji}"
create_reaction_message(last_customer_message, emoji, message_external_id)
{ success: true, message: "Reacted with #{emoji}" }.to_json
rescue StandardError => e
Rails.logger.error "[ReactToMessageTool] Failed: #{e.message}"
error_response(e.message)
end
private
def create_reaction_message(_target_message, emoji, external_id)
@conversation.messages.create!(
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
sender: @assistant,
message_type: :outgoing,
content: emoji,
content_attributes: {
'in_reply_to_external_id' => external_id,
'is_reaction' => true
}
)
end
def error_response(message)
{ success: false, error: message }.to_json
end
end end
# Get the external message ID (source_id) - required for WhatsApp reactions
message_external_id = last_customer_message.source_id
if message_external_id.blank?
Rails.logger.warn "[ReactToMessageTool] Failure: Message #{last_customer_message.id} has no source_id"
return error_response('Message has no external ID for reaction')
end
Rails.logger.info "[ReactToMessageTool] Reacting to message #{last_customer_message.id} (source: #{message_external_id}) with #{emoji}"
create_reaction_message(last_customer_message, emoji, message_external_id)
{ success: true, message: "Reacted with #{emoji}" }.to_json
rescue StandardError => e
Rails.logger.error "[ReactToMessageTool] Failed: #{e.message}"
error_response(e.message)
end
private
def create_reaction_message(_target_message, emoji, external_id)
@conversation.messages.create!(
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
sender: @assistant,
message_type: :outgoing,
content: emoji,
content_attributes: {
'in_reply_to_external_id' => external_id,
'is_reaction' => true
}
)
end
def error_response(message)
{ success: false, error: message }.to_json
end end
end end

View File

@ -1,61 +1,57 @@
module Captain class Captain::Tools::ReminderTool < BaseTool
module Tools def self.name
class ReminderTool < BaseTool 'reminder'
def self.name end
'reminder'
end
description 'Schedule a reminder to send a message to the customer at a specific time.' description 'Schedule a reminder to send a message to the customer at a specific time.'
param :message, type: 'string', desc: 'Message to send to the customer' param :message, type: 'string', desc: 'Message to send to the customer'
param :scheduled_at, type: 'string', desc: 'ISO datetime for when to send the reminder' param :scheduled_at, type: 'string', desc: 'ISO datetime for when to send the reminder'
param :minutes_from_now, type: 'integer', desc: 'Alternative to scheduled_at: minutes from now' param :minutes_from_now, type: 'integer', desc: 'Alternative to scheduled_at: minutes from now'
def initialize(assistant, user: nil, conversation: nil) def initialize(assistant, user: nil, conversation: nil)
@conversation = conversation @conversation = conversation
super(assistant, user: user) super(assistant, user: user)
end end
def execute(*args, **params) def execute(*args, **params)
actual_params = resolve_params(args, params) actual_params = resolve_params(args, params)
message = actual_params[:message] message = actual_params[:message]
scheduled_at = actual_params[:scheduled_at] scheduled_at = actual_params[:scheduled_at]
minutes_from_now = actual_params[:minutes_from_now] 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? return error_response('Message is required') if message.blank?
schedule_time = parse_schedule_time(scheduled_at, minutes_from_now) schedule_time = parse_schedule_time(scheduled_at, minutes_from_now)
return error_response('Scheduled time is required') unless schedule_time return error_response('Scheduled time is required') unless schedule_time
reminder = Captain::Reminders::CreateService.new( reminder = Captain::Reminders::CreateService.new(
account: @assistant.account, account: @assistant.account,
params: { params: {
conversation_id: @conversation.id, conversation_id: @conversation.id,
message: message, message: message,
scheduled_at: schedule_time, scheduled_at: schedule_time,
reminder_type: 'manual' reminder_type: 'manual'
}, },
created_by: @user created_by: @user
).perform ).perform
{ success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[ReminderTool] Failed: #{e.message}" Rails.logger.error "[ReminderTool] Failed: #{e.message}"
error_response(e.message) error_response(e.message)
end end
private private
def parse_schedule_time(scheduled_at, minutes_from_now) def parse_schedule_time(scheduled_at, minutes_from_now)
return Time.zone.parse(scheduled_at) if scheduled_at.present? return Time.zone.parse(scheduled_at) if scheduled_at.present?
return Time.current + minutes_from_now.to_i.minutes if minutes_from_now.present? return Time.current + minutes_from_now.to_i.minutes if minutes_from_now.present?
nil nil
end end
def error_response(message) def error_response(message)
{ success: false, error: message }.to_json { success: false, error: message }.to_json
end
end
end end
end end

View File

@ -45,7 +45,7 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
end end
def low_confidence?(response) 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 response.neighbor_distance.to_f > LOW_CONFIDENCE_DISTANCE
end end

View File

@ -1,61 +1,57 @@
module Captain class Captain::Tools::StatusSuitesTool < BaseTool
module Tools def self.name
class StatusSuitesTool < BaseTool 'status_suites'
def self.name end
'status_suites'
end
description 'Check specific availability, status, and prices of suites/rooms. Returns a list of suites categorized by status (free, occupied, cleaning) and their types.' description 'Check specific availability, status, and prices of suites/rooms. Returns a list of suites categorized by status (free, occupied, cleaning) and their types.'
def initialize(assistant, user: nil, conversation: nil) def initialize(assistant, user: nil, conversation: nil)
@conversation = conversation @conversation = conversation
super(assistant, user: user) super(assistant, user: user)
end end
def execute(*_args, **_params) def execute(*_args, **_params)
config = find_tool_config config = find_tool_config
return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled
uri = URI('https://oxpi.com.br/api/PlugPlay/api/SuitesStatus') uri = URI('https://oxpi.com.br/api/PlugPlay/api/SuitesStatus')
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true http.use_ssl = true
http.read_timeout = 8 http.read_timeout = 8
request = Net::HTTP::Get.new(uri) request = Net::HTTP::Get.new(uri)
request['PLUG-PLAY-ID'] = config.plug_play_id.to_s request['PLUG-PLAY-ID'] = config.plug_play_id.to_s
request['PLUG-PLAY-TOKEN'] = config.plug_play_token.to_s request['PLUG-PLAY-TOKEN'] = config.plug_play_token.to_s
response = http.request(request) response = http.request(request)
if response.is_a?(Net::HTTPSuccess) if response.is_a?(Net::HTTPSuccess)
parsed = Captain::Tools::Parsers::StatusSuitesParser.parse(response.body) parsed = Captain::Tools::Parsers::StatusSuitesParser.parse(response.body)
parsed.to_json parsed.to_json
else else
{ success: false, error: "API Error: #{response.code}" }.to_json { success: false, error: "API Error: #{response.code}" }.to_json
end
rescue StandardError => e
Rails.logger.error "[StatusSuitesTool] Failed: #{e.message}"
{ success: false, error: e.message }.to_json
end
private
def find_tool_config
# 1. Try Assistant specific config
config = @assistant.tool_configs.find_by(tool_key: 'status_suites') if @assistant.respond_to?(:tool_configs)
return config if config.present?
# 2. Try Inbox specific config (if conversation exists)
if @conversation&.inbox.present?
config = Captain::ToolConfig.find_by(
inbox: @conversation.inbox,
tool_key: 'status_suites'
)
return config if config.present?
end
nil
end
end end
rescue StandardError => e
Rails.logger.error "[StatusSuitesTool] Failed: #{e.message}"
{ success: false, error: e.message }.to_json
end
private
def find_tool_config
# 1. Try Assistant specific config
config = @assistant.tool_configs.find_by(tool_key: 'status_suites') if @assistant.respond_to?(:tool_configs)
return config if config.present?
# 2. Try Inbox specific config (if conversation exists)
if @conversation&.inbox.present?
config = Captain::ToolConfig.find_by(
inbox: @conversation.inbox,
tool_key: 'status_suites'
)
return config if config.present?
end
nil
end end
end end

View File

@ -1,59 +1,55 @@
module Captain class Captain::Tools::SuiteWatchdogTool < BaseTool
module Tools def self.name
class SuiteWatchdogTool < BaseTool 'suite_watchdog'
def self.name end
'suite_watchdog'
end
description 'Monitor a suite availability and notify the customer when it becomes free.' description 'Monitor a suite availability and notify the customer when it becomes free.'
param :suite_identifier, type: 'string', desc: 'Suite number or identifier (e.g. 102)' param :suite_identifier, type: 'string', desc: 'Suite number or identifier (e.g. 102)'
param :interval_minutes, type: 'integer', desc: 'How often to check availability' param :interval_minutes, type: 'integer', desc: 'How often to check availability'
param :message, type: 'string', desc: 'Message to send when the suite becomes available' param :message, type: 'string', desc: 'Message to send when the suite becomes available'
def initialize(assistant, user: nil, conversation: nil) def initialize(assistant, user: nil, conversation: nil)
@conversation = conversation @conversation = conversation
super(assistant, user: user) super(assistant, user: user)
end end
def execute(*args, **params) def execute(*args, **params)
actual_params = resolve_params(args, params) actual_params = resolve_params(args, params)
suite_identifier = actual_params[:suite_identifier] suite_identifier = actual_params[:suite_identifier]
interval_minutes = actual_params[:interval_minutes] || 10 interval_minutes = actual_params[:interval_minutes] || 10
message = actual_params[:message] 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? return error_response('Suite identifier is required') if suite_identifier.blank?
reminder = Captain::Reminders::CreateService.new( reminder = Captain::Reminders::CreateService.new(
account: @assistant.account, account: @assistant.account,
params: { params: {
conversation_id: @conversation.id, conversation_id: @conversation.id,
message: message.presence || default_message(suite_identifier), message: message.presence || default_message(suite_identifier),
scheduled_at: Time.current + interval_minutes.to_i.minutes, scheduled_at: Time.current + interval_minutes.to_i.minutes,
reminder_type: 'suite_watchdog', reminder_type: 'suite_watchdog',
metadata: { metadata: {
suite_identifier: suite_identifier, suite_identifier: suite_identifier,
interval_minutes: interval_minutes.to_i interval_minutes: interval_minutes.to_i
} }
}, },
created_by: @user created_by: @user
).perform ).perform
{ success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[SuiteWatchdogTool] Failed: #{e.message}" Rails.logger.error "[SuiteWatchdogTool] Failed: #{e.message}"
error_response(e.message) error_response(e.message)
end end
private private
def default_message(suite_identifier) def default_message(suite_identifier)
I18n.t('captain.reminders.defaults.suite_available', suite: suite_identifier) I18n.t('captain.reminders.defaults.suite_available', suite: suite_identifier)
end end
def error_response(message) def error_response(message)
{ success: false, error: message }.to_json { success: false, error: message }.to_json
end
end
end end
end end

View File

@ -1,316 +1,310 @@
require 'net/http' require 'net/http'
require 'uri' require 'uri'
module Captain class Captain::Tools::ToolRunner
module Tools def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {})
class ToolRunner new(assistant, tool_key, inbox, conversation, additional_data).run
def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {}) end
new(assistant, tool_key, inbox, conversation, additional_data).run
end
def initialize(assistant, tool_key, inbox, conversation, additional_data) def initialize(assistant, tool_key, inbox, conversation, additional_data)
@assistant = assistant @assistant = assistant
@tool_key = tool_key @tool_key = tool_key
@inbox = inbox @inbox = inbox
@conversation = conversation @conversation = conversation
@definition = resolve_definition @definition = resolve_definition
@config = resolve_config @config = resolve_config
@contact = conversation.contact @contact = conversation.contact
@additional_data = additional_data @additional_data = additional_data
end end
def run def run
start_time = Time.current start_time = Time.current
result = if !tool_enabled? result = if !tool_enabled?
failed_response('Tool not configured or disabled') failed_response('Tool not configured or disabled')
elsif !@definition elsif !@definition
failed_response('Tool definition not found') failed_response('Tool definition not found')
else else
case @definition[:type] case @definition[:type]
when :http then execute_http when :http then execute_http
when :webhook then execute_webhook when :webhook then execute_webhook
when :internal then execute_internal when :internal then execute_internal
when :scenario then execute_scenario when :scenario then execute_scenario
else failed_response('Unknown tool type') else failed_response('Unknown tool type')
end end
end end
duration = (Time.current - start_time) * 1000 duration = (Time.current - start_time) * 1000
result = apply_fallback(result) result = apply_fallback(result)
result.merge(duration_ms: duration) result.merge(duration_ms: duration)
end end
private private
def resolve_definition def resolve_definition
return Captain::Tools::Definitions::ALL[@tool_key] if Captain::Tools::Definitions::ALL.key?(@tool_key) return Captain::Tools::Definitions::ALL[@tool_key] if Captain::Tools::Definitions::ALL.key?(@tool_key)
scenario = find_scenario_by_tool_key scenario = find_scenario_by_tool_key
return { type: :scenario, scenario: scenario } if scenario return { type: :scenario, scenario: scenario } if scenario
Rails.logger.warn "[ToolRunner] Unknown tool_key: #{@tool_key}" Rails.logger.warn "[ToolRunner] Unknown tool_key: #{@tool_key}"
nil nil
end end
def resolve_config def resolve_config
Captain::ToolConfig.find_by(captain_assistant_id: @assistant.id, tool_key: @tool_key) || Captain::ToolConfig.find_by(captain_assistant_id: @assistant.id, tool_key: @tool_key) ||
Captain::ToolConfig.find_by(inbox: @inbox, account: @inbox.account, tool_key: @tool_key) Captain::ToolConfig.find_by(inbox: @inbox, account: @inbox.account, tool_key: @tool_key)
end end
def find_scenario_by_tool_key def find_scenario_by_tool_key
@assistant.scenarios.enabled.find do |scenario| @assistant.scenarios.enabled.find do |scenario|
"consultar_#{scenario.title.parameterize.underscore}" == @tool_key "consultar_#{scenario.title.parameterize.underscore}" == @tool_key
end end
end end
def tool_enabled? def tool_enabled?
return true if @definition && @definition[:type] == :scenario return true if @definition && @definition[:type] == :scenario
return true if @definition && @definition[:always_on] return true if @definition && @definition[:always_on]
@config&.is_enabled @config&.is_enabled
end end
def execute_scenario def execute_scenario
scenario = @definition[:scenario] scenario = @definition[:scenario]
# We pass the contact (user) and conversation context. # We pass the contact (user) and conversation context.
# We use the full user message as the 'pergunta_interna' for the sub-agent. # We use the full user message as the 'pergunta_interna' for the sub-agent.
tool = Captain::Tools::ScenarioDelegatorTool.new(scenario, user: @contact, conversation: @conversation) tool = Captain::Tools::ScenarioDelegatorTool.new(scenario, user: @contact, conversation: @conversation)
# ScenarioDelegatorTool expects 'pergunta_interna' # ScenarioDelegatorTool expects 'pergunta_interna'
params = tool_params params = tool_params
params[:pergunta_interna] ||= @additional_data[:message] params[:pergunta_interna] ||= @additional_data[:message]
execution_result = tool.execute(params) execution_result = tool.execute(params)
if execution_result.is_a?(String) if execution_result.is_a?(String)
{ success: true, body: { message: execution_result } } { success: true, body: { message: execution_result } }
else else
{ success: true, body: execution_result } { success: true, body: execution_result }
end end
rescue StandardError => e rescue StandardError => e
{ success: false, error: "Scenario Error: #{e.message}" } { success: false, error: "Scenario Error: #{e.message}" }
end end
def execute_http def execute_http
uri = URI(@definition[:url]) uri = URI(@definition[:url])
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https' http.use_ssl = uri.scheme == 'https'
http.read_timeout = 8 http.read_timeout = 8
request = Net::HTTP::Get.new(uri) request = Net::HTTP::Get.new(uri)
request['PLUG-PLAY-ID'] = @config.plug_play_id request['PLUG-PLAY-ID'] = @config.plug_play_id
request['PLUG-PLAY-TOKEN'] = @config.plug_play_token request['PLUG-PLAY-TOKEN'] = @config.plug_play_token
response = http.request(request) response = http.request(request)
if response.is_a?(Net::HTTPSuccess) if response.is_a?(Net::HTTPSuccess)
parsed_body = parse_response(response.body) parsed_body = parse_response(response.body)
{ success: true, body: parsed_body, status: response.code } { success: true, body: parsed_body, status: response.code }
else else
{ success: false, status: response.code, error: 'HTTP Request Failed' } { success: false, status: response.code, error: 'HTTP Request Failed' }
end end
rescue StandardError => e rescue StandardError => e
{ success: false, error: e.message } { success: false, error: e.message }
end end
def execute_internal def execute_internal
tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool" tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool"
# Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions) # Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions)
klass = tool_class_name.safe_constantize klass = tool_class_name.safe_constantize
return failed_response("Tool Class #{tool_class_name} not found") unless klass return failed_response("Tool Class #{tool_class_name} not found") unless klass
tool_instance = klass.new(@assistant, user: @contact, conversation: @conversation) tool_instance = klass.new(@assistant, user: @contact, conversation: @conversation)
# Merge additional data into params if the tool expects them # Merge additional data into params if the tool expects them
# Typically internal tools take a params hash. # Typically internal tools take a params hash.
# We assume tool_output from brain is passed as params or we build it here? # We assume tool_output from brain is passed as params or we build it here?
# ToolRunner.run signature: additional_data: { message: ... } # ToolRunner.run signature: additional_data: { message: ... }
# The 'params' usuall come from the Brain's tool_input. # The 'params' usuall come from the Brain's tool_input.
# But ToolRunner is called with `tool_key`... wait. # But ToolRunner is called with `tool_key`... wait.
# AssistantChatService (line 48) calls ToolRunner with tool_key. # AssistantChatService (line 48) calls ToolRunner with tool_key.
# But WHERE are the arguments (e.g. suite="Stilo")? # But WHERE are the arguments (e.g. suite="Stilo")?
# A HA! JasmineBrain.decide returns `tool_key`. # A HA! JasmineBrain.decide returns `tool_key`.
# But does it return parameters? # But does it return parameters?
# Looking at AssistantChatService again... # Looking at AssistantChatService again...
# brain_decision has .tool_key. # brain_decision has .tool_key.
# It DOES NOT look like it passes parameters to ToolRunner! # It DOES NOT look like it passes parameters to ToolRunner!
# CHECK THIS before committing logic. # CHECK THIS before committing logic.
# Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`. # Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`.
# Params need to come from somewhere. # Params need to come from somewhere.
# In current AssistantChatService, `runner_result` is called without `tool_input`. # In current AssistantChatService, `runner_result` is called without `tool_input`.
# This implies tools must parse the message THEMSELVES or parameters are missing? # This implies tools must parse the message THEMSELVES or parameters are missing?
# Let's verify AssistantChatService brain decision struct. # Let's verify AssistantChatService brain decision struct.
# For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty. # For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty.
# But CheckAvailabilityTool explicitly does `suite_category = params['suite']`. # But CheckAvailabilityTool explicitly does `suite_category = params['suite']`.
# If params are empty, it fails. # If params are empty, it fails.
# CRITICAL: Is JasmineBrain extracting parameters? # CRITICAL: Is JasmineBrain extracting parameters?
# If not, the tool must extract them from @conversation. # If not, the tool must extract them from @conversation.
# But CheckAvailabilityTool uses `params`. # But CheckAvailabilityTool uses `params`.
# Let's verify `JasmineBrain` in a later step if needed. # Let's verify `JasmineBrain` in a later step if needed.
# For now, we pass `additional_data` as params which usually contains `message`. # For now, we pass `additional_data` as params which usually contains `message`.
# But `CheckAvailabilityTool` expects 'suite'. # But `CheckAvailabilityTool` expects 'suite'.
# I will pass `additional_data` merged with any extracted parameters if available. # I will pass `additional_data` merged with any extracted parameters if available.
# But since I can't change the inputs to ToolRunner right here easily without finding the caller... # But since I can't change the inputs to ToolRunner right here easily without finding the caller...
# I will assume the Tool executes with what it has. # I will assume the Tool executes with what it has.
# Wait, BaseTool has access to @conversation. # Wait, BaseTool has access to @conversation.
# CheckAvailabilityTool reads `params['suite']`. # CheckAvailabilityTool reads `params['suite']`.
# If JasmineBrain doesn't extract 'suite', this fails. # If JasmineBrain doesn't extract 'suite', this fails.
# RE-READING `GeneratePixTool` (Step 8838 modified): # RE-READING `GeneratePixTool` (Step 8838 modified):
# "Refactored... to operate on an active draft reservation, removing direct parameter requirements". # "Refactored... to operate on an active draft reservation, removing direct parameter requirements".
# RE-READING `CreateReservationIntentTool` (Step 8868): # RE-READING `CreateReservationIntentTool` (Step 8868):
# `suite_category = params['suite']`. It NEEDS params! # `suite_category = params['suite']`. It NEEDS params!
# RE-READING `AssistantChatService` (Step 8898): # RE-READING `AssistantChatService` (Step 8898):
# `brain_decision = Captain::Llm::JasmineBrain.decide(...)`. # `brain_decision = Captain::Llm::JasmineBrain.decide(...)`.
# `ToolRunner.run(..., additional_data: { message: additional_data })`. # `ToolRunner.run(..., additional_data: { message: additional_data })`.
# Does `JasmineBrain` decision contain parameters? # Does `JasmineBrain` decision contain parameters?
# `brain_decision.tool_key`. # `brain_decision.tool_key`.
# If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments. # If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments.
# But `AssistantChatService` doesn't seem to pass them to `ToolRunner`. # But `AssistantChatService` doesn't seem to pass them to `ToolRunner`.
# This might be ANOTHER bug. # This might be ANOTHER bug.
# But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty? # But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty?
# No, it checks `params['suite']`. # No, it checks `params['suite']`.
# I will inject a "Smart Parameter Extraction" if params are missing? # I will inject a "Smart Parameter Extraction" if params are missing?
# OR: I assume `additional_data` CONTAINS the params? # OR: I assume `additional_data` CONTAINS the params?
# `AssistantChatService` passes `additional_data: { message: additional_message }`. # `AssistantChatService` passes `additional_data: { message: additional_message }`.
# That's just the message string. # That's just the message string.
# Warning: `CreateReservationIntentTool` will fail if it receives empty params. # Warning: `CreateReservationIntentTool` will fail if it receives empty params.
# But `ToolRunner` must support it first. # But `ToolRunner` must support it first.
# I will implement the execution. # I will implement the execution.
# If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters. # If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters.
execution_result = tool_instance.execute(tool_params) execution_result = tool_instance.execute(tool_params)
# Normalize result. Tool execute usually returns a String or Hash. # Normalize result. Tool execute usually returns a String or Hash.
if execution_result.is_a?(String) if execution_result.is_a?(String)
{ success: true, body: { message: execution_result } } { success: true, body: { message: execution_result } }
else else
{ success: true, body: execution_result } { success: true, body: execution_result }
end end
rescue StandardError => e rescue StandardError => e
{ success: false, error: e.message } { success: false, error: e.message }
end end
def execute_webhook def execute_webhook
url = @config.webhook_url url = @config.webhook_url
return failed_response('Webhook URL missing') if url.blank? return failed_response('Webhook URL missing') if url.blank?
payload = build_webhook_payload payload = build_webhook_payload
# Specific logic for escalate_human # Specific logic for escalate_human
if @tool_key == 'escalar_humano' if @tool_key == 'escalar_humano'
# Label logic handles in brain/response service if success # Label logic handles in brain/response service if success
end end
response = RestClient::Request.execute( response = RestClient::Request.execute(
method: :post, method: :post,
url: url, url: url,
payload: payload.to_json, payload: payload.to_json,
headers: { content_type: :json, accept: :json }, headers: { content_type: :json, accept: :json },
timeout: 8 timeout: 8
) )
{ success: true, status: response.code, body: { message: 'Webhook sent' } } { success: true, status: response.code, body: { message: 'Webhook sent' } }
rescue StandardError => e rescue StandardError => e
{ success: false, error: e.message } { success: false, error: e.message }
end end
def tool_params def tool_params
params = @additional_data[:tool_input] params = @additional_data[:tool_input]
params = {} unless params.is_a?(Hash) params = {} unless params.is_a?(Hash)
params.with_indifferent_access params.with_indifferent_access
end end
def build_webhook_payload def build_webhook_payload
base = { base = {
conversation_id: @conversation.id, conversation_id: @conversation.id,
contact_id: @contact.id, contact_id: @contact.id,
event: @tool_key event: @tool_key
} }
# Specific payloads # Specific payloads
if @tool_key == 'maria_fotos' if @tool_key == 'maria_fotos'
base.merge!( base[:suite_category] = extract_suite_category || 'Indefinida'
suite_category: extract_suite_category || 'Indefinida', base[:message] = 'User requested photos'
message: 'User requested photos' elsif @tool_key == 'escalar_humano'
) base[:reason] = 'User requested human agent'
elsif @tool_key == 'escalar_humano' end
base.merge!(reason: 'User requested human agent')
end
base base
end end
def extract_suite_category def extract_suite_category
# Simple extraction or ask JasmineBrain to help later? # Simple extraction or ask JasmineBrain to help later?
# For V1, we try to grab from message content or nil # For V1, we try to grab from message content or nil
msg = @additional_data[:message]&.downcase || '' msg = @additional_data[:message]&.downcase || ''
return 'Alexa' if msg.include?('alexa') return 'Alexa' if msg.include?('alexa')
return 'Stilo' if msg.include?('stilo') return 'Stilo' if msg.include?('stilo')
return 'Hidromassagem' if msg.include?('hidro') return 'Hidromassagem' if msg.include?('hidro')
nil nil
end end
def parse_response(body) def parse_response(body)
case @tool_key case @tool_key
when 'status_suites' when 'status_suites'
Captain::Tools::Parsers::StatusSuitesParser.parse(body) Captain::Tools::Parsers::StatusSuitesParser.parse(body)
else else
begin begin
JSON.parse(body) JSON.parse(body)
rescue StandardError rescue StandardError
body body
end
end
end
def failed_response(msg)
{ success: false, error: msg }
end
def apply_fallback(result)
failed = !result[:success]
# Treat generate_pix business failures as fallback-worthy.
if !failed && @tool_key == 'generate_pix'
body = result[:body]
body_success =
if body.is_a?(Hash)
body[:success].nil? ? body['success'] : body[:success]
end
failed = body_success == false
end
return result unless failed
return result unless fallback_configured?
{
success: true,
body: { message: @config.fallback_message.to_s },
fallback: true,
error: result[:error] || result.dig(:body, :error)
}
end
def fallback_configured?
return false unless @config&.fallback_message.present?
return false if %w[faq_lookup react_to_message].include?(@tool_key)
true
end end
end end
end end
def failed_response(msg)
{ success: false, error: msg }
end
def apply_fallback(result)
failed = !result[:success]
# Treat generate_pix business failures as fallback-worthy.
if !failed && @tool_key == 'generate_pix'
body = result[:body]
body_success =
if body.is_a?(Hash)
body[:success].nil? ? body['success'] : body[:success]
end
failed = body_success == false
end
return result unless failed
return result unless fallback_configured?
{
success: true,
body: { message: @config.fallback_message.to_s },
fallback: true,
error: result[:error] || result.dig(:body, :error)
}
end
def fallback_configured?
return false if @config&.fallback_message.blank?
return false if %w[faq_lookup react_to_message].include?(@tool_key)
true
end
end end

View File

@ -1,93 +1,88 @@
module Captain class Captain::Tools::UpdateContactTool < BaseTool
module Tools def name
class UpdateContactTool < BaseTool 'update_contact'
def name end
'update_contact'
end
def description def description
'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.' 'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.'
end end
def tool_parameters_schema def tool_parameters_schema
{ {
type: 'object', type: 'object',
properties: { properties: {
nome: { nome: {
type: 'string', type: 'string',
description: 'Nome completo do cliente para cadastro.' description: 'Nome completo do cliente para cadastro.'
}, },
cpf: { cpf: {
type: 'string', type: 'string',
description: 'CPF do cliente (apenas números ou formatado).' description: 'CPF do cliente (apenas números ou formatado).'
}
},
required: []
} }
end },
required: []
}
end
def execute(*args, **params) def execute(*args, **params)
actual_params = resolve_params(args, params) actual_params = resolve_params(args, params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| 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]
cpf = actual_params[:cpf]
return 'Erro: Nenhum dado fornecido (nome ou cpf).' if name.blank? && cpf.blank?
ensure_conversation_context!
unless @conversation && @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}" }
return msg
end
contact = @conversation.contact
contact.name = name if name.present?
contact.custom_attributes ||= {}
contact.custom_attributes['cpf'] = cpf if cpf.present?
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
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
end
end
private
# Helper to ensure we have a conversation object
def ensure_conversation_context!
return if @conversation.present?
end
def update_sticky_state(name:, cpf:)
return unless @conversation.respond_to?(:active_scenario_state)
return if name.blank? && cpf.blank?
state = @conversation.active_scenario_state || {}
collected = (state['collected'] || {}).merge(
'name' => name.presence,
'cpf' => cpf.presence
).compact
@conversation.update!(
active_scenario_state: state.merge(
'collected' => collected,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[UpdateContactTool] Failed to update sticky state: #{e.message}"
end
end end
name = actual_params[:nome] || actual_params[:name]
cpf = actual_params[:cpf]
return 'Erro: Nenhum dado fornecido (nome ou cpf).' if name.blank? && cpf.blank?
ensure_conversation_context!
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.zone.now}] FAILURE: #{msg}" }
return msg
end
contact = @conversation.contact
contact.name = name if name.present?
contact.custom_attributes ||= {}
contact.custom_attributes['cpf'] = cpf if cpf.present?
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.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.zone.now}] FAILURE: #{msg}" }
end
msg
end
private
# Helper to ensure we have a conversation object
def ensure_conversation_context!
return if @conversation.present?
end
def update_sticky_state(name:, cpf:)
return unless @conversation.respond_to?(:active_scenario_state)
return if name.blank? && cpf.blank?
state = @conversation.active_scenario_state || {}
collected = (state['collected'] || {}).merge(
'name' => name.presence,
'cpf' => cpf.presence
).compact
@conversation.update!(
active_scenario_state: state.merge(
'collected' => collected,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[UpdateContactTool] Failed to update sticky state: #{e.message}"
end end
end end

View File

@ -1,67 +1,65 @@
module Captain class Captain::WebhookSenderService
class WebhookSenderService def initialize(reservation)
def initialize(reservation) @reservation = reservation
@reservation = reservation @unit = reservation.unit
@unit = reservation.unit end
end
def perform def perform
return unless @unit&.webhook_url.present? return if @unit&.webhook_url.blank?
payload = build_payload payload = build_payload
send_webhook(payload) send_webhook(payload)
end end
private private
def build_payload def build_payload
contact = @reservation.contact contact = @reservation.contact
brand = @reservation.brand brand = @reservation.brand
{ {
event: 'reservation_update', event: 'reservation_update',
timestamp: Time.current.iso8601, timestamp: Time.current.iso8601,
reservation: { reservation: {
id: @reservation.id, id: @reservation.id,
suite: @reservation.suite_identifier, suite: @reservation.suite_identifier,
status: @reservation.status, status: @reservation.status,
payment_status: @reservation.payment_status, payment_status: @reservation.payment_status,
check_in: @reservation.check_in_at&.iso8601, check_in: @reservation.check_in_at&.iso8601,
check_out: @reservation.check_out_at&.iso8601, check_out: @reservation.check_out_at&.iso8601,
created_at: @reservation.created_at&.iso8601 created_at: @reservation.created_at&.iso8601
}, },
financial: { financial: {
total_amount: @reservation.total_amount, total_amount: @reservation.total_amount,
paid_amount: @reservation.active? ? (@reservation.total_amount * 0.5) : 0.0, paid_amount: @reservation.active? ? (@reservation.total_amount * 0.5) : 0.0,
currency: 'BRL' currency: 'BRL'
}, },
customer: { customer: {
name: contact&.name, name: contact&.name,
email: contact&.email, email: contact&.email,
phone: contact&.phone_number phone: contact&.phone_number
}, },
unit: { unit: {
name: @unit.name, name: @unit.name,
brand: brand&.name brand: brand&.name
}, },
metadata: @reservation.metadata metadata: @reservation.metadata
} }
end end
def send_webhook(payload) def send_webhook(payload)
uri = URI.parse(@unit.webhook_url) uri = URI.parse(@unit.webhook_url)
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https') http.use_ssl = (uri.scheme == 'https')
request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' }) request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' })
request.body = payload.to_json request.body = payload.to_json
begin begin
response = http.request(request) response = http.request(request)
Rails.logger.info "[Captain::WebhookSender] Sent to #{@unit.webhook_url} | Status: #{response.code}" Rails.logger.info "[Captain::WebhookSender] Sent to #{@unit.webhook_url} | Status: #{response.code}"
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[Captain::WebhookSender] Failed to send: #{e.message}" Rails.logger.error "[Captain::WebhookSender] Failed to send: #{e.message}"
end
end end
end end
end end

View File

@ -1,98 +1,96 @@
module Captain class Captain::WhatsappNotificationService
class WhatsappNotificationService def initialize(reservation)
def initialize(reservation) @reservation = reservation
@reservation = reservation @unit = reservation.unit
@unit = reservation.unit @account = reservation.account
@account = reservation.account @contact = reservation.contact
@contact = reservation.contact @conversation = reservation.conversation
@conversation = reservation.conversation end
def perform
return unless valid_for_sending?
# Find sender (Captain Bot or First Admin)
sender = find_sender
# Build Message Content
content = build_message_content
# Send Message
::Messages::MessageBuilder.new(
sender,
@conversation,
{
content: content,
private: false,
message_type: 'outgoing'
}
).perform
Rails.logger.info "[Captain::WhatsappNotification] 📨 Message sent to #{@contact.name} (Reserva ##{@reservation.id})"
rescue StandardError => e
Rails.logger.error "[Captain::WhatsappNotification] ❌ Error sending message: #{e.message}"
end
private
def valid_for_sending?
unless @unit&.inbox_id
Rails.logger.warn "[Captain::WhatsappNotification] ⚠️ No Inbox configured for Unit ##{@unit&.id}"
return false
end end
def perform # Ensure conversation belongs to the correct inbox?
return unless valid_for_sending? # If the reservation conversation is in a different inbox (e.g. Web Widget), we might need to create a new conversation in the WhatsApp Inbox.
# For now, let's assume the reservation conversation IS the one we want to reply to, OR we simply send to that conversation.
# BUT, if the user requested "Send using the Unit's specific inbox", we must respect that.
# Find sender (Captain Bot or First Admin) if @conversation.inbox_id != @unit.inbox_id
sender = find_sender # We need to find or create a conversation in the Target Inbox
target_inbox = @account.inboxes.find_by(id: @unit.inbox_id)
# Build Message Content unless target_inbox
content = build_message_content Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found"
# Send Message
::Messages::MessageBuilder.new(
sender,
@conversation,
{
content: content,
private: false,
message_type: 'outgoing'
}
).perform
Rails.logger.info "[Captain::WhatsappNotification] 📨 Message sent to #{@contact.name} (Reserva ##{@reservation.id})"
rescue StandardError => e
Rails.logger.error "[Captain::WhatsappNotification] ❌ Error sending message: #{e.message}"
end
private
def valid_for_sending?
unless @unit&.inbox_id
Rails.logger.warn "[Captain::WhatsappNotification] ⚠️ No Inbox configured for Unit ##{@unit&.id}"
return false return false
end end
# Ensure conversation belongs to the correct inbox? # Find contact inbox for the target inbox
# If the reservation conversation is in a different inbox (e.g. Web Widget), we might need to create a new conversation in the WhatsApp Inbox. contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id)
# For now, let's assume the reservation conversation IS the one we want to reply to, OR we simply send to that conversation.
# BUT, if the user requested "Send using the Unit's specific inbox", we must respect that.
if @conversation.inbox_id != @unit.inbox_id # If contact doesn't exist in that inbox, we might need to create source_id (phone)?
# We need to find or create a conversation in the Target Inbox # Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number.
target_inbox = @account.inboxes.find_by(id: @unit.inbox_id) contact_inbox ||= ::ContactInbox.create!(
unless target_inbox contact_id: @contact.id,
Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found" inbox_id: target_inbox.id,
return false source_id: @contact.phone_number # Assuming source_id for WA is phone
end )
# Find contact inbox for the target inbox # Find or Create Conversation in Target Inbox
contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id) @conversation = ::Conversation.create!(
account_id: @account.id,
# If contact doesn't exist in that inbox, we might need to create source_id (phone)? inbox_id: target_inbox.id,
# Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number. contact_id: @contact.id,
contact_inbox ||= ::ContactInbox.create!( contact_inbox_id: contact_inbox.id,
contact_id: @contact.id, status: :open
inbox_id: target_inbox.id, )
source_id: @contact.phone_number # Assuming source_id for WA is phone
)
# Find or Create Conversation in Target Inbox
@conversation = ::Conversation.create!(
account_id: @account.id,
inbox_id: target_inbox.id,
contact_id: @contact.id,
contact_inbox_id: contact_inbox.id,
status: :open
)
end
true
end end
def find_sender true
# Try to find a Captain Agent Bot first end
# Valid logic: defaults to the account administrator
@account.administrators.first || @account.users.first
end
def build_message_content def find_sender
# Format Dates # Try to find a Captain Agent Bot first
check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M') # Valid logic: defaults to the account administrator
@account.administrators.first || @account.users.first
end
"Olá #{@contact.name}, confirmamos o pagamento da sua reserva no #{@unit.name}! 🎉\n\n" \ def build_message_content
"🏨 *Reserva:* ##{@reservation.id}\n" \ # Format Dates
"📅 *Check-in:* #{check_in}\n" \ check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M')
"✅ *Status:* Confirmado\n\n" \
'Estamos ansiosos para recebê-lo!' "Olá #{@contact.name}, confirmamos o pagamento da sua reserva no #{@unit.name}! 🎉\n\n" \
end "🏨 *Reserva:* ##{@reservation.id}\n" \
"📅 *Check-in:* #{check_in}\n" \
"✅ *Status:* Confirmado\n\n" \
'Estamos ansiosos para recebê-lo!'
end end
end end

View File

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

View File

@ -14,7 +14,8 @@ class Llm::BaseAiService
setup_temperature setup_temperature
end 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 = RubyLLM.chat(model: model)
# client = client.with_api_key(api_key) if api_key.present? # client = client.with_api_key(api_key) if api_key.present?
client.with_temperature(temperature) client.with_temperature(temperature)

View File

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

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