From b8fd2c61fb0f96351056fa7536eb7692c42b51c3 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 27 Oct 2025 20:30:27 +0530 Subject: [PATCH] feat: kickstart lsq transcript attachment --- app/models/message.rb | 24 +++ .../crm/leadsquared/api/activity_client.rb | 55 ++++++- .../crm/leadsquared/api/base_client.rb | 20 +++ .../mappers/conversation_mapper.rb | 19 ++- .../crm/leadsquared/processor_service.rb | 64 +++++++- lsq.md | 71 ++++++++ spec/models/message_spec.rb | 148 +++++++++++++++++ .../leadsquared/api/activity_client_spec.rb | 154 +++++++++++++++++- .../mappers/conversation_mapper_spec.rb | 91 +++++++++++ .../crm/leadsquared/processor_service_spec.rb | 93 ++++++++++- 10 files changed, 721 insertions(+), 18 deletions(-) create mode 100644 lsq.md diff --git a/app/models/message.rb b/app/models/message.rb index 5f98493d0..5ba5de998 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -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 diff --git a/app/services/crm/leadsquared/api/activity_client.rb b/app/services/crm/leadsquared/api/activity_client.rb index b9255ca8c..fce5c3154 100644 --- a/app/services/crm/leadsquared/api/activity_client.rb +++ b/app/services/crm/leadsquared/api/activity_client.rb @@ -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 diff --git a/app/services/crm/leadsquared/api/base_client.rb b/app/services/crm/leadsquared/api/base_client.rb index 09bf9f202..f12edd590 100644 --- a/app/services/crm/leadsquared/api/base_client.rb +++ b/app/services/crm/leadsquared/api/base_client.rb @@ -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 diff --git a/app/services/crm/leadsquared/mappers/conversation_mapper.rb b/app/services/crm/leadsquared/mappers/conversation_mapper.rb index c5c358cbf..5988437d6 100644 --- a/app/services/crm/leadsquared/mappers/conversation_mapper.rb +++ b/app/services/crm/leadsquared/mappers/conversation_mapper.rb @@ -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) diff --git a/app/services/crm/leadsquared/processor_service.rb b/app/services/crm/leadsquared/processor_service.rb index ef33718f2..1404ee034 100644 --- a/app/services/crm/leadsquared/processor_service.rb +++ b/app/services/crm/leadsquared/processor_service.rb @@ -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 diff --git a/lsq.md b/lsq.md new file mode 100644 index 000000000..d1bf1b398 --- /dev/null +++ b/lsq.md @@ -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 `+-` 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: + - ` Conversation Started` + - ` 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. diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 9cc43d0fd..56f69e168 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -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: '

This is HTML content

', + full: '
Full HTML
' + } + } + } + ) + 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: '

Full HTML only

' + } + } + } + ) + 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) } diff --git a/spec/services/crm/leadsquared/api/activity_client_spec.rb b/spec/services/crm/leadsquared/api/activity_client_spec.rb index 61c64cc00..10c3b060c 100644 --- a/spec/services/crm/leadsquared/api/activity_client_spec.rb +++ b/spec/services/crm/leadsquared/api/activity_client_spec.rb @@ -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 diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb index 0ddd4ac9f..85d427250 100644 --- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -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: '

This is HTML content

' + } + } + }, + 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('') + end + end + end end diff --git a/spec/services/crm/leadsquared/processor_service_spec.rb b/spec/services/crm/leadsquared/processor_service_spec.rb index 7008eb064..6fea019a2 100644 --- a/spec/services/crm/leadsquared/processor_service_spec.rb +++ b/spec/services/crm/leadsquared/processor_service_spec.rb @@ -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