From 4ebfae8b44327100cb9602a4850e4ff972835e52 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 8 Aug 2025 17:56:49 +0530 Subject: [PATCH 1/3] fix: date filter breaking in custom view (#12132) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../conversations/helpers/filterHelpers.js | 9 ++- .../helpers/specs/filterHelpers.spec.js | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js index d64d3c56b..3f1c32059 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js @@ -167,7 +167,14 @@ const contains = (filterValue, conversationValue) => { */ const compareDates = (conversationValue, filterValue, compareFn) => { const conversationDate = coerceToDate(conversationValue); - const filterDate = coerceToDate(filterValue); + + // In saved views, the filterValue might be returned as an Array + // In conversation list, when filtering, the filterValue will be returned as a string + const valueToCompare = Array.isArray(filterValue) + ? filterValue[0] + : filterValue; + const filterDate = coerceToDate(valueToCompare); + if (conversationDate === null || filterDate === null) return false; return compareFn(conversationDate, filterDate); }; diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js index 0c9e6a5c0..b36128819 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js @@ -602,6 +602,59 @@ describe('filterHelpers', () => { expect(matchesFilters(conversation, filters)).toBe(false); }); + // Test for array filter values (saved views) + it('should handle array filter values for date comparison in saved views', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: ['2022-03-19'], // Array format from saved views + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should handle array filter values with is_less_than operator', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_less_than', + values: ['2022-03-21'], // Array format from saved views + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should handle array filter values with timestamp', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: [1647691200], // March 19, 2022 as array (in seconds) + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should handle empty array filter values', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'is_greater_than', + values: [], // Empty array + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + it('should handle non-date string values in date comparison', () => { const conversation = { created_at: 1647777600000 }; // March 20, 2022 const filters = [ @@ -721,6 +774,19 @@ describe('filterHelpers', () => { ]; expect(matchesFilters(conversation, filters)).toBe(true); }); + + it('should handle array values for days_before operator', () => { + const conversation = { created_at: 1647777600000 }; // March 20, 2022 + const filters = [ + { + attribute_key: 'created_at', + filter_operator: 'days_before', + values: ['3'], // Array format from saved views + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); }); // Multiple filters tests From b5f5c5c1bc130015eafca1566c91af7b95e934f3 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 8 Aug 2025 17:57:30 +0530 Subject: [PATCH 2/3] feat: add more tools (#12116) Co-authored-by: Muhsin Keloth --- config/agents/tools.yml | 10 ++ .../lib/captain/tools/faq_lookup_tool.rb | 39 ++++ enterprise/lib/captain/tools/handoff_tool.rb | 52 ++++++ .../lib/captain/tools/update_priority_tool.rb | 4 +- .../lib/captain/tools/faq_lookup_tool_spec.rb | 120 +++++++++++++ .../lib/captain/tools/handoff_tool_spec.rb | 166 ++++++++++++++++++ 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 enterprise/lib/captain/tools/faq_lookup_tool.rb create mode 100644 enterprise/lib/captain/tools/handoff_tool.rb create mode 100644 spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/handoff_tool_spec.rb 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/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 From fd28ed8d83499aab42b1f7dcd1f4dc1204a9803d Mon Sep 17 00:00:00 2001 From: Petterson <58094725+hahuma@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:58:50 -0300 Subject: [PATCH 3/3] feat: add support to embedded whatsapp coexistence method (#12108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update adds support to the coexistence method to Embedded Whatsapp, allowing users to add their existing whatsapp business number in order to use it in both places(chatwoot and whatsapp business) at the same time. This update require some changes in the permissions for the Meta App, as described in the Meta Oficial Docs, I'll leave this listed below: - **history** — describes past messages the business customer has sent/received - **smb_app_state_sync** — describes the business customer's current and new contacts - **smb_message_echoes** — describes any new messages the business customer sends with the WhatsApp Business app after having been onboarded Co-authored-by: Muhsin Keloth Co-authored-by: Sojan Jose Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> --- .../dashboard/i18n/locale/en/inboxMgmt.json | 5 ++ .../inbox/channels/WhatsappEmbeddedSignup.vue | 26 +++++- .../settings/inbox/channels/whatsapp/utils.js | 2 +- app/services/whatsapp/facebook_api_client.rb | 10 +++ .../whatsapp/webhook_setup_service.rb | 12 ++- .../super_admin/app_configs/show.html.erb | 12 ++- config/installation_config.yml | 3 +- .../whatsapp/webhook_setup_service_spec.rb | 83 +++++++++++++------ 8 files changed, 120 insertions(+), 33 deletions(-) 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(() => { +
+ + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }} + {{ ' ' }} + + {{ + $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT') + }} + + +
+
{ override_default_response_type: true, extras: { setup: {}, - featureType: '', + featureType: 'whatsapp_business_app_onboarding', sessionInfoVersion: '3', }, } diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 441e41b1a..55ce4e698 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -50,6 +50,16 @@ class Whatsapp::FacebookApiClient handle_response(response, 'Phone registration failed') end + def phone_number_verified?(phone_number_id) + response = HTTParty.get( + "#{BASE_URI}/#{@api_version}/#{phone_number_id}", + headers: request_headers + ) + + data = handle_response(response, 'Phone status check failed') + data['code_verification_status'] == 'VERIFIED' + end + def subscribe_waba_webhook(waba_id, callback_url, verify_token) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb index 6aff531f7..a3faaaa56 100644 --- a/app/services/whatsapp/webhook_setup_service.rb +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -8,7 +8,8 @@ class Whatsapp::WebhookSetupService def perform validate_parameters! - register_phone_number + # Since coexistence method does not need to register, we check it + register_phone_number unless phone_number_verified? setup_webhook end @@ -64,4 +65,13 @@ class Whatsapp::WebhookSetupService "#{frontend_url}/webhooks/whatsapp/#{phone_number}" end + + def phone_number_verified? + phone_number_id = @channel.provider_config['phone_number_id'] + + @api_client.phone_number_verified?(phone_number_id) + rescue StandardError => e + Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}") + false + end end diff --git a/app/views/super_admin/app_configs/show.html.erb b/app/views/super_admin/app_configs/show.html.erb index 6f95a6418..8bf513597 100644 --- a/app/views/super_admin/app_configs/show.html.erb +++ b/app/views/super_admin/app_configs/show.html.erb @@ -53,9 +53,15 @@
- <% else %> - <%= form.text_field "app_config[#{key}]", value: @app_config[key] %> - <% end %> + <% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %> + <%= form.select "app_config[#{key}]", + @installation_configs[key]['options'].map { |val, label| [label, val] }, + { selected: @app_config[key] }, + class: "mt-2 border border-slate-100 p-1 rounded-md" + %> + <% else %> + <%= form.text_field "app_config[#{key}]", value: @app_config[key] %> + <% end %> <%if @installation_configs[key]&.dig('description').present? %>

<%= @installation_configs[key]&.dig('description') %> 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/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!)