fix: Handle rich message signatures & attachment overflow (#13045)

This commit is contained in:
Sivin Varghese
2025-12-10 23:13:04 +05:30
committed by GitHub
parent 89d02e2c92
commit f2054e703a
9 changed files with 197 additions and 63 deletions

View File

@@ -5,9 +5,36 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import {
FORMATTING,
MARKDOWN_PATTERNS,
CHANNEL_WITH_RICH_SIGNATURE,
} from 'dashboard/constants/editor';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
@@ -69,15 +96,32 @@ export function findSignatureInBody(body, signature) {
return -1;
}
/**
* Checks if the channel supports image signatures.
*
* @param {string} channelType - The channel type.
* @returns {boolean} - True if the channel supports image signatures.
*/
export function supportsImageSignature(channelType) {
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips images for channels that don't support image signatures.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The channel type to determine if images should be stripped.
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
export function appendSignature(body, signature, channelType) {
// For channels that don't support images, strip markdown formatting
const shouldStripImages = channelType && !supportsImageSignature(channelType);
const preparedSignature = shouldStripImages
? extractTextFromMarkdown(signature)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
@@ -88,16 +132,27 @@ export function appendSignature(body, signature) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version (for non-image channels).
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
// Build list of signatures to try: original first, then stripped version
// Always try both to handle cases where channelType is unknown or inbox is being removed
const cleanedSignature = cleanSignature(signature);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
const signaturesToTry =
cleanedSignature === strippedSignature
? [cleanedSignature]
: [cleanedSignature, strippedSignature];
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
@@ -138,28 +193,6 @@ export function replaceSignature(body, oldSignature, newSignature) {
return appendSignature(withoutSignature, newSignature);
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*

View File

@@ -5,6 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
supportsImageSignature,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -144,6 +145,47 @@ describe('appendSignature', () => {
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
const strippedSignature = 'Thanks';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain(strippedSignature);
});
it('strips images for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain(strippedSignature);
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
@@ -202,6 +244,37 @@ describe('removeSignature', () => {
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
@@ -258,6 +331,24 @@ describe('extractTextFromMarkdown', () => {
});
});
describe('supportsImageSignature', () => {
it('returns true for Email channel', () => {
expect(supportsImageSignature('Channel::Email')).toBe(true);
});
it('returns true for WebWidget channel', () => {
expect(supportsImageSignature('Channel::WebWidget')).toBe(true);
});
it('returns false for Api channel', () => {
expect(supportsImageSignature('Channel::Api')).toBe(false);
});
it('returns false for WhatsApp channel', () => {
expect(supportsImageSignature('Channel::Whatsapp')).toBe(false);
});
it('returns false for Telegram channel', () => {
expect(supportsImageSignature('Channel::Telegram')).toBe(false);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);