diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 821e2ecbd..4006212a3 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -280,6 +280,11 @@ "SECURE_AUTH": "Secure OAuth based authentication", "AUTO_CONFIG": "Automatic webhook and phone number configuration" }, + "LEARN_MORE": { + "TEXT": "To learn more about integrated signup, pricing, and limitations, visit", + "LINK_TEXT": "this link.", + "LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations" + }, "SUBMIT_BUTTON": "Connect with WhatsApp Business", "AUTH_PROCESSING": "Authenticating with Meta", "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue index 384082b71..583ca2413 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -107,7 +107,7 @@ const completeSignupFlow = async businessDataParam => { code: authCode.value, business_id: businessDataParam.business_id, waba_id: businessDataParam.waba_id, - phone_number_id: businessDataParam.phone_number_id, + phone_number_id: businessDataParam?.phone_number_id || '', }; const responseData = await store.dispatch( @@ -127,7 +127,10 @@ const completeSignupFlow = async businessDataParam => { // Message handling const handleEmbeddedSignupData = async data => { - if (data.event === 'FINISH') { + if ( + data.event === 'FINISH' || + data.event === 'FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING' + ) { const businessDataLocal = data.data; if (isValidBusinessData(businessDataLocal)) { @@ -262,6 +265,25 @@ onBeforeUnmount(() => { +
<%= @installation_configs[key]&.dig('description') %> diff --git a/config/agents/tools.yml b/config/agents/tools.yml index b994e93d3..c2faf75e7 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -24,3 +24,13 @@ title: 'Add Label to Conversation' description: 'Add a label to a conversation' icon: 'tag' + +- id: faq_lookup + title: 'FAQ Lookup' + description: 'Search FAQ responses using semantic similarity' + icon: 'search' + +- id: handoff + title: 'Handoff to Human' + description: 'Hand off the conversation to a human agent' + icon: 'user-switch' diff --git a/config/installation_config.yml b/config/installation_config.yml index 53251fc3c..01799e830 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -10,7 +10,8 @@ # locked: if you don't specify locked attribute in yaml, the default value will be true, # which means the particular config will be locked and won't be available in `super_admin/installation_configs` # premium: These values get overwritten unless the user is on a premium plan -# type: The type of the config. Default is text, boolean is also supported +# type: The type of the config. Default is text, select and boolean are also supported +# options: For select types, its required to have options for the select in the following pattern: "option_value":"Human readable option" # ------- Branding Related Config ------- # - name: INSTALLATION_NAME diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb new file mode 100644 index 000000000..d09b7c2d7 --- /dev/null +++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb @@ -0,0 +1,39 @@ +class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool + description 'Search FAQ responses using semantic similarity to find relevant answers' + param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database' + + def perform(_tool_context, query:) + log_tool_usage('searching', { query: query }) + + # Use existing vector search on approved responses + responses = @assistant.responses.approved.search(query).to_a + + if responses.empty? + log_tool_usage('no_results', { query: query }) + "No relevant FAQs found for: #{query}" + else + log_tool_usage('found_results', { query: query, count: responses.size }) + format_responses(responses) + end + end + + private + + def format_responses(responses) + responses.map { |response| format_response(response) }.join + end + + def format_response(response) + formatted_response = " + Question: #{response.question} + Answer: #{response.answer} + " + if response.documentable.present? && response.documentable.try(:external_link) + formatted_response += " + Source: #{response.documentable.external_link} + " + end + + formatted_response + end +end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb new file mode 100644 index 000000000..49f7c5a65 --- /dev/null +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -0,0 +1,52 @@ +class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool + description 'Hand off the conversation to a human agent when unable to assist further' + param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false + + def perform(tool_context, reason: nil) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + # Log the handoff with reason + log_tool_usage('tool_handoff', { + conversation_id: conversation.id, + reason: reason || 'Agent requested handoff' + }) + + # Use existing handoff mechanism from ResponseBuilderJob + trigger_handoff(conversation, reason) + + "Conversation handed off to human support team#{" (Reason: #{reason})" if reason}" + rescue StandardError => e + ChatwootExceptionTracker.new(e).capture_exception + 'Failed to handoff conversation' + end + + private + + def trigger_handoff(conversation, reason) + # post the reason as a private note + conversation.messages.create!( + message_type: :outgoing, + private: true, + sender: @assistant, + account: conversation.account, + inbox: conversation.inbox, + content: reason + ) + + # Trigger the bot handoff (sets status to open + dispatches events) + conversation.bot_handoff! + end + + # TODO: Future enhancement - Add team assignment capability + # This tool could be enhanced to: + # 1. Accept team_id parameter for routing to specific teams + # 2. Set conversation priority based on handoff reason + # 3. Add metadata for intelligent agent assignment + # 4. Support escalation levels (L1 -> L2 -> L3) + # + # Example future signature: + # param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false + # param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false + # param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false +end diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb index 8fc75f601..1196911fa 100644 --- a/enterprise/lib/captain/tools/update_priority_tool.rb +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool end def normalize_priority(priority) - priority == 'nil' || priority.blank? ? nil : priority + return nil if priority == 'nil' || priority.blank? + + priority.downcase end def valid_priority?(priority) diff --git a/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb new file mode 100644 index 000000000..ccae44ac2 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::FaqLookupTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:tool_context) { Struct.new(:state).new({}) } + + before do + # Create installation config for OpenAI API key to avoid errors + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + + # Mock embedding service to avoid actual API calls + embedding_service = instance_double(Captain::Llm::EmbeddingService) + allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service) + allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1)) + end + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Search FAQ responses using semantic similarity to find relevant answers') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:query) + expect(tool.parameters[:query].name).to eq(:query) + expect(tool.parameters[:query].type).to eq('string') + expect(tool.parameters[:query].description).to eq('The question or topic to search for in the FAQ database') + end + end + + describe '#perform' do + context 'when FAQs exist' do + let(:document) { create(:captain_document, assistant: assistant) } + let!(:response1) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to reset password?', + answer: 'Click on forgot password link', + documentable: document, + status: 'approved') + end + let!(:response2) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to change email?', + answer: 'Go to settings and update email', + status: 'approved') + end + + before do + # Mock nearest_neighbors to return our test responses + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return( + Captain::AssistantResponse.where(id: [response1.id, response2.id]) + ) + end + + it 'searches FAQs and returns formatted responses' do + result = tool.perform(tool_context, query: 'password reset') + + expect(result).to include('Question: How to reset password?') + expect(result).to include('Answer: Click on forgot password link') + expect(result).to include('Question: How to change email?') + expect(result).to include('Answer: Go to settings and update email') + end + + it 'includes source link when document has external_link' do + document.update!(external_link: 'https://help.example.com/password') + + result = tool.perform(tool_context, query: 'password') + + expect(result).to include('Source: https://help.example.com/password') + end + + it 'logs tool usage for search' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' }) + expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 }) + + tool.perform(tool_context, query: 'password reset') + end + end + + context 'when no FAQs found' do + before do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + end + + it 'returns no results message' do + result = tool.perform(tool_context, query: 'nonexistent topic') + expect(result).to eq('No relevant FAQs found for: nonexistent topic') + end + + it 'logs tool usage for no results' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'nonexistent topic' }) + expect(tool).to receive(:log_tool_usage).with('no_results', { query: 'nonexistent topic' }) + + tool.perform(tool_context, query: 'nonexistent topic') + end + end + + context 'with blank query' do + it 'handles empty query' do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + + result = tool.perform(tool_context, query: '') + expect(result).to eq('No relevant FAQs found for: ') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb new file mode 100644 index 000000000..16b46c08a --- /dev/null +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::HandoffTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:reason) + expect(tool.parameters[:reason].name).to eq(:reason) + expect(tool.parameters[:reason].type).to eq('string') + expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)') + expect(tool.parameters[:reason].required).to be false + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with reason provided' do + it 'creates a private note with reason and hands off conversation' do + reason = 'Customer needs specialized support' + + expect do + result = tool.perform(tool_context, reason: reason) + expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})") + end.to change(Message, :count).by(1) + end + + it 'creates message with correct attributes' do + reason = 'Customer needs specialized support' + tool.perform(tool_context, reason: reason) + + created_message = Message.last + expect(created_message.content).to eq(reason) + expect(created_message.message_type).to eq('outgoing') + expect(created_message.private).to be true + expect(created_message.sender).to eq(assistant) + expect(created_message.account).to eq(account) + expect(created_message.inbox).to eq(inbox) + expect(created_message.conversation).to eq(conversation) + end + + it 'triggers bot handoff on conversation' do + # The tool finds the conversation by ID, so we need to mock the found conversation + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + expect(found_conversation).to receive(:bot_handoff!) + + tool.perform(tool_context, reason: 'Test reason') + end + + it 'logs tool usage with reason' do + reason = 'Customer needs help' + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: reason } + ) + + tool.perform(tool_context, reason: reason) + end + end + + context 'without reason provided' do + it 'creates a private note with nil content and hands off conversation' do + expect do + result = tool.perform(tool_context) + expect(result).to eq('Conversation handed off to human support team') + end.to change(Message, :count).by(1) + + created_message = Message.last + expect(created_message.content).to be_nil + end + + it 'logs tool usage with default reason' do + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: 'Agent requested handoff' } + ) + + tool.perform(tool_context) + end + end + + context 'when handoff fails' do + before do + # Mock the conversation lookup and handoff failure + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error') + + exception_tracker = instance_double(ChatwootExceptionTracker) + allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + end + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Failed to handoff conversation') + end + + it 'captures exception' do + exception_tracker = instance_double(ChatwootExceptionTracker) + expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker) + expect(exception_tracker).to receive(:capture_exception) + + tool.perform(tool_context, reason: 'Test') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, reason: 'Test') + end.not_to change(Message, :count) + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index 89beca922..7cee115c2 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -24,25 +24,19 @@ describe Whatsapp::WebhookSetupService do end describe '#perform' do - context 'when all operations succeed' do + context 'when phone number is NOT verified (should register)' do before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token') - .and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) allow(channel).to receive(:save!) end - it 'registers the phone number' do + it 'registers the phone number and sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) - service.perform - end - end - - it 'sets up webhook subscription' do - with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:subscribe_waba_webhook) .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') service.perform @@ -50,33 +44,71 @@ describe Whatsapp::WebhookSetupService do end end - context 'when phone registration fails' do + context 'when phone number IS verified (should NOT register)' do before do - allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) - allow(api_client).to receive(:register_phone_number) - .and_raise('Registration failed') + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true) allow(api_client).to receive(:subscribe_waba_webhook) - .and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) end - it 'continues with webhook setup' do + it 'does NOT register phone, but sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).not_to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + service.perform + end + end + end + + context 'when phone_number_verified? raises error' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down') + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number) + allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'tries to register phone and proceeds with webhook setup' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) expect(api_client).to receive(:subscribe_waba_webhook) expect { service.perform }.not_to raise_error end end end - context 'when webhook setup fails' do + context 'when phone registration fails (not blocking)' do before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) + allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) + allow(api_client).to receive(:register_phone_number).and_raise('Registration failed') + allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) + allow(channel).to receive(:save!) + end + + it 'continues with webhook setup even if registration fails' do + with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) + expect { service.perform }.not_to raise_error + end + end + end + + context 'when webhook setup fails (should raise)' do + before do + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number) - allow(api_client).to receive(:subscribe_waba_webhook) - .and_raise('Webhook failed') + allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Webhook failed') end it 'raises an error' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do + expect(api_client).to receive(:register_phone_number) + expect(api_client).to receive(:subscribe_waba_webhook) expect { service.perform }.to raise_error(/Webhook setup failed/) end end @@ -84,24 +116,25 @@ describe Whatsapp::WebhookSetupService do context 'when required parameters are missing' do it 'raises error when channel is nil' do - service = described_class.new(nil, waba_id, access_token) - expect { service.perform }.to raise_error(ArgumentError, 'Channel is required') + service_invalid = described_class.new(nil, waba_id, access_token) + expect { service_invalid.perform }.to raise_error(ArgumentError, 'Channel is required') end it 'raises error when waba_id is blank' do - service = described_class.new(channel, '', access_token) - expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required') + service_invalid = described_class.new(channel, '', access_token) + expect { service_invalid.perform }.to raise_error(ArgumentError, 'WABA ID is required') end it 'raises error when access_token is blank' do - service = described_class.new(channel, waba_id, '') - expect { service.perform }.to raise_error(ArgumentError, 'Access token is required') + service_invalid = described_class.new(channel, waba_id, '') + expect { service_invalid.perform }.to raise_error(ArgumentError, 'Access token is required') end end context 'when PIN already exists' do before do channel.provider_config['verification_pin'] = 123_456 + allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false) allow(api_client).to receive(:register_phone_number) allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true }) allow(channel).to receive(:save!)