chatwoot/lib/integrations/slack/update_slack_message_service.rb
Muhsin Keloth f7bbd40816
fix(slack): Sync bot interactive responses (#14076)
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.

Closes https://linear.app/chatwoot/issue/PLA-147

### Preview

<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>

### How to test

  1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
  3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 10:29:03 +04:00

153 lines
4.2 KiB
Ruby

class Integrations::Slack::UpdateSlackMessageService
include RegexHelper
SUPPORTED_CONTENT_TYPES = %w[input_select form input_csat input_email].freeze
pattr_initialize [:message!, :hook!]
def perform
return unless updateable_message?
slack_client.chat_update(
channel: hook.reference_id,
ts: slack_message_ts,
text: updated_message_content
)
rescue Slack::Web::Api::Errors::MessageNotFound => e
# Original Slack message no longer exists (e.g. channel was reconfigured), skip gracefully.
Rails.logger.error "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
rescue Slack::Web::Api::Errors::IsArchived, 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 "[Slack] chat_update failed (account=#{message.account_id}, hook=#{hook.id}): #{e.message}"
hook.prompt_reauthorization!
hook.disable
end
private
def updateable_message?
hook&.reference_id.present? &&
slack_message_ts.present? &&
message.content_type.in?(SUPPORTED_CONTENT_TYPES) &&
(message.submitted_values.present? || message.submitted_email.present?)
end
def slack_message_ts
source_id = message.external_source_id_slack.to_s
return unless source_id.start_with?('cw-origin-')
source_id.delete_prefix('cw-origin-').presence
end
def updated_message_content
question = sanitized_content(message_text).presence
response = formatted_response
return question.to_s if response.blank?
[question, response].compact.join("\n\n")
end
def formatted_response
case message.content_type
when 'input_select'
format_input_select_response
when 'form'
format_form_response
when 'input_csat'
format_csat_response
when 'input_email'
format_email_response
end
end
def format_input_select_response
item = Array(message.submitted_values).first
return if item.blank?
value = item['title'] || item[:title] || item['value'] || item[:value]
value = sanitized_content(value)
return if value.blank?
"*Response:* #{value}"
end
def format_email_response
email = sanitized_content(message.submitted_email)
return if email.blank?
"*Email:* #{email}"
end
def format_form_response
submitted_values = Array(message.submitted_values)
return if submitted_values.blank?
items_by_name = Array(message.items).index_by { |i| flex_value(i, 'name') }
lines = submitted_values.filter_map do |sv|
format_form_line(sv, items_by_name)
end
return if lines.blank?
"*Responses:*\n#{lines.join("\n")}"
end
def format_csat_response
csat_response = flex_value(message.submitted_values, 'csat_survey_response', 'csatSurveyResponse')
return if csat_response.blank?
rating = flex_value(csat_response, 'rating')
feedback = flex_value(csat_response, 'feedback_message', 'feedbackMessage')
lines = []
lines << "• Rating: #{rating}" if rating.present?
lines << "• Feedback: #{sanitized_content(feedback)}" if feedback.present?
return if lines.blank?
"*CSAT:*\n#{lines.join("\n")}"
end
def format_form_line(submitted_value, items_by_name)
name = flex_value(submitted_value, 'name')
value = sanitized_content(flex_value(submitted_value, 'value'))
return if value.blank?
label = sanitized_content(flex_value(items_by_name[name], 'label') || name)
return if label.blank?
"#{label}: #{value}"
end
def flex_value(hash, *keys)
return if hash.blank?
keys.each do |key|
value = hash[key.to_sym] || hash[key.to_s]
return value if value.present?
end
nil
end
def message_text
content = message.processed_message_content || message.content
if content.present?
content.to_s.gsub(MENTION_REGEX, '\1')
else
content
end
end
def sanitized_content(text)
ActionView::Base.full_sanitizer.sanitize(text.to_s).strip
end
def slack_client
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
end
end