From 559ccde0159731ed1b8fd7eec6b5d91e3ab39b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 05:30:58 +0000 Subject: [PATCH 1/2] fix(list-message): remove duplicate header in nativeList conversion When title is not provided, the text was used as fallback for both title and description, causing the header to appear twice. Now title only uses an explicit title field, description uses text. https://claude.ai/code/session_01SJdSHiUxtwzV8bb5dedodb --- src/Utils/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 7592da3d..113901a5 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -1081,7 +1081,7 @@ export const generateWAMessageContent = async ( // Legacy format (listMessage) works on all platforms const generated = generateListMessageLegacy( { sections: listMsg.nativeList.sections }, - listMsg.title || listMsg.text || '', + listMsg.title || '', listMsg.text || '', listMsg.nativeList.buttonText, listMsg.footer From 930ca0a36b3e15719ab4f9e48ac097f1d40f92b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 05:42:28 +0000 Subject: [PATCH 2/2] feat(list-message): add WhatsApp limit validation for list messages Validates sections, rows, and string lengths before sending: - Max 10 sections, 10 rows/section, 30 total rows - Section title max 24 chars, row title max 24 chars - Row description max 72 chars, row ID max 200 chars - buttonText max 20 chars - At least 1 section and 1 row per section required Applied to all 3 list message paths: generateListMessage, generateListMessageLegacy, and inline sections handler. https://claude.ai/code/session_01SJdSHiUxtwzV8bb5dedodb --- src/Utils/messages.ts | 104 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 113901a5..3aa68fd0 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -695,13 +695,105 @@ export const generateCarouselMessage = async ( * await sock.sendMessage(jid, msg) * ``` */ -export const generateListMessage = (options: ListMessageOptions): WAMessageContent => { - const { buttonText, sections, text, title, footer } = options +// ========== List Message Limits (WhatsApp enforced) ========== +const LIST_LIMITS = { + MAX_SECTIONS: 10, + MAX_ROWS_PER_SECTION: 10, + MAX_TOTAL_ROWS: 30, + MAX_SECTION_TITLE: 24, + MAX_ROW_TITLE: 24, + MAX_ROW_DESCRIPTION: 72, + MAX_ROW_ID: 200, + MAX_BUTTON_TEXT: 20, +} as const + +/** + * Validates and sanitizes list message sections according to WhatsApp limits. + * Truncates strings that exceed max length, throws on structural violations. + */ +const validateListSections = ( + sections: Array<{ title: string; rows: Array<{ id?: string; rowId?: string; title: string; description?: string }> }>, + buttonText?: string +): void => { if (!sections || sections.length === 0) { throw new Boom('At least one section is required', { statusCode: 400 }) } + if (sections.length > LIST_LIMITS.MAX_SECTIONS) { + throw new Boom( + `Maximum ${LIST_LIMITS.MAX_SECTIONS} sections allowed, got ${sections.length}`, + { statusCode: 400 } + ) + } + + if (buttonText && buttonText.length > LIST_LIMITS.MAX_BUTTON_TEXT) { + throw new Boom( + `buttonText max ${LIST_LIMITS.MAX_BUTTON_TEXT} characters, got ${buttonText.length}`, + { statusCode: 400 } + ) + } + + let totalRows = 0 + for (const section of sections) { + if (section.title && section.title.length > LIST_LIMITS.MAX_SECTION_TITLE) { + throw new Boom( + `Section title max ${LIST_LIMITS.MAX_SECTION_TITLE} characters, got ${section.title.length}: "${section.title}"`, + { statusCode: 400 } + ) + } + + if (!section.rows || section.rows.length === 0) { + throw new Boom('Each section must have at least one row', { statusCode: 400 }) + } + + if (section.rows.length > LIST_LIMITS.MAX_ROWS_PER_SECTION) { + throw new Boom( + `Maximum ${LIST_LIMITS.MAX_ROWS_PER_SECTION} rows per section, got ${section.rows.length} in "${section.title}"`, + { statusCode: 400 } + ) + } + + for (const row of section.rows) { + const rowId = row.id || row.rowId || '' + if (rowId.length > LIST_LIMITS.MAX_ROW_ID) { + throw new Boom( + `Row ID max ${LIST_LIMITS.MAX_ROW_ID} characters, got ${rowId.length}`, + { statusCode: 400 } + ) + } + + if (row.title && row.title.length > LIST_LIMITS.MAX_ROW_TITLE) { + throw new Boom( + `Row title max ${LIST_LIMITS.MAX_ROW_TITLE} characters, got ${row.title.length}: "${row.title}"`, + { statusCode: 400 } + ) + } + + if (row.description && row.description.length > LIST_LIMITS.MAX_ROW_DESCRIPTION) { + throw new Boom( + `Row description max ${LIST_LIMITS.MAX_ROW_DESCRIPTION} characters, got ${row.description.length}: "${row.description.substring(0, 30)}..."`, + { statusCode: 400 } + ) + } + } + + totalRows += section.rows.length + } + + if (totalRows > LIST_LIMITS.MAX_TOTAL_ROWS) { + throw new Boom( + `Maximum ${LIST_LIMITS.MAX_TOTAL_ROWS} total rows allowed, got ${totalRows}`, + { statusCode: 400 } + ) + } +} + +export const generateListMessage = (options: ListMessageOptions): WAMessageContent => { + const { buttonText, sections, text, title, footer } = options + + validateListSections(sections, buttonText) + // Build sections for single_select const formattedSections = sections.map(section => ({ title: section.title, @@ -1014,6 +1106,8 @@ export const generateListMessageLegacy = ( buttonText: string, footer?: string ): WAMessageContent => { + validateListSections(listInfo.sections, buttonText) + return { listMessage: WAProto.Message.ListMessage.fromObject({ title, @@ -1158,7 +1252,11 @@ export const generateWAMessageContent = async ( m.templateMessage = templateMessage options.logger?.warn('[EXPERIMENTAL] Sending templateMessage - this may not work and can cause bans') } else if (hasNonNullishProperty(message, 'sections')) { - // Process list messages + // Process list messages - validate limits + validateListSections( + (message as any).sections, + (message as any).buttonText + ) const listMessage: proto.Message.IListMessage = { title: (message as any).title, description: (message as any).text,