Compare commits

...
Author SHA1 Message Date
Shivam Mishra b8fd2c61fb feat: kickstart lsq transcript attachment 2025-10-27 20:30:27 +05:30
10 changed files with 721 additions and 18 deletions
+24
View File
@@ -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
+71
View File
@@ -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 hooks `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 LeadSquareds `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 hooks `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 hooks 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 conversations 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 frontends “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.
+148
View File
@@ -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