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>
This commit is contained in:
Muhsin Keloth
2026-04-28 10:29:03 +04:00
committed by GitHub
co-authored by Muhsin Claude Opus 4.6 Copilot
parent 7820739f3a
commit f7bbd40816
8 changed files with 492 additions and 14 deletions
+17 -6
View File
@@ -23,13 +23,24 @@ class HookJob < MutexApplicationJob
private
def process_slack_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
message = event_data[:message]
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
case event_name
when 'message.created'
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
when 'message.updated'
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
# Skip other content types to avoid unnecessary job enqueues on every message update.
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
# while submitted_values is already present on the message.
return unless event_data[:previous_changes]&.key?('content_attributes')
::UpdateSlackMessageJob.perform_later(message, hook)
end
end
+11
View File
@@ -0,0 +1,11 @@
class UpdateSlackMessageJob < MutexApplicationJob
queue_as :medium
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(message, hook)
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
with_lock(key) do
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
end
end
end
+2 -2
View File
@@ -43,7 +43,7 @@ class HookListener < BaseListener
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message)
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
end
end
@@ -59,7 +59,7 @@ class HookListener < BaseListener
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created'],
'slack' => ['message.created', 'message.updated'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
@@ -0,0 +1,152 @@
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
+38
View File
@@ -67,6 +67,44 @@ RSpec.describe HookJob do
end
end
context 'when handleable events like message.updated for slack' do
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'calls UpdateSlackMessageJob when content_attributes changed' do
message = create(:message, account: account, content: 'Pick one', message_type: :outgoing,
content_type: :input_select, content_attributes: { items: [{ title: 'A', value: 'a' }] })
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'content_attributes' => [{}, { 'submitted_values' => [{ 'title' => 'A' }] }] } }
allow(UpdateSlackMessageJob).to receive(:perform_later).and_return(process_service)
expect(UpdateSlackMessageJob).to receive(:perform_later).with(message, hook)
described_class.perform_now(hook, 'message.updated', event_data)
end
it 'does not call UpdateSlackMessageJob when content_attributes did not change' do
message = create(:message, account: account, content: 'Pick one', message_type: :outgoing,
content_type: :input_select, content_attributes: { items: [{ title: 'A', value: 'a' }] })
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'status' => %w[sent delivered] } }
expect(UpdateSlackMessageJob).not_to receive(:perform_later)
described_class.perform_now(hook, 'message.updated', event_data)
end
it 'does not call UpdateSlackMessageJob for unsupported content types' do
message = create(:message, account: account, content: 'Hello', message_type: :outgoing, content_type: :text)
hook = create(:integrations_hook, app_id: 'slack', account: account)
event_data = { message: message, previous_changes: { 'content_attributes' => [{}, {}] } }
expect(UpdateSlackMessageJob).not_to receive(:perform_later)
described_class.perform_now(hook, 'message.updated', event_data)
end
end
context 'when processing leadsquared integration' do
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
@@ -0,0 +1,21 @@
require 'rails_helper'
RSpec.describe UpdateSlackMessageJob do
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, app_id: 'slack', account: account) }
let(:message) do
create(:message, account: account, content: 'Pick one', message_type: :outgoing, content_type: :input_select,
content_attributes: { items: [{ title: 'Option A', value: 'a' }] })
end
before do
stub_request(:post, 'https://slack.com/api/chat.update')
end
it 'calls Integrations::Slack::UpdateSlackMessageService' do
service_instance = Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook)
expect(Integrations::Slack::UpdateSlackMessageService).to receive(:new).with(message: message, hook: hook).and_return(service_instance)
described_class.perform_now(message, hook)
end
end
@@ -0,0 +1,245 @@
require 'rails_helper'
describe Integrations::Slack::UpdateSlackMessageService do
let(:account) { create(:account) }
let(:channel_email) { create(:channel_email, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: '12345.6789') }
let(:hook) { create(:integrations_hook, account: account, reference_id: 'C123') }
let(:slack_client) { double }
before do
allow(Slack::Web::Client).to receive(:new).and_return(slack_client)
end
describe '#perform' do
context 'with input_select' do
it 'updates the Slack message with the submitted response' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }, { title: 'Option B', value: 'b' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Pick one', 'Option A')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with form' do
it 'updates the Slack message with all submitted fields' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :form,
content: 'Please fill this',
content_attributes: {
items: [{ name: 'email', label: 'Email' }, { name: 'company', label: 'Company' }],
submitted_values: [{ name: 'email', value: 'a@example.com' }, { name: 'company', value: 'Acme' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Please fill this', 'Email', 'a@example.com', 'Company', 'Acme')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with input_csat' do
it 'updates the Slack message with the CSAT rating and feedback' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_csat,
content: 'Rate us',
content_attributes: {
submitted_values: {
'csat_survey_response' => { 'rating' => 5, 'feedback_message' => 'Great support!' }
}
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Rate us', 'Rating: 5', 'Great support!')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'with input_email' do
it 'updates the Slack message with the submitted email' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_email,
content: 'Get notified by email',
content_attributes: {
submitted_email: 'user@example.com'
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).to receive(:chat_update).with(
channel: 'C123',
ts: '6789.12345',
text: a_string_including('Get notified by email', 'user@example.com')
)
described_class.new(message: message, hook: hook).perform
end
end
context 'when there is no submitted response' do
it 'skips the update' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook).perform
end
end
context 'when the message was not originated from Chatwoot' do
it 'skips the update' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: '6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook).perform
end
end
context 'when hook has no reference_id' do
it 'skips the update' do
hook_without_ref = create(:integrations_hook, account: account, reference_id: nil)
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
expect(slack_client).not_to receive(:chat_update)
described_class.new(message: message, hook: hook_without_ref).perform
end
end
context 'when Slack API raises an auth error' do
it 'disables the hook and prompts reauthorization' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
allow(slack_client).to receive(:chat_update).and_raise(Slack::Web::Api::Errors::AccountInactive.new('account_inactive'))
allow(hook).to receive(:prompt_reauthorization!)
described_class.new(message: message, hook: hook).perform
expect(hook).to have_received(:prompt_reauthorization!)
expect(hook.reload.status).to eq('disabled')
end
end
context 'when the original Slack message no longer exists' do
it 'skips gracefully without disabling the hook' do
message = create(
:message,
account: account,
inbox: channel_email.inbox,
conversation: conversation,
message_type: :outgoing,
content_type: :input_select,
content: 'Pick one',
content_attributes: {
items: [{ title: 'Option A', value: 'a' }],
submitted_values: [{ title: 'Option A', value: 'a' }]
},
external_source_id_slack: 'cw-origin-6789.12345'
)
allow(slack_client).to receive(:chat_update).and_raise(Slack::Web::Api::Errors::MessageNotFound.new('message_not_found'))
described_class.new(message: message, hook: hook).perform
expect(hook.reload.status).not_to eq('disabled')
end
end
end
end
+6 -6
View File
@@ -9,7 +9,7 @@ describe HookListener do
create(:message, message_type: 'outgoing',
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message, previous_changes: nil) }
let(:contact_event) { Events::Base.new('contact.updated', Time.zone.now, contact: conversation.contact) }
let(:conversation_event) { Events::Base.new('conversation.created', Time.zone.now, conversation: conversation) }
@@ -26,7 +26,7 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message).once
expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message, previous_changes: nil).once
listener.message_created(event)
end
end
@@ -45,7 +45,7 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message, previous_changes: nil).once
listener.message_updated(event)
end
end
@@ -66,21 +66,21 @@ describe HookListener do
context 'when hook is enabled and app_id is supported' do
it 'enqueues the job for slack' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
listener.message_created(event)
end
it 'enqueues the job for dialogflow' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
listener.message_created(event)
end
it 'enqueues the job for google_translate' do
hook = create(:integrations_hook, :google_translate, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message, previous_changes: nil)
listener.message_created(event)
end