Merge branch 'develop' into feat/reporting-reopen
This commit is contained in:
@@ -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...",
|
||||
|
||||
+24
-2
@@ -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(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.TEXT') }}
|
||||
{{ ' ' }}
|
||||
<a
|
||||
:href="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_URL')
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline text-primary"
|
||||
>
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LEARN_MORE.LINK_TEXT')
|
||||
}}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating"
|
||||
|
||||
@@ -74,7 +74,7 @@ export const initWhatsAppEmbeddedSignup = configId => {
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '',
|
||||
featureType: 'whatsapp_business_app_onboarding',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+66
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,9 +53,15 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% 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? %>
|
||||
<p class="pt-2 text-xs italic text-slate-400">
|
||||
<%= @installation_configs[key]&.dig('description') %>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!)
|
||||
|
||||
Reference in New Issue
Block a user