class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService include RegexHelper pattr_initialize [:message!, :hook!] def perform # overriding the base class logic since the validations are different in this case. # FIXME: for now we will only send messages from widget to slack return unless valid_channel_for_slack? # we don't want message loop in slack return if message.external_source_id_slack.present? # we don't want to start slack thread from agent conversation as of now return if invalid_message? perform_reply end def link_unfurl(event) slack_client.chat_unfurl( event ) # You may wonder why we're not requesting reauthorization and disabling hooks when scope errors occur. # Since link unfurling is just a nice-to-have feature that doesn't affect core functionality, we will silently ignore these errors. rescue Slack::Web::Api::Errors::MissingScope => e Rails.logger.warn "Slack: Missing scope error: #{e.message}" end private def valid_channel_for_slack? # slack wouldn't be an ideal interface to reply to tweets, hence disabling that case return false if channel.is_a?(Channel::TwitterProfile) && conversation.additional_attributes['type'] == 'tweet' true end def invalid_message? (message.outgoing? || message.template?) && conversation.identifier.blank? end def perform_reply send_message return unless @slack_message update_reference_id update_external_source_id_slack end def message_content private_indicator = message.private? ? 'private: ' : '' sanitized_content = ActionView::Base.full_sanitizer.sanitize(format_message_content) if conversation.identifier.present? "#{private_indicator}#{sanitized_content}" else "#{formatted_inbox_name}#{formatted_conversation_link}#{email_subject_line}\n#{sanitized_content}" end end def format_message_content message.message_type == 'activity' ? "_#{message_text}_" : message_text end def message_text content = message.processed_message_content || message.content if content.present? content.gsub(MENTION_REGEX, '\1') else content end end def formatted_inbox_name "\n*Inbox:* #{message.inbox.name} (#{message.inbox.inbox_type})\n" end def formatted_conversation_link "#{link_to_conversation} to view the conversation.\n" end def email_subject_line return '' unless message.inbox.email? email_payload = message.content_attributes['email'] return "*Subject:* #{email_payload['subject']}\n\n" if email_payload.present? && email_payload['subject'].present? '' end def avatar_url(sender) sender_type = sender_type(sender).downcase blob_key = sender&.avatar&.attached? ? sender.avatar.blob.key : nil generate_url(sender_type, blob_key) end def generate_url(sender_type, blob_key) base_url = ENV.fetch('FRONTEND_URL', nil) "#{base_url}/slack_uploads?blob_key=#{blob_key}&sender_type=#{sender_type}" end def send_message post_message if message_content.present? upload_file if message.attachments.any? rescue Slack::Web::Api::Errors::AccountInactive, Slack::Web::Api::Errors::MissingScope, Slack::Web::Api::Errors::InvalidAuth, Slack::Web::Api::Errors::ChannelNotFound, Slack::Web::Api::Errors::NotInChannel => e Rails.logger.error e hook.prompt_reauthorization! hook.disable end def post_message @slack_message = slack_client.chat_postMessage( channel: hook.reference_id, text: message_content, username: sender_name(message.sender), thread_ts: conversation.identifier, icon_url: avatar_url(message.sender), unfurl_links: conversation.identifier.present? ) end def upload_file message.attachments.each do |attachment| next unless attachment.with_attached_file? begin result = slack_client.files_upload_v2( filename: attachment.file.filename.to_s, content: attachment.file.download, initial_comment: 'Attached File!', thread_ts: conversation.identifier, channel_id: hook.reference_id ) Rails.logger.info "slack_upload_result: #{result}" rescue Slack::Web::Api::Errors::SlackError => e Rails.logger.error "Failed to upload file #{attachment.file.filename}: #{e.message}" end end end def file_type File.extname(message.attachments.first.download_url).strip.downcase[1..] end def file_information { filename: message.attachments.first.file.filename, filetype: file_type, content: message.attachments.first.file.download, title: message.attachments.first.file.filename } end def sender_name(sender) sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender) end def sender_type(sender) if sender.instance_of?(Contact) 'Contact' elsif sender.instance_of?(User) 'Agent' elsif message.message_type == 'activity' && sender.nil? 'System' else 'Bot' end end def update_reference_id return unless should_update_reference_id? conversation.update!(identifier: @slack_message['ts']) end def update_external_source_id_slack return unless @slack_message['message'] message.update!(external_source_id_slack: "cw-origin-#{@slack_message['message']['ts']}") end def slack_client @slack_client ||= Slack::Web::Client.new(token: hook.access_token) end def link_to_conversation "<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{conversation.account_id}/conversations/#{conversation.display_id}|Click here>" end # Determines whether the conversation identifier should be updated with the ts value. # The identifier should be updated in the following cases: # - If the conversation identifier is blank, it means a new conversation is being created. # - If the thread_ts is blank, it means that the conversation was previously connected in a different channel. def should_update_reference_id? conversation.identifier.blank? || @slack_message['message']['thread_ts'].blank? end end