mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 02:02:27 +00:00
Fixes https://linear.app/chatwoot/issue/CW-5646/add-support-for-grouped-file-uploads-in-slack Previously, when sending multiple attachments to Slack, we uploaded them one by one. For example, sending 5 images would result in 5 separate Slack messages. This created clutter and a poor user experience, since Slack displayed each file as an individual message. This PR updates the implementation to group all attachments from a message and send them as a single Slack message. As a result, attachments now appear together in one grouped block, providing a much cleaner and more intuitive experience for users. **Before:** Each file uploaded as a separate Slack message. <img width="400" height="800" alt="before" src="https://github.com/user-attachments/assets/c8c7f666-549b-428f-bd19-c94e39ed2513" /> **After:** All files from a single message grouped and displayed together in one Slack message (similar to how Slack natively handles grouped uploads). <img width="400" height="800" alt="after" src="https://github.com/user-attachments/assets/0b1f22d5-4d37-4b84-905a-15e742317e72" /> **Changes** - Upgraded Slack file upload implementation to use the new multiple attachments API available in slack-ruby-client `v2.7.0`. - Updated attachment handling to upload all files from a message in a single API call. - Enabled proper attachment grouping in Slack, ensuring related files are presented together.
198 lines
5.9 KiB
Ruby
198 lines
5.9 KiB
Ruby
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_files 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_files
|
|
return unless message.attachments.any?
|
|
|
|
files = build_files_array
|
|
return if files.empty?
|
|
|
|
begin
|
|
result = slack_client.files_upload_v2(
|
|
files: files,
|
|
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 files: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def build_files_array
|
|
message.attachments.filter_map do |attachment|
|
|
next unless attachment.with_attached_file?
|
|
|
|
{
|
|
filename: attachment.file.filename.to_s,
|
|
content: attachment.file.download,
|
|
title: attachment.file.filename.to_s
|
|
}
|
|
end
|
|
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
|