Files
chatwoot/lib/integrations/slack/send_on_slack_service.rb
Muhsin Keloth 44fab70048 feat: Add support for grouped file uploads in Slack (#12454)
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.
2025-09-24 11:31:06 +05:30

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