From 36a05097fa44b716a8d489d0aa40e188c5d4cc30 Mon Sep 17 00:00:00 2001 From: ramalau <71857041+ramalau0@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:58:13 +0200 Subject: [PATCH] fix(webhooks): strip trailing newlines from webhook message content (#14272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TipTap/ProseMirror editor stores agent messages with trailing paragraph nodes that produce trailing newlines (e.g. \`\n\n\n\`) in the \`content\` field. While Chatwoot's native channel delivery already handles this, webhook payloads and API responses were returning raw content with trailing whitespace — causing visible blank space below messages in every external integration that consumes Chatwoot webhooks (WhatsApp via Evolution API, Telegram bots, custom webhook consumers). Closes #13459 ## Root cause \`Messages::WebhookContentNormalizer\` already strips CommonMark hard line breaks (\`\\\` + newline) for webhook consumers, but it did not strip trailing whitespace. All webhook and API responses flow through this normaliser, so it is the single correct place to apply the fix without touching stored data. ## What changed Added \`.rstrip\` to \`Messages::WebhookContentNormalizer.normalize\`: \`\`\`ruby # before text.gsub(/\\\r?\n/, "\n") # after text.gsub(/\\\r?\n/, "\n").rstrip \`\`\` ## Trade-offs considered | Option | Decision | |---|---| | \`before_save\` on \`Message\` model | Would clean stored data but is a broader change affecting all message creation paths and would require a data migration for existing records. Out of scope for this bug. | | Trim in each channel's send path | DRY violation — many channels, each would need the same patch. | | Fix at normaliser level (chosen) | Single location, only affects webhook/API output, zero risk to stored data or native channel delivery. | **Known limitation:** existing messages in the database still have trailing newlines in storage. They will be delivered correctly through webhooks after this fix, but a follow-up migration could clean stored content if needed. ## How to reproduce 1. Send an agent reply from the Chatwoot UI 2. Inspect the \`content\` field of the outgoing \`message_created\` webhook payload 3. Observe trailing \`\n\n\n\` after the message text After this fix, the \`content\` field is trimmed before delivery. --------- Co-authored-by: Ramalau Debeila Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../messages/webhook_content_normalizer.rb | 3 +- .../webhook_content_normalizer_spec.rb | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 spec/services/messages/webhook_content_normalizer_spec.rb diff --git a/app/services/messages/webhook_content_normalizer.rb b/app/services/messages/webhook_content_normalizer.rb index b45f83ad0..5fa66fe9c 100644 --- a/app/services/messages/webhook_content_normalizer.rb +++ b/app/services/messages/webhook_content_normalizer.rb @@ -1,10 +1,11 @@ # Strips CommonMark hard line breaks from stored markdown source (backslash before newline). # ProseMirror / the dashboard editor emits this form so soft breaks survive as markdown; # webhook consumers expect plain newlines without a visible backslash (e.g. WhatsApp gateways). +# Also strips trailing newlines introduced by TipTap/ProseMirror trailing paragraph nodes. class Messages::WebhookContentNormalizer def self.normalize(text) return text if text.blank? - text.gsub(/\\\r?\n/, "\n") + text.gsub(/\\\r?\n/, "\n").sub(/(\r?\n)+\z/, '') end end diff --git a/spec/services/messages/webhook_content_normalizer_spec.rb b/spec/services/messages/webhook_content_normalizer_spec.rb new file mode 100644 index 000000000..ca5023b9d --- /dev/null +++ b/spec/services/messages/webhook_content_normalizer_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Messages::WebhookContentNormalizer do + describe '.normalize' do + it 'returns nil unchanged' do + expect(described_class.normalize(nil)).to be_nil + end + + it 'returns blank string unchanged' do + expect(described_class.normalize('')).to eq('') + end + + it 'strips trailing newlines added by TipTap/ProseMirror' do + expect(described_class.normalize("hello\n\n\n")).to eq('hello') + end + + it 'preserves intentional trailing spaces' do + expect(described_class.normalize("hello \n\n")).to eq('hello ') + end + + it 'replaces CommonMark hard line breaks (backslash-newline) with plain newlines' do + expect(described_class.normalize("hello\\\nworld")).to eq("hello\nworld") + end + + it 'replaces CommonMark hard line breaks with CRLF with plain newlines' do + expect(described_class.normalize("hello\\\r\nworld")).to eq("hello\nworld") + end + + it 'preserves intentional internal newlines' do + expect(described_class.normalize("line one\nline two")).to eq("line one\nline two") + end + + it 'strips trailing CRLF newlines without leaving dangling carriage returns' do + expect(described_class.normalize("hello\r\n\r\n")).to eq('hello') + end + + it 'handles both hard line breaks and trailing newlines together' do + expect(described_class.normalize("hello\\\nworld\n\n\n")).to eq("hello\nworld") + end + end +end