Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
class Messages::InReplyToMessageBuilder
|
||||
pattr_initialize [:message!, :in_reply_to!, :in_reply_to_external_id!]
|
||||
|
||||
delegate :conversation, to: :message
|
||||
|
||||
def perform
|
||||
set_in_reply_to_attribute if @in_reply_to.present? || @in_reply_to_external_id.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_in_reply_to_attribute
|
||||
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
|
||||
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
|
||||
end
|
||||
|
||||
def in_reply_to_message
|
||||
return conversation.messages.find_by(id: @in_reply_to) if @in_reply_to.present?
|
||||
|
||||
return conversation.messages.find_by(source_id: @in_reply_to_external_id) if @in_reply_to_external_id
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
class Messages::MarkdownRendererService
|
||||
CHANNEL_RENDERERS = {
|
||||
'Channel::Email' => :render_html,
|
||||
'Channel::WebWidget' => :render_html,
|
||||
'Channel::Telegram' => :render_telegram_html,
|
||||
'Channel::Whatsapp' => :render_whatsapp,
|
||||
'Channel::FacebookPage' => :render_instagram,
|
||||
'Channel::Instagram' => :render_instagram,
|
||||
'Channel::Line' => :render_line,
|
||||
'Channel::TwitterProfile' => :render_plain_text,
|
||||
'Channel::Sms' => :render_plain_text,
|
||||
'Channel::TwilioSms' => :render_plain_text
|
||||
}.freeze
|
||||
|
||||
def initialize(content, channel_type, channel = nil)
|
||||
@content = content
|
||||
@channel_type = channel_type
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def render
|
||||
return @content if @content.blank?
|
||||
|
||||
renderer_method = CHANNEL_RENDERERS[effective_channel_type]
|
||||
renderer_method ? send(renderer_method) : @content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def effective_channel_type
|
||||
# For Twilio SMS channel, check if it's actually WhatsApp
|
||||
if @channel_type == 'Channel::TwilioSms' && @channel&.whatsapp?
|
||||
'Channel::Whatsapp'
|
||||
else
|
||||
@channel_type
|
||||
end
|
||||
end
|
||||
|
||||
def commonmarker_doc
|
||||
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
end
|
||||
|
||||
def render_html
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
|
||||
markdown_renderer.render(doc)
|
||||
end
|
||||
|
||||
def render_telegram_html
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
def render_whatsapp
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
def render_instagram
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
def render_line
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::LineRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
def render_plain_text
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
|
||||
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
|
||||
def preserve_multiple_newlines(content)
|
||||
content.gsub(/\n{2,}/) do |match|
|
||||
"{{PRESERVE_#{match.length}_NEWLINES}}"
|
||||
end
|
||||
end
|
||||
|
||||
# Restore multiple newlines from placeholders
|
||||
def restore_multiple_newlines(content)
|
||||
content.gsub(/\{\{PRESERVE_(\d+)_NEWLINES\}\}/) do |_match|
|
||||
"\n" * Regexp.last_match(1).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
|
||||
def document(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def paragraph(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def text(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out(' ')
|
||||
end
|
||||
|
||||
def linebreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
def strikethrough(_node)
|
||||
out('<del>')
|
||||
out(:children)
|
||||
out('</del>')
|
||||
end
|
||||
|
||||
def method_missing(method_name, node = nil, *args, **kwargs, &)
|
||||
return super unless node.is_a?(CommonMarker::Node)
|
||||
|
||||
out(:children)
|
||||
cr unless %i[text softbreak linebreak].include?(node.type)
|
||||
end
|
||||
|
||||
def respond_to_missing?(_method_name, _include_private = false)
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('_', :children, '_')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def strong(_node)
|
||||
out(' *', :children, '* ')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out(' _', :children, '_ ')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(' `', node.string_content, '` ')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(' ```', "\n", node.string_content, '``` ', "\n")
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(:children)
|
||||
out(' ', node.url) if node.url.present?
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content, "\n")
|
||||
end
|
||||
|
||||
def header(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def thematic_break(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('<strong>', :children, '</strong>')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('<em>', :children, '</em>')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out('<code>', node.string_content, '</code>')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out('<a href="', node.url, '">', :children, '</a>')
|
||||
end
|
||||
|
||||
def strikethrough(_node)
|
||||
out('<del>', :children, '</del>')
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out('<blockquote>', :children, '</blockquote>')
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out('<pre>', node.string_content, '</pre>')
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('• ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def header(_node)
|
||||
out('<strong>', :children, '</strong>')
|
||||
cr
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('_', :children, '_')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out('`', node.string_content, '`')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out('> ', :children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
68
research/chatwoot/app/services/messages/mention_service.rb
Normal file
68
research/chatwoot/app/services/messages/mention_service.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
class Messages::MentionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return unless valid_mention_message?(message)
|
||||
|
||||
validated_mentioned_ids = filter_mentioned_ids_by_inbox
|
||||
return if validated_mentioned_ids.blank?
|
||||
|
||||
Conversations::UserMentionJob.perform_later(validated_mentioned_ids, message.conversation.id, message.account.id)
|
||||
generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_mention_message?(message)
|
||||
message.private? && message.content.present? && mentioned_ids.present?
|
||||
end
|
||||
|
||||
def mentioned_ids
|
||||
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
|
||||
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
|
||||
|
||||
expanded_user_ids = expand_team_mentions_to_users(team_mentions)
|
||||
|
||||
(user_mentions + expanded_user_ids).uniq
|
||||
end
|
||||
|
||||
def expand_team_mentions_to_users(team_ids)
|
||||
return [] if team_ids.blank?
|
||||
|
||||
message.inbox.account.teams
|
||||
.joins(:team_members)
|
||||
.where(id: team_ids)
|
||||
.pluck('team_members.user_id')
|
||||
.map(&:to_s)
|
||||
end
|
||||
|
||||
def valid_mentionable_user_ids
|
||||
@valid_mentionable_user_ids ||= begin
|
||||
inbox = message.inbox
|
||||
inbox.account.administrators.pluck(:id) + inbox.members.pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def filter_mentioned_ids_by_inbox
|
||||
mentioned_ids & valid_mentionable_user_ids.map(&:to_s)
|
||||
end
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: User.find(user_id),
|
||||
account: message.account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).perform
|
||||
end
|
||||
end
|
||||
|
||||
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Messages::NewMessageNotificationService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return unless message.notifiable?
|
||||
|
||||
notify_conversation_assignee
|
||||
notify_participating_users
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :conversation, :sender, :account, to: :message
|
||||
|
||||
def notify_conversation_assignee
|
||||
return if conversation.assignee.blank?
|
||||
return if already_notified?(conversation.assignee)
|
||||
return if conversation.assignee == sender
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'assigned_conversation_new_message',
|
||||
user: conversation.assignee,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).perform
|
||||
end
|
||||
|
||||
def notify_participating_users
|
||||
participating_users = conversation.conversation_participants.map(&:user)
|
||||
participating_users -= [sender] if sender.is_a?(User)
|
||||
|
||||
participating_users.uniq.each do |participant|
|
||||
next if already_notified?(participant)
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'participating_conversation_new_message',
|
||||
user: participant,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
).perform
|
||||
end
|
||||
end
|
||||
|
||||
# The user could already have been notified via a mention or via assignment
|
||||
# So we don't need to notify them again
|
||||
def already_notified?(user)
|
||||
conversation.notifications.exists?(user: user, secondary_actor: message)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Messages::SendEmailNotificationService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return unless should_send_email_notification?
|
||||
|
||||
conversation = message.conversation
|
||||
conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
|
||||
|
||||
# Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
|
||||
# the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
|
||||
# the worker never manages to clean up.
|
||||
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
|
||||
|
||||
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
|
||||
message.account.increment_email_sent_count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_send_email_notification?
|
||||
return false unless message.email_notifiable_message?
|
||||
return false if message.conversation.contact.email.blank?
|
||||
return false unless message.account.within_email_rate_limit?
|
||||
|
||||
email_reply_enabled?
|
||||
end
|
||||
|
||||
def email_reply_enabled?
|
||||
inbox = message.inbox
|
||||
case inbox.channel.class.to_s
|
||||
when 'Channel::WebWidget'
|
||||
inbox.channel.continuity_via_email
|
||||
when 'Channel::Api'
|
||||
inbox.account.feature_enabled?('email_continuity_on_api_channel')
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class Messages::StatusUpdateService
|
||||
attr_reader :message, :status, :external_error
|
||||
|
||||
def initialize(message, status, external_error = nil)
|
||||
@message = message
|
||||
@status = status
|
||||
@external_error = external_error
|
||||
end
|
||||
|
||||
def perform
|
||||
return false unless valid_status_transition?
|
||||
|
||||
update_message_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_message_status
|
||||
# Update status and set external_error only when failed
|
||||
message.update!(
|
||||
status: status,
|
||||
external_error: (status == 'failed' ? external_error : nil)
|
||||
)
|
||||
end
|
||||
|
||||
def valid_status_transition?
|
||||
return false unless Message.statuses.key?(status)
|
||||
|
||||
# Don't allow changing from 'read' to 'delivered'
|
||||
return false if message.read? && status == 'delivered'
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user