Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
7
research/chatwoot/app/presenters/agent_bot_presenter.rb
Normal file
7
research/chatwoot/app/presenters/agent_bot_presenter.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class AgentBotPresenter < SimpleDelegator
|
||||
def access_token
|
||||
return if account_id.blank?
|
||||
|
||||
Current.account.id == account_id ? super&.token : nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Conversations::EventDataPresenter < SimpleDelegator
|
||||
def push_data
|
||||
{
|
||||
additional_attributes: additional_attributes,
|
||||
can_reply: can_reply?,
|
||||
channel: inbox.try(:channel_type),
|
||||
contact_inbox: contact_inbox,
|
||||
id: display_id,
|
||||
inbox_id: inbox_id,
|
||||
messages: push_messages,
|
||||
labels: label_list,
|
||||
meta: push_meta,
|
||||
status: status,
|
||||
custom_attributes: custom_attributes,
|
||||
snoozed_until: snoozed_until,
|
||||
unread_count: unread_incoming_messages.count,
|
||||
first_reply_created_at: first_reply_created_at,
|
||||
priority: priority,
|
||||
waiting_since: waiting_since.to_i,
|
||||
**push_timestamps
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def push_messages
|
||||
[messages.chat.last&.push_event_data].compact
|
||||
end
|
||||
|
||||
def push_meta
|
||||
{
|
||||
sender: contact.push_event_data,
|
||||
assignee: assigned_entity&.push_event_data,
|
||||
assignee_type: assignee_type,
|
||||
team: team&.push_event_data,
|
||||
hmac_verified: contact_inbox&.hmac_verified
|
||||
}
|
||||
end
|
||||
|
||||
def push_timestamps
|
||||
{
|
||||
agent_last_seen_at: agent_last_seen_at.to_i,
|
||||
contact_last_seen_at: contact_last_seen_at.to_i,
|
||||
last_activity_at: last_activity_at.to_i,
|
||||
timestamp: last_activity_at.to_i,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
Conversations::EventDataPresenter.prepend_mod_with('Conversations::EventDataPresenter')
|
||||
30
research/chatwoot/app/presenters/html_parser.rb
Normal file
30
research/chatwoot/app/presenters/html_parser.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
class HtmlParser
|
||||
def self.parse_reply(raw_body)
|
||||
new(raw_body).filtered_text
|
||||
end
|
||||
|
||||
attr_reader :raw_body
|
||||
|
||||
def initialize(raw_body)
|
||||
@raw_body = raw_body
|
||||
end
|
||||
|
||||
def document
|
||||
@document ||= Nokogiri::HTML(raw_body)
|
||||
end
|
||||
|
||||
def filter_replies!
|
||||
document.xpath('//blockquote').each { |n| n.replace('> ') }
|
||||
end
|
||||
|
||||
def filtered_html
|
||||
@filtered_html ||= begin
|
||||
filter_replies!
|
||||
document.inner_html
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_text
|
||||
@filtered_text ||= Html2Text.convert(filtered_html)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
class Inbox::EventDataPresenter < SimpleDelegator
|
||||
def push_data
|
||||
{
|
||||
# Conversation thread config
|
||||
allow_messages_after_resolved: allow_messages_after_resolved,
|
||||
lock_to_single_conversation: lock_to_single_conversation,
|
||||
|
||||
# Auto Assignment config
|
||||
auto_assignment_config: auto_assignment_config,
|
||||
enable_auto_assignment: enable_auto_assignment,
|
||||
|
||||
# Feature flag for message events
|
||||
enable_email_collect: enable_email_collect,
|
||||
greeting_enabled: greeting_enabled,
|
||||
greeting_message: greeting_message,
|
||||
csat_survey_enabled: csat_survey_enabled,
|
||||
|
||||
# Outbound email sender config
|
||||
business_name: business_name,
|
||||
sender_name_type: sender_name_type,
|
||||
|
||||
# Business hour config
|
||||
timezone: timezone,
|
||||
out_of_office_message: out_of_office_message,
|
||||
working_hours_enabled: working_hours_enabled,
|
||||
working_hours: working_hours,
|
||||
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
|
||||
# Associated channel attributes
|
||||
channel: channel
|
||||
}
|
||||
end
|
||||
end
|
||||
223
research/chatwoot/app/presenters/mail_presenter.rb
Normal file
223
research/chatwoot/app/presenters/mail_presenter.rb
Normal file
@@ -0,0 +1,223 @@
|
||||
class MailPresenter < SimpleDelegator
|
||||
attr_accessor :mail
|
||||
|
||||
def initialize(mail, account = nil)
|
||||
super(mail)
|
||||
@mail = mail
|
||||
@account = account
|
||||
end
|
||||
|
||||
def subject
|
||||
encode_to_unicode(@mail.subject)
|
||||
end
|
||||
|
||||
# encode decoded mail text_part or html_part if mail is multipart email
|
||||
# encode decoded mail raw bodyt if mail is not multipart email but the body content is text/html
|
||||
def mail_content(mail_part)
|
||||
if multipart_mail_body?
|
||||
decoded_multipart_mail(mail_part)
|
||||
else
|
||||
text_html_mail(mail_part)
|
||||
end
|
||||
end
|
||||
|
||||
# encodes mail if mail.parts is present
|
||||
# encodes mail content type is multipart
|
||||
def decoded_multipart_mail(mail_part)
|
||||
encoded = encode_to_unicode(mail_part&.decoded)
|
||||
|
||||
encoded if text_mail_body? || html_mail_body?
|
||||
end
|
||||
|
||||
# encodes mail raw body if mail.parts is empty
|
||||
# encodes mail raw body if mail.content_type is plain/text
|
||||
# encodes mail raw body if mail.content_type is html/text
|
||||
def text_html_mail(mail_part)
|
||||
decoded = mail_part&.decoded || @mail.decoded
|
||||
encoded = encode_to_unicode(decoded)
|
||||
|
||||
encoded if html_mail_body? || text_mail_body?
|
||||
end
|
||||
|
||||
def text_content
|
||||
@decoded_text_content = mail_content(text_part) || ''
|
||||
|
||||
encoding = @decoded_text_content.encoding
|
||||
|
||||
body = EmailReplyTrimmer.trim(@decoded_text_content)
|
||||
|
||||
return {} if @decoded_text_content.blank? || !text_mail_body?
|
||||
|
||||
@text_content ||= {
|
||||
full: mail_content(text_part),
|
||||
reply: @decoded_text_content,
|
||||
quoted: body.force_encoding(encoding).encode('UTF-8')
|
||||
}
|
||||
end
|
||||
|
||||
def html_content
|
||||
encoded = mail_content(html_part) || ''
|
||||
@decoded_html_content = ::HtmlParser.parse_reply(encoded)
|
||||
|
||||
return {} if @decoded_html_content.blank? || !html_mail_body?
|
||||
|
||||
body = EmailReplyTrimmer.trim(@decoded_html_content)
|
||||
|
||||
@html_content ||= {
|
||||
full: mail_content(html_part),
|
||||
reply: @decoded_html_content,
|
||||
quoted: body
|
||||
}
|
||||
end
|
||||
|
||||
# check content disposition check
|
||||
# if inline, upload to AWS and and take the URL
|
||||
def attachments
|
||||
# ref : https://github.com/gorails-screencasts/action-mailbox-action-text/blob/master/app/mailboxes/posts_mailbox.rb
|
||||
mail.attachments.map do |attachment|
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(attachment.body.to_s),
|
||||
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
|
||||
content_type: attachment.content_type
|
||||
)
|
||||
{ original: attachment, blob: blob }
|
||||
end
|
||||
end
|
||||
|
||||
def number_of_attachments
|
||||
mail.attachments.count
|
||||
end
|
||||
|
||||
def serialized_data
|
||||
{
|
||||
bcc: bcc,
|
||||
cc: cc,
|
||||
content_type: content_type,
|
||||
date: date,
|
||||
from: from,
|
||||
headers: headers_data,
|
||||
html_content: html_content,
|
||||
in_reply_to: in_reply_to,
|
||||
message_id: message_id,
|
||||
multipart: multipart?,
|
||||
number_of_attachments: number_of_attachments,
|
||||
references: references,
|
||||
subject: subject,
|
||||
text_content: text_content,
|
||||
to: to,
|
||||
auto_reply: auto_reply?
|
||||
}
|
||||
end
|
||||
|
||||
def in_reply_to
|
||||
return if @mail.in_reply_to.blank?
|
||||
|
||||
# Although the "in_reply_to" field in the email can potentially hold multiple values,
|
||||
# our current system does not have the capability to handle this.
|
||||
# FIX ME: Address this issue by returning the complete results and utilizing them for querying conversations.
|
||||
@mail.in_reply_to.is_a?(Array) ? @mail.in_reply_to.first : @mail.in_reply_to
|
||||
end
|
||||
|
||||
def references
|
||||
return [] if @mail.references.blank?
|
||||
|
||||
Array.wrap(@mail.references)
|
||||
end
|
||||
|
||||
def from
|
||||
# changing to downcase to avoid case mismatch while finding contact
|
||||
Array.wrap(@mail.reply_to.presence || @mail.from).map(&:downcase)
|
||||
end
|
||||
|
||||
def sender_name
|
||||
parse_mail_address((@mail[:reply_to] || @mail[:from]).value)&.name
|
||||
end
|
||||
|
||||
def original_sender
|
||||
[
|
||||
@mail[:reply_to]&.value,
|
||||
@mail['X-Original-Sender']&.value,
|
||||
@mail[:from]&.value
|
||||
].filter_map { |email| parse_mail_address(email)&.address }.first
|
||||
end
|
||||
|
||||
def headers_data
|
||||
headers = {
|
||||
'x-original-from' => @mail['X-Original-From']&.value,
|
||||
'x-original-sender' => @mail['X-Original-Sender']&.value,
|
||||
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
|
||||
}.compact
|
||||
|
||||
headers.presence
|
||||
end
|
||||
|
||||
def email_forwarded_for
|
||||
@mail['X-Forwarded-For'].try(:value)
|
||||
end
|
||||
|
||||
def mail_receiver
|
||||
if @mail.to.blank?
|
||||
return [email_forwarded_for] if email_forwarded_for.present?
|
||||
|
||||
[]
|
||||
else
|
||||
@mail.to
|
||||
end
|
||||
end
|
||||
|
||||
def auto_reply?
|
||||
auto_submitted? || x_auto_reply?
|
||||
end
|
||||
|
||||
def bounced?
|
||||
@mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
|
||||
end
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
# notification emails are send via mailer sender email address. so it should match
|
||||
configured_sender = Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
original_sender.to_s.casecmp?(configured_sender)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_mail_address(email)
|
||||
return if email.blank?
|
||||
|
||||
Mail::Address.new(email)
|
||||
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
|
||||
nil
|
||||
end
|
||||
|
||||
def auto_submitted?
|
||||
@mail['Auto-Submitted'].present? && @mail['Auto-Submitted'].value != 'no'
|
||||
end
|
||||
|
||||
def x_auto_reply?
|
||||
@mail['X-Autoreply'].present? && @mail['X-Autoreply'].value == 'yes'
|
||||
end
|
||||
|
||||
# forcing the encoding of the content to UTF-8 so as to be compatible with database and serializers
|
||||
def encode_to_unicode(str)
|
||||
return '' if str.blank?
|
||||
|
||||
current_encoding = str.encoding.name
|
||||
return str if current_encoding == 'UTF-8'
|
||||
|
||||
str.encode(current_encoding, 'UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
||||
rescue StandardError
|
||||
''
|
||||
end
|
||||
|
||||
def html_mail_body?
|
||||
((mail.content_type || '').include? 'text/html') || @mail.html_part&.content_type&.include?('text/html')
|
||||
end
|
||||
|
||||
def text_mail_body?
|
||||
((mail.content_type || '').include? 'text/plain') || @mail.text_part&.content_type&.include?('text/plain')
|
||||
end
|
||||
|
||||
def multipart_mail_body?
|
||||
((mail.content_type || '').include? 'multipart') || @mail.parts.any?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class MessageContentPresenter < SimpleDelegator
|
||||
def outgoing_content
|
||||
content_to_send = if should_append_survey_link?
|
||||
survey_link = survey_url(conversation.uuid)
|
||||
custom_message = inbox.csat_config&.dig('message')
|
||||
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
|
||||
else
|
||||
content
|
||||
end
|
||||
|
||||
Messages::MarkdownRendererService.new(
|
||||
content_to_send,
|
||||
conversation.inbox.channel_type,
|
||||
conversation.inbox.channel
|
||||
).render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_append_survey_link?
|
||||
input_csat? && !inbox.web_widget?
|
||||
end
|
||||
|
||||
def survey_url(conversation_uuid)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
class Messages::SearchDataPresenter < SimpleDelegator
|
||||
def search_data
|
||||
{
|
||||
**searchable_content,
|
||||
**message_attributes,
|
||||
additional_attributes: additional_attributes_data,
|
||||
conversation: conversation_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def searchable_content
|
||||
{
|
||||
content: content,
|
||||
attachments: attachment_data,
|
||||
content_attributes: content_attributes_data
|
||||
}
|
||||
end
|
||||
|
||||
def message_attributes
|
||||
{
|
||||
account_id: account_id,
|
||||
inbox_id: inbox_id,
|
||||
conversation_id: conversation_id,
|
||||
message_type: message_type,
|
||||
private: private,
|
||||
created_at: created_at,
|
||||
source_id: source_id,
|
||||
sender_id: sender_id,
|
||||
sender_type: sender_type
|
||||
}
|
||||
end
|
||||
|
||||
def attachment_data
|
||||
attachments.filter_map do |a|
|
||||
{ transcribed_text: a.meta&.dig('transcribed_text') }
|
||||
end.presence
|
||||
end
|
||||
|
||||
def content_attributes_data
|
||||
email_subject = content_attributes.dig(:email, :subject)
|
||||
return {} if email_subject.blank?
|
||||
|
||||
{ email: { subject: email_subject } }
|
||||
end
|
||||
|
||||
def conversation_data
|
||||
{ id: conversation.display_id }
|
||||
end
|
||||
|
||||
def additional_attributes_data
|
||||
{
|
||||
automation_rule_id: content_attributes&.dig('automation_rule_id')
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
class Reports::TimeFormatPresenter
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
attr_reader :seconds
|
||||
|
||||
def initialize(seconds = nil)
|
||||
@seconds = seconds.to_i if seconds.present?
|
||||
end
|
||||
|
||||
def format
|
||||
return 'N/A' if seconds.nil? || seconds.zero?
|
||||
|
||||
days, remainder = seconds.divmod(86_400)
|
||||
hours, remainder = remainder.divmod(3600)
|
||||
minutes, seconds = remainder.divmod(60)
|
||||
|
||||
format_components(days: days, hours: hours, minutes: minutes, seconds: seconds)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_components(components)
|
||||
formatted_components = components.filter_map do |unit, value|
|
||||
next if value.zero?
|
||||
|
||||
I18n.t("time_units.#{unit}", count: value)
|
||||
end
|
||||
|
||||
return I18n.t('time_units.seconds', count: 0) if formatted_components.empty?
|
||||
|
||||
formatted_components.first(2).join(' ')
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user