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

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

View File

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

331
.rubocop_todo.yml Normal file
View File

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

View File

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

View File

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

View File

@ -1,51 +1,42 @@
module Api
module V1
module Accounts
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
def index
scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope
end
def index
scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope
end
def create
@collection = Current.account.jasmine_collections.new(collection_params)
def create
@collection = Current.account.jasmine_collections.new(collection_params)
if @collection.save
# Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
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
if @collection.save
# Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
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
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
module V1
module Accounts
module Jasmine
class DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def index
render json: @collection.documents.order(created_at: :desc)
end
class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def create
@document = @collection.documents.new(document_params)
@document.account = Current.account
if @document.save
render json: @document
else
render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def index
render json: @collection.documents.order(created_at: :desc)
end
def destroy
@document = @collection.documents.find(params[:id])
@document.destroy!
head :no_content
end
def create
@document = @collection.documents.new(document_params)
@document.account = Current.account
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
if @document.save
render json: @document
else
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

View File

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

View File

@ -1,36 +1,28 @@
module Api
module V1
module Accounts
module Jasmine
class InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config
end
def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config
end
def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
if config.update(config_params)
render json: config
else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
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
if config.update(config_params)
render json: config
else
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

View File

@ -1,25 +1,17 @@
module Public
module Api
module V1
module Captain
class PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
class Public::Api::V1::Captain::PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
def show
@charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment)
def show
@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
return
render plain: 'Link de pagamento inválido ou expirado.', status: :not_found
return
# @charge is available for the view
# It should be a Captain::PixCharge model
end
end
end
end
# @charge is available for the view
# It should be a Captain::PixCharge model
end
end

View File

@ -37,9 +37,22 @@ class Webhooks::WhatsappController < ActionController::API
# This prevents ANY binary data from leaking into JSON serialization
def sanitize_payload_for_sidekiq
raw = params.to_unsafe_hash
clean_payload = build_base_payload(raw)
# Build a completely NEW payload with only safe fields
clean_payload = {
clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash)
if raw['whatsapp'].is_a?(Hash)
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
)
end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
deep_force_utf8(clean_payload)
end
def build_base_payload(raw)
{
'type' => raw['type'],
'state' => raw['state'],
'instanceName' => raw['instanceName'],
@ -48,142 +61,102 @@ class Webhooks::WhatsappController < ActionController::API
'action' => raw['action'],
'phone_number' => raw['phone_number']
}
end
# Only copy safe event fields
if raw['event'].is_a?(Hash)
clean_event = {}
def build_clean_event(raw_event)
clean_event = {}
# Info fields (all safe strings/ids)
if raw['event']['Info'].is_a?(Hash)
clean_event['Info'] = raw['event']['Info'].slice(
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
'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')
if raw_event['Info'].is_a?(Hash)
clean_event['Info'] = raw_event['Info'].slice(
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
'Timestamp', 'PushName', 'MessageSource'
)
end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
deep_force_utf8(clean_payload)
# 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
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
def deep_force_utf8(obj)

View File

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

View File

@ -1,134 +1,132 @@
module Conversations
class AutoLabelJob < ApplicationJob
queue_as :low_priority
class Conversations::AutoLabelJob < ApplicationJob
queue_as :low_priority
TAXONOMY = {
'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_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'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.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze
TAXONOMY = {
'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_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'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.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze
def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
return unless conversation.messages.count > 0
def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
return unless conversation.messages.count.positive?
# Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any?
# Evita re-classificar se já tiver alguma label de IA (opcional)
# 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
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
private
# Se chegou aqui, todos falharam
Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}"
nil
end
def process_classification(conversation)
messages_text = prepare_history(conversation)
return if messages_text.blank?
def apply_label(conversation, result)
label_name = result['label']
question_summary = result['question']
result = call_llm_classification(messages_text)
return unless result
apply_label(conversation, result)
unless TAXONOMY.key?(label_name)
Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
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")
# 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
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é?".
conversation.add_labels([label_name])
Categorias permitidas:
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")}
# Salva a dúvida canônica nos atributos adicionais
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:
{
"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
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
end
end

View File

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

View File

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

View File

@ -1,61 +1,59 @@
module Jasmine
class ResponseJob < ApplicationJob
queue_as :default
class Jasmine::ResponseJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :polynomially_longer, attempts: 2
retry_on StandardError, wait: :polynomially_longer, attempts: 2
def perform(message_id)
message = Message.find_by(id: message_id)
return unless message
def perform(message_id)
message = Message.find_by(id: message_id)
return unless message
conversation = message.conversation
inbox = message.inbox
config = inbox.jasmine_inbox_config
conversation = message.conversation
inbox = message.inbox
config = inbox.jasmine_inbox_config
# 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}"
return unless config&.is_enabled?
return if conversation.assignee.present?
# 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}"
return unless config&.is_enabled?
return if conversation.assignee.present?
# Send typing indicator
inbox.channel.toggle_typing_status('typing_on', conversation: conversation)
# Send typing indicator
inbox.channel.toggle_typing_status('typing_on', conversation: conversation)
begin
# Sleep for verification (optimized to 1.5s per recommendation)
sleep 1.5
begin
# Sleep for verification (optimized to 1.5s per recommendation)
sleep 1.5
# Get response from BrainService
response_text = BrainService.new(
inbox: inbox,
conversation: conversation,
message: message
).respond
# Get response from BrainService
response_text = BrainService.new(
inbox: inbox,
conversation: conversation,
message: message
).respond
return if response_text.blank?
return if response_text.blank?
# Send response as outgoing message
send_response(conversation, response_text)
ensure
# 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"
sleep 0.5
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}"
# Send response as outgoing message
send_response(conversation, response_text)
ensure
# 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"
sleep 0.5
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

View File

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

View File

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

View File

@ -38,23 +38,21 @@
# fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Captain
class Unit < ApplicationRecord
self.table_name = 'captain_units'
class Captain::Unit < ApplicationRecord
self.table_name = 'captain_units'
belongs_to :account
belongs_to :captain_brand
belongs_to :inbox, optional: true
belongs_to :account
belongs_to :captain_brand
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
# 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.
# 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`.
# Let's check generally used pattern later, but for now defining relations is key.
# Encrypted fields for PlugPlay Integration
# 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.
# 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`.
# Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true
end
validates :name, presence: true
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,118 +4,116 @@ require 'json'
require 'openssl'
require 'base64'
module Captain
class InterService
# Constants for API URLs
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
class Captain::InterService
# Constants for API URLs
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
# initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id
@client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path
@key_path = key_path
@pix_key = pix_key
@account_number = account_number
end
# initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id
@client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path
@key_path = key_path
@pix_key = pix_key
@account_number = account_number
end
def create_pix_charge(reservation)
token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token
def create_pix_charge(reservation)
token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token
payload = {
calendario: {
expiracao: 3600 # 1 hour
},
devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name]
},
valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0)
},
chave: @pix_key,
solicitacaoPagador: "Reserva #{reservation[:id]}"
payload = {
calendario: {
expiracao: 3600 # 1 hour
},
devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name]
},
valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0)
},
chave: @pix_key,
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
}
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
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 }
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
private
private
def get_token
uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
'client_id' => @client_id,
'client_secret' => @client_secret,
'grant_type' => 'client_credentials',
'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read'
)
def get_token
uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
'client_id' => @client_id,
'client_secret' => @client_secret,
'grant_type' => 'client_credentials',
'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
JSON.parse(response.body)['access_token']
else
Rails.logger.error "Inter Auth Error: #{response.body}"
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)
if response.code.to_i == 200
JSON.parse(response.body)['access_token']
else
Rails.logger.error "Inter Auth Error: #{response.body}"
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

View File

@ -1,175 +1,171 @@
module Captain
module Reservations
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
class Captain::Reservations::SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
def initialize(unit)
@unit = unit
@account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
def initialize(unit)
@unit = unit
@account = unit.account
@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
def perform
return unless @unit.reservations_sync_enabled?
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
page += 1
# Safety break to avoid infinite loops in case of API issues
break if page > 50
end
page = 1
loop do
reservations_data = fetch_page(page)
break if reservations_data.empty?
@unit.update(last_synced_at: Time.current)
end
reservations_data.each do |reservation_data|
process_reservation(reservation_data)
end
private
page += 1
# Safety break to avoid infinite loops in case of API issues
break if page > 50
end
def fetch_page(page)
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
response = HTTParty.get(url, headers: headers)
@unit.update(last_synced_at: Time.current)
end
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)
if response.success?
begin
JSON.parse(response.body)
rescue StandardError
nil
[]
end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def map_status(data)
# MVP Logic based on dates and 'cancelada'
return :cancelled if data['cancelada'] == true
def headers
{
'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'])
check_out = parse_date(data['saidaPrevistaOuNegociada'])
now = Time.current
def process_reservation(data)
external_id = data['id']
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
:completed
elsif now >= check_in && now < check_out
:active
else
:scheduled
end
end
# 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
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

View File

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

View File

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

View File

@ -1,359 +1,357 @@
module CrmInsights
class UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
end
class CrmInsights::UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
end
def call
session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id)
return result_payload(last_success, 'no_messages') if last_message_id.blank?
def call
session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id)
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
to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil
to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
result = GenerateService.new(
conversation: @conversation,
insight: last_success,
sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id,
to_message_id: to_message_id
).generate
result = GenerateService.new(
conversation: @conversation,
insight: last_success,
sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id,
to_message_id: to_message_id
).generate
if result[:data].blank?
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,
if result[:data].blank?
create_failed_insight(
session_stats: session_stats,
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
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
@relevant_messages ||= @conversation.messages.where(
message_type: %i[incoming outgoing],
private: false
)
insight = create_success_insight(
result: sanitized_result,
session_stats: session_stats,
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
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
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
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
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?)
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
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'] = ''
time_range = '' unless time_evidence
days = [] unless day_evidence
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
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
def sanitize_frictions(frictions, text, prior_frictions)
items = Array(frictions).map(&:to_s)
return Array(prior_frictions).map(&:to_s) if items.empty?
{
'time_range' => time_range,
'days' => days
}
end
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
}
def sanitize_preferences(preferences, text, prior_preferences)
return Array(prior_preferences).map(&:to_s) if preferences.blank?
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) }
tokens = if preferences.is_a?(Array)
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
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
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)
parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
if inbound_count < 3
return prior_pattern if prior_pattern.present?
parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
return { 'time_range' => '', 'days' => [] }
end
parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
day_evidence = 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?
time_range = '' unless time_evidence
days = [] unless day_evidence
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
end
end
"Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
end
{
'time_range' => time_range,
'days' => days
}
def minimal_payload(incoming_messages, contact)
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
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
def sanitize_preferences(preferences, text, prior_preferences)
return Array(prior_preferences).map(&:to_s) if preferences.blank?
tokens = if preferences.is_a?(Array)
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
room_type = nil
if normalized_text.include?('hidro')
room_type = 'suite_hidro'
evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
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
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."
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
def minimal_payload(incoming_messages, contact)
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
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
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
}
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
def evidence_ids_for(pattern, messages)
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
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
def normalize_text(value)
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
end
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
}
def day_map
{
'segunda' => 'segunda',
'terca' => 'terca',
'quarta' => 'quarta',
'quinta' => 'quinta',
'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
{
'summary_text' => summary_text,
'structured_data' => structured_data
}
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 evidence_ids_for(pattern, messages)
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
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 normalize_text(value)
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
end
def result_payload(insight, status, error_message = nil)
{
insight: insight,
status: status,
error_message: error_message
}
end
def day_map
{
'segunda' => 'segunda',
'terca' => 'terca',
'quarta' => 'quarta',
'quinta' => 'quinta',
'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

View File

@ -1,286 +1,284 @@
module Jasmine
class BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
info_request: %w[como funciona detalhe explica suíte quarto tipo],
policy: %w[horário check-in check-out política regra pagamento cancelamento],
greeting: %w[oi olá bom dia boa tarde boa noite],
objection: %w[caro não sei preciso pensar depois conversar],
closing: %w[reservar agendar confirmar fechar quero sim pode],
general: [] # fallback
}.freeze
class Jasmine::BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
info_request: %w[como funciona detalhe explica suíte quarto tipo],
policy: %w[horário check-in check-out política regra pagamento cancelamento],
greeting: %w[oi olá bom dia boa tarde boa noite],
objection: %w[caro não sei preciso pensar depois conversar],
closing: %w[reservar agendar confirmar fechar quero sim pode],
general: [] # fallback
}.freeze
# Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze
# Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze
attr_reader :inbox, :conversation, :message, :config
attr_reader :inbox, :conversation, :message, :config
def initialize(inbox:, conversation:, message:)
@inbox = inbox
@conversation = conversation
@message = message
@config = load_config
def initialize(inbox:, conversation:, message:)
@inbox = inbox
@conversation = conversation
@message = message
@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
def respond
trigger_media_analysis if message.attachments.any?
llm_content = message.content_for_llm
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
intent = IntentDetector.new(llm_content, intent_keywords).detect
strategy = StrategyDecider.new(intent, jasmine_state).decide
:general # fallback
end
end
rag_context = fetch_rag_if_needed(strategy, llm_content)
# =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
prompt = PromptAssembler.new(
config: config,
state: jasmine_state,
history: recent_history,
rag_context: rag_context,
current_message: llm_content
).assemble
def initialize(intent, state)
@intent = intent
@state = state
end
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
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}"
'Desculpe, tive um problema. Pode repetir?'
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 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
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 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
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
def self.cleanup(conversation)
attrs = conversation.custom_attributes || {}
attrs.delete('jasmine_state')
conversation.update!(custom_attributes: attrs)
end
end
end

View File

@ -1,129 +1,126 @@
module Jasmine
class EmbeddingService
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
class Jasmine::EmbeddingService
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
def initialize(document)
@document = document
def initialize(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
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
@document.with_lock do
return if @document.indexed?
private
@document.update!(status: :processing)
@document.chunks.delete_all
def chunk_content(content)
chunks = []
return chunks if content.blank?
chunks = chunk_content(@document.content)
create_chunks(chunks)
start_index = 0
chunk_index = 0
@document.update!(status: :indexed)
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
while start_index < content.length
end_index = [start_index + CHUNK_SIZE, content.length].min
private
def chunk_content(content)
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)
if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
end_index = start_index + last_space if last_space
end
chunks
end
chunk_text = content[start_index...end_index].strip
def create_chunks(chunks)
return if chunks.empty?
chunks.each do |chunk_data|
embedding = generate_embedding(chunk_data[:content])
Jasmine::DocumentChunk.create!(
account: @document.account,
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
)
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
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
chunks
end
def openai_configured?
ENV['OPENAI_API_KEY'].present?
end
def create_chunks(chunks)
return if chunks.empty?
def generate_openai_embedding(text)
response = RubyLLM.embed(text, model: embedding_model)
response.vectors.first
end
chunks.each do |chunk_data|
embedding = generate_embedding(chunk_data[:content])
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')
Jasmine::DocumentChunk.create!(
account: @document.account,
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
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
module Jasmine
class MediaAnalyzerService
attr_reader :message
class Jasmine::MediaAnalyzerService
attr_reader :message
def initialize(message:)
@message = message
end
def initialize(message:)
@message = message
end
def perform
analyze_audio
analyze_images
end
def perform
analyze_audio
analyze_images
end
private
private
def analyze_audio
message.attachments.where(file_type: :audio).each do |attachment|
next if attachment.meta&.dig('transcribed_text').present?
def analyze_audio
message.attachments.where(file_type: :audio).find_each do |attachment|
next if attachment.meta&.dig('transcribed_text').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
# Try to use the standard AudioTranscriptionService (usually in enterprise folder)
begin
if defined?(Messages::AudioTranscriptionService)
# This service updates the attachment meta internally
Messages::AudioTranscriptionService.new(attachment).perform
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
# Try to use the standard AudioTranscriptionService (usually in enterprise folder)
begin
if defined?(Messages::AudioTranscriptionService)
# This service updates the attachment meta internally
Messages::AudioTranscriptionService.new(attachment).perform
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
end
end
end
def analyze_images
message.attachments.where(file_type: :image).each do |attachment|
next if attachment.meta&.dig('description').present?
def analyze_images
message.attachments.where(file_type: :image).find_each do |attachment|
next if attachment.meta&.dig('description').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
begin
description = Jasmine::VisionService.new(attachment: attachment).perform
if description.present?
new_meta = (attachment.meta || {}).merge('description' => description)
attachment.update!(meta: new_meta)
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}"
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
begin
description = Jasmine::VisionService.new(attachment: attachment).perform
if description.present?
new_meta = (attachment.meta || {}).merge('description' => description)
attachment.update!(meta: new_meta)
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
end

View File

@ -1,178 +1,176 @@
module Jasmine
class SemanticSearchService
CANDIDATES_PER_PRIORITY = 50
TOP_K_PER_PRIORITY = 10
MAX_CHUNKS_PER_DOC = 2
class Jasmine::SemanticSearchService
CANDIDATES_PER_PRIORITY = 50
TOP_K_PER_PRIORITY = 10
MAX_CHUNKS_PER_DOC = 2
def initialize(inbox)
@inbox = inbox
@account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end
def initialize(inbox)
@inbox = inbox
@account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end
def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections
.where(is_enabled: true)
.order(priority: :desc)
.includes(:collection)
def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections
.where(is_enabled: true)
.order(priority: :desc)
.includes(:collection)
return [] if enabled_links.empty?
return [] if enabled_links.empty?
# Group by exact priority
priority_groups = enabled_links.group_by(&:priority)
# Group by exact priority
priority_groups = enabled_links.group_by(&:priority)
# Prepare query embedding
query_embedding = generate_embedding(query)
# Prepare query embedding
query_embedding = generate_embedding(query)
final_results = []
processed_chunk_ids = Set.new
final_results = []
processed_chunk_ids = Set.new
# 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id)
# 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id)
# Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates)
# Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates)
# Waterfall Logic
group_results.each do |result|
next if processed_chunk_ids.include?(result.id)
# Waterfall Logic
group_results.each do |result|
next if processed_chunk_ids.include?(result.id)
final_results << result
processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
final_results << result
processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
final_results
break if final_results.size >= limit
end
private
final_results
end
def retrieve_candidates(query_embedding, collection_ids)
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.order(Arel.sql("embedding <=> '#{query_embedding}'"))
.limit(CANDIDATES_PER_PRIORITY)
private
def retrieve_candidates(query_embedding, collection_ids)
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>)
Jasmine::DocumentChunk
.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
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.
# Better Implementation using Neighbor Gem capabilities which handles this
# But filtering by priority group AND threshold AND limit is complex chain.
# [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"
)
# Let's use Raw SQL for the whole Step 1 + Distance Select
# This is safer.
# Filter by Threshold
# We need to re-query or calculate.
# Let's refine retrieve_candidates to include distance.
return [] if candidates.empty?
end
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation.
# We'll map them to objects and filters.
# Simplified re-implementation of retrieve + process
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.
Hash.new(0)
# Overwrite process_candidates with the list from above
def process_candidates(candidates)
filtered = []
doc_counts = 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.
candidates.each do |chunk|
# 1. Threshold Check
# distance is a string/float from SQL
dist = chunk[:distance].to_f
next if dist > @threshold
# Let's fix retrieve_candidates to return distance
# Re-doing retrieval with select
# 2. Doc Dedupe
limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
# 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
# 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?
doc_counts[chunk.document_id] += 1
filtered << chunk
end
# Simplified re-implementation of retrieve + process
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
# 3. Top K per Priority
filtered.first(TOP_K_PER_PRIORITY)
end
# Overwrite process_candidates with the list from above
def process_candidates(candidates)
filtered = []
doc_counts = Hash.new(0)
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
candidates.each do |chunk|
# 1. Threshold Check
# distance is a string/float from SQL
dist = chunk[:distance].to_f
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
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

View File

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

View File

@ -3,67 +3,65 @@
require 'openai'
require 'base64'
module Jasmine
class VisionService
attr_reader :attachment
class Jasmine::VisionService
attr_reader :attachment
def initialize(attachment:)
@attachment = attachment
end
def initialize(attachment:)
@attachment = attachment
end
def perform
return nil unless attachment.image?
def perform
return nil unless attachment.image?
api_key = ENV.fetch('OPENAI_API_KEY', nil)
return nil if api_key.blank?
api_key = ENV.fetch('OPENAI_API_KEY', nil)
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
return nil if image_data.blank?
image_data = get_image_data
return nil if image_data.blank?
response = client.chat(
parameters: {
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
image_data
]
}
],
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}"
}
response = client.chat(
parameters: {
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
image_data
]
}
],
max_tokens: 300
}
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
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
attachment.file.blob.open do |file|
Base64.strict_encode64(file.read)
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,250 +1,248 @@
module Whatsapp
class IncomingMessageWuzapiService < IncomingMessageBaseService
def perform
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
class Whatsapp::IncomingMessageWuzapiService < IncomingMessageBaseService
def perform
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
# 1. Message Type Check
if parser.message_type == :ignore
Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)'
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
# 1. Message Type Check
if parser.message_type == :ignore
Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)'
return
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
contact_inbox.contact
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
def find_or_create_conversation(contact)
conversation = inbox.conversations.where(contact_id: contact.id)
.where.not(status: :resolved)
.last
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
)
# 2. V1 Scope: Ignore Groups
if parser.group_message?
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
return
end
def build_message(parser, conversation, clean_source_id)
is_outgoing = parser.from_me?
# 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
msg_params = {
content: parser.text_content,
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
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
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
# Handle Reply Logic (Aligned with Reference)
if (reply_id = parser.in_reply_to_external_id).present?
clean_reply_id = "WAID:#{reply_id}"
contact_inbox.contact
end
# Strict lookup within conversation to prevent cross-inbox leaks
original_message = conversation.messages.find_by(source_id: clean_reply_id)
def find_or_create_conversation(contact)
conversation = inbox.conversations.where(contact_id: contact.id)
.where.not(status: :resolved)
.last
if original_message
msg_params[:in_reply_to_id] = original_message.id
else
# Fallback: Store ID for UI "Replying to..." display even if not linked
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
end
end
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
# Use .build so we can attach files before .save!
conversation.messages.build(msg_params)
end
def build_message(parser, conversation, clean_source_id)
is_outgoing = parser.from_me?
def attach_files(parser)
attachment_data = parser.attachment_params
return if attachment_data.blank? || attachment_data[:external_url].blank?
msg_params = {
content: parser.text_content,
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
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
# Handle Replies
# 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
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
return if file_io.blank?
# Strict lookup within conversation to prevent cross-inbox leaks
original_message = conversation.messages.find_by(source_id: clean_reply_id)
# 2. Determine filename
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
extension = File.extname(original_filename)
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
# 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")
if original_message
msg_params[:in_reply_to_id] = original_message.id
else
# Fallback: Store ID for UI "Replying to..." display even if not linked
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
end
end
def download_or_decrypt_media(attachment_data, message_type)
media_url = attachment_data[:external_url]
# Use .build so we can attach files before .save!
conversation.messages.build(msg_params)
end
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
# This is the equivalent of Cloud API's media download
begin
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
def attach_files(parser)
attachment_data = parser.attachment_params
return if attachment_data.blank? || attachment_data[:external_url].blank?
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
# WuzAPI returns base64 in 'data' field
image_data = wuzapi_response['data']
# Strip data URI prefix if present
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
begin
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
decoded = Base64.decode64(image_data)
if decoded.bytesize > 1000 # Valid image should be > 1KB
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
return StringIO.new(decoded)
end
end
rescue StandardError => e
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
end
# 1. Download/Decrypt to get a file
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
return if file_io.blank?
# METHOD 2: Try local decryption if we have mediaKey
if attachment_data[:media_key].present?
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
decrypted = Whatsapp::DecryptionService.new(
media_url,
attachment_data[:media_key],
file_content_type(message_type)
).decrypt
# 2. Determine filename
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
extension = File.extname(original_filename)
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
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
# 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: All download methods failed - #{e.message}"
nil
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
Rails.logger.error e.backtrace.first(10).join("\n")
end
end
def wuzapi_client
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
end
def download_or_decrypt_media(attachment_data, message_type)
media_url = attachment_data[:external_url]
def wuzapi_token
@inbox.channel.wuzapi_user_token
end
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
# This is the equivalent of Cloud API's media download
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)
return '.jpg' if message_type == :image || message_type == :sticker
return '.mp3' if message_type == :audio
return '.mp4' if message_type == :video
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
# WuzAPI returns base64 in 'data' field
image_data = wuzapi_response['data']
# Strip data URI prefix if present
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
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'
decoded = Base64.decode64(image_data)
if decoded.bytesize > 1000 # Valid image should be > 1KB
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
return StringIO.new(decoded)
end
end
rescue StandardError => e
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
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
# METHOD 2: Try local decryption if we have mediaKey
if attachment_data[:media_key].present?
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
decrypted = Whatsapp::DecryptionService.new(
media_url,
attachment_data[:media_key],
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

View File

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

View File

@ -1,178 +1,176 @@
module Whatsapp::Providers
class WuzapiService < BaseService
attr_reader :whatsapp_channel
class Whatsapp::Providers::WuzapiService < BaseService
attr_reader :whatsapp_channel
def initialize(whatsapp_channel:)
super(whatsapp_channel: whatsapp_channel)
@base_url = whatsapp_channel.provider_config['wuzapi_base_url']
end
def initialize(whatsapp_channel:)
super(whatsapp_channel: whatsapp_channel)
@base_url = whatsapp_channel.provider_config['wuzapi_base_url']
end
def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, )
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?
send_attachment_message(user_token, normalized_phone, message)
else
params = {}
# Extract and clean reply ID (remove WAID: prefix if stored)
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
elsif (reply_id = message.in_reply_to_external_id).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
end
client.send_text(user_token, normalized_phone, message.content, **params)
response = if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message)
else
params = {}
# Extract and clean reply ID (remove WAID: prefix if stored)
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
elsif (reply_id = message.in_reply_to_external_id).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
end
# Extract message ID from WuzAPI response and format as WAID:xxx
extract_message_id(response)
end
client.send_text(user_token, normalized_phone, message.content, **params)
end
def send_attachment_message(user_token, phone_number, message)
attachment = message.attachments.first
base64_data = Base64.strict_encode64(attachment.file.download)
mime_type = attachment.file.content_type
data_uri = "data:#{mime_type};base64,#{base64_data}"
# Extract message ID from WuzAPI response and format as WAID:xxx
extract_message_id(response)
end
if mime_type.start_with?('image/')
client.send_image(user_token, phone_number, data_uri, message.content)
else
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
end
end
def send_attachment_message(user_token, phone_number, message)
attachment = message.attachments.first
base64_data = Base64.strict_encode64(attachment.file.download)
mime_type = attachment.file.content_type
data_uri = "data:#{mime_type};base64,#{base64_data}"
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 unless target_message.present?
target_message.outgoing? || target_message.template?
if mime_type.start_with?('image/')
client.send_image(user_token, phone_number, data_uri, message.content)
else
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ Rails.application.config.after_initialize do
if api_key.present?
# Sanitize the key: remove common accidental image suffixes and whitespace
sanitized_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
Agents.configure do |config|
config.openai_api_key = sanitized_key
if api_endpoint.present?

View File

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

0
configure_captain_webhook.rb Normal file → Executable file
View File

View File

@ -19,7 +19,7 @@ class CreateJasmineTables < ActiveRecord::Migration[7.1]
t.string :name, null: false
t.text :description
# Owner inbox is optional (nullable), but if present, must exist in inboxes table
t.references :owner_inbox, null: true, foreign_key: { to_table: :inboxes }
t.references :owner_inbox, null: true, foreign_key: { to_table: :inboxes }
t.integer :visibility, default: 0 # 0=private, 1=shared, 2=global
t.boolean :is_active, default: true
@ -69,9 +69,9 @@ class CreateJasmineTables < ActiveRecord::Migration[7.1]
t.timestamps
end
add_index :jasmine_document_chunks, [:account_id, :collection_id, :document_id], name: 'index_jasmine_chunks_on_acc_coll_doc'
# HNSW Index for Vector Search (Cosine Distance)
# Ensure pgvector extension is enabled in a previous migration or here if needed,
# Ensure pgvector extension is enabled in a previous migration or here if needed,
# but based on plan it is already enabled.
add_index :jasmine_document_chunks, :embedding, using: :hnsw, opclass: :vector_cosine_ops
end

View File

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

View File

@ -4,12 +4,12 @@ class CreateJasmineToolConfigs < ActiveRecord::Migration[7.1]
t.references :account, null: false, foreign_key: true
t.references :inbox, null: false, foreign_key: true
t.string :tool_key, null: false, index: true
# Configs
t.boolean :is_enabled, default: false, null: false
t.string :plug_play_id
t.text :plug_play_token
# Stats
t.datetime :last_tested_at
t.integer :last_test_status

View File

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

View File

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

View File

@ -1,52 +1,44 @@
module Api
module V1
module Accounts
module Captain
class BrandsController < Api::V1::Accounts::BaseController
before_action :fetch_brand, only: [:show, :update, :destroy]
class Api::V1::Accounts::Captain::BrandsController < Api::V1::Accounts::BaseController
before_action :fetch_brand, only: [:show, :update, :destroy]
def index
@brands = Current.account.captain_brands
end
def index
@brands = Current.account.captain_brands
end
def show; end
def show; end
def create
@brand = Current.account.captain_brands.new(brand_params)
if @brand.save
render :show, status: :created
else
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
def create
@brand = Current.account.captain_brands.new(brand_params)
if @brand.save
render :show, status: :created
else
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

View File

@ -1,34 +1,26 @@
module Api
module V1
module Accounts
module Captain
class ConfigurationsController < Api::V1::Accounts::BaseController
before_action :fetch_config
class Api::V1::Accounts::Captain::ConfigurationsController < Api::V1::Accounts::BaseController
before_action :fetch_config
def show
render json: @config
end
def show
render json: @config
end
def update
if @config.update(config_params)
render json: @config
else
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
def update
if @config.update(config_params)
render json: @config
else
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

View File

@ -1,52 +1,44 @@
module Api
module V1
module Accounts
module Captain
class ExtrasController < Api::V1::Accounts::BaseController
before_action :fetch_extra, only: [:show, :update, :destroy]
class Api::V1::Accounts::Captain::ExtrasController < Api::V1::Accounts::BaseController
before_action :fetch_extra, only: [:show, :update, :destroy]
def index
@extras = Current.account.captain_extras
end
def index
@extras = Current.account.captain_extras
end
def show; end
def show; end
def create
@extra = Current.account.captain_extras.new(extra_params)
if @extra.save
render :show, status: :created
else
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
def create
@extra = Current.account.captain_extras.new(extra_params)
if @extra.save
render :show, status: :created
else
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

View File

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

View File

@ -1,54 +1,46 @@
module Api
module V1
module Accounts
module Captain
class UnitsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(::Captain::Assistant) }
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(::Captain::Assistant) }
def index
@units = if params[:all]
Current.account.captain_units
else
Current.account.captain_units.active.select(:id, :name)
end
render json: @units
end
def index
@units = if params[:all]
Current.account.captain_units
else
Current.account.captain_units.active.select(:id, :name)
end
render json: @units
end
def create
@unit = Current.account.captain_units.new(unit_params)
def create
@unit = Current.account.captain_units.new(unit_params)
if @unit.save
render json: @unit
else
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
if @unit.save
render json: @unit
else
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

View File

@ -1,49 +1,39 @@
module Api
module V1
module Accounts
module Inboxes
module Captain
class ReminderSettingsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_settings
class Api::V1::Accounts::Inboxes::Captain::ReminderSettingsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_settings
def show
render json: @settings
end
def show
render json: @settings
end
def update
if @settings.update(settings_params)
render json: @settings
else
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
def update
if @settings.update(settings_params)
render json: @settings
else
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

View File

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

View File

@ -1,33 +1,25 @@
module Public
module Api
module V1
module Captain
class MasterDataController < ActionController::API
def show
# Assuming account_id is passed or derived from domain
account = Account.find(params[:account_id])
class Public::Api::V1::Captain::MasterDataController < ActionController::API
def show
# Assuming account_id is passed or derived from domain
account = Account.find(params[:account_id])
brands = account.captain_brands.includes(:units, :pricings)
extras = account.captain_extras.where(active: true).order(:order)
suites = account.captain_suites.all
brands = account.captain_brands.includes(:units, :pricings)
extras = account.captain_extras.where(active: true).order(:order)
suites = account.captain_suites.all
begin
config = account.captain_configuration || account.create_captain_configuration!
rescue StandardError => e
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' }
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
begin
config = account.captain_configuration || account.create_captain_configuration!
rescue StandardError => e
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' }
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

View File

@ -1,172 +1,164 @@
module Public
module Api
module V1
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
class Public::Api::V1::Captain::ReservationsController < ActionController::API
# Use basic rescue_from for generic error handling to capture 'Inbox not found' or similar if they bubble up
rescue_from StandardError, with: :handle_standard_error
def create
Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation'
Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}"
def create
Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation'
Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}"
# 1. Parse Params
# 3. Persistence Logic
ActiveRecord::Base.transaction do
# A. Find or Create Contact (Simple Logic for now)
contact = find_or_create_contact(reservation_params)
# 1. Parse Params
# 3. Persistence Logic
ActiveRecord::Base.transaction do
# A. Find or Create Contact (Simple Logic for now)
contact = find_or_create_contact(reservation_params)
# A.1 Find or Create Conversation
# We need an inbox to create a conversation.
inbox_id = unit.account.inboxes.first&.id
raise 'Inbox not found' unless inbox_id
# A.1 Find or Create Conversation
# We need an inbox to create a conversation.
inbox_id = unit.account.inboxes.first&.id
raise 'Inbox not found' unless inbox_id
conversation = ::Conversation.create!(
account_id: unit.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,
status: :open
)
conversation = ::Conversation.create!(
account_id: unit.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,
status: :open
)
# B. Create Reservation
@reservation = ::Captain::Reservation.create!(
account_id: unit.account_id,
inbox_id: conversation.inbox_id,
contact_id: contact.id,
contact_inbox_id: conversation.contact_inbox_id,
conversation_id: conversation.id,
captain_brand_id: unit.captain_brand_id,
captain_unit_id: unit.id,
suite_identifier: params[:selected_category] || 'Standard',
check_in_at: params[:check_in_at],
check_out_at: params[:check_out_at],
total_amount: params[:total_value] || params[:total_amount],
status: :pending_payment,
payment_status: :pending,
metadata: {
observacao: params[:observacao]
}
)
# B. Create Reservation
@reservation = ::Captain::Reservation.create!(
account_id: unit.account_id,
inbox_id: conversation.inbox_id,
contact_id: contact.id,
contact_inbox_id: conversation.contact_inbox_id,
conversation_id: conversation.id,
captain_brand_id: unit.captain_brand_id,
captain_unit_id: unit.id,
suite_identifier: params[:selected_category] || 'Standard',
check_in_at: params[:check_in_at],
check_out_at: params[:check_out_at],
total_amount: params[:total_value] || params[:total_amount],
status: :pending_payment,
payment_status: :pending,
metadata: {
observacao: params[:observacao]
}
)
# C. Generate PIX (Real or Mock)
pix_result = generate_pix(unit, reservation_params)
# C. Generate PIX (Real or Mock)
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
::Captain::PixCharge.create!(
reservation_id: @reservation.id,
unit_id: unit.id,
txid: pix_result[:txid],
pix_copia_e_cola: pix_result[:pix_copy_paste],
status: 'active', # Means created/waiting
raw_webhook_payload: {}, # Will be filled by webhook later
paid_at: nil
)
# D. Save Pix Charge
::Captain::PixCharge.create!(
reservation_id: @reservation.id,
unit_id: unit.id,
txid: pix_result[:txid],
pix_copia_e_cola: pix_result[:pix_copy_paste],
status: 'active', # Means created/waiting
raw_webhook_payload: {}, # Will be filled by webhook later
paid_at: nil
)
Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted."
render_success(pix_result, @reservation.id)
end
rescue StandardError => e
Rails.logger.error "❌ Failed to create reservation: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted."
render_success(pix_result, @reservation.id)
end
rescue StandardError => e
Rails.logger.error "❌ Failed to create reservation: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def status
reservation = ::Captain::Reservation.find_by(id: params[:id])
if reservation
render json: {
id: reservation.id,
status: reservation.status,
payment_status: reservation.payment_status
}, status: :ok
else
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
def status
reservation = ::Captain::Reservation.find_by(id: params[:id])
if reservation
render json: {
id: reservation.id,
status: reservation.status,
payment_status: reservation.payment_status
}, status: :ok
else
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

View File

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

View File

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

View File

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

View File

@ -1,87 +1,85 @@
module Captain
class IntentClassificationJob < ApplicationJob
queue_as :default
class Captain::IntentClassificationJob < ApplicationJob
queue_as :default
CATEGORIES = %w[
valores
disponibilidade
localizacao
checkin_checkout
pet_friendly
cancelamento
cafe_da_manha
estacionamento
pagamento
outros
].freeze
CATEGORIES = %w[
valores
disponibilidade
localizacao
checkin_checkout
pet_friendly
cancelamento
cafe_da_manha
estacionamento
pagamento
outros
].freeze
def perform(conversation_id, message_content)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
def perform(conversation_id, message_content)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
# 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.
# Let's classify every message that is substantial enough.
return if message_content.to_s.strip.length < 5
# 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.
# Let's classify every message that is substantial enough.
return if message_content.to_s.strip.length < 5
intent = classify_intent(message_content)
return unless intent.present? && CATEGORIES.include?(intent)
intent = classify_intent(message_content)
return unless intent.present? && CATEGORIES.include?(intent)
label_name = "duvida:#{intent}"
label_name = "duvida:#{intent}"
# Add label if not present
unless conversation.labels.exists?(name: label_name)
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}"
end
rescue StandardError => e
Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}"
# Add label if not present
unless conversation.labels.exists?(name: label_name)
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}"
end
rescue StandardError => e
Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}"
end
private
private
def classify_intent(text)
# We use a simple prompt for the LLM
prompt = <<~PROMPT
Classifique a mensagem do usuário em UMA das seguintes categorias:
#{CATEGORIES.join(', ')}
def classify_intent(text)
# We use a simple prompt for the LLM
prompt = <<~PROMPT
Classifique a mensagem do usuário em UMA das seguintes categorias:
#{CATEGORIES.join(', ')}
Se não se encaixar claramente, responda 'outros'.
Responda APENAS com o nome da categoria.
Se não se encaixar claramente, responda 'outros'.
Responda APENAS com o nome da categoria.
Mensagem: "#{text}"
PROMPT
Mensagem: "#{text}"
PROMPT
# Using the existing LLM infrastructure
# 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,
# or fallback to the conversation's assistant if available.
# Using the existing LLM infrastructure
# 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,
# 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
# if available via the Agents gem or RubyLLM configuration already set up.
# 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.
messages = [{ role: 'user', content: prompt }]
messages = [{ role: 'user', content: prompt }]
# Robust API Key fetching for background jobs
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
api_key ||= ENV.fetch('OPENAI_API_KEY', nil)
# Robust API Key fetching for background jobs
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
api_key ||= ENV.fetch('OPENAI_API_KEY', nil)
# 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
# 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
client = OpenAI::Client.new(access_token: api_key)
client = OpenAI::Client.new(access_token: api_key)
response = client.chat(
parameters: {
model: 'gpt-4o-mini', # Cost effective
messages: messages,
temperature: 0.0,
max_tokens: 10
}
)
response = client.chat(
parameters: {
model: 'gpt-4o-mini', # Cost effective
messages: messages,
temperature: 0.0,
max_tokens: 10
}
)
content = response.dig('choices', 0, 'message', 'content')
content&.strip&.downcase
end
content = response.dig('choices', 0, 'message', 'content')
content&.strip&.downcase
end
end

View File

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

View File

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

View File

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

View File

@ -1,37 +1,35 @@
module Captain
class Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
class Captain::Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
belongs_to :account
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, class_name: '::Conversation', 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 :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
belongs_to :account
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, class_name: '::Conversation', 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 :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 payment_status: { pending: 'pending', paid: 'paid', failed: 'failed' }, _prefix: :payment
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
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_date_range, lambda { |from, to|
if from.present? && to.present?
where(check_in_at: from..to)
elsif from.present?
where('check_in_at >= ?', from)
elsif to.present?
where('check_in_at <= ?', to)
end
}
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_date_range, lambda { |from, to|
if from.present? && to.present?
where(check_in_at: from..to)
elsif from.present?
where('check_in_at >= ?', from)
elsif to.present?
where('check_in_at <= ?', to)
end
}
validates :suite_identifier, presence: true
validates :check_in_at, presence: true
validates :check_out_at, presence: true
validates :suite_identifier, presence: true
validates :check_in_at, presence: true
validates :check_out_at, presence: true
delegate :name, :email, :phone_number, to: :contact, prefix: true
end
delegate :name, :email, :phone_number, to: :contact, prefix: true
end

View File

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

View File

@ -1,15 +1,13 @@
module Captain
class ToolConfig < ApplicationRecord
self.table_name = 'captain_tool_configs'
belongs_to :account
belongs_to :inbox, optional: true
belongs_to :captain_assistant, class_name: 'Captain::Assistant', optional: true
class Captain::ToolConfig < ApplicationRecord
self.table_name = 'captain_tool_configs'
belongs_to :account
belongs_to :inbox, 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
validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :inbox_id] }, if: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? }
end
# Ensure tool config is either linked to an inbox or an assistant
validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :inbox_id] }, if: -> { inbox_id.present? }
validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? }
end

View File

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

View File

@ -1,105 +1,103 @@
# frozen_string_literal: true
module Captain
class HandoffWebhookService
def initialize(conversation:, assistant:, handoff_context: {})
@conversation = conversation
@assistant = assistant
@handoff_context = handoff_context
class Captain::HandoffWebhookService
def initialize(conversation:, assistant:, handoff_context: {})
@conversation = conversation
@assistant = assistant
@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
def deliver
return unless webhook_enabled?
log_success(response)
rescue StandardError => e
log_failure(e)
# Enqueue retry if configured
retry_if_needed(e) if should_retry?
end
webhook_config = @assistant.handoff_webhook_config
url = webhook_config['url']
headers = build_headers(webhook_config['headers'] || {})
private
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|
req.options.timeout = webhook_config['timeout_seconds'] || 5
end
def conversation_data
{
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)
rescue StandardError => e
log_failure(e)
# Enqueue retry if configured
retry_if_needed(e) if should_retry?
end
def contact_data
contact = @conversation.contact
{
id: contact.id,
name: contact.name,
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?
config = @assistant.handoff_webhook_config
config.present? && config['enabled'] == true && config['url'].present?
end
def build_headers(custom_headers)
{
'Content-Type' => 'application/json',
'User-Agent' => 'Chatwoot-Captain/1.0'
}.merge(custom_headers)
end
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
def should_retry?
(@assistant.handoff_webhook_config['retry_attempts'] || 0).positive?
end
def conversation_data
{
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
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 contact_data
contact = @conversation.contact
{
id: contact.id,
name: contact.name,
phone_number: contact.phone_number,
email: contact.email,
additional_attributes: contact.additional_attributes
}
end
def log_success(response)
Rails.logger.info "[HandoffWebhook] Success: #{response.status} for conversation #{@conversation.id}"
end
def assistant_data
{
id: @assistant.id,
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
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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,383 +1,379 @@
module Captain
module Llm
class JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
class Captain::Llm::JasmineBrain
Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true)
def self.decide(assistant:, conversation:, message:, history:)
new(assistant, conversation, message, history).decide
def self.decide(assistant:, conversation:, message:, history:)
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
def initialize(assistant, conversation, message, history)
@assistant = assistant
@conversation = conversation
@message = message.to_s
@history = history
@contact = conversation.contact
end
raw_response = chat.ask(@message)
parse_json(raw_response)
end
def decide
Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}"
def build_classification_prompt
# 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
return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label?
tools_list = available_tools.map do |tool|
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
# 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
<<~PROMPT
You are Jasmine, the Brain of the operation.
Your goal is to classify the user's intent based on their latest message and decide the action strategy.
sticky_decision = sticky_decision_for_message
if sticky_decision
Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}"
return sticky_decision
end
AVAILABLE INTENTS (TOOLS):
#{tools_list}
- direct: For general conversation, doubts unrelated to specific tools, or if unsure.
# [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
IMPORTANT:
- 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": "😀"}.
- 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>"}.
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']
)
Output MUST be a valid JSON object with:
{
"strategy": "execute_tool" OR "direct",
"tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL",
"tool_input": { "key": "value" } OR null,
"reasoning": "A brief explanation of why you chose this intent."
}
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?
end
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
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
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
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
def sticky_decision_for_message
return nil unless sticky_scenario_active?
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?
@conversation.labels.exists?(name: 'desligar_ia')
rescue StandardError
false
end
Decision.new(
strategy: :execute_tool,
tool_key: @conversation.active_scenario_key,
reasoning: 'Sticky scenario active',
tool_input: { pergunta_interna: @message }
)
end
def sticky_scenario_active?
return false unless @conversation.respond_to?(:active_scenario_key)
def feature_faq_enabled?
@assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true
end
expires_at = @conversation.active_scenario_expires_at
if expires_at.present? && expires_at < Time.current
clear_sticky_session
return false
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
@conversation.active_scenario_key.present?
end
# 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
]
def exit_keyword?(message)
text = message.to_s.downcase
return false if text.blank?
# Check if message is ONLY emoji(s)
only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u)
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
]
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
exit_patterns.any? { |pattern| text.match?(pattern) }
end
nil
end
def clear_sticky_session
@conversation.update!(
active_scenario_key: nil,
active_scenario_expires_at: nil,
active_scenario_state: {}
)
def question_like?(message)
text = message.to_s.strip.downcase
return false if text.empty?
# [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
text.end_with?('?') ||
text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/)
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 reservation_like?(message)
text = message.to_s.downcase
keywords = %w[
reserv agendar agendamento suite pernoite diaria
check-in checkin entrada saida horario amanha hoje
]
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
}
keywords.any? { |keyword| text.include?(keyword) }
end
Rails.logger.info payload.to_json
rescue StandardError => e
Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}"
end
def strong_reservation_intent?(message)
text = message.to_s.downcase
return false if text.blank?
def ask_brain_for_classification
system_prompt = build_classification_prompt
model = @assistant.try(:llm_model).presence || 'gpt-4o-mini'
# 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
]
chat = RubyLLM.chat(model: model)
chat = chat.with_params(
response_format: { type: 'json_object' },
temperature: 0.1
)
patterns.any? { |pattern| text.match?(pattern) }
end
chat.add_message({ role: 'system', content: system_prompt })
def check_dynamic_triggers
return nil if @message.blank?
if @history.is_a?(Array)
@history.each do |msg|
content = msg[:content]
next if content.blank?
# Carrega cenarios ativos que tenham palavras-chave definidas
scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, ''])
chat.add_message({ role: msg[:role], content: content })
end
end
text = @message.downcase.strip
raw_response = chat.ask(@message)
parse_json(raw_response)
end
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?)
def build_classification_prompt
# 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)
# Verifica se alguma palavra-chave esta contida na mensagem
match = keywords.find { |kw| text.include?(kw) }
tools_list = available_tools.map do |tool|
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")
next unless match
<<~PROMPT
You are Jasmine, the Brain of the operation.
Your goal is to classify the user's intent based on their latest message and decide the action strategy.
tool_key = "consultar_#{scenario.title.parameterize.underscore}"
Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})"
AVAILABLE INTENTS (TOOLS):
#{tools_list}
- direct: For general conversation, doubts unrelated to specific tools, or if unsure.
return Decision.new(
strategy: :execute_tool,
tool_key: tool_key,
reasoning: "Dynamic Trigger matched keyword: '#{match}'",
tool_input: { pergunta_interna: @message }
)
end
IMPORTANT:
- 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": "😀"}.
- 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>"}.
nil
end
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",
"tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL",
"tool_input": { "key": "value" } OR null,
"reasoning": "A brief explanation of why you chose this intent."
}
Example:
User: "Tem vaga agora?"
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
type: param.type,
description: param.description
}.compact
end,
required: params.select { |_, param| param.required }.keys
}
else
params
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,465 +1,459 @@
module Captain
module Tools
class CheckAvailabilityTool < BaseTool
def name
'check_availability'
end
class Captain::Tools::CheckAvailabilityTool < BaseTool
def name
'check_availability'
end
def description
'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.'
end
def description
'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)'
},
duration: {
type: 'integer',
description: 'Duração em horas (padrão: 1)'
},
date: {
type: 'string',
description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)'
},
check_in_at: {
type: 'string',
description: 'Data/hora desejada para o check-in (ISO ou texto livre)'
}
},
required: %w[suite]
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)'
},
duration: {
type: 'integer',
description: 'Duração em horas (padrão: 1)'
},
date: {
type: 'string',
description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)'
},
check_in_at: {
type: 'string',
description: 'Data/hora desejada para o check-in (ISO ou texto livre)'
}
},
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
def execute(*args, **params)
actual_params = resolve_params(args, params)
account_id = @conversation&.account_id || @assistant&.account_id
# Check keywords match
# suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" }
brand.suite_keywords&.each do |cat_name, keywords_str|
next if keywords_str.blank?
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?'."
keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip)
if keywords_list.any? { |kw| normalized_input.include?(kw) }
matched_category = cat_name
break
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]
target_date = resolve_target_date(actual_params)
Rails.logger.info "[CheckAvailabilityTool] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}"
pricing_scope = Captain::Pricing.where(account_id: account_id)
.where('suite_category ILIKE ?', final_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)
# [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
# Try to find a category that matches the input directly (case insensitive)
matched_category = nil
# 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 }
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
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
# [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(', ')
# Check keywords match
# suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" }
brand.suite_keywords&.each do |cat_name, keywords_str|
next if keywords_str.blank?
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}"
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)
if keywords_list.any? { |kw| normalized_input.include?(kw) }
matched_category = cat_name
break
end
end
break if matched_category
end
pricing = pick_pricing_for_duration(pricing_scope, requested_duration)
# Use the matched category if found, otherwise stick to the original input (fallback)
final_suite_category = matched_category || suite_category
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}"
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}'"
pricing_scope = Captain::Pricing.where(account_id: account_id)
.where('suite_category ILIKE ?', final_suite_category)
# [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
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
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
def ensure_conversation_context!
return if @conversation.present?
private
# 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
return matched if matched
end
def resolve_account_id(conversation, assistant)
conversation&.account_id || assistant&.account_id
end
# 2. Tenta match pelo texto normalizado (ex: "pernoite")
requested_text = requested_duration.to_s.strip.downcase
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
@conversation&.inbox&.captain_inbox&.unit
end
return matched if matched
def persist_last_availability(suite_category, duration, pricing, target_date)
return unless @conversation
# [FIX] Strict Mode com Log:
Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}"
nil
end
@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!
def normalize_duration_input(input)
text = input.to_s.downcase.strip
update_sticky_state(suite_category, duration, pricing, target_date)
rescue StandardError => e
Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}"
end
# 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
}
def update_sticky_state(suite_category, duration, pricing, target_date)
return unless @conversation.respond_to?(:active_scenario_state)
# Verifica palavras por extenso
word_to_num.each do |word, num|
return num if text.include?(word)
end
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
# Verifica dígitos
match = text.match(/(\d+)/)
return match[1].to_i if match
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
}
)
text # Retorna o texto original se não for número (ex: "pernoite")
end
@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 extract_duration_number(value)
return nil if value.blank?
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?
text = value.to_s.downcase
match = text.match(/(\d+)/)
match ? match[1].to_i : nil
end
# Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3)
normalized_request = normalize_duration_input(requested_duration)
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. 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
return matched if matched
end
# 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. Tenta match pelo texto normalizado (ex: "pernoite")
requested_text = requested_duration.to_s.strip.downcase
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
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)}"
# 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
def normalize_duration_input(input)
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
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).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

View File

@ -1,364 +1,361 @@
module Captain
module Tools
class CreateReservationIntentTool < BaseTool
def name
'create_reservation_intent'
end
class Captain::Tools::CreateReservationIntentTool < BaseTool
def name
'create_reservation_intent'
end
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.'
end
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.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
},
price: {
type: 'number',
description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
},
deposit_value: {
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'
}
},
required: %w[suite price]
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
},
price: {
type: 'number',
description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
},
deposit_value: {
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'
}
},
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
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)
actual_params = resolve_params(args, params)
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.now}] STARTING CreateReservationIntentTool with params: #{actual_params}"
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)..-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
# 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.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
last_availability[:price])
msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
price)}) diverge da última cotação (R$ #{format('%.2f',
last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f|
f.puts "[#{Time.zone.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}"
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)
s1.to_s.downcase.strip == s2.to_s.downcase.strip
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.zone.now}] RETURN: #{msg}" }
return msg
end
def price_mismatch?(p1, p2)
(p1.to_f - p2.to_f).abs > 0.01
end
ensure_conversation_context!
def resolve_check_in_and_out(params)
c_in = params[:check_in_at] || params[:date] || params[:day]
c_out = params[:check_out_at]
unless @conversation&.inbox
msg = "Erro Crítico: Contexto de conversa não disponível (Conversation/Inbox nil). Params: #{actual_params}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" }
return msg
end
# Try to parse check-in
begin
check_in = if c_in.present?
Time.zone.parse(c_in.to_s)
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.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
# Default to tomorrow if not specified
Time.zone.now.tomorrow.change(hour: 14)
price / 2.0
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
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.zone.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" }
return msg
end
[check_in, check_out]
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)
def send_pix_message(pix_code)
return if pix_code.blank?
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
)
@conversation.messages.create!(
content: pix_code,
message_type: :outgoing,
account: @conversation.account,
inbox: @conversation.inbox,
sender: @assistant
)
end
update_sticky_state(
suite: suite_category,
price: deposit_amount,
check_in_at: check_in_at,
check_out_at: check_out_at
)
# 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 {} 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
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.zone.now}] SUCCESS: #{msg}" }
return msg
rescue StandardError => e
error_msg = "ERRO FATAL NA CRIACAO: #{e.message} | #{e.backtrace.first}"
File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] EXCEPTION: #{error_msg}" }
return "Erro técnico ao criar reserva: #{e.message}"
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

View File

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

View File

@ -1,148 +1,144 @@
module Captain
module Tools
class GeneratePixTool < BaseTool
def name
'generate_pix'
end
class Captain::Tools::GeneratePixTool < BaseTool
def name
'generate_pix'
end
def description
'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.'
end
def description
'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
amount: {
type: 'number',
description: 'Opcional. Valor final exato para cobrar no Pix. Se informado, atualiza o valor da reserva antes de gerar.'
}
}
def tool_parameters_schema
{
type: 'object',
properties: {
amount: {
type: 'number',
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
def execute(*args, **params)
actual_params = resolve_params(args, params)
input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f > 0
# ... (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.')
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
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)
return build_pix_response(charge, pending,
prefix: 'Pix ainda válido. Segue abaixo para pagamento:')
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
return generate_new_pix(pending, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.')
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

View File

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

View File

@ -1,80 +1,76 @@
module Captain
module Tools
class ReactToMessageTool < BaseTool
def name
'react_to_message'
end
class Captain::Tools::ReactToMessageTool < BaseTool
def name
'react_to_message'
end
def description
'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.'
end
def description
'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
emoji: {
type: 'string',
description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.'
}
},
required: ['emoji']
def tool_parameters_schema
{
type: 'object',
properties: {
emoji: {
type: 'string',
description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.'
}
end
},
required: ['emoji']
}
end
def initialize(assistant, user: nil, conversation: nil)
super(assistant, user: user, conversation: conversation)
end
def initialize(assistant, user: nil, conversation: nil)
super(assistant, user: user, conversation: conversation)
end
def execute(*args, **params)
actual_params = resolve_params(args, params)
emoji = actual_params[:emoji]
return error_response('Conversation not found') unless @conversation.present?
return error_response('Emoji is required') if emoji.blank?
def execute(*args, **params)
actual_params = resolve_params(args, params)
emoji = actual_params[:emoji]
return error_response('Conversation not found') if @conversation.blank?
return error_response('Emoji is required') if emoji.blank?
# Get the last incoming message from the customer
last_customer_message = @conversation.messages.incoming.last
if last_customer_message.blank?
Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}"
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
# Get the last incoming message from the customer
last_customer_message = @conversation.messages.incoming.last
if last_customer_message.blank?
Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}"
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

View File

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

View File

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

View File

@ -1,61 +1,57 @@
module Captain
module Tools
class StatusSuitesTool < BaseTool
def self.name
'status_suites'
end
class Captain::Tools::StatusSuitesTool < BaseTool
def self.name
'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)
@conversation = conversation
super(assistant, user: user)
end
def initialize(assistant, user: nil, conversation: nil)
@conversation = conversation
super(assistant, user: user)
end
def execute(*_args, **_params)
config = find_tool_config
return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled
def execute(*_args, **_params)
config = find_tool_config
return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled
uri = URI('https://oxpi.com.br/api/PlugPlay/api/SuitesStatus')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 8
uri = URI('https://oxpi.com.br/api/PlugPlay/api/SuitesStatus')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 8
request = Net::HTTP::Get.new(uri)
request['PLUG-PLAY-ID'] = config.plug_play_id.to_s
request['PLUG-PLAY-TOKEN'] = config.plug_play_token.to_s
request = Net::HTTP::Get.new(uri)
request['PLUG-PLAY-ID'] = config.plug_play_id.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)
parsed = Captain::Tools::Parsers::StatusSuitesParser.parse(response.body)
parsed.to_json
else
{ 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
if response.is_a?(Net::HTTPSuccess)
parsed = Captain::Tools::Parsers::StatusSuitesParser.parse(response.body)
parsed.to_json
else
{ 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

View File

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

View File

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

View File

@ -1,93 +1,88 @@
module Captain
module Tools
class UpdateContactTool < BaseTool
def name
'update_contact'
end
class Captain::Tools::UpdateContactTool < BaseTool
def name
'update_contact'
end
def description
'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.'
end
def description
'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
nome: {
type: 'string',
description: 'Nome completo do cliente para cadastro.'
},
cpf: {
type: 'string',
description: 'CPF do cliente (apenas números ou formatado).'
}
},
required: []
def tool_parameters_schema
{
type: 'object',
properties: {
nome: {
type: 'string',
description: 'Nome completo do cliente para cadastro.'
},
cpf: {
type: 'string',
description: 'CPF do cliente (apenas números ou formatado).'
}
end
},
required: []
}
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.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
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 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&.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

View File

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

View File

@ -1,98 +1,96 @@
module Captain
class WhatsappNotificationService
def initialize(reservation)
@reservation = reservation
@unit = reservation.unit
@account = reservation.account
@contact = reservation.contact
@conversation = reservation.conversation
class Captain::WhatsappNotificationService
def initialize(reservation)
@reservation = reservation
@unit = reservation.unit
@account = reservation.account
@contact = reservation.contact
@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
def perform
return unless valid_for_sending?
# Ensure conversation belongs to the correct 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.
# 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)
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}"
if @conversation.inbox_id != @unit.inbox_id
# We need to find or create a conversation in the Target Inbox
target_inbox = @account.inboxes.find_by(id: @unit.inbox_id)
unless target_inbox
Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found"
return false
end
# Ensure conversation belongs to the correct 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.
# 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 contact inbox for the target inbox
contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id)
if @conversation.inbox_id != @unit.inbox_id
# We need to find or create a conversation in the Target Inbox
target_inbox = @account.inboxes.find_by(id: @unit.inbox_id)
unless target_inbox
Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found"
return false
end
# If contact doesn't exist in that inbox, we might need to create source_id (phone)?
# Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number.
contact_inbox ||= ::ContactInbox.create!(
contact_id: @contact.id,
inbox_id: target_inbox.id,
source_id: @contact.phone_number # Assuming source_id for WA is phone
)
# Find contact inbox for the target inbox
contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id)
# If contact doesn't exist in that inbox, we might need to create source_id (phone)?
# Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number.
contact_inbox ||= ::ContactInbox.create!(
contact_id: @contact.id,
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
# 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
def find_sender
# Try to find a Captain Agent Bot first
# Valid logic: defaults to the account administrator
@account.administrators.first || @account.users.first
end
true
end
def build_message_content
# Format Dates
check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M')
def find_sender
# Try to find a Captain Agent Bot first
# 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" \
"🏨 *Reserva:* ##{@reservation.id}\n" \
"📅 *Check-in:* #{check_in}\n" \
"✅ *Status:* Confirmado\n\n" \
'Estamos ansiosos para recebê-lo!'
end
def build_message_content
# Format Dates
check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M')
"Olá #{@contact.name}, confirmamos o pagamento da sua reserva no #{@unit.name}! 🎉\n\n" \
"🏨 *Reserva:* ##{@reservation.id}\n" \
"📅 *Check-in:* #{check_in}\n" \
"✅ *Status:* Confirmado\n\n" \
'Estamos ansiosos para recebê-lo!'
end
end

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