Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Dialogflow::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:hook) { create(:integrations_hook, :dialogflow, inbox: inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:template_message) { create(:message, account: account, conversation: conversation, message_type: :template, content: 'Bot message') }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
let(:dialogflow_text_double) { double }
|
||||
|
||||
describe '#perform' do
|
||||
let(:dialogflow_service) { double }
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [
|
||||
{ text: dialogflow_text_double }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response)
|
||||
allow(processor).to receive(:get_response).and_return(dialogflow_service)
|
||||
allow(dialogflow_text_double).to receive(:to_h).and_return({ text: ['hello payload'] })
|
||||
end
|
||||
|
||||
context 'when valid message and dialogflow returns fullfillment text' do
|
||||
it 'creates the response message' do
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid message and dialogflow returns empty block' do
|
||||
it 'will not create the response message' do
|
||||
event_data = { message: template_message }
|
||||
processor = described_class.new(event_name: event_name, hook: hook, event_data: event_data)
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).not_to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dilogflow raises exception' do
|
||||
it 'tracks hook into exception tracked' do
|
||||
last_message = conversation.reload.messages.last.content
|
||||
allow(dialogflow_service).to receive(:query_result).and_raise(StandardError)
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql(last_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dilogflow settings are not present' do
|
||||
it 'will get empty response' do
|
||||
last_count = conversation.reload.messages.count
|
||||
allow(processor).to receive(:get_response).and_return({})
|
||||
hook.settings = { 'project_id' => 'something_invalid', 'credentials' => {} }
|
||||
hook.save!
|
||||
processor.perform
|
||||
|
||||
expect(conversation.reload.messages.count).to eql(last_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns fullfillment text to be empty' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { content: 'hello payload random' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates the response message based on fulfillment messages' do
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql('hello payload random')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns action' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'handoff' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'handsoff the conversation to agent' do
|
||||
processor.perform
|
||||
expect(conversation.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns action and messages if available' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'handoff' } }, { text: dialogflow_text_double }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'handsoff the conversation to agent' do
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('open')
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns resolve action' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'resolve' } }, { text: dialogflow_text_double }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'resolves the conversation without moving it to an agent' do
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('resolved')
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not bot' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is private' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message updated' do
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, private: true,
|
||||
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
||||
end
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
it 'returns submitted value for message content' do
|
||||
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_response' do
|
||||
let(:google_dialogflow) { Google::Cloud::Dialogflow::V2::Sessions::Client }
|
||||
let(:session_client) { double }
|
||||
let(:session) { double }
|
||||
let(:query_input) { { text: { text: message, language_code: 'en-US' } } }
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
before do
|
||||
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds' })
|
||||
allow(google_dialogflow).to receive(:new).and_return(session_client)
|
||||
allow(session_client).to receive(:detect_intent).and_return({ session: session, query_input: query_input })
|
||||
end
|
||||
|
||||
it 'returns intended response' do
|
||||
response = processor.send(:get_response, conversation.contact_inbox.source_id, message.content)
|
||||
expect(response[:query_input][:text][:text]).to eq(message)
|
||||
expect(response[:query_input][:text][:language_code]).to eq('en-US')
|
||||
end
|
||||
|
||||
it 'disables the hook if permission errors are thrown' do
|
||||
allow(session_client).to receive(:detect_intent).and_raise(Google::Cloud::PermissionDeniedError)
|
||||
|
||||
expect { processor.send(:get_response, conversation.contact_inbox.source_id, message.content) }
|
||||
.to change(hook, :status).from('enabled').to('disabled')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'region configuration' do
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
context 'when region is global or not specified' do
|
||||
it 'uses global endpoint and session path' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
||||
|
||||
expect(processor.send(:dialogflow_endpoint)).to eq('dialogflow.googleapis.com')
|
||||
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/agent/sessions/test-session')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when region is specified' do
|
||||
it 'uses regional endpoint and session path' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
|
||||
expect(processor.send(:dialogflow_endpoint)).to eq('europe-west1-dialogflow.googleapis.com')
|
||||
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/locations/europe-west1/agent/sessions/test-session')
|
||||
end
|
||||
end
|
||||
|
||||
it 'configures client with correct endpoint' do
|
||||
hook.update(settings: { 'project_id' => 'test', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
config = OpenStruct.new
|
||||
expect(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:configure).and_yield(config)
|
||||
|
||||
processor.send(:configure_dialogflow_client_defaults)
|
||||
expect(config.endpoint).to eq('europe-west1-dialogflow.googleapis.com')
|
||||
end
|
||||
|
||||
context 'when calling detect_intent' do
|
||||
let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
|
||||
|
||||
before do
|
||||
allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
|
||||
end
|
||||
|
||||
it 'uses global session path when region is not specified' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
||||
|
||||
expect(mock_client).to receive(:detect_intent).with(
|
||||
session: 'projects/test-project/agent/sessions/test-session',
|
||||
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
||||
)
|
||||
|
||||
processor.send(:detect_intent, 'test-session', 'Hello')
|
||||
end
|
||||
|
||||
it 'uses regional session path when region is specified' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
|
||||
expect(mock_client).to receive(:detect_intent).with(
|
||||
session: 'projects/test-project/locations/europe-west1/agent/sessions/test-session',
|
||||
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
||||
)
|
||||
|
||||
processor.send(:detect_intent, 'test-session', 'Hello')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Dyte::ProcessorService do
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:processor) { described_class.new(account: account, conversation: conversation) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :dyte, account: account)
|
||||
end
|
||||
|
||||
describe '#create_a_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates an integration message in the conversation' do
|
||||
response = processor.create_a_meeting(agent)
|
||||
expect(response[:content]).to eq("#{agent.available_name} has started a meeting")
|
||||
expect(conversation.reload.messages.last.content_type).to eq('integrations')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not create an integration message in the conversation' do
|
||||
response = processor.create_a_meeting(agent)
|
||||
expect(response).to eq({ error: { 'data' => { 'message' => 'Title is required' }, 'success' => false }, error_code: 422 })
|
||||
expect(conversation.reload.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_participant_to_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'return the authResponse' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent)
|
||||
expect(response).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Facebook::DeliveryStatus do
|
||||
subject(:message_builder) { described_class.new(message_deliveries, facebook_channel.inbox).perform }
|
||||
|
||||
before do
|
||||
stub_request(:post, /graph\.facebook\.com/)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:facebook_channel) { create(:channel_facebook_page, page_id: '117172741761305') }
|
||||
let!(:message_delivery_object) { build(:message_deliveries).to_json }
|
||||
let!(:message_deliveries) { Integrations::Facebook::MessageParser.new(message_delivery_object) }
|
||||
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_channel.inbox, source_id: '3383290475046708') }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_channel.inbox, contact: contact, contact_inbox: contact_inbox) }
|
||||
|
||||
let!(:message_read_object) { build(:message_reads).to_json }
|
||||
let!(:message_reads) { Integrations::Facebook::MessageParser.new(message_read_object) }
|
||||
let!(:message1) do
|
||||
create(:message, content: 'facebook message', message_type: 'outgoing', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
let!(:message2) do
|
||||
create(:message, content: 'facebook message', message_type: 'incoming', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message_deliveries callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is delivered' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_deliveries.delivery['watermark'].to_i).to_datetime,
|
||||
:delivered
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_deliveries.delivery['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_reads callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is read' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_reads.read['watermark'].to_i).to_datetime,
|
||||
:read
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_reads.read['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
describe Integrations::GoogleTranslate::DetectLanguageService do
|
||||
let(:account) { create(:account) }
|
||||
let(:message) { create(:message, account: account, content: 'muchas muchas gracias') }
|
||||
let(:hook) { create(:integrations_hook, :google_translate, account: account) }
|
||||
let(:translate_client) { double }
|
||||
|
||||
before do
|
||||
allow(Google::Cloud::Translate::V3::TranslationService::Client).to receive(:new).and_return(translate_client)
|
||||
allow(translate_client).to receive(:detect_language).and_return(Google::Cloud::Translate::V3::DetectLanguageResponse
|
||||
.new({ languages: [{ language_code: 'es', confidence: 0.71875 }] }))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'detects and updates the conversation language' do
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to eq('es')
|
||||
end
|
||||
|
||||
it 'will not update the conversation language if it is already present' do
|
||||
message.conversation.update!(additional_attributes: { conversation_language: 'en' })
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to eq('en')
|
||||
end
|
||||
|
||||
it 'will not update the conversation language if the message is not incoming' do
|
||||
message.update!(message_type: :outgoing)
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to be_nil
|
||||
end
|
||||
|
||||
it 'will not execute if the message content is blank' do
|
||||
message.update!(content: nil)
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,301 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Linear::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:api_key) { 'valid_api_key' }
|
||||
let(:linear_client) { instance_double(Linear) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
allow(Linear).to receive(:new).and_return(linear_client)
|
||||
end
|
||||
|
||||
describe '#teams' do
|
||||
context 'when Linear client returns valid data' do
|
||||
let(:teams_response) do
|
||||
{ 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } }
|
||||
end
|
||||
|
||||
it 'returns parsed team data' do
|
||||
allow(linear_client).to receive(:teams).and_return(teams_response)
|
||||
result = service.teams
|
||||
expect(result).to eq({ data: [{ 'id' => 'team1', 'name' => 'Team One' }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:teams).and_return(error_response)
|
||||
result = service.teams
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#team_entities' do
|
||||
let(:team_id) { 'team1' }
|
||||
let(:entities_response) do
|
||||
{
|
||||
'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] },
|
||||
'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] },
|
||||
'workflowStates' => { 'nodes' => [] },
|
||||
'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed entity data' do
|
||||
allow(linear_client).to receive(:team_entities).with(team_id).and_return(entities_response)
|
||||
result = service.team_entities(team_id)
|
||||
expect(result).to eq({ :data => { :users =>
|
||||
[{ 'id' => 'user1', 'name' => 'User One' }],
|
||||
:projects => [{ 'id' => 'project1', 'name' => 'Project One' }],
|
||||
:states => [], :labels => [{ 'id' => 'bug', 'name' => 'Bug' }] } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:team_entities).with(team_id).and_return(error_response)
|
||||
result = service.team_entities(team_id)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_issue' do
|
||||
let(:params) do
|
||||
{
|
||||
title: 'Issue title',
|
||||
team_id: 'team1',
|
||||
description: 'Issue description',
|
||||
assignee_id: 'user1',
|
||||
priority: 2,
|
||||
state_id: 'state1',
|
||||
label_ids: %w[bug]
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:issue_response) do
|
||||
{
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'issue1',
|
||||
'title' => 'Issue title',
|
||||
'identifier' => 'ENG-123'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed issue data with identifier' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(issue_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, user).and_return(issue_response)
|
||||
result = service.create_issue(params, user)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(error_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link_issue' do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
|
||||
let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed link data' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, user).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title, user)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(error_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlink_issue' do
|
||||
let(:link_id) { 'attachment1' }
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns unlink data with link_id' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
expect(result).to eq({ data: { link_id: link_id } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(error_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#search_issue' do
|
||||
let(:term) { 'search term' }
|
||||
let(:search_response) do
|
||||
{
|
||||
'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Issue title', 'description' => 'Issue description' }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed search data' do
|
||||
allow(linear_client).to receive(:search_issue).with(term).and_return(search_response)
|
||||
result = service.search_issue(term)
|
||||
expect(result).to eq({ :data => [{ 'description' => 'Issue description', 'id' => 'issue1', 'title' => 'Issue title' }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:search_issue).with(term).and_return(error_response)
|
||||
result = service.search_issue(term)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#linked_issues' do
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:linked_response) do
|
||||
{
|
||||
'attachmentsForURL' => { 'nodes' => [{ 'id' => 'attachment1', :issue => { 'id' => 'issue1' } }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed linked data' do
|
||||
allow(linear_client).to receive(:linked_issues).with(url).and_return(linked_response)
|
||||
result = service.linked_issues(url)
|
||||
expect(result).to eq({ :data => [{ 'id' => 'attachment1', 'issue' => { 'id' => 'issue1' } }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:linked_issues).with(url).and_return(error_response)
|
||||
result = service.linked_issues(url)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Tests specifically for activity message integration
|
||||
describe 'activity message data compatibility' do
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
describe '#create_issue' do
|
||||
it 'includes identifier field needed for activity messages' do
|
||||
params = { title: 'Test Issue', team_id: 'team1' }
|
||||
response = {
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'internal_id_123',
|
||||
'title' => 'Test Issue',
|
||||
'identifier' => 'ENG-456'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(response)
|
||||
result = service.create_issue(params)
|
||||
|
||||
expect(result[:data]).to have_key(:identifier)
|
||||
expect(result[:data][:identifier]).to eq('ENG-456')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link_issue' do
|
||||
it 'returns issue_id in response for activity messages' do
|
||||
link = 'https://example.com'
|
||||
issue_id = 'ENG-789'
|
||||
title = 'Test Issue'
|
||||
response = {
|
||||
'attachmentLinkURL' => {
|
||||
'attachment' => { 'id' => 'attachment123' }
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
|
||||
expect(result[:data][:id]).to eq(issue_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlink_issue' do
|
||||
it 'returns structured data for activity messages' do
|
||||
link_id = 'attachment456'
|
||||
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
|
||||
expect(result).to eq({ data: { link_id: link_id } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,269 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::LlmInstrumentation do
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
let(:instance) { test_class.new }
|
||||
let!(:otel_config) do
|
||||
InstallationConfig.find_or_create_by(name: 'OTEL_PROVIDER') do |config|
|
||||
config.value = 'langfuse'
|
||||
end
|
||||
end
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
span_name: 'llm.test',
|
||||
account_id: 123,
|
||||
conversation_id: 456,
|
||||
feature_name: 'reply_suggestion',
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ 'role' => 'user', 'content' => 'Hello' }],
|
||||
temperature: 0.7
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_create_by(name: 'LANGFUSE_SECRET_KEY') do |config|
|
||||
config.value = 'test-secret-key'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_llm_call' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
it 'executes the block and returns the result' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'creates a tracing span with the provided span name' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_tracer).to have_received(:in_span).with('llm.test')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'handles errors gracefully and captures exceptions' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
allow(mock_span).to receive(:set_attribute).and_raise(StandardError.new('Span error'))
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
|
||||
it 'sets correct request attributes on the span' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.provider.name', 'openai')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4o-mini')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.temperature', 0.7)
|
||||
end
|
||||
|
||||
it 'sets correct prompt message attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
custom_params = params.merge(
|
||||
messages: [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful assistant' },
|
||||
{ 'role' => 'user', 'content' => 'Hello' }
|
||||
]
|
||||
)
|
||||
|
||||
instance.instrument_llm_call(custom_params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.role', 'system')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.content', 'You are a helpful assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.role', 'user')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.content', 'Hello')
|
||||
end
|
||||
|
||||
it 'sets correct metadata attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
|
||||
end
|
||||
|
||||
it 'sets completion message attributes when result contains message' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{ message: 'AI response here' }
|
||||
end
|
||||
|
||||
expect(result).to eq({ message: 'AI response here' })
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.role', 'assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.content', 'AI response here')
|
||||
end
|
||||
|
||||
it 'sets usage metrics when result contains usage data' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
usage: {
|
||||
'prompt_tokens' => 150,
|
||||
'completion_tokens' => 200,
|
||||
'total_tokens' => 350
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:usage]['prompt_tokens']).to eq(150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.input_tokens', 150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.output_tokens', 200)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.total_tokens', 350)
|
||||
end
|
||||
|
||||
it 'sets error attributes when result contains error' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
mock_status = instance_double(OpenTelemetry::Trace::Status)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
allow(OpenTelemetry::Trace::Status).to receive(:error).and_return(mock_status)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
error: 'API rate limit exceeded'
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:error]).to eq('API rate limit exceeded')
|
||||
expect(mock_span).to have_received(:set_attribute)
|
||||
.with('gen_ai.response.error', '"API rate limit exceeded"')
|
||||
expect(mock_span).to have_received(:status=).with(mock_status)
|
||||
expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API rate limit exceeded')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_agent_session' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
|
||||
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
|
||||
|
||||
before do
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
end
|
||||
|
||||
it 'executes the block and returns the result' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'sets trace input and output attributes' do
|
||||
result_data = { content: 'AI response' }
|
||||
instance.instrument_agent_session(params) { result_data }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', params[:messages].to_json)
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
||||
end
|
||||
|
||||
# Regression test for Langfuse double-counting bug.
|
||||
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
|
||||
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
|
||||
# (once for the parent, once for each child GENERATION).
|
||||
# See: https://github.com/langfuse/langfuse/issues/7549
|
||||
it 'does NOT set gen_ai.request.model to avoid being classified as a GENERATION' do
|
||||
instance.instrument_agent_session(params) { 'result' }
|
||||
|
||||
expect(mock_span).not_to have_received(:set_attribute).with('gen_ai.request.model', anything)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::HookBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { SecureRandom.hex }
|
||||
let(:token) { SecureRandom.hex }
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates hook' do
|
||||
hooks_count = account.hooks.count
|
||||
|
||||
builder = described_class.new(account: account, code: code)
|
||||
allow(builder).to receive(:fetch_access_token).and_return(token)
|
||||
|
||||
builder.perform
|
||||
expect(account.hooks.count).to eql(hooks_count + 1)
|
||||
|
||||
hook = account.hooks.last
|
||||
expect(hook.access_token).to eql(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::IncomingMessageBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:message_params) { slack_message_stub }
|
||||
let(:builder) { described_class.new(hook: hook) }
|
||||
let(:private_message_params) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ text: 'pRivate: A private note message' }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:sub_type_message) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ type: 'message', subtype: 'bot_message' }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:message_without_user) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ type: 'message', user: nil }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:slack_client) { double }
|
||||
let(:link_unfurl_service) { double }
|
||||
let(:message_with_attachments) { slack_attachment_stub }
|
||||
let(:message_without_thread_ts) { slack_message_stub_without_thread_ts }
|
||||
let(:verification_params) { slack_url_verification_stub }
|
||||
|
||||
let!(:hook) { create(:integrations_hook, account: account, reference_id: message_params[:event][:channel]) }
|
||||
let!(:conversation) { create(:conversation, identifier: message_params[:event][:thread_ts]) }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.png'),
|
||||
headers: {}
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when url verification' do
|
||||
it 'return challenge code as response' do
|
||||
builder = described_class.new(verification_params)
|
||||
response = builder.perform
|
||||
expect(response[:challenge]).to eql(verification_params[:challenge])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message creation' do
|
||||
it 'doesnot create message if thread info is missing' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_without_thread_ts)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message if message already exists' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
2.times.each { builder.perform }
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
end
|
||||
|
||||
it 'creates message' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
end
|
||||
|
||||
it 'creates a private note' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(private_message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('pRivate: A private note message')
|
||||
expect(conversation.messages.last.private).to be(true)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:type] = 'invalid_event_type'
|
||||
builder = described_class.new(message_params)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event name' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:event][:type] = 'invalid_event_name'
|
||||
builder = described_class.new(message_params)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for message sub type events' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(sub_type_message)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message if user is missing' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_without_user)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type and event files is not present' do
|
||||
messages_count = conversation.messages.count
|
||||
message_with_attachments[:event][:files] = nil
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'saves attachment if params files present' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'ignore message if it is postback of CW attachment message' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
message_with_attachments[:event][:text] = 'Attached File!'
|
||||
builder = described_class.new(message_with_attachments)
|
||||
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'handles different file types correctly' do
|
||||
expect(hook).not_to be_nil
|
||||
video_attachment_params = message_with_attachments.deep_dup
|
||||
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
|
||||
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
|
||||
|
||||
builder = described_class.new(video_attachment_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link shared' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge({ links: [{ url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com' }] }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
|
||||
it 'unfurls link' do
|
||||
builder = described_class.new(link_shared)
|
||||
expect(SlackUnfurlJob).to receive(:perform_later).with(link_shared)
|
||||
builder.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::LinkUnfurlFormatter do
|
||||
let(:contact) { create(:contact) }
|
||||
let(:inbox) { create(:inbox) }
|
||||
let(:url) { 'https://example.com/app/accounts/1/conversations/100' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when unfurling a URL' do
|
||||
let(:user_info) do
|
||||
{
|
||||
user_name: contact.name,
|
||||
email: contact.email,
|
||||
phone_number: '---',
|
||||
company_name: '---'
|
||||
}
|
||||
end
|
||||
|
||||
let(:expected_payload) do
|
||||
{
|
||||
url => {
|
||||
'blocks' => [
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Name:*\n#{contact.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Email:*\n#{contact.email}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Phone:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Company:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox:*\n#{inbox.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox Type:*\n#{inbox.channel_type}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => { 'type' => 'plain_text', 'text' => 'Open conversation', 'emoji' => true },
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns expected unfurl blocks when the URL is not blank' do
|
||||
formatter = described_class.new(url: url, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq(expected_payload)
|
||||
end
|
||||
|
||||
it 'returns an empty hash when the URL is blank' do
|
||||
formatter = described_class.new(url: nil, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,406 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::SendOnSlackService do
|
||||
let!(:contact) { create(:contact) }
|
||||
let(:channel_email) { create(:channel_email) }
|
||||
let!(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: nil) }
|
||||
let(:account) { conversation.account }
|
||||
let!(:hook) { create(:integrations_hook, account: account) }
|
||||
let!(:message) do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation)
|
||||
end
|
||||
let!(:template_message) do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation, message_type: :template)
|
||||
end
|
||||
|
||||
let(:slack_message) { double }
|
||||
let(:file_attachment) { double }
|
||||
let(:slack_message_content) { double }
|
||||
let(:slack_client) { double }
|
||||
let(:builder) { described_class.new(message: message, hook: hook) }
|
||||
let(:link_builder) { described_class.new(message: nil, hook: hook) }
|
||||
let(:conversation_link) do
|
||||
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}|Click here> to view the conversation."
|
||||
end
|
||||
|
||||
before do
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(link_builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(slack_message).to receive(:[]).with('ts').and_return('12345.6789')
|
||||
allow(slack_message).to receive(:[]).with('message').and_return(slack_message_content)
|
||||
allow(slack_message_content).to receive(:[]).with('ts').and_return('6789.12345')
|
||||
allow(slack_message_content).to receive(:[]).with('thread_ts').and_return('12345.6789')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'without identifier' do
|
||||
it 'updates slack thread id in conversation' do
|
||||
inbox = conversation.inbox
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n\n#{message.content}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.reload.identifier).to eq '12345.6789'
|
||||
end
|
||||
|
||||
context 'with subject line in email' do
|
||||
let(:message) do
|
||||
create(:message,
|
||||
content_attributes: { 'email': { 'subject': 'Sample subject line' } },
|
||||
content: 'Sample Body',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox, conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates slack message with subject line' do
|
||||
inbox = conversation.inbox
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n" \
|
||||
"*Subject:* Sample subject line\n\n\n#{message.content}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.reload.identifier).to eq '12345.6789'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with identifier' do
|
||||
before do
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
end
|
||||
|
||||
it 'sent message to slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'sent message will send to the the previous thread if the slack disconnects and connects to a same channel.' do
|
||||
allow(slack_message).to receive(:[]).with('message').and_return({ 'thread_ts' => conversation.identifier })
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.identifier).to eq 'random_slack_thread_ts'
|
||||
end
|
||||
|
||||
it 'sent message will create a new thread if the slack disconnects and connects to a different channel' do
|
||||
allow(slack_message).to receive(:[]).with('message').and_return({ 'thread_ts' => nil })
|
||||
allow(slack_message).to receive(:[]).with('ts').and_return('1691652432.896169')
|
||||
|
||||
hook.update!(reference_id: 'C12345')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: 'C12345',
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(hook.reload.reference_id).to eq 'C12345'
|
||||
expect(conversation.identifier).to eq '1691652432.896169'
|
||||
end
|
||||
|
||||
it 'sent lnk unfurl to slack' do
|
||||
unflur_payload = { :channel => 'channel',
|
||||
:ts => 'timestamp',
|
||||
:unfurls =>
|
||||
{ :'https://qa.chatwoot.com/app/accounts/1/conversations/1' =>
|
||||
{ :blocks => [{ :type => 'section',
|
||||
:text => { :type => 'plain_text', :text => 'This is a plain text section block.', :emoji => true } }] } } }
|
||||
allow(slack_client).to receive(:chat_unfurl).with(unflur_payload)
|
||||
link_builder.link_unfurl(unflur_payload)
|
||||
expect(slack_client).to have_received(:chat_unfurl).with(unflur_payload)
|
||||
end
|
||||
|
||||
it 'sent attachment on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: [{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
title: attachment.file.filename.to_s
|
||||
}],
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
expect(message.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'sent multiple attachments on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).and_return(slack_message)
|
||||
|
||||
attachment1 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'logo.png', content_type: 'image/png')
|
||||
|
||||
expected_files = [
|
||||
{ filename: 'avatar.png', content: anything, title: 'avatar.png' },
|
||||
{ filename: 'logo.png', content: anything, title: 'logo.png' }
|
||||
]
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: expected_files,
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
builder.perform
|
||||
|
||||
expect(message.attachments.count).to eq 2
|
||||
end
|
||||
|
||||
it 'streams attachment blobs and uploads only once' do
|
||||
expect(slack_client).to receive(:chat_postMessage).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
blob = attachment.file.blob
|
||||
allow(blob).to receive(:open).and_call_original
|
||||
|
||||
expect(blob).to receive(:open).and_call_original
|
||||
expect(slack_client).to receive(:files_upload_v2).once.and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'handles file upload errors gracefully' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: [{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
title: attachment.file.filename.to_s
|
||||
}],
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_raise(Slack::Web::Api::Errors::SlackError.new('File upload failed'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('Failed to upload files: File upload failed')
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'will not call file_upload if attachment does not have a file (e.g facebook - fallback type)' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
message.attachments.new(account_id: message.account_id, file_type: :fallback)
|
||||
|
||||
expect(slack_client).not_to receive(:files_upload)
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
expect(message.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'sent a template message on slack' do
|
||||
builder = described_class.new(message: template_message, hook: hook)
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
template_message.update!(sender: nil)
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: template_message.content,
|
||||
username: 'Bot',
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'sent a activity message on slack' do
|
||||
template_message.update!(message_type: :activity)
|
||||
template_message.update!(sender: nil)
|
||||
builder = described_class.new(message: template_message, hook: hook)
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "_#{template_message.content}_",
|
||||
username: 'System',
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'disables hook on Slack AccountInactive error' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_raise(Slack::Web::Api::Errors::AccountInactive.new('Account disconnected'))
|
||||
|
||||
allow(hook).to receive(:prompt_reauthorization!)
|
||||
|
||||
builder.perform
|
||||
expect(hook).to be_disabled
|
||||
expect(hook).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
|
||||
it 'disables hook on Slack MissingScope error' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_raise(Slack::Web::Api::Errors::MissingScope.new('Account disconnected'))
|
||||
|
||||
allow(hook).to receive(:prompt_reauthorization!)
|
||||
|
||||
builder.perform
|
||||
expect(hook).to be_disabled
|
||||
expect(hook).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
|
||||
it 'logs MissingScope error during link unfurl' do
|
||||
unflur_payload = { channel: 'channel', ts: 'timestamp', unfurls: {} }
|
||||
error = Slack::Web::Api::Errors::MissingScope.new('Missing required scope')
|
||||
|
||||
expect(slack_client).to receive(:chat_unfurl)
|
||||
.with(unflur_payload)
|
||||
.and_raise(error)
|
||||
|
||||
expect(Rails.logger).to receive(:warn).with('Slack: Missing scope error: Missing required scope')
|
||||
|
||||
link_builder.link_unfurl(unflur_payload)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains mentions' do
|
||||
it 'sends formatted message to slack along with inbox name when identifier not present' do
|
||||
inbox = conversation.inbox
|
||||
message.update!(content: "Hi [@#{contact.name}](mention://user/#{contact.id}/#{contact.name}), welcome to Chatwoot!")
|
||||
formatted_message_text = message.content.gsub(RegexHelper::MENTION_REGEX, '\1')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n\n#{formatted_message_text}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'sends formatted message to slack when identifier is present' do
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
message.update!(content: "Hi [@#{contact.name}](mention://user/#{contact.id}/#{contact.name}), welcome to Chatwoot!")
|
||||
formatted_message_text = message.content.gsub(RegexHelper::MENTION_REGEX, '\1')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: formatted_message_text,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: 'random_slack_thread_ts',
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'will not throw error if message content is nil' do
|
||||
message.update!(content: nil)
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::SlackLinkUnfurlService do
|
||||
let!(:contact) { create(:contact, name: 'Contact 1', email: nil, phone_number: nil) }
|
||||
let(:channel_email) { create(:channel_email) }
|
||||
let!(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: nil) }
|
||||
let(:account) { conversation.account }
|
||||
let!(:hook) { create(:integrations_hook, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the event does not contain any link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: []),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the account id which does not match the integration hook account id' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1212/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the conversation id which does not belong to the account' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1213',
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing single link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'sends a POST unfurl request to Slack' do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([{ url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1', domain: 'qa.chatwoot.com' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing multiple links' do
|
||||
let(:link_shared_1) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
},
|
||||
{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared_1, integration_hook: hook) }
|
||||
|
||||
it('sends multiple POST unfurl request to Slack') do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
expect { link_builder.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user