diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index 6bbf8355d..deb7c81d8 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -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 diff --git a/app/jobs/update_slack_message_job.rb b/app/jobs/update_slack_message_job.rb new file mode 100644 index 000000000..4b969e42b --- /dev/null +++ b/app/jobs/update_slack_message_job.rb @@ -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 diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb index 936d104a0..6176d53dd 100644 --- a/app/listeners/hook_listener.rb +++ b/app/listeners/hook_listener.rb @@ -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'] diff --git a/lib/integrations/slack/update_slack_message_service.rb b/lib/integrations/slack/update_slack_message_service.rb new file mode 100644 index 000000000..b11828774 --- /dev/null +++ b/lib/integrations/slack/update_slack_message_service.rb @@ -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 diff --git a/spec/jobs/hook_job_spec.rb b/spec/jobs/hook_job_spec.rb index aff68e512..b55b05838 100644 --- a/spec/jobs/hook_job_spec.rb +++ b/spec/jobs/hook_job_spec.rb @@ -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) } diff --git a/spec/jobs/update_slack_message_job_spec.rb b/spec/jobs/update_slack_message_job_spec.rb new file mode 100644 index 000000000..3077f40e9 --- /dev/null +++ b/spec/jobs/update_slack_message_job_spec.rb @@ -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 diff --git a/spec/lib/integrations/slack/update_slack_message_service_spec.rb b/spec/lib/integrations/slack/update_slack_message_service_spec.rb new file mode 100644 index 000000000..3ac087a1b --- /dev/null +++ b/spec/lib/integrations/slack/update_slack_message_service_spec.rb @@ -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 diff --git a/spec/listeners/hook_listener_spec.rb b/spec/listeners/hook_listener_spec.rb index 3ae031237..c048761d1 100644 --- a/spec/listeners/hook_listener_spec.rb +++ b/spec/listeners/hook_listener_spec.rb @@ -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