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:
parent
a392d81f06
commit
2672d21136
@ -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
331
.rubocop_todo.yml
Normal 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'
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength
|
||||
module Whatsapp::BaileysHandlers::MessagesUpsert
|
||||
include Whatsapp::BaileysHandlers::Helpers
|
||||
include BaileysHelper
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
0
configure_captain_webhook.rb
Normal file → Executable 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
|
||||
|
||||
@ -8,4 +8,3 @@ class AddBrainFieldsToJasmineInboxSettings < ActiveRecord::Migration[7.1]
|
||||
add_column :jasmine_inbox_settings, :intent_keywords, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user