Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8fd2c61fb |
@@ -259,6 +259,30 @@ class Message < ApplicationRecord
|
||||
true
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
def plain_text_content
|
||||
# For email messages, extract clean plain text from HTML or text content
|
||||
if incoming_email? && content_attributes.present? && content_attributes[:email].present?
|
||||
email_content = content_attributes[:email]
|
||||
|
||||
# Try text content first
|
||||
if email_content[:text_content].present?
|
||||
text_content = email_content[:text_content][:reply] || email_content[:text_content][:full]
|
||||
return text_content if text_content.present?
|
||||
end
|
||||
|
||||
# Fall back to HTML content, strip HTML tags
|
||||
if email_content[:html_content].present?
|
||||
html_content = email_content[:html_content][:reply] || email_content[:html_content][:full]
|
||||
return ActionView::Base.full_sanitizer.sanitize(html_content) if html_content.present?
|
||||
end
|
||||
end
|
||||
|
||||
# For non-email messages, return content as-is
|
||||
content
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
||||
|
||||
private
|
||||
|
||||
def prevent_message_flooding
|
||||
|
||||
@@ -23,7 +23,8 @@ class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
|
||||
body = {
|
||||
'ActivityEventName' => name,
|
||||
'Score' => score.to_i,
|
||||
'Direction' => direction.to_i
|
||||
'Direction' => direction.to_i,
|
||||
'AllowAttachments' => 1
|
||||
}
|
||||
|
||||
response = post(path, {}, body)
|
||||
@@ -33,4 +34,56 @@ class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
|
||||
def fetch_activity_types
|
||||
get('ProspectActivity.svc/ActivityTypes.Get')
|
||||
end
|
||||
|
||||
# https://apidocs.leadsquared.com/file-upload-to-leads/
|
||||
def upload_file(activity_event_code, file_name, file_content)
|
||||
raise ArgumentError, 'Activity event code is required' if activity_event_code.blank?
|
||||
raise ArgumentError, 'File name is required' if file_name.blank?
|
||||
raise ArgumentError, 'File content is required' if file_content.blank?
|
||||
|
||||
# Derive the files host from the endpoint URL
|
||||
files_host = derive_files_host
|
||||
|
||||
form_data = {
|
||||
FileType: 0, # 0 = Document
|
||||
Entity: 1, # 1 = Activity
|
||||
Id: activity_event_code.to_s,
|
||||
FileStorageType: 0, # 0 = Permanent
|
||||
EnableResize: false,
|
||||
AccessKey: @access_key,
|
||||
SecretKey: @secret_key
|
||||
}
|
||||
|
||||
multipart_post("#{files_host}/File/Upload", file_name, file_content, form_data)
|
||||
end
|
||||
|
||||
# https://apidocs.leadsquared.com/attach-a-file-to-activities/#api
|
||||
def attach_file_to_activity(activity_id, file_url, file_name, description = nil)
|
||||
raise ArgumentError, 'Activity ID is required' if activity_id.blank?
|
||||
raise ArgumentError, 'File URL is required' if file_url.blank?
|
||||
raise ArgumentError, 'File name is required' if file_name.blank?
|
||||
|
||||
path = 'ProspectActivity.svc/Attachment/Add'
|
||||
body = {
|
||||
'ProspectActivityId' => activity_id,
|
||||
'AttachmentName' => file_name,
|
||||
'Description' => description || 'Full conversation transcript',
|
||||
'AttachmentFile' => file_url
|
||||
}
|
||||
|
||||
response = post(path, {}, body)
|
||||
response['Message']['Id']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def derive_files_host
|
||||
# Convert API host to files host
|
||||
# api-in21.leadsquared.com -> files-in21.leadsquared.com
|
||||
# api-us11.leadsquared.com -> files-us11.leadsquared.com
|
||||
# api.leadsquared.com -> files.leadsquared.com
|
||||
host = URI.parse(@base_uri).host
|
||||
files_host = host.gsub(/^api(-)?/, 'files\1')
|
||||
"https://#{files_host}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,26 @@ class Crm::Leadsquared::Api::BaseClient
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
def multipart_post(full_url, file_name, file_content, form_data = {})
|
||||
# Create temp file for upload
|
||||
temp_file = Tempfile.new([File.basename(file_name, '.*'), File.extname(file_name)])
|
||||
temp_file.binmode
|
||||
temp_file.write(file_content)
|
||||
temp_file.rewind
|
||||
|
||||
options = {
|
||||
body: form_data.merge({
|
||||
uploadFiles: temp_file
|
||||
})
|
||||
}
|
||||
|
||||
response = self.class.post(full_url, options)
|
||||
handle_response(response)
|
||||
ensure
|
||||
temp_file&.close
|
||||
temp_file&.unlink
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
|
||||
@@ -88,7 +88,24 @@ class Crm::Leadsquared::Mappers::ConversationMapper
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
message.content.presence || I18n.t('crm.no_content')
|
||||
# Use plain_text_content for email messages to get clean text
|
||||
content = message.incoming_email? ? message.plain_text_content : message.content
|
||||
content.presence || I18n.t('crm.no_content')
|
||||
end
|
||||
|
||||
def full_transcript_text
|
||||
# Generate complete transcript without truncation for file upload
|
||||
separator = "\n\n"
|
||||
header = "Conversation Transcript from #{brand_name}\n\n"
|
||||
header += "Channel: #{conversation.inbox.name}\n"
|
||||
header += "Conversation ID: #{conversation.display_id}\n"
|
||||
header += "View in #{brand_name}: #{conversation_url}\n\n"
|
||||
header += "Transcript:\n"
|
||||
header += "#{('=' * 50)}\n\n"
|
||||
|
||||
messages_text = transcript_messages.reverse.map { |message| format_message(message) }.join(separator)
|
||||
|
||||
header + messages_text
|
||||
end
|
||||
|
||||
def attachment_info(message)
|
||||
|
||||
@@ -45,13 +45,22 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
return unless @allow_transcript
|
||||
return unless conversation.status == 'resolved'
|
||||
|
||||
create_conversation_activity(
|
||||
conversation: conversation,
|
||||
activity_type: 'transcript',
|
||||
activity_code_key: 'transcript_activity_code',
|
||||
metadata_key: 'transcript_activity_id',
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
|
||||
)
|
||||
mapper = Crm::Leadsquared::Mappers::ConversationMapper.new(@hook, conversation)
|
||||
full_transcript = mapper.full_transcript_text
|
||||
activity_note = mapper.transcript_activity
|
||||
|
||||
# Check if full transcript exceeds the limit
|
||||
if full_transcript.length > Crm::Leadsquared::Mappers::ConversationMapper::ACTIVITY_NOTE_MAX_SIZE
|
||||
create_transcript_with_attachment(conversation, activity_note, full_transcript)
|
||||
else
|
||||
create_conversation_activity(
|
||||
conversation: conversation,
|
||||
activity_type: 'transcript',
|
||||
activity_code_key: 'transcript_activity_code',
|
||||
metadata_key: 'transcript_activity_id',
|
||||
activity_note: activity_note
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -118,4 +127,45 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
|
||||
lead_id
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def create_transcript_with_attachment(conversation, activity_note, full_transcript)
|
||||
lead_id = get_lead_id(conversation.contact)
|
||||
return if lead_id.blank?
|
||||
|
||||
activity_code = get_activity_code('transcript_activity_code')
|
||||
|
||||
# Add note about attached file to the activity note
|
||||
note_with_attachment_info = "#{activity_note}\n\n[Full transcript attached as file due to length]"
|
||||
|
||||
# Create the activity first
|
||||
activity_id = @activity_client.post_activity(lead_id, activity_code, note_with_attachment_info)
|
||||
return if activity_id.blank?
|
||||
|
||||
# Upload the full transcript as a text file
|
||||
file_name = "conversation_#{conversation.display_id}_transcript.txt"
|
||||
upload_response = @activity_client.upload_file(activity_code, file_name, full_transcript)
|
||||
|
||||
# Attach the file to the activity using the S3 URL
|
||||
if upload_response['s3FilePath'].present?
|
||||
@activity_client.attach_file_to_activity(
|
||||
activity_id,
|
||||
upload_response['s3FilePath'],
|
||||
file_name,
|
||||
"Full transcript for conversation ##{conversation.display_id}"
|
||||
)
|
||||
Rails.logger.info("Attached full transcript file to LeadSquared activity #{activity_id} for conversation #{conversation.id}")
|
||||
end
|
||||
|
||||
# Store the activity ID
|
||||
metadata = { 'transcript_activity_id' => activity_id }
|
||||
store_conversation_metadata(conversation, metadata)
|
||||
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "LeadSquared API error uploading transcript: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "Error uploading transcript to LeadSquared: #{e.message}"
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
end
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# LeadSquared CRM Integration (Backend)
|
||||
|
||||
## High-Level Flow
|
||||
- Accounts enable the `leadsquared` app via `config/integration/apps.yml`; the app is gated by the `crm_integration` feature flag and exposes settings for API keys, activity toggles, and scores.
|
||||
- Creating an `Integrations::Hook` (`app/models/integrations/hook.rb`) for this app stores credentials, marks the hook as `account`-scoped, and enqueues `Crm::SetupJob` to prepare LeadSquared-side prerequisites.
|
||||
- `Crm::SetupJob` (`app/jobs/crm/setup_job.rb`) instantiates `Crm::Leadsquared::SetupService` to resolve the tenant-specific API endpoint/timezone and ensure required custom activity types exist. The job persists setup output back into the hook’s `settings` json.
|
||||
- Runtime events (`contact.updated`, `conversation.created`, `conversation.resolved`) fan out through `HookListener` (`app/listeners/hook_listener.rb`) to `HookJob` (`app/jobs/hook_job.rb`), which serialises processing per hook with a Redis mutex and delegates to `Crm::Leadsquared::ProcessorService`.
|
||||
- Processor services under `app/services/crm/leadsquared/` orchestrate LeadSquared API calls, map Chatwoot data into LeadSquared payloads, and store external IDs/activity metadata on contacts and conversations to retain linkage.
|
||||
|
||||
## Key Responsibilities by File
|
||||
- `config/integration/apps.yml`: Declares the LeadSquared app, required credentials (`access_key`, `secret_key`), optional activity toggles, score defaults, and visibility of stored settings. The schema enforces the shape of hook `settings` coming from the UI.
|
||||
- `app/models/integrations/hook.rb`: Persists integration hooks, enforces uniqueness per account, validates settings via the JSON schema above, checks the `crm_integration` feature flag, and enqueues CRM setup after creation. Stores a deterministic encrypted `access_token` and exposes `feature_allowed?`.
|
||||
- `app/jobs/crm/setup_job.rb`: Background job that loads the hook, short-circuits disabled/missing hooks, and invokes the right CRM-specific setup service (currently only LeadSquared).
|
||||
- `app/services/crm/leadsquared/setup_service.rb`: Calls LeadSquared’s `Authentication.svc/UserByAccessKey.Get` to derive tenant-specific API and app hosts, updates hook settings (`endpoint_url`, `app_url`, `timezone`), and ensures custom activity types for conversation start/transcript exist (creating them if absent) while persisting their numeric codes.
|
||||
- `app/jobs/hook_job.rb`: Consumes domain events and routes them per integration. For LeadSquared it locks on `Redis::Alfred::CRM_PROCESS_MUTEX` (`lib/redis/redis_keys.rb`) to avoid creating duplicate leads during rapid event bursts, then passes processing to the CRM service.
|
||||
- `app/services/crm/base_processor_service.rb`: Shared helpers for CRM processors—enforces interface (`handle_contact_created/updated`, `handle_conversation_created/resolved`), provides helpers for checking contact identifiability, reading/storing external IDs on contacts, and attaching metadata to conversations.
|
||||
- `app/services/crm/leadsquared/processor_service.rb`: Main runtime handler. Wires API clients, honours the hook’s `enable_*` toggles, processes contacts (create/update leads) and conversations (post LeadSquared activities), and records resulting IDs/metadata back to Chatwoot models. Uses `ChatwootExceptionTracker` for surfaced errors.
|
||||
- `app/services/crm/leadsquared/lead_finder_service.rb`: Retrieves an existing lead ID from stored contact metadata, searches LeadSquared by email or phone when absent, and creates a new lead as a last resort.
|
||||
- `app/services/crm/leadsquared/api/base_client.rb`: Thin HTTParty wrapper that signs requests with LeadSquared access/secret keys, centralises response parsing, and raises `ApiError` on non-success or malformed responses.
|
||||
- `app/services/crm/leadsquared/api/lead_client.rb`: Exposes search/update/create operations for leads, including `create_or_update_lead` (default LeadSquared behaviour) and an explicit `update_lead` used when the Chatwoot contact already stores the external ID.
|
||||
- `app/services/crm/leadsquared/api/activity_client.rb`: Manages posting activities against leads and creating/fetching activity types.
|
||||
- `app/services/crm/leadsquared/mappers/contact_mapper.rb`: Normalises Chatwoot contact fields to LeadSquared attribute keys, reformats phone numbers to `+<country>-<national>` form, and injects brand/source info.
|
||||
- `app/services/crm/leadsquared/mappers/conversation_mapper.rb`: Builds human-readable activity notes for conversation creation and transcript events, trimming content to LeadSquared limits and linking back to the Chatwoot conversation URL.
|
||||
|
||||
## Lifecycle by Use Case
|
||||
|
||||
### Hook Creation & Setup
|
||||
1. Admin enables the integration, supplying at least `access_key`/`secret_key`.
|
||||
2. `Integrations::Hook` persists the hook (`hook_type: account`) and enqueues `Crm::SetupJob`.
|
||||
3. `SetupService` resolves tenant metadata, resets clients to the tenant-specific base URL, and ensures two activity types:
|
||||
- `<Brand> Conversation Started`
|
||||
- `<Brand> Conversation Transcript`
|
||||
Their numeric codes and resolved URLs/timezone are stored back into `hook.settings`.
|
||||
4. Subsequent API calls use the tenant-specific endpoint and the hook’s stored codes/timezone.
|
||||
|
||||
### Contact Sync (`contact.updated`)
|
||||
- Trigger: `HookListener#contact_updated` dispatches when a Chatwoot contact changes.
|
||||
- `HookJob` locks per hook, instantiates `Crm::Leadsquared::ProcessorService`, and calls `handle_contact`.
|
||||
- Processor reloads the latest contact, skips if lacking email/phone/social profile, and retrieves any stored LeadSquared ID.
|
||||
- With an ID it calls `LeadClient#update_lead`; otherwise it calls `LeadClient#create_or_update_lead` and persists the returned `ProspectID` into `contact.additional_attributes['external']['leadsquared_id']`.
|
||||
- Any API/runtime error is captured via `ChatwootExceptionTracker` and logged; failures do not retry via the mutex job by default.
|
||||
|
||||
### Conversation Created (`conversation.created`)
|
||||
- Trigger: `HookListener#conversation_created`.
|
||||
- `HookJob` locks and delegates to `handle_conversation_created`.
|
||||
- Processor exits early if the hook has `enable_conversation_activity` disabled.
|
||||
- Uses `LeadFinderService` to fetch or create the lead ID tied to the conversation’s contact (persisting it if newly discovered).
|
||||
- Builds the activity note via `ConversationMapper.map_conversation_activity`, fetches the configured `conversation_activity_code`, and posts the activity with `ActivityClient#post_activity`.
|
||||
- Stores the resulting activity ID under `conversation.additional_attributes['leadsquared']['created_activity_id']` for traceability.
|
||||
|
||||
### Conversation Resolved (`conversation.resolved`)
|
||||
- Trigger: `HookListener#conversation_resolved` when status transitions to `resolved`.
|
||||
- `HookJob` locks and calls `handle_conversation_resolved`.
|
||||
- Processor requires `enable_transcript_activity` to be true and the conversation status to still be `resolved`.
|
||||
- Builds a transcript note (reverse-chronological, capped at 1,800 chars, including attachments) via `ConversationMapper.map_transcript_activity`.
|
||||
- Posts the transcript activity using `transcript_activity_code` and records the returned ID as `conversation.additional_attributes['leadsquared']['transcript_activity_id']`.
|
||||
|
||||
## Stored Metadata & Settings
|
||||
- Hook settings persist credentials, resolved endpoint/app URLs, timezone, activity toggles, scores, and the created activity type codes. These values feed processor behaviour and the frontend’s “visible properties” list.
|
||||
- Contacts store `additional_attributes['external']['leadsquared_id']` once a LeadSquared prospect exists.
|
||||
- Conversations store a nested `additional_attributes['leadsquared']` hash with any activity IDs created.
|
||||
|
||||
## Error Handling & Concurrency
|
||||
- All API wrappers raise `ApiError` on non-success; callers rescue to log and forward to `ChatwootExceptionTracker`.
|
||||
- `HookJob` inherits from `MutexApplicationJob`; coupled with the per-hook Redis mutex it prevents concurrent processing of contact/conversation events that would otherwise create duplicate leads.
|
||||
- Unsupported events or disabled hooks short-circuit in `HookListener`/`HookJob`, avoiding unnecessary API calls.
|
||||
|
||||
## Extensibility Notes
|
||||
- The LeadSquared implementation follows the `Crm::BaseProcessorService` contract, simplifying future CRM additions.
|
||||
- Setup work is encapsulated so additional CRMs can hook into `Crm::SetupJob#create_setup_service`.
|
||||
- Feature flag (`crm_integration`) and hook JSON schema guardrails ensure enterprise-only exposure and predictable payloads.
|
||||
@@ -694,6 +694,154 @@ RSpec.describe Message do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#plain_text_content' do
|
||||
let(:email_channel) { create(:channel_email) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel) }
|
||||
let(:conversation) { create(:conversation, inbox: email_inbox) }
|
||||
|
||||
context 'when message is not an email' do
|
||||
let(:message) { create(:message, conversation: conversation, content: 'Regular text message') }
|
||||
|
||||
it 'returns the content as-is' do
|
||||
expect(message.plain_text_content).to eq('Regular text message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is an incoming email' do
|
||||
context 'with text content' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Email body',
|
||||
content_attributes: {
|
||||
email: {
|
||||
text_content: {
|
||||
reply: 'This is the reply text',
|
||||
full: 'This is the full text'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the reply text content' do
|
||||
expect(message.plain_text_content).to eq('This is the reply text')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text content full only' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Email body',
|
||||
content_attributes: {
|
||||
email: {
|
||||
text_content: {
|
||||
full: 'This is the full text only'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the full text content' do
|
||||
expect(message.plain_text_content).to eq('This is the full text only')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with HTML content only' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Email body',
|
||||
content_attributes: {
|
||||
email: {
|
||||
html_content: {
|
||||
reply: '<p>This is <strong>HTML</strong> content</p>',
|
||||
full: '<div>Full HTML</div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'strips HTML tags and returns plain text' do
|
||||
expect(message.plain_text_content).to eq('This is HTML content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with HTML content full only' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Email body',
|
||||
content_attributes: {
|
||||
email: {
|
||||
html_content: {
|
||||
full: '<div><p>Full <em>HTML</em> only</p></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'strips HTML tags from full content' do
|
||||
expect(message.plain_text_content).to eq('Full HTML only')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no email content attributes' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Fallback content'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the regular content' do
|
||||
expect(message.plain_text_content).to eq('Fallback content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty email content' do
|
||||
let(:message) do
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Fallback content',
|
||||
content_attributes: {
|
||||
email: {
|
||||
text_content: { reply: '', full: '' },
|
||||
html_content: { reply: '', full: '' }
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the regular content as fallback' do
|
||||
expect(message.plain_text_content).to eq('Fallback content')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reindex_for_search callback' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
@@ -141,7 +141,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
'Direction' => activity_params[:direction],
|
||||
'AllowAttachments' => 1
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
@@ -173,7 +174,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
'Direction' => activity_params[:direction],
|
||||
'AllowAttachments' => 1
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
@@ -197,7 +199,8 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
|
||||
body: {
|
||||
'ActivityEventName' => activity_params[:name],
|
||||
'Score' => activity_params[:score],
|
||||
'Direction' => activity_params[:direction]
|
||||
'Direction' => activity_params[:direction],
|
||||
'AllowAttachments' => 1
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
@@ -214,4 +217,149 @@ RSpec.describe Crm::Leadsquared::Api::ActivityClient do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#upload_file' do
|
||||
let(:activity_event_code) { 1001 }
|
||||
let(:file_name) { 'transcript.txt' }
|
||||
let(:file_content) { 'Full conversation transcript content here' }
|
||||
let(:files_url) { 'https://files.leadsquared.com/File/Upload' }
|
||||
|
||||
context 'with missing required parameters' do
|
||||
it 'raises ArgumentError when activity_event_code is missing' do
|
||||
expect { client.upload_file(nil, file_name, file_content) }
|
||||
.to raise_error(ArgumentError, 'Activity event code is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when file_name is missing' do
|
||||
expect { client.upload_file(activity_event_code, nil, file_content) }
|
||||
.to raise_error(ArgumentError, 'File name is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when file_content is missing' do
|
||||
expect { client.upload_file(activity_event_code, file_name, nil) }
|
||||
.to raise_error(ArgumentError, 'File content is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request is successful' do
|
||||
let(:s3_file_path) { 'https://landingpagecontentv2.s3.amazonaws.com/test/transcript.txt' }
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'uploadedFile' => file_name,
|
||||
'relativeFilePath' => 'https://f2.leadsquaredcdn.com/test/transcript.txt',
|
||||
's3FilePath' => s3_file_path,
|
||||
'description' => nil
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, files_url)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'uploads the file and returns the response' do
|
||||
response = client.upload_file(activity_event_code, file_name, file_content)
|
||||
expect(response['s3FilePath']).to eq(s3_file_path)
|
||||
expect(response['uploadedFile']).to eq(file_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request fails' do
|
||||
before do
|
||||
stub_request(:post, files_url)
|
||||
.to_return(
|
||||
status: 500,
|
||||
body: 'Internal Server Error',
|
||||
headers: { 'Content-Type' => 'text/plain' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError when the request fails' do
|
||||
expect { client.upload_file(activity_event_code, file_name, file_content) }
|
||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attach_file_to_activity' do
|
||||
let(:path) { 'ProspectActivity.svc/Attachment/Add' }
|
||||
let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s }
|
||||
let(:activity_id) { SecureRandom.uuid }
|
||||
let(:file_url) { 'https://landingpagecontentv2.s3.amazonaws.com/test/transcript.txt' }
|
||||
let(:file_name) { 'transcript.txt' }
|
||||
let(:description) { 'Full conversation transcript' }
|
||||
|
||||
context 'with missing required parameters' do
|
||||
it 'raises ArgumentError when activity_id is missing' do
|
||||
expect { client.attach_file_to_activity(nil, file_url, file_name) }
|
||||
.to raise_error(ArgumentError, 'Activity ID is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when file_url is missing' do
|
||||
expect { client.attach_file_to_activity(activity_id, nil, file_name) }
|
||||
.to raise_error(ArgumentError, 'File URL is required')
|
||||
end
|
||||
|
||||
it 'raises ArgumentError when file_name is missing' do
|
||||
expect { client.attach_file_to_activity(activity_id, file_url, nil) }
|
||||
.to raise_error(ArgumentError, 'File name is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when request is successful' do
|
||||
let(:attachment_id) { SecureRandom.uuid }
|
||||
let(:success_response) do
|
||||
{
|
||||
'Status' => 'Success',
|
||||
'Message' => {
|
||||
'Id' => attachment_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.with(
|
||||
body: {
|
||||
'ProspectActivityId' => activity_id,
|
||||
'AttachmentName' => file_name,
|
||||
'Description' => description,
|
||||
'AttachmentFile' => file_url
|
||||
}.to_json,
|
||||
headers: headers
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: success_response.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'attaches the file to the activity and returns attachment ID' do
|
||||
response = client.attach_file_to_activity(activity_id, file_url, file_name, description)
|
||||
expect(response).to eq(attachment_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API request fails' do
|
||||
before do
|
||||
stub_request(:post, full_url)
|
||||
.to_return(
|
||||
status: 500,
|
||||
body: 'Internal Server Error',
|
||||
headers: { 'Content-Type' => 'text/plain' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises ApiError when the request fails' do
|
||||
expect { client.attach_file_to_activity(activity_id, file_url, file_name, description) }
|
||||
.to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -251,4 +251,95 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#full_transcript_text' do
|
||||
let(:mapper) { described_class.new(hook, conversation) }
|
||||
let(:user) { create(:user, name: 'Agent Smith') }
|
||||
let(:contact) { create(:contact, name: 'Customer Jones') }
|
||||
|
||||
before do
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: 'First message',
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00'))
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: contact,
|
||||
content: 'Second message',
|
||||
message_type: :incoming,
|
||||
created_at: Time.zone.parse('2024-01-01 10:01:00'))
|
||||
end
|
||||
|
||||
it 'generates a complete transcript without truncation' do
|
||||
result = mapper.send(:full_transcript_text)
|
||||
|
||||
expect(result).to include('Conversation Transcript from TestBrand')
|
||||
expect(result).to include('Channel: Test Inbox')
|
||||
expect(result).to include("Conversation ID: #{conversation.display_id}")
|
||||
expect(result).to include('View in TestBrand:')
|
||||
expect(result).to include('Transcript:')
|
||||
expect(result).to include('=' * 50)
|
||||
expect(result).to include('[2024-01-01 10:01] Customer Jones: Second message')
|
||||
expect(result).to include('[2024-01-01 10:00] Agent Smith: First message')
|
||||
end
|
||||
|
||||
it 'includes all messages without length restriction' do
|
||||
# Create many messages
|
||||
20.times do |i|
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
sender: user,
|
||||
content: "Message number #{i}" * 50, # Long content
|
||||
message_type: :outgoing,
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00') + i.hours)
|
||||
end
|
||||
|
||||
result = mapper.send(:full_transcript_text)
|
||||
|
||||
# Verify all messages are included (no truncation)
|
||||
20.times do |i|
|
||||
expect(result).to include("Message number #{i}")
|
||||
end
|
||||
|
||||
# The full transcript can exceed ACTIVITY_NOTE_MAX_SIZE
|
||||
expect(result.length).to be > described_class::ACTIVITY_NOTE_MAX_SIZE
|
||||
end
|
||||
|
||||
context 'with email messages' do
|
||||
let(:email_channel) { create(:channel_email) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel, account: account, name: 'Email Inbox') }
|
||||
let(:email_conversation) { create(:conversation, account: account, inbox: email_inbox) }
|
||||
let(:email_mapper) { described_class.new(hook, email_conversation) }
|
||||
|
||||
it 'uses plain_text_content for email messages' do
|
||||
email_message = create(
|
||||
:message,
|
||||
conversation: email_conversation,
|
||||
sender: contact,
|
||||
message_type: :incoming,
|
||||
content_type: 'incoming_email',
|
||||
content: 'Email body',
|
||||
content_attributes: {
|
||||
email: {
|
||||
html_content: {
|
||||
reply: '<p>This is <strong>HTML</strong> content</p>'
|
||||
}
|
||||
}
|
||||
},
|
||||
created_at: Time.zone.parse('2024-01-01 10:00:00')
|
||||
)
|
||||
|
||||
# Mock plain_text_content to return stripped content
|
||||
allow(email_message).to receive(:plain_text_content).and_return('This is HTML content')
|
||||
allow(email_conversation.messages).to receive(:chat).and_return([email_message])
|
||||
|
||||
result = email_mapper.send(:full_transcript_text)
|
||||
|
||||
expect(result).to include('This is HTML content')
|
||||
expect(result).not_to include('<strong>')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -177,11 +177,13 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
|
||||
describe '#handle_conversation_resolved' do
|
||||
let(:activity_note) { 'Conversation transcript' }
|
||||
let(:full_transcript) { 'Full conversation transcript text' }
|
||||
let(:mapper) { instance_double(Crm::Leadsquared::Mappers::ConversationMapper) }
|
||||
|
||||
before do
|
||||
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
|
||||
.with(hook, conversation)
|
||||
.and_return(activity_note)
|
||||
# Stub the mapper class methods
|
||||
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(full_transcript)
|
||||
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:transcript_activity).and_return(activity_note)
|
||||
end
|
||||
|
||||
context 'when transcript activities are enabled and conversation is resolved' do
|
||||
@@ -198,9 +200,88 @@ RSpec.describe Crm::Leadsquared::ProcessorService do
|
||||
.and_return('test_activity_id')
|
||||
end
|
||||
|
||||
it 'creates the transcript activity and stores metadata' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
|
||||
context 'when transcript is within the size limit' do
|
||||
it 'creates the transcript activity and stores metadata' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
|
||||
end
|
||||
|
||||
it 'does not upload file' do
|
||||
expect(activity_client).not_to receive(:upload_file)
|
||||
service.handle_conversation_resolved(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transcript exceeds the size limit' do
|
||||
let(:long_transcript) { 'A' * 2000 } # Exceeds ACTIVITY_NOTE_MAX_SIZE (1800)
|
||||
let(:s3_file_path) { 'https://s3.amazonaws.com/test/transcript.txt' }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(long_transcript)
|
||||
|
||||
allow(activity_client).to receive(:upload_file)
|
||||
.with(1002, "conversation_#{conversation.display_id}_transcript.txt", long_transcript)
|
||||
.and_return({ 's3FilePath' => s3_file_path })
|
||||
|
||||
allow(activity_client).to receive(:attach_file_to_activity)
|
||||
.with('test_activity_id', s3_file_path, "conversation_#{conversation.display_id}_transcript.txt",
|
||||
"Full transcript for conversation ##{conversation.display_id}")
|
||||
.and_return('attachment_id')
|
||||
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
.with('test_lead_id', 1002, /Full transcript attached as file/)
|
||||
.and_return('test_activity_id')
|
||||
end
|
||||
|
||||
it 'uploads the full transcript as a file' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(activity_client).to have_received(:upload_file)
|
||||
.with(1002, "conversation_#{conversation.display_id}_transcript.txt", long_transcript)
|
||||
end
|
||||
|
||||
it 'attaches the file to the activity' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(activity_client).to have_received(:attach_file_to_activity)
|
||||
.with('test_activity_id', s3_file_path, anything, anything)
|
||||
end
|
||||
|
||||
it 'creates activity with attachment info note' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(activity_client).to have_received(:post_activity)
|
||||
.with('test_lead_id', 1002, /Full transcript attached as file/)
|
||||
end
|
||||
|
||||
it 'stores the activity metadata' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when file upload fails' do
|
||||
let(:long_transcript) { 'A' * 2000 }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:full_transcript_text).and_return(long_transcript)
|
||||
|
||||
# Stub the post_activity call that happens before upload fails
|
||||
allow(activity_client).to receive(:post_activity)
|
||||
.with('test_lead_id', 1002, /Full transcript attached as file/)
|
||||
.and_return('test_activity_id')
|
||||
|
||||
allow(activity_client).to receive(:upload_file)
|
||||
.and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('Upload error'))
|
||||
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
service.handle_conversation_resolved(conversation)
|
||||
expect(Rails.logger).to have_received(:error).with(/LeadSquared API error uploading transcript/)
|
||||
end
|
||||
|
||||
it 'does not raise an error' do
|
||||
expect { service.handle_conversation_resolved(conversation) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user