Class: SmsMessage

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, Models::EventPublishable
Defined in:
app/models/sms_message.rb

Overview

rubocop:disable Metrics/ClassLength
== Schema Information

Table name: sms_messages
Database name: primary

id :integer not null, primary key
autoreplied_at :datetime
body :text
direction :enum default("inbound"), not null
num_media :integer default(0), not null
raw_data :jsonb
recipient :string not null
sender :string not null
state :string default("draft"), not null
status_message :string
transmit_at :datetime
unread :boolean default(TRUE), not null
created_at :datetime not null
updated_at :datetime not null
creator_id :integer
recipient_party_id :integer
sender_party_id :integer
unique_id :string
updater_id :integer

Indexes

idx_direction_recipient (direction,recipient)
index_sms_messages_on_recipient (recipient)
index_sms_messages_on_recipient_party_id (recipient_party_id)
index_sms_messages_on_sender (sender)
index_sms_messages_on_sender_party_id (sender_party_id)
index_sms_messages_on_state (state)
index_sms_messages_on_transmit_at (transmit_at)
sms_messages_unique_id (unique_id) UNIQUE

Foreign Keys

fk_rails_... (recipient_party_id => parties.id)
fk_rails_... (sender_party_id => parties.id)

Constant Summary collapse

SMS_GLOBAL_NUMBERS =

Sms global numbers.

['+18008755285', '+18475407775', '+18473183355', '+18473501641',
'+18473501655', '+18473489826', '+18473507429', '+18473501980',
'+18472629814', '+18473507262', '+18473507212'].freeze
TWILIO_ERROR_MESSAGES =
{
  '30003' => 'Recipient phone is unreachable',
  '30004' => 'Recipient has blocked messages from this number',
  '30005' => 'Recipient phone number is unknown or no longer in service',
  '30006' => 'Recipient number is a landline or an unreachable carrier and cannot receive SMS',
  '30007' => 'Message was flagged as spam and filtered by the carrier',
  '30008' => 'Unknown delivery failure reported by the carrier'
}.freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has and belongs to many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::EventPublishable

#publish_event

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Instance Attribute Details

#bodyObject (readonly)



85
# File 'app/models/sms_message.rb', line 85

validates :body, length: { maximum: 1600 }, if: :outbound?

#directionObject (readonly)



84
# File 'app/models/sms_message.rb', line 84

validates :sender, :recipient, :direction, presence: true

#recipientObject (readonly)



84
# File 'app/models/sms_message.rb', line 84

validates :sender, :recipient, :direction, presence: true

#senderObject (readonly)



84
# File 'app/models/sms_message.rb', line 84

validates :sender, :recipient, :direction, presence: true

Class Method Details

.between_participantsActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are between participants. Active Record Scope

Returns:

See Also:



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'app/models/sms_message.rb', line 95

scope :between_participants, ->(*numbers) {
  number1, number2 = [numbers].flatten.filter_map(&:presence).filter_map { |n| PhoneNumber.parse_and_format(n) }
  # Two numbers specified (or more but we ignore anything beyond 2) then let's pull only those
  # conversations
  if number1 && number2
    where('
      (sms_messages.sender = :number1 OR sms_messages.recipient = :number1)
      AND
      (sms_messages.sender = :number2 OR sms_messages.recipient = :number2)
    ', number1:, number2:)
  # One number? a simple match for both
  elsif (number = number1 || number2)
    for_numbers(number)
  else
    SmsMessage.none
  end
}

.employee_names_by_number(numbers) ⇒ Object

Map SMS numbers to the active employee who owns them (their pbx_did /
company SMS line), for the sender dropdown.



150
151
152
153
154
155
156
157
# File 'app/models/sms_message.rb', line 150

def self.employee_names_by_number(numbers)
  return {} if numbers.blank?

  ContactPoint.joins(:party, :employee_phone_status)
              .where(detail: numbers)
              .pluck(:detail, 'parties.full_name')
              .to_h
end

.for_employee_idsActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are for employee ids. Active Record Scope

Returns:

See Also:



87
88
89
90
91
# File 'app/models/sms_message.rb', line 87

scope :for_employee_ids, ->(*rep_ids, include_global: true) {
  sms_numbers = Employee.all_active_employees_sms_numbers(rep_ids:)
  sms_numbers += SMS_GLOBAL_NUMBERS if include_global
  for_numbers(sms_numbers)
}

.for_numbersActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are for numbers. Active Record Scope

Returns:

See Also:



92
93
94
# File 'app/models/sms_message.rb', line 92

scope :for_numbers, ->(*numbers) {
  where('sms_messages.sender IN (:numbers) OR sms_messages.recipient IN (:numbers)', numbers: [numbers].flatten.compact.uniq)
}

.global_numbers_for_selectObject



402
403
404
# File 'app/models/sms_message.rb', line 402

def self.global_numbers_for_select
  SMS_GLOBAL_NUMBERS.map { |n| [n, n] }
end

.latest_per_pairActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are latest per pair. Active Record Scope

Returns:

See Also:



122
123
124
125
126
# File 'app/models/sms_message.rb', line 122

scope :latest_per_pair, lambda {
  exprs = pair_expressions
  select("DISTINCT ON (#{exprs.join(', ')}) sms_messages.*")
    .order(Arel.sql("#{exprs.join(', ')}, created_at DESC"))
}

.match_inbound_unknown_senders(numbers: nil, party: nil, date_range: nil) ⇒ Object

Bulk inbound sender match



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
# File 'app/models/sms_message.rb', line 578

def self.match_inbound_unknown_senders(numbers: nil, party: nil, date_range: nil)
  messages = SmsMessage.inbound.where(sender_party_id: nil)
  messages = messages.where(created_at: date_range) if date_range
  messages = messages.where(sender: numbers) if numbers.present?
  messages.each do |m|
    # party specified if any
    m.sender_party = party
    # Attempt a general lookup, active contacts first
    m.sender_party ||= parties_matching_number(m.sender).active.find_by(type: 'Contact')
    # Active customer next
    m.sender_party ||= parties_matching_number(m.sender).active.find_by(type: 'Customer')
    # Inactive contact next
    m.sender_party ||= parties_matching_number(m.sender).inactive.find_by(type: 'Contact')
    # Inactive customer finally
    m.sender_party ||= parties_matching_number(m.sender).inactive.find_by(type: 'Customer')
    m.save if m.sender_party
  end
end

.match_outbound_unknown_recipient(numbers: nil, party: nil, date_range: nil) ⇒ Object



597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
# File 'app/models/sms_message.rb', line 597

def self.match_outbound_unknown_recipient(numbers: nil, party: nil, date_range: nil)
  messages = SmsMessage.outbound.where(recipient_party_id: nil)
  messages = messages.where(created_at: date_range) if date_range
  messages = messages.where(recipient: numbers) if numbers.present?
  messages.each do |m|
    # party specified if any
    m.recipient_party = party
    # Attempt a general lookup, active contacts first
    m.recipient_party ||= parties_matching_number(m.recipient).active.where(type: 'Contact').first
    # Active customer next
    m.recipient_party ||= parties_matching_number(m.recipient).active.where(type: 'Customer').first
    # Inactive contact next
    m.recipient_party ||= parties_matching_number(m.recipient).inactive.where(type: 'Contact').first
    # Inactive customer finally
    m.recipient_party ||= parties_matching_number(m.recipient).inactive.where(type: 'Customer').first
    m.save if m.recipient_party
  end
end

.match_unknown(**args) ⇒ void

This method returns an undefined value.

Matches both inbound unknown senders and outbound unknown recipients.

Parameters:

  • args (Hash)

    match options, forwarded to both matchers

Options Hash (**args):

  • numbers (Array<String>, nil)

    restrict to these phone numbers

  • party (Party, nil)

    party to assign

  • date_range (Range, nil)

    restrict to this created_at range



572
573
574
575
# File 'app/models/sms_message.rb', line 572

def self.match_unknown(**args)
  match_inbound_unknown_senders(**args)
  match_outbound_unknown_recipient(**args)
end

.pair_counts(scope = all) ⇒ Object

Total + unread message counts per number-pair, keyed by [low, high] number.



129
130
131
132
133
# File 'app/models/sms_message.rb', line 129

def self.pair_counts(scope = all)
  scope.group(*pair_expressions)
       .pluck(*pair_expressions, Arel.sql('COUNT(*)'), Arel.sql('COUNT(*) FILTER (WHERE unread)'))
       .to_h { |low, high, total, unread| [[low, high], [total, unread]] }
end

.pair_expressionsObject

SQL for the unordered number-pair of a message, so the same exchange keys
the same regardless of send direction. Mirrors #conversation_id in SQL.



117
118
119
# File 'app/models/sms_message.rb', line 117

def self.pair_expressions
  [Arel.sql('LEAST(sender, recipient)'), Arel.sql('GREATEST(sender, recipient)')]
end

.parties_matching_number(number) ⇒ Object



616
617
618
619
620
# File 'app/models/sms_message.rb', line 616

def self.parties_matching_number(number)
  return Party.none if number.blank?

  Party.joins(:contact_points).where(ContactPoint[:detail].eq(number))
end

.party_names_by_number(numbers) ⇒ Object

Map SMS numbers to a party (contact/customer) that owns them, for the
recipient dropdown. First non-blank name per number wins.



139
140
141
142
143
144
145
146
# File 'app/models/sms_message.rb', line 139

def self.party_names_by_number(numbers)
  return {} if numbers.blank?

  ContactPoint.eager_load(:party)
              .where(detail: numbers)
              .pluck(:detail, 'parties.full_name')
              .each_with_object({}) { |(number, name), map| map[number] ||= name if name.present? }
end

.ransackable_scopes(_auth_object = nil) ⇒ Object



177
178
179
# File 'app/models/sms_message.rb', line 177

def self.ransackable_scopes(_auth_object = nil)
  [:between_participants]
end

.sender_line_label(number, owner_name = nil) ⇒ Object

Friendly label for a company SMS line: the owning rep if known, else the
toll-free / local identity from PhoneNumbers, else a generic shared line.



161
162
163
164
165
166
167
168
169
# File 'app/models/sms_message.rb', line 161

def self.sender_line_label(number, owner_name = nil)
  owner = owner_name ||
          case number
          when "+#{PhoneNumbers::TOLLFREE_LINK}" then 'WarmlyYours (toll-free)'
          when "+#{PhoneNumbers::LOCAL_LINK}"    then 'WarmlyYours (local)'
          else 'Company line'
          end
  "#{number} - #{owner}"
end

.unreadActiveRecord::Relation<SmsMessage>

A relation of SmsMessages that are unread. Active Record Scope

Returns:

See Also:



113
# File 'app/models/sms_message.rb', line 113

scope :unread, -> { where(unread: true) }

.update_status(params) ⇒ Object

Processes events coming in from the twilio gateway basically discover
the message that was sent and update its status



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'app/models/sms_message.rb', line 496

def self.update_status(params)
  return false if params.blank?
  return false unless (sid = params['SmsSid'].presence)
  return false unless (msg = SmsMessage.outbound.where(unique_id: sid).first)

  msg.raw_data = params.except(:action, :controller)
  case params['SmsStatus']
  when 'delivered'
    msg.trigger_delivered
  when 'failed', 'undelivered'
    msg.trigger_exception
  else
    msg.save
  end
  true
end

Instance Method Details



482
483
484
# File 'app/models/sms_message.rb', line 482

def all_related_sms_messages
  SmsMessage.where('sms_messages.sender IN (:participants) OR sms_messages.recipient IN (:participants)', participants: [sender, recipient]).order('sms_messages.created_at DESC')
end

#auto_reply_outside_business_hoursObject



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'app/models/sms_message.rb', line 363

def auto_reply_outside_business_hours
  # Only send this auto reply if we are outside business hours, we have not responded yet, and there's been no conversation within the last 24 hours
  return unless received_outside_business_hours? && autoreplied_at.nil? && !messages_in_conversation.where(transmit_at: 1.hour.ago..).exists?

  # Skip auto-reply to shortcodes (5-6 digit numbers used by businesses like Amazon)
  # We cannot send SMS to shortcodes, only receive from them
  return if PhoneNumber.shortcode?(sender)

  body = 'Thank you for your message. We will get back to you during business hours, Mon-Fri from 7:30am to 5:30pm CST'
  begin
    TwilioClient.send_message(from: recipient, to: sender, body:)
    update(autoreplied_at: Time.current)
  rescue Twilio::REST::RestError => e
    # Error 21610: Recipient has unsubscribed (opted out of SMS)
    # This is expected behavior - they texted us but have opted out of receiving replies
    # Log it but don't raise an exception
    raise unless e.code == 21_610

    Rails.logger.info "[SmsMessage] Auto-reply skipped - recipient #{sender} has unsubscribed from SMS"
  end
end

#block_senderObject



283
284
285
286
287
288
# File 'app/models/sms_message.rb', line 283

def block_sender
  return false unless inbound?
  return true if sender_sms_block

  build_sender_sms_block.save
end

#conversation_idObject



337
338
339
# File 'app/models/sms_message.rb', line 337

def conversation_id
  [sender, recipient].sort
end

#create_media_urlsObject



326
327
328
329
330
331
332
333
334
335
# File 'app/models/sms_message.rb', line 326

def create_media_urls
  twilio_config = Heatwave::Configuration.fetch(:twilio)
  base_url = twilio_config[:sms_base_wehook_url]
  raise 'Unable to configure base URL for media download' unless base_url

  uploads.map do |upload|
    token = upload.active_download_token
    "#{base_url}/media_download/#{token}"
  end.presence
end

#deep_dupObject



181
182
183
184
185
186
187
188
# File 'app/models/sms_message.rb', line 181

def deep_dup
  deep_clone(
    include: :uploads,
    except: %i[unique_id transmit_at created_at updated_at]
  ) do |_original, copy|
    copy.state = 'draft' if copy.is_a?(SmsMessage)
  end
end

#editable?Boolean

Returns:

  • (Boolean)


490
491
492
# File 'app/models/sms_message.rb', line 490

def editable?
  draft? && outbound?
end

#enqueue_crm_navbar_refreshObject

Only enqueue when the change can affect the navbar badge — i.e. the
inbound/unread tuple. Skips chatty outbound updates (state machine churn,
delivery callbacks) and any inbound update that doesn't move unread.



629
630
631
632
633
634
635
636
637
638
639
# File 'app/models/sms_message.rb', line 629

def enqueue_crm_navbar_refresh
  return unless inbound?

  if previously_new_record?
    return unless unread?
  elsif !saved_change_to_unread?
    return
  end

  CrmNavbarFanoutWorker.perform_async('sms')
end

#exception_reasonObject

Human-readable reason an exception-state message failed. Media-fetch
failures already populate status_message; send failures only get a
Twilio ErrorCode via the status webhook (see #update_status).



435
436
437
438
439
440
441
# File 'app/models/sms_message.rb', line 435

def exception_reason
  return nil unless exception?

  status_message.presence ||
    TWILIO_ERROR_MESSAGES[raw_data.is_a?(Hash) ? raw_data['ErrorCode'].to_s : nil] ||
    'Message could not be delivered'
end

#fetch_mediaObject



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'app/models/sms_message.rb', line 443

def fetch_media
  return true unless num_media.positive?

  begin
    self.raw_data ||= {}
    (0..(num_media - 1)).to_a.each do |i|
      media_content_type = raw_data["MediaContentType#{i}"]
      media_url = raw_data["MediaUrl#{i}"]
      next unless media_url

      uri = Addressable::URI.parse(media_url)
      uri.user = Heatwave::Configuration.fetch(:twilio, :account_sid)
      uri.password = Heatwave::Configuration.fetch(:twilio, :auth_token)

      ext = Mime::Type.lookup(media_content_type).symbol.to_s
      upload = Upload.uploadify_from_url(file_name: "attachment_#{i}.#{ext}",
                                         url: uri.to_s,
                                         category: nil,
                                         resource: self)
      uploads << upload
    end
    true
  rescue Down::NotFound => e
    # Twilio media URLs are temporary and may expire - this is expected behavior
    self.status_message = e.to_s
    ErrorReporting.warning(e, reason: 'twilio_media_expired', sms_message_id: id)
    false
  rescue Down::ClientError => e
    # Handle 401/403 auth errors - media may have been deleted or access revoked
    self.status_message = e.to_s
    ErrorReporting.warning(e, reason: 'twilio_media_access_denied', sms_message_id: id)
    false
  rescue StandardError => e
    self.status_message = e.to_s
    ErrorReporting.error(e)
    false
  end
end

#from_country_isoObject



418
419
420
# File 'app/models/sms_message.rb', line 418

def from_country_iso
  raw_data['FromCountry'].presence || PhoneNumber.parse(sender).country
end

#increment_publication_countersObject

Increment the counter of our publications, sort of a way to keep track of popularity



662
663
664
# File 'app/models/sms_message.rb', line 662

def increment_publication_counters
  publications.each { |p| p.increment!(:requested_counter) }
end

#literaturesObject

Alias for Uploads#literatures

Returns:

  • (Object)

    Uploads#literatures

See Also:



655
# File 'app/models/sms_message.rb', line 655

delegate :literatures, to: :uploads

#mark_conversation_read!Integer

Clear unread on every inbound message in this conversation. Replying to
someone means you read what they sent (Mary Godawa, 2026-07-27) — before
this, only the single message whose Reply link was clicked lost its unread
flag, so the rest of the thread stayed bold and kept feeding the navbar
badge. Updated one row at a time on purpose: the unread after_commit is
what refreshes that badge, and update_all would skip it.

Returns:

  • (Integer)

    how many messages were marked read



353
354
355
356
357
# File 'app/models/sms_message.rb', line 353

def mark_conversation_read!
  marked = messages_in_conversation.inbound.unread.to_a
  marked.each { |message| message.update_attribute!(:unread, false) }
  marked.size
end

#mark_external_numbers_as_sms_enabledObject



622
623
624
# File 'app/models/sms_message.rb', line 622

def mark_external_numbers_as_sms_enabled
  ContactPoint.dialable.where('contact_points.detail = :sender OR contact_points.detail = :recipient', sender:, recipient:).sms_none.each(&:sms_enabled!)
end

#match_inbound_recipientObject



517
518
519
520
521
522
523
524
525
# File 'app/models/sms_message.rb', line 517

def match_inbound_recipient
  return unless inbound? && recipient_party.nil?

  # We match by employee phone status which is an absolute unique number
  employee = Employee.joins(:contact_points, :employee_phone_status).active_employees.find_by(ContactPoint[:detail].eq(recipient))
  return unless employee

  self.recipient_party = employee
end

#match_inbound_senderObject



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'app/models/sms_message.rb', line 549

def match_inbound_sender
  return unless inbound?

  # To attempt an accurate match, we will first see if we find a party to an outbound conversation
  prev_message = SmsMessage.outbound.order(created_at: :desc).where.not(recipient_party_id: nil).find_by(recipient: sender)
  self.sender_party ||= prev_message.recipient_party if prev_message
  # Attempt a general lookup, active contacts first
  self.sender_party ||= self.class.parties_matching_number(sender).active.order(created_at: :desc).find_by(type: 'Contact')
  # Active customer next
  self.sender_party ||= self.class.parties_matching_number(sender).active.order(created_at: :desc).find_by(type: 'Customer')
  # Inactive contact next
  self.sender_party ||= self.class.parties_matching_number(sender).inactive.order(created_at: :desc).find_by(type: 'Contact')
  # Inactive customer finally
  self.sender_party ||= self.class.parties_matching_number(sender).inactive.order(created_at: :desc).find_by(type: 'Customer')
end

#match_outbound_recipientObject



536
537
538
539
540
541
542
543
544
545
546
547
# File 'app/models/sms_message.rb', line 536

def match_outbound_recipient
  return unless outbound?

  # Attempt a general lookup, active contacts first
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).active.find_by(type: 'Contact')
  # Active customer next
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).active.find_by(type: 'Customer')
  # Inactive contact next
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).inactive.find_by(type: 'Contact')
  # Inactive customer finally
  self.recipient_party ||= self.class.parties_matching_number(recipient).order(created_at: :desc).inactive.find_by(type: 'Customer')
end

#match_outbound_senderObject



527
528
529
530
531
532
533
534
# File 'app/models/sms_message.rb', line 527

def match_outbound_sender
  return unless outbound? && sender_party.nil?

  employee = Employee.joins(:contact_points, :employee_phone_status).active_employees.find_by(ContactPoint[:detail].eq(sender))
  return unless employee

  self.sender_party = employee
end

#messages_in_conversationObject



341
342
343
# File 'app/models/sms_message.rb', line 341

def messages_in_conversation
  self.class.between_participants(sender, recipient)
end

#no_media?Boolean

Returns:

  • (Boolean)


513
514
515
# File 'app/models/sms_message.rb', line 513

def no_media?
  uploads.blank?
end

#publicationsObject



657
658
659
# File 'app/models/sms_message.rb', line 657

def publications
  Item.joins(:literature).merge(literatures)
end

#receive_messageObject



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'app/models/sms_message.rb', line 385

def receive_message
  return false unless can_trigger_received?

  # Since we received an SMS from a number, we can therefore mark those sms as enabled
  mark_external_numbers_as_sms_enabled
  auto_reply_outside_business_hours
  # We do a recipient party match on the local number, basically an employee
  match_inbound_recipient
  # Try a match
  match_inbound_sender
  if fetch_media
    trigger_received
  else
    trigger_exception
  end
end

#received_outside_business_hours?Boolean

Returns:

  • (Boolean)


359
360
361
# File 'app/models/sms_message.rb', line 359

def received_outside_business_hours?
  !WorkingHours.in_working_hours?(created_at)
end

#recipient_do_not_textDoNotCall

Returns:

See Also:



68
# File 'app/models/sms_message.rb', line 68

has_one :recipient_do_not_text, -> { where(do_not_text: true) }, class_name: 'DoNotCall', primary_key: :recipient, foreign_key: :number

#recipient_partyParty

Returns:

See Also:



65
# File 'app/models/sms_message.rb', line 65

belongs_to :recipient_party, class_name: 'Party', inverse_of: :recipient_sms_messages, optional: true

#recipient_sms_blockSmsBlock

Returns:

See Also:



67
# File 'app/models/sms_message.rb', line 67

has_one :recipient_sms_block, class_name: 'SmsBlock', primary_key: :recipient, foreign_key: :number

#recipient_to_sObject



412
413
414
415
416
# File 'app/models/sms_message.rb', line 412

def recipient_to_s
  r = PhoneNumber.parse_and_format(recipient).to_s
  r << " (#{recipient_party.full_name})" if recipient_party
  r
end

#send_messageObject



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'app/models/sms_message.rb', line 298

def send_message
  return false unless valid? && can_trigger_send?

  match_outbound_sender
  match_outbound_recipient
  mark_external_numbers_as_sms_enabled
  # Build media urls
  # Create a public url for these files
  media_urls = create_media_urls
  res = TwilioClient.send_message(from: sender, to: recipient, body:, media_urls:)
  self.unique_id = res.sid
  trigger_send
rescue Twilio::REST::RestError => e
  # Twilio rejects some sends (unpermitted region, invalid number, etc.).
  # Surface it as a validation error so both controller callers re-render the
  # form with a friendly flash instead of 500ing on the raw API exception.
  Rails.logger.warn("[SmsMessage##{id}] Twilio send failed: #{e.code} #{e.message}")
  errors.add(:base, twilio_error_message(e))
  false
end

#sender_blocked?Boolean

Returns:

  • (Boolean)


276
277
278
279
280
281
# File 'app/models/sms_message.rb', line 276

def sender_blocked?
  return false unless inbound?

  # sender_sms_block.present?  Until Rails 7.1 there is no way to reset_ on has-one/belongs to so this would be cached and undesirable
  SmsBlock.where(number: sender).exists?
end

#sender_do_not_textDoNotCall

Returns:

See Also:



69
# File 'app/models/sms_message.rb', line 69

has_one :sender_do_not_text, -> { where(do_not_text: true) }, class_name: 'DoNotCall', primary_key: :sender, foreign_key: :number

#sender_partyParty

Returns:

See Also:



64
# File 'app/models/sms_message.rb', line 64

belongs_to :sender_party, class_name: 'Party', inverse_of: :sender_sms_messages, optional: true

#sender_sms_blockSmsBlock

Returns:

See Also:



66
# File 'app/models/sms_message.rb', line 66

has_one :sender_sms_block, class_name: 'SmsBlock', primary_key: :sender, foreign_key: :number

#sender_to_sObject



406
407
408
409
410
# File 'app/models/sms_message.rb', line 406

def sender_to_s
  r = PhoneNumber.parse_and_format(sender).to_s
  r << " (#{sender_party.full_name})" if sender_party
  r
end

#set_defaultsObject



641
642
643
644
645
646
647
648
649
650
651
652
# File 'app/models/sms_message.rb', line 641

def set_defaults
  self.unread = false if outbound?
  # Try to cleanup sender and recipient numbers
  if (sn = PhoneNumber.parse_and_format(sender)).present?
    self.sender = sn
  end
  if (rn = PhoneNumber.parse_and_format(recipient)).present?
    self.recipient = rn
  end

  :continue
end

#to_partial_pathObject

The partial for this model lives under app/views/crm/sms_messages/,
not the conventional app/views/sms_messages/. Without this override
render @sms_messages from any non-crm/sms_messages controller
(CustomersController#tab_sms, OrdersController#tab_sms, …) resolves
the derived sms_messages/sms_message path against the calling
controller's view dir and raises ActionView::MissingTemplate.
See AppSignal #5422 / #5532.



55
56
57
# File 'app/models/sms_message.rb', line 55

def to_partial_path
  'crm/sms_messages/sms_message'
end

#to_sObject



486
487
488
# File 'app/models/sms_message.rb', line 486

def to_s
  "SMS #{id}"
end

#twilio_error_message(error) ⇒ Object

Twilio surfaces the human-readable reason after the "NN : " code prefix;
fall back to the whole message if the shape ever changes.



321
322
323
324
# File 'app/models/sms_message.rb', line 321

def twilio_error_message(error)
  detail = error.message.to_s.split(' : ', 2).last.presence || error.message.to_s
  "Twilio could not send this message: #{detail.split("\n").first.strip}"
end

#unblock_senderObject



290
291
292
293
294
295
296
# File 'app/models/sms_message.rb', line 290

def unblock_sender
  return false unless inbound?
  return true unless sender_sms_block

  sender_sms_block.destroy
  true
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



71
# File 'app/models/sms_message.rb', line 71

has_and_belongs_to_many :uploads