Files
moreminimore-chat/app/services/data_imports/importer.rb
Sony Mathew 56be133bb0 feat(data-imports): add Freshdesk migration (1/3) (#15261)
## Description

Adds Freshdesk as an integration import source so administrators can
validate a Freshdesk domain and API key, then import contacts, tickets,
public replies, customer replies, and private notes while tracking
progress from Data Imports.

The integration has now been validated against a live Freshdesk trial
tenant with contacts, Web Chat and phone tickets, public replies,
customer replies, a private note, pagination, requester expansion, and
attachment metadata. That validation found and fixed the current Web
Chat source mapping and prevented the ticket description from
duplicating the initial Web Chat message.

Related: #15116

## Closes

Closes
[CW-7639](https://linear.app/chatwoot/issue/CW-7639/freshdesk-freshworks-migration)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## What changed

- Added a shared source adapter, importer, job, retry, restart,
creation, and placeholder inbox contract used by Intercom and Freshdesk.
- Added Freshdesk API authentication, contact and ticket pagination,
requester expansion, conversation retrieval, normalization, channel
grouping, and error handling.
- Added current Freshdesk source identifiers through SMS, including Web
Chat source `15`, and grouped equivalent sources into placeholder
inboxes.
- Used Web Chat conversation events as the complete message history so
the generated ticket description does not duplicate the initial customer
message.
- Preserved Freshdesk ticket subjects in source metadata and added a
sanitized live-derived Web Chat fixture with structured bodies and
attachment metadata.
- Added Freshdesk selection, domain and API key validation, and
provider-neutral import status handling in the Data Imports UI.

## How to test

1. Enable the data_import feature for an account and open Settings >
Data > New import.
2. Select Freshdesk and enter a Freshdesk domain and API key.
3. Select contacts and/or conversations, validate the credentials, and
start the import.
4. Confirm progress is displayed and imported tickets appear as resolved
conversations in Freshdesk placeholder inboxes with public replies and
private notes preserved.
5. Verify Web Chat tickets appear in the Chat placeholder inbox and the
initial customer message is imported once.
6. Verify an abandoned import can be restarted and a stalled import can
be retried.

## Current scope

- **Product decision:** Attachment binaries are intentionally not
imported in the current migration scope. Attachment metadata is
preserved and messages include a skipped-attachment marker.
- Adaptive Retry-After scheduling and handling the 30,000-ticket listing
ceiling are covered by stacked follow-up PRs.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-08-05 16:25:30 +05:30

1351 lines
48 KiB
Ruby

# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations
class DataImports::Importer
class InvalidMessagePayloadError < StandardError; end
PageResult = Struct.new(:next_cursor, keyword_init: true) do
def done?
next_cursor.blank?
end
end
DEFAULT_IMPORT_TYPES = %w[contacts conversations].freeze
MESSAGES_PER_BATCH = 100
HEARTBEAT_INTERVAL = 1.minute
QUERY_TIMEOUT_RETRY_LIMIT = 1
QUERY_TIMEOUT_RETRY_DELAY_RANGE = (0.2..0.5)
MESSAGE_MAPPING_UNIQUE_INDEX = :idx_data_import_mappings_on_account_and_source
E164_REGEX = /\A\+[1-9]\d{1,14}\z/
PHONE_WITHOUT_PLUS_REGEX = /\A[1-9]\d{1,14}\z/
MessageBatchResult = Struct.new(
:imported_entries,
:skipped_entries,
:current_entries,
:previous_entries,
:messages,
:failed_entries,
keyword_init: true
)
def initialize(data_import:, run_id: nil, source: nil)
@data_import = data_import
@run_id = run_id
@account = data_import.account
@source = source || DataImports::Source.for(data_import)
@placeholder_inboxes = @source.placeholder_inbox_builder(account: @account)
@stats = default_stats.deep_merge(data_import.stats || {})
@persisted_cursor = data_import.cursor.to_h.deep_stringify_keys
@persisted_stats = @stats.deep_dup
@dirty_stat_groups = {}
end
def perform
return unless start!
import_contacts if import_type?('contacts')
import_conversations if import_type?('conversations')
finish!
rescue StandardError => e
fail!(e)
raise
end
def start!
return if @data_import.reload.abandoned?
@data_import.update!(status: :processing, started_at: @data_import.started_at || Time.current)
end
def finish!
@data_import.with_lock do
next if @data_import.abandoned? || stale_import_run?
error_count = @data_import.import_errors.non_skip_logs.count + @data_import.import_errors.failed.count
@stats['errors']['count'] = error_count
status = error_count.positive? ? :completed_with_errors : :completed
@data_import.update!(
status: status,
completed_at: Time.current,
stats: @stats,
total_records: total_processed_records,
processed_records: total_successful_records
)
end
end
def fail!(error)
@data_import.with_lock do
next if @data_import.abandoned? || stale_import_run?
record_run_error(error)
@data_import.update!(status: :failed, last_error_at: Time.current)
end
end
def import_contacts_page(starting_after: cursor_for('contacts'))
response = @source.list_contacts(starting_after: starting_after, per_page: @source.contacts_per_page)
update_stat_total('contacts', response['total_count']) if response['total_count'].present?
Array(response['data'] || response['contacts']).each do |contact|
break if import_stopped?
import_contact(contact)
end
return PageResult.new(next_cursor: nil) if import_stopped?
reconcile_dirty_stats
next_cursor = response.dig('pages', 'next', 'starting_after')
next_cursor = update_cursor('contacts', next_cursor)
PageResult.new(next_cursor: next_cursor)
end
def import_conversations_page(starting_after: cursor_for('conversations'))
response = @source.list_conversations(starting_after: starting_after, per_page: @source.conversations_per_page)
update_stat_total('conversations', response['total_count']) if response['total_count'].present?
Array(response['data'] || response['conversations']).each do |conversation_summary|
break if import_stopped?
import_conversation_from_summary(conversation_summary)
end
return PageResult.new(next_cursor: nil) if import_stopped?
reconcile_dirty_stats
next_cursor = response.dig('pages', 'next', 'starting_after')
next_cursor = update_cursor('conversations', next_cursor)
PageResult.new(next_cursor: next_cursor)
end
def import_contacts?
import_type?('contacts')
end
def import_conversations?
import_type?('conversations')
end
def contacts_completed?
stage_completed?('contacts')
end
def conversations_completed?
stage_completed?('conversations')
end
def cursor_for(key)
@data_import.cursor&.dig(key, 'starting_after')
end
private
def import_contacts
cursor = cursor_for('contacts')
loop do
result = import_contacts_page(starting_after: cursor)
break if result.done?
cursor = result.next_cursor
end
end
def import_conversations
cursor = cursor_for('conversations')
loop do
result = import_conversations_page(starting_after: cursor)
break if result.done?
cursor = result.next_cursor
end
end
def import_conversation_from_summary(conversation_summary)
source_id = source_id_for(conversation_summary)
item, already_handled = prepare_import_item('conversation', source_id, conversation_summary)
conversation = @source.retrieve_conversation(source_id)
return if import_stopped?
with_import_item_lock(item, already_handled: already_handled) do |locked_item, item_already_handled|
import_conversation_item(locked_item, conversation_summary, conversation, source_id, item_already_handled)
end
rescue StandardError => e
raise if @source.client_error?(e)
fail_item(item, e)
ensure
persist_stats unless @import_stopped
end
def import_conversation_item(item, conversation_summary, conversation, source_id, already_handled)
mapping = find_mapping('conversation', source_id)
update_message_total(item, conversation)
contact_payloads = conversation_contacts(conversation)
contact = import_contact(contact_payloads.first, required_for_conversation: true)
contact_payloads.drop(1).each { |contact_payload| import_contact(contact_payload) }
source_type = conversation_source_type(conversation, conversation_summary)
inbox = @placeholder_inboxes.inbox_for(source_type)
contact_inbox = contact_inbox_for(contact, inbox)
mapped_conversation = mapping&.chatwoot_record
if mapped_conversation && mapping.data_import_id != @data_import.id
skip_already_imported_item(item, mapping, already_handled: already_handled)
reconcile_item_stats('conversation') if already_handled
return unless import_conversation_messages(conversation, mapped_conversation, contact)
update_conversation_activity(mapped_conversation)
return
end
chatwoot_conversation = mapped_conversation || create_conversation(conversation, contact, contact_inbox, inbox, source_type)
if mapped_conversation
record_mapping('conversation', source_id, chatwoot_conversation, metadata: conversation_metadata(conversation, inbox, source_type))
end
item.update!(status: :imported, chatwoot_record_type: 'Conversation', chatwoot_record_id: chatwoot_conversation.id)
if already_handled
reconcile_item_stats('conversation')
else
increment_stat('conversations', 'imported')
end
return unless import_conversation_messages(conversation, chatwoot_conversation, contact)
update_conversation_activity(chatwoot_conversation)
end
def import_stopped?
return true if @import_stopped
@data_import.reload
@import_stopped = @data_import.abandoned? || @data_import.completed? || @data_import.completed_with_errors? || stale_import_run?
end
def continue_import_with_heartbeat?
return false if import_stopped?
return true if @data_import.updated_at > HEARTBEAT_INTERVAL.ago
@data_import.touch if @data_import.updated_at <= HEARTBEAT_INTERVAL.ago
true
end
def stale_import_run?
active_run_id = @data_import.active_import_run_id
@run_id.present? && active_run_id.present? && active_run_id != @run_id
end
def import_contact(contact_payload, required_for_conversation: false)
item = nil
with_query_timeout_retry do
source_id = source_id_for(contact_payload)
mapping = find_mapping('contact', source_id) if source_id.present?
contact_payload = retrieve_contact_payload(contact_payload) unless mapping&.chatwoot_record
source_id = source_id_for(contact_payload)
item, already_handled = prepare_import_item('contact', source_id, contact_payload)
with_import_item_lock(item, already_handled: already_handled) do |locked_item, item_already_handled|
import_contact_item(locked_item, contact_payload, source_id, item_already_handled)
end
end
rescue StandardError => e
raise if @source.client_error?(e)
fail_item(item, e)
raise if required_for_conversation
ensure
persist_stats
end
def import_contact_item(item, contact_payload, source_id, already_handled)
mapping = find_mapping('contact', source_id)
mapped_contact = mapping&.chatwoot_record
return reuse_mapped_contact(item, mapping, mapped_contact, already_handled: already_handled) if mapped_contact
contact = Contact.transaction do
imported_contact = find_existing_contact(contact_payload) || create_contact(contact_payload)
update_existing_contact(imported_contact, contact_payload)
record_mapping('contact', source_id, imported_contact, metadata: contact_metadata(contact_payload))
item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: imported_contact.id)
imported_contact
end
increment_stat('contacts', 'imported') unless already_handled
contact
end
def retrieve_contact_payload(contact_payload)
return contact_payload if contact_payload.blank?
return contact_payload if contact_payload['email'].present? || contact_payload['phone'].present? || contact_payload['name'].present?
return contact_payload if contact_payload['id'].blank?
@source.retrieve_contact(contact_payload['id'])
rescue StandardError => e
raise unless @source.client_error?(e)
raise unless e.status == 404
contact_payload
end
def create_contact(contact_payload)
attrs = contact_attributes(contact_payload).merge(created_at: timestamp_for(contact_payload['created_at']), updated_at: Time.current)
result = Contact.insert_all!([attrs], returning: %w[id])
Contact.find(result.rows.first.first)
rescue ActiveRecord::RecordNotUnique
find_existing_contact(contact_payload)
end
def reuse_mapped_contact(item, mapping, mapped_contact, already_handled:)
if mapping.data_import_id == @data_import.id
reconcile_current_run_contact(item, mapped_contact)
return mapped_contact
end
skip_already_imported_item(item, mapping, already_handled: already_handled)
mapped_contact
end
def update_existing_contact(contact, contact_payload)
attrs = contact_attributes(contact_payload)
updates = {}
updates[:name] = attrs[:name] if contact.name.blank? && attrs[:name].present?
updates[:email] = attrs[:email] if contact_email_available?(contact, attrs[:email])
updates[:phone_number] = attrs[:phone_number] if contact_phone_number_available?(contact, attrs[:phone_number])
updates[:identifier] = attrs[:identifier] if contact.identifier.blank? && attrs[:identifier].present?
updates[:last_activity_at] = attrs[:last_activity_at] if contact.last_activity_at.blank? && attrs[:last_activity_at].present?
updates[:additional_attributes] = contact.additional_attributes.to_h.deep_merge(attrs[:additional_attributes])
updates[:custom_attributes] = contact.custom_attributes.to_h.deep_merge(attrs[:custom_attributes])
if contact.visitor? && attrs[:contact_type].present? && contact_resolved_after_update?(contact, updates)
updates[:contact_type] = attrs[:contact_type]
end
updates[:updated_at] = Time.current
contact.update_columns(updates) if updates.present?
contact.reload
end
def contact_email_available?(contact, email)
return false if contact.email.present? || email.blank?
@account.contacts.where.not(id: contact.id).where('LOWER(email) = ?', email.downcase).empty?
end
def contact_phone_number_available?(contact, phone_number)
return false if contact.phone_number.present? || phone_number.blank?
@account.contacts.where.not(id: contact.id).where(phone_number: phone_number).empty?
end
def contact_resolved_after_update?(contact, updates)
contact.email.present? || contact.phone_number.present? || updates[:email].present? || updates[:phone_number].present?
end
def find_existing_contact(contact_payload)
identifier = normalized_identifier(contact_payload)
email = normalized_email(contact_payload)
phone_number = normalized_phone(contact_payload)
if identifier.present?
contact = @account.contacts.find_by(identifier: identifier)
return contact if contact.present?
end
if email.present?
contact = @account.contacts.from_email(email)
return contact if contact.present?
end
return @account.contacts.find_by(phone_number: phone_number) if phone_number.present?
nil
end
def contact_attributes(contact_payload)
attrs = {
account_id: @account.id,
name: contact_payload['name'].presence || contact_payload['email'].presence || '',
email: normalized_email(contact_payload),
phone_number: normalized_phone(contact_payload),
identifier: normalized_identifier(contact_payload),
last_activity_at: contact_activity_at(contact_payload),
additional_attributes: {
source: @source.contact_source_metadata(contact_payload).merge(provider: @source.provider)
},
custom_attributes: @source.contact_custom_attributes(contact_payload)
}
attrs[:contact_type] = Contact.contact_types[:lead] if attrs[:email].present? || attrs[:phone_number].present?
attrs
end
def create_conversation(conversation, contact, contact_inbox, inbox, source_type)
source_id = source_id_for(conversation)
metadata = conversation_metadata(conversation, inbox, source_type)
if (existing_conversation = @account.conversations.find_by(identifier: conversation_identifier(conversation)))
record_mapping('conversation', source_id, existing_conversation, metadata: metadata)
return existing_conversation
end
attrs = {
account_id: @account.id,
inbox_id: inbox.id,
status: Conversation.statuses['resolved'],
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: conversation_identifier(conversation),
additional_attributes: metadata,
custom_attributes: @source.conversation_custom_attributes(conversation),
created_at: timestamp_for(conversation['created_at']),
updated_at: timestamp_for(conversation['updated_at']),
last_activity_at: timestamp_for(conversation['updated_at'])
}
Conversation.transaction do
result = Conversation.insert_all!([attrs], returning: %w[id])
chatwoot_conversation = Conversation.find(result.rows.first.first)
record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
chatwoot_conversation
end
rescue ActiveRecord::RecordNotUnique
@account.conversations.find_by!(identifier: conversation_identifier(conversation)).tap do |chatwoot_conversation|
record_mapping('conversation', source_id, chatwoot_conversation, metadata: metadata)
end
end
def import_conversation_messages(conversation, chatwoot_conversation, contact)
parts_payload = conversation['conversation_parts'].to_h
parts = Array(parts_payload['conversation_parts'])
batch_builder = @source.message_batch_builder(
data_import: @data_import,
conversation: chatwoot_conversation,
source_conversation: conversation
)
batch = begin
with_query_timeout_retry { batch_builder.perform }
rescue ActiveRecord::QueryCanceled
nil
end
return import_conversation_messages_individually(conversation, chatwoot_conversation, contact, batch_builder, parts.size) if batch.nil?
record_truncated_conversation_parts(conversation, parts.size)
batch.entries.each_slice(MESSAGES_PER_BATCH) do |entries|
return false unless continue_import_with_heartbeat?
import_message_batch(chatwoot_conversation, contact, batch_builder, entries)
return false if @import_stopped
end
return false if import_stopped?
true
end
def import_message_batch(conversation, contact, batch_builder, entries)
result = bulk_message_batch_result(conversation, contact, batch_builder, entries)
return if result.blank?
result.current_entries.each do |entry|
reconcile_bulk_message_entry(conversation, entry) do
reconcile_current_run_message_mapping(conversation, entry.mapping, entry.part)
end
end
result.previous_entries.each do |entry|
reconcile_bulk_message_entry(conversation, entry) do
skip_existing_message_mapping(conversation, entry.mapping, entry.part)
end
end
result.skipped_entries.each do |entry|
reconcile_bulk_message_entry(conversation, entry) { record_bulk_skipped_message(conversation, entry) }
end
Array(result.failed_entries).each { |entry, error| fail_message(conversation, entry.source_id, entry.part, error) }
increment_stat('messages', 'imported', result.imported_entries.size)
result.messages.each { |message| reindex_message_for_search(message) }
end
def bulk_message_batch_result(conversation, contact, batch_builder, entries)
with_query_timeout_retry do
bulk_write_message_entries(conversation, contact, batch_builder, entries)
end
rescue ActiveRecord::ActiveRecordError
fallback_message_entries(conversation, contact, batch_builder, entries) unless @import_stopped
nil
end
def fallback_message_entries(conversation, contact, batch_builder, entries)
entries.each do |entry|
break unless continue_import_with_heartbeat?
message = fallback_message_entry(conversation, contact, batch_builder, entry)
reindex_message_for_search(message) if message.is_a?(Message)
end
end
def fallback_message_entry(conversation, contact, batch_builder, entry)
with_query_timeout_retry do
@data_import.with_lock do
if inactive_import_run?
@import_stopped = true
next
end
refreshed_entry = batch_builder.refresh([entry]).entries.first
import_message(conversation, contact, refreshed_entry, reindex: false)
end
end
rescue ActiveRecord::ActiveRecordError => e
fail_message(conversation, entry.source_id, entry.part, e)
end
def bulk_write_message_entries(conversation, contact, batch_builder, entries)
@data_import.with_lock do
if inactive_import_run?
@import_stopped = true
next
end
refreshed_entries = batch_builder.refresh(entries).entries
persist_message_entries(conversation, contact, refreshed_entries)
end
end
def inactive_import_run?
@data_import.abandoned? || @data_import.failed? || @data_import.completed? ||
@data_import.completed_with_errors? || stale_import_run?
end
def persist_message_entries(conversation, contact, entries)
grouped_entries = entries.group_by(&:classification)
writable_entries = entries.select do |entry|
%i[repairable_stale_mapping existing_message new_message].include?(entry.classification)
end
content_by_source_id = {}
attributes_by_source_id = {}
failed_entries = []
writable_entries.select! do |entry|
validate_message_payload!(entry.part)
content = content_for(entry.part)
content_by_source_id[entry.source_id] = content
if content.present? && entry.message.blank?
attributes_by_source_id[entry.source_id] = message_attributes(conversation, contact, entry.part, entry.source_id, content)
end
true
rescue InvalidMessagePayloadError => e
failed_entries << [entry, e]
false
end
skipped_entries, imported_entries = writable_entries.partition { |entry| content_by_source_id[entry.source_id].blank? }
messages = insert_messages(imported_entries, attributes_by_source_id)
upsert_message_mappings(conversation, imported_entries, skipped_entries, messages)
MessageBatchResult.new(
imported_entries: imported_entries,
skipped_entries: skipped_entries,
current_entries: grouped_entries.fetch(:current_import, []),
previous_entries: grouped_entries.fetch(:previous_import, []),
messages: messages,
failed_entries: failed_entries
)
end
def insert_messages(entries, attributes_by_source_id)
new_entries = entries.reject(&:message)
if new_entries.present?
attributes = new_entries.map { |entry| attributes_by_source_id.fetch(entry.source_id) }
result = Message.insert_all!(attributes, returning: %w[id source_id])
inserted_messages = Message.where(id: result.pluck('id')).index_by(&:source_id)
end
entries.map do |entry|
entry.message || inserted_messages.fetch("#{@source.provider}:#{entry.source_id}")
end
end
def validate_message_payload!(part)
raise InvalidMessagePayloadError, "#{@source.display_name} message payload must be an object" unless part.is_a?(Hash)
%w[author assigned_to event_details].each do |field|
value = part[field]
next if value.nil? || value.is_a?(Hash)
raise InvalidMessagePayloadError, "#{@source.display_name} message #{field} must be an object"
end
participant = part.dig('event_details', 'participant')
unless participant.nil? || participant.is_a?(Hash)
raise InvalidMessagePayloadError, "#{@source.display_name} message event_details.participant must be an object"
end
%w[created_at updated_at].each do |field|
value = part[field]
valid_timestamp = value.nil? || value.is_a?(Integer) || value.is_a?(Float) ||
(value.is_a?(String) && (value.blank? || value.match?(/\A-?\d+(?:\.\d+)?\z/)))
raise InvalidMessagePayloadError, "#{@source.display_name} message #{field} must be a Unix timestamp" unless valid_timestamp
timestamp_for(value) if value.present?
rescue RangeError
raise InvalidMessagePayloadError, "#{@source.display_name} message #{field} must be a Unix timestamp"
end
%w[body subject].each do |field|
value = part[field]
next unless value.is_a?(String) && !value.valid_encoding?
raise InvalidMessagePayloadError, "#{@source.display_name} message #{field} must use valid encoding"
end
end
def upsert_message_mappings(conversation, imported_entries, skipped_entries, messages)
now = Time.current
mapping_attributes = imported_entries.zip(messages).map do |entry, message|
message_mapping_attributes(entry, 'Message', message.id, message_metadata(entry.part), now)
end
mapping_attributes.concat(skipped_entries.filter_map do |entry|
next if entry.mapping
metadata = message_metadata(entry.part).merge(skipped: true, reason: @source.skipped_message_reason)
message_mapping_attributes(entry, 'Conversation', conversation.id, metadata, now)
end)
return if mapping_attributes.empty?
DataImportMapping.upsert_all(
mapping_attributes,
unique_by: MESSAGE_MAPPING_UNIQUE_INDEX,
update_only: %i[data_import_id chatwoot_record_type chatwoot_record_id metadata updated_at],
record_timestamps: false
)
end
def message_mapping_attributes(entry, record_type, record_id, metadata, now)
{
account_id: @account.id,
data_import_id: @data_import.id,
source_provider: @source.provider,
source_object_type: 'message',
source_object_id: entry.source_id,
chatwoot_record_type: record_type,
chatwoot_record_id: record_id,
metadata: metadata,
created_at: entry.mapping&.created_at || now,
updated_at: now
}
end
def record_bulk_skipped_message(conversation, entry)
already_recorded = skip_log_recorded?('message', entry.source_id, @source.skipped_message_error_code)
record_skipped_message_log(conversation, entry.source_id, entry.part)
increment_stat('messages', 'skipped') unless already_recorded
end
def reconcile_bulk_message_entry(conversation, entry, &)
with_query_timeout_retry(&)
rescue StandardError => e
fail_message(conversation, entry.source_id, entry.part, e)
end
def import_conversation_messages_individually(conversation, chatwoot_conversation, contact, batch_builder, parts_count)
source_entries, part_entries = batch_builder.unprepared_entries.partition { |entry| entry[:part]['part_type'] == 'source' }
source_entries.each { |entry| import_unprepared_message(chatwoot_conversation, contact, batch_builder, entry) }
record_truncated_conversation_parts(conversation, parts_count)
part_entries.each do |entry|
return false unless continue_import_with_heartbeat?
import_unprepared_message(chatwoot_conversation, contact, batch_builder, entry)
end
return false if import_stopped?
true
end
def import_unprepared_message(conversation, contact, batch_builder, source_entry)
with_query_timeout_retry do
@data_import.with_lock do
if inactive_import_run?
@import_stopped = true
next
end
entry = batch_builder.perform([source_entry]).entries.first
import_message(conversation, contact, entry)
end
end
rescue StandardError => e
fail_message(conversation, source_entry[:source_id], source_entry[:part], e)
end
def import_message(conversation, contact, entry, reindex: true)
message = with_query_timeout_retry do
Message.transaction(requires_new: true) do
case entry.classification
when :current_import
reconcile_current_run_message_mapping(conversation, entry.mapping, entry.part)
when :previous_import
skip_existing_message_mapping(conversation, entry.mapping, entry.part)
when :repairable_stale_mapping, :existing_message, :new_message
create_message(conversation, contact, entry)
else
raise ArgumentError, "Unsupported #{@source.display_name} message classification: #{entry.classification}"
end
end
end
reindex_message_for_search(message) if reindex && message.is_a?(Message)
message
rescue StandardError => e
fail_message(conversation, entry.source_id, entry.part, e)
end
def create_message(conversation, contact, entry)
validate_message_payload!(entry.part)
content = content_for(entry.part)
return record_skipped_message(conversation, entry) if content.blank?
attrs = message_attributes(conversation, contact, entry.part, entry.source_id, content)
message = entry.message
unless message
result = Message.insert_all!([attrs], returning: %w[id])
message = Message.find(result.rows.first.first)
end
record_message_mapping(entry, message)
increment_stat('messages', 'imported')
message
end
def reindex_message_for_search(message)
return unless message.should_index?
message.__send__(:reindex_for_search)
rescue StandardError => e
Rails.logger.warn("#{@source.display_name} import message reindex failed for message #{message.id}: #{e.class} - #{e.message}")
end
def record_skipped_message(conversation, entry)
if entry.mapping
already_recorded = skip_log_recorded?('message', entry.source_id, @source.skipped_message_error_code)
record_skipped_message_log(conversation, entry.source_id, entry.part)
increment_stat('messages', 'skipped') unless already_recorded
return entry.message
end
DataImportMapping.create!(
account: @account,
data_import: @data_import,
source_provider: @source.provider,
source_object_type: 'message',
source_object_id: entry.source_id,
chatwoot_record_type: 'Conversation',
chatwoot_record_id: conversation.id,
metadata: message_metadata(entry.part).merge(skipped: true, reason: @source.skipped_message_reason)
)
record_skipped_message_log(conversation, entry.source_id, entry.part)
increment_stat('messages', 'skipped')
end
def record_message_mapping(entry, message)
(entry.mapping || DataImportMapping.new(
account: @account,
source_provider: @source.provider,
source_object_type: 'message',
source_object_id: entry.source_id
)).tap do |mapping|
mapping.data_import = @data_import
mapping.chatwoot_record_type = 'Message'
mapping.chatwoot_record_id = message.id
mapping.metadata = message_metadata(entry.part)
mapping.save!
end
end
def message_attributes(conversation, contact, part, message_source_id, content)
message_type = message_type_for(part)
sender = message_sender_for(part, contact, message_type)
created_at = timestamp_for(part['created_at'])
{
account_id: @account.id,
inbox_id: conversation.inbox_id,
conversation_id: conversation.id,
message_type: Message.message_types[message_type],
content_type: Message.content_types['text'],
content: content,
processed_message_content: content,
private: message_type != 'activity' && part['part_type'] == 'note',
status: Message.statuses['sent'],
sender_type: sender.present? ? 'Contact' : nil,
sender_id: sender&.id,
source_id: "#{@source.provider}:#{message_source_id}",
external_source_ids: { @source.provider => message_source_id },
content_attributes: {},
additional_attributes: message_metadata(part),
created_at: created_at,
updated_at: part['updated_at'].present? ? timestamp_for(part['updated_at']) : created_at
}
end
def message_type_for(part)
return 'activity' if activity_part?(part)
author_type = part.dig('author', 'type').to_s
return 'incoming' if %w[user contact lead].include?(author_type)
'outgoing'
end
def content_for(part)
return @source.activity_content(part) if activity_part?(part)
message_content(part)
end
def activity_part?(part)
@source.activity_part?(part)
end
def message_content(part)
body = sanitized_text(part['body'])
subject = sanitized_text(part['subject'])
attachments = Array(part['attachments'])
content = [subject, body].reject(&:blank?).join("\n\n")
return content if attachments.blank?
[content.presence, "[#{@source.display_name} attachment skipped: #{attachments.size}]"].compact.join("\n\n")
end
def sanitized_text(value)
Rails::HTML5::FullSanitizer.new.sanitize(value.to_s).squish
end
def update_conversation_activity(conversation)
latest_message = conversation.messages.reorder(created_at: :desc).first
return if latest_message.blank?
conversation.update_columns(last_activity_at: latest_message.created_at, updated_at: Time.current)
end
def contact_inbox_for(contact, inbox)
ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |contact_inbox|
contact_inbox.source_id = "#{@source.provider}:#{contact.id}"
end
end
def conversation_contacts(conversation)
contacts = Array(conversation.dig('contacts', 'contacts'))
contacts.presence || [conversation.dig('source', 'author') || {}]
end
def message_sender_for(part, primary_contact, message_type)
return unless message_type == 'incoming'
author = part['author'].to_h
source_id = source_id_for(author)
mapped_contact = find_mapping('contact', source_id)&.chatwoot_record if source_id.present?
mapped_contact || find_existing_contact(author) || primary_contact
end
def conversation_source_type(conversation, conversation_summary)
conversation.dig('source', 'type').presence ||
conversation.dig('first_contact_reply', 'type').presence ||
conversation_summary.dig('source', 'type').presence ||
conversation_summary.dig('first_contact_reply', 'type').presence
end
def normalized_identifier(contact_payload)
contact_payload['external_id'].presence
end
def normalized_email(contact_payload)
email = contact_payload['email'].to_s.strip.downcase
email.match?(Devise.email_regexp) ? email : nil
end
def normalized_phone(contact_payload)
phone = contact_payload['phone'].to_s.strip
phone = "+#{phone}" if phone.match?(PHONE_WITHOUT_PLUS_REGEX)
phone.match?(E164_REGEX) ? phone : nil
end
def contact_activity_at(contact_payload)
return timestamp_for(contact_payload['last_seen_at']) if contact_payload['last_seen_at'].present?
return timestamp_for(contact_payload['last_replied_at']) if contact_payload['last_replied_at'].present?
nil
end
def source_id_for(payload)
payload['id'].presence || payload['external_id'].presence || payload['email'].presence
end
def conversation_identifier(conversation)
"#{@source.provider}:#{source_id_for(conversation)}"
end
def find_or_create_import_item(object_type, source_id)
attributes = {
source_provider: @source.provider,
source_object_type: object_type,
source_object_id: source_id
}
@data_import.items.find_by(attributes) || @data_import.items.create!(attributes)
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
@data_import.items.find_by(attributes) || raise(e)
end
def prepare_import_item(object_type, source_id, metadata)
item = find_or_create_import_item(object_type, source_id)
already_handled = item.with_lock do
already_handled = item.imported? || item.skipped?
item.status = :processing
item.attempt_count += 1
item.metadata = item.metadata.to_h.merge(metadata.to_h)
item.save!
already_handled
end
[item, already_handled]
end
def with_import_item_lock(item, already_handled:)
ApplicationRecord.connection_pool.with_connection do |connection|
lock_id = -item.id
connection.execute("SELECT pg_advisory_lock(#{lock_id})")
begin
item.reload
yield item, already_handled || item.imported? || item.skipped?
ensure
connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
end
end
end
def find_mapping(object_type, source_id)
DataImportMapping.find_by(
account: @account,
source_provider: @source.provider,
source_object_type: object_type,
source_object_id: source_id
)
end
def record_mapping(object_type, source_id, record, metadata: {})
DataImportMapping.find_or_initialize_by(
account: @account,
source_provider: @source.provider,
source_object_type: object_type,
source_object_id: source_id
).tap do |mapping|
mapping.data_import = @data_import
mapping.chatwoot_record_type = record.class.name
mapping.chatwoot_record_id = record.id
mapping.metadata = metadata
mapping.save!
end
end
def reconcile_current_run_contact(item, mapped_contact)
item.update!(status: :imported, chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id)
mark_stat_group_dirty('contacts')
end
def reconcile_item_stats(source_object_type)
items = @data_import.items.where(source_provider: @source.provider, source_object_type: source_object_type)
group = stat_group_for(source_object_type)
@stats[group]['imported'] = items.imported.count
@stats[group]['skipped'] = items.skipped.count
end
def reconcile_current_run_message_mapping(conversation, mapping, part)
record_skipped_message_log(conversation, mapping.source_object_id, part) if mapping.metadata['skipped']
mark_stat_group_dirty('messages')
end
def reconcile_message_stats
mappings = @data_import.mappings.where(source_provider: @source.provider, source_object_type: 'message')
skipped_mappings = mappings.where("metadata ->> 'skipped' = ?", 'true').count
message_logs = @data_import.import_errors.where(source_object_type: 'message')
@stats['messages']['imported'] = mappings.count - skipped_mappings
@stats['messages']['skipped'] = message_logs.where("details ->> 'kind' = ?", 'skipped').count
end
def skip_already_imported_item(item, mapping, already_handled:)
DataImportItem.transaction do
item.update!(
status: :skipped,
chatwoot_record_type: mapping.chatwoot_record_type,
chatwoot_record_id: mapping.chatwoot_record_id,
last_error_code: @source.already_imported_error_code,
last_error_message: 'Already imported in a previous import.'
)
record_already_imported_log(
data_import_item: item,
source_object_type: item.source_object_type,
source_object_id: item.source_object_id,
mapping: mapping
)
end
increment_stat(stat_group_for(item.source_object_type), 'skipped') unless already_handled
end
def skip_existing_message_mapping(conversation, mapping, part)
if mapping.metadata['skipped']
already_recorded = skip_log_recorded?('message', mapping.source_object_id, @source.skipped_message_error_code)
record_skipped_message_log(conversation, mapping.source_object_id, part)
else
already_recorded = skip_log_recorded?('message', mapping.source_object_id, @source.already_imported_error_code)
record_already_imported_log(source_object_type: 'message', source_object_id: mapping.source_object_id, mapping: mapping)
end
if already_recorded
message_logs = @data_import.import_errors.where(source_object_type: 'message')
@stats['messages']['skipped'] = message_logs.where("details ->> 'kind' = ?", 'skipped').count
else
increment_stat('messages', 'skipped')
end
end
def fail_item(item, error)
increment_stat('errors', 'count')
item&.update!(status: :failed, last_error_code: error.class.name, last_error_message: error.message)
record_skip_log(
data_import_item: item,
source_object_type: item&.source_object_type,
source_object_id: item&.source_object_id,
error_code: error.class.name,
message: error.message,
details: {
kind: 'failed',
source_provider: @source.provider,
error_class: error.class.name
}
)
end
def fail_message(conversation, message_source_id, part, error)
increment_stat('errors', 'count')
record_skip_log(
source_object_type: 'message',
source_object_id: message_source_id,
error_code: error.class.name,
message: error.message,
details: message_metadata(part).merge(
kind: 'failed',
source_provider: @source.provider,
error_class: error.class.name,
conversation_id: conversation.identifier
)
)
end
def record_skipped_message_log(conversation, message_source_id, part)
record_skip_log(
source_object_type: 'message',
source_object_id: message_source_id,
error_code: @source.skipped_message_error_code,
message: skipped_message_log_message(part),
details: skipped_message_details(conversation, part)
)
end
def record_already_imported_log(source_object_type:, source_object_id:, mapping:, data_import_item: nil)
record_skip_log(
data_import_item: data_import_item,
source_object_type: source_object_type,
source_object_id: source_object_id,
error_code: @source.already_imported_error_code,
message: 'Already imported in a previous import.',
details: {
kind: 'skipped',
reason: 'already_imported',
source_provider: @source.provider,
previous_data_import_id: mapping.data_import_id,
chatwoot_record_type: mapping.chatwoot_record_type,
chatwoot_record_id: mapping.chatwoot_record_id
}
)
end
def record_truncated_conversation_parts(conversation, imported_parts_count)
total_parts_count = total_conversation_parts_count(conversation)
return if total_parts_count <= imported_parts_count
source_id = source_id_for(conversation)
already_recorded = @data_import.import_errors.exists?(
source_object_type: 'conversation',
source_object_id: source_id,
error_code: @source.truncated_parts_error_code
)
record_import_error(
source_object_type: 'conversation',
source_object_id: source_id,
error_code: @source.truncated_parts_error_code,
message: "#{@source.display_name} returned #{imported_parts_count} of #{total_parts_count} conversation parts.",
details: {
kind: 'incomplete',
source_provider: @source.provider,
imported_parts_count: imported_parts_count,
total_parts_count: total_parts_count
}
)
increment_stat('errors', 'count') unless already_recorded
end
def total_conversation_parts_count(conversation)
conversation_parts_total_count = conversation.dig('conversation_parts', 'total_count')
return conversation_parts_total_count.to_i if conversation_parts_total_count.present?
[
conversation.dig('statistics', 'count_conversation_parts'),
conversation.dig('statistics', 'count_conversations_parts')
].compact.map(&:to_i).max || 0
end
def source_message_importable?(source)
@source.source_message_importable?(source)
end
def skipped_message_log_message(part)
"Skipped #{@source.display_name} #{source_event_name(part)} event#{source_part_id_suffix(part)}: #{skipped_message_reason_details(part)}."
end
def skipped_message_details(conversation, part)
author = part['author'].to_h
message_metadata(part).merge(
{
kind: 'skipped',
reason: @source.skipped_message_reason,
reason_details: skipped_message_reason_details(part),
event_name: source_event_name(part),
event_type: part['part_type'],
author_type: author['type'],
author_name: author['name'],
conversation_id: conversation.identifier
}.compact
)
end
def skipped_message_reason_details(part)
return 'message body did not contain readable text after HTML sanitization' if part['body'].present?
return 'attachments are present but no importable message text was found' if Array(part['attachments']).present?
'no message body or attachments to import'
end
def source_event_name(part)
part['part_type'].to_s.tr('_', ' ').presence || 'message part'
end
def source_part_id_suffix(part)
part['id'].present? ? " #{part['id']}" : ''
end
def skip_log_recorded?(source_object_type, source_object_id, error_code)
@data_import.import_errors.skip_logs.exists?(
source_object_type: source_object_type,
source_object_id: source_object_id,
error_code: error_code
)
end
def record_run_error(error)
@data_import.import_errors.create!(
error_code: error.class.name,
message: error.message,
details: {
kind: 'run_error',
source_provider: @source.provider,
error_class: error.class.name
}
)
end
def record_skip_log(attributes)
record_import_error(attributes)
end
def record_import_error(attributes)
@data_import.import_errors.find_or_initialize_by(
data_import_item: attributes[:data_import_item],
source_object_type: attributes[:source_object_type],
source_object_id: attributes[:source_object_id],
error_code: attributes[:error_code]
).tap do |import_error|
import_error.message = attributes[:message]
import_error.details = attributes[:details]
import_error.save!
end
end
def conversation_metadata(conversation, inbox, source_type)
{
source: {
provider: @source.provider,
conversation_id: source_id_for(conversation),
source_type: source_type,
**@source.conversation_source_metadata(conversation),
routing_method: 'source_bucket_api_inbox',
routed_inbox_id: inbox.id,
import_id: @data_import.id
}.compact
}
end
def contact_metadata(contact_payload)
{
source: {
provider: @source.provider,
**@source.contact_source_metadata(contact_payload)
}.compact
}
end
def message_metadata(part)
{
source: {
provider: @source.provider,
**@source.message_source_metadata(part)
}.compact
}
end
def timestamp_for(value)
return Time.current if value.blank?
@source.timestamp_for(value)
end
def update_cursor(key, cursor)
@data_import.with_lock do
current_cursor = @data_import.cursor.to_h.deep_stringify_keys
if inactive_import_run?
@import_stopped = true
next current_cursor.dig(key, 'starting_after')
end
if current_cursor != @persisted_cursor
@persisted_cursor = current_cursor.deep_dup
next current_cursor.dig(key, 'starting_after')
end
updated_cursor = current_cursor.merge(
key => { starting_after: cursor, completed: cursor.blank?, updated_at: Time.current.iso8601 }
).deep_stringify_keys
@data_import.update!(cursor: updated_cursor)
@persisted_cursor = updated_cursor.deep_dup
cursor
end
end
def stage_completed?(key)
@data_import.cursor&.dig(key, 'completed') == true
end
def import_type?(type)
import_types.include?(type)
end
def import_types
@import_types ||= (@data_import.import_types.presence || DEFAULT_IMPORT_TYPES)
end
def increment_stat(group, key, amount = 1)
@stats[group] ||= {}
@stats[group][key] = @stats[group][key].to_i + amount
end
def mark_stat_group_dirty(group)
@dirty_stat_groups[group] = true
end
def reconcile_dirty_stats
return if @dirty_stat_groups.empty?
with_query_timeout_retry do
@dirty_stat_groups.each_key do |group|
case group
when 'contacts'
reconcile_item_stats('contact')
when 'messages'
reconcile_message_stats
else
raise ArgumentError, "Unsupported #{@source.display_name} import stat group: #{group}"
end
end
persist_stats
end
@dirty_stat_groups.clear
end
def with_query_timeout_retry
retries = 0
begin
yield
rescue ActiveRecord::QueryCanceled
raise if retries >= QUERY_TIMEOUT_RETRY_LIMIT
retries += 1
sleep(rand(QUERY_TIMEOUT_RETRY_DELAY_RANGE))
retry
end
end
def update_stat_total(group, total)
@stats[group] ||= {}
@stats[group]['total'] = total.to_i
persist_stats
end
def update_message_total(item, conversation)
parts = conversation['conversation_parts'].to_h
conversation_parts_total = if parts.key?('total_count')
parts['total_count'].to_i
else
Array(parts['conversation_parts']).size
end
contribution = conversation_parts_total
contribution += 1 if source_message_importable?(conversation['source'].to_h)
previous_contribution = item.metadata.to_h['message_total_contribution'].to_i
@stats['messages']['total'] = @stats['messages']['total'].to_i + contribution - previous_contribution
item.update!(metadata: item.metadata.to_h.merge('message_total_contribution' => contribution))
persist_stats
end
def stat_group_for(source_object_type)
"#{source_object_type}s"
end
def persist_stats
@data_import.with_lock do
current_stats = default_stats.deep_merge(@data_import.stats || {})
reconcile_concurrent_stats(current_stats) if current_stats != @persisted_stats || @data_import.cursor.to_h != @persisted_cursor
@data_import.update_columns(stats: @stats, updated_at: Time.current)
@persisted_stats = @stats.deep_dup
end
end
def reconcile_concurrent_stats(current_stats)
local_stats = @stats
@stats = current_stats.deep_merge(local_stats)
%w[contacts conversations messages].each do |group|
totals = [current_stats.dig(group, 'total'), local_stats.dig(group, 'total')].compact
@stats[group]['total'] = totals.map(&:to_i).max if totals.any?
end
reconcile_item_stats('contact')
reconcile_item_stats('conversation')
reconcile_message_stats
@stats['errors']['count'] = @data_import.import_errors.non_skip_logs.count + @data_import.import_errors.failed.count
end
def default_stats
{
'contacts' => { 'imported' => 0, 'skipped' => 0 },
'conversations' => { 'imported' => 0, 'skipped' => 0 },
'messages' => { 'imported' => 0, 'skipped' => 0 },
'errors' => { 'count' => 0 }
}
end
def total_processed_records
total_successful_records +
@stats.fetch('contacts', {}).fetch('skipped', 0).to_i +
@stats.fetch('conversations', {}).fetch('skipped', 0).to_i +
@stats.fetch('messages', {}).fetch('skipped', 0).to_i +
@stats.fetch('errors', {}).fetch('count', 0).to_i
end
def total_successful_records
@stats.fetch('contacts', {}).fetch('imported', 0).to_i +
@stats.fetch('conversations', {}).fetch('imported', 0).to_i +
@stats.fetch('messages', {}).fetch('imported', 0).to_i
end
end
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Rails/SkipsModelValidations