Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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