Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions client/api_patches/src/controller/buttonReplyController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { Request, Response } from 'express';

// The answer to a button of a business's message, sent the way WhatsApp Web
// sends it when the button is tapped.
//
// Read over CDP on 2026-10-21 (WAWebGetInteractiveActionsFromButtons and
// WAWebSendButtonsMsgReplyChatAction):
// - "dynamic": the reply buttons of a plain message (dynamicReplyButtons).
// WAWebSendButtonsMsgReplyChatAction(message, button, chat) sends a
// "buttons response" carrying the button's id. Checked live: the business
// went on with its flow.
// - "native": a quick reply of an interactive (native flow) message. The web
// client sends a text with the button's label that quotes the message and
// carries the button's index and id (sendTextMsgToChat, selectedIndex and
// selectedId), then marks the button as clicked.
// - "template": the buttons of a template message, answered by index by
// WA-JS itself.
// - "list": a row of a list message. The web client sends a list response
// (type list_response) that carries the row's title, description and id,
// quoting the list.
// Links, copied codes and phone numbers are never sent: the window acts on
// them.
//
// Sent once, never retried: a send that threw may have reached the business.
//
// Runs inside the page: self-contained, no closure over anything here.
export async function replyButtonInPage(request: any): Promise<any> {
const root = globalThis as any;
const load = (name: string) => {
try {
return typeof root.require === 'function' ? root.require(name) : undefined;
} catch (_) {
return undefined;
}
};
const store = load('WAWebMsgCollection')?.MsgCollection;
const frontend = load('WAWebFrontendMsgGetters');
if (!store || !frontend) return { error: 'unavailable' };

let msg = store.get(request.messageId);
if (!msg && typeof root.WPP?.chat?.getMessageById === 'function') {
try {
await root.WPP.chat.getMessageById(request.messageId);
} catch (_) {
// Not in the chat: answered below.
}
msg = store.get(request.messageId);
}
if (!msg) return { error: 'message_not_found' };
const raw = typeof msg.unsafe === 'function' ? msg.unsafe() : msg;
const chat = frontend.getChat(raw);
if (!chat) return { error: 'unavailable' };

if (request.source === 'dynamic') {
const button = raw.replyButtons?.getModelsArray?.()?.[request.index];
const send = load('WAWebSendButtonsMsgReplyChatAction');
if (!button) return { error: 'button_not_found' };
if (typeof send !== 'function') return { error: 'unavailable' };
await send(msg, button, chat);
return { sent: true };
}

if (request.source === 'native') {
const button = raw.interactivePayload?.buttons?.[request.index];
let params: any = null;
try {
params = JSON.parse(button?.buttonParamsJson);
} catch (_) {
params = null;
}
if (!button || button.name !== 'quick_reply' || !params || typeof params.display_text !== 'string') {
return { error: 'button_not_found' };
}
if (params.disabled === true) return { error: 'button_used' };
const sendText = load('WAWebSendTextMsgChatAction')?.sendTextMsgToChat;
const mark = load('WAWebBizInteractiveMessageQuickReplyAction')?.markInteractiveButtonClicked;
if (typeof sendText !== 'function') return { error: 'unavailable' };
await sendText(chat, params.display_text, {
quotedMsg: raw, selectedIndex: request.index, selectedId: params.id,
});
if (typeof mark === 'function') {
try {
await mark(raw, request.index);
} catch (_) {
// Only the button's look; the answer was sent.
}
}
return { sent: true };
}

if (request.source === 'list') {
const rows = (raw.list?.sections ?? []).flatMap((section: any) => section?.rows ?? []);
const row = rows[request.index];
if (!row || typeof row.rowId !== 'string' || typeof row.title !== 'string') return { error: 'button_not_found' };
const me = load('WAWebUserPrefsMeUser')?.getMePnUserOrThrow_DO_NOT_USE;
const MsgKey = load('WAWebMsgKey');
const ack = load('WAWebAck')?.ACK;
const time = load('WATimeUtils')?.unixTime;
const sender = load('WAWebSendMsgChatAction')?.addAndSendMsgToChat;
const types = load('WAWebMsgType');
const listType = load('WAWebProtobufsE2E.pb')?.Message$ListResponseMessage$ListType?.SINGLE_SELECT;
if (typeof me !== 'function' || typeof MsgKey?.newId !== 'function' || !ack || typeof time !== 'function'
|| typeof sender !== 'function' || !types?.MSG_TYPE?.LIST_RESPONSE || !types?.MsgKind?.ListResponse
|| typeof msg.msgContextInfo !== 'function' || listType === undefined) {
return { error: 'unavailable' };
}
const mePn = me();
const description = typeof row.description === 'string' ? row.description : '';
await sender(chat, {
type: types.MSG_TYPE.LIST_RESPONSE,
kind: types.MsgKind.ListResponse,
ack: ack.CLOCK,
from: mePn,
id: new MsgKey({ from: mePn, to: chat.id, id: await MsgKey.newId(), participant: undefined, selfDir: 'out' }),
local: true,
isNewMsg: true,
t: time(),
to: chat.id,
...msg.msgContextInfo(chat.id),
listResponse: { title: row.title, description, listType, singleSelectReply: { selectedRowId: row.rowId } },
body: description ? `${row.title}\n${description}` : row.title,
viewMode: 'VISIBLE',
});
return { sent: true };
}

const buttons = raw.hydratedButtons;
if (!Array.isArray(buttons) || !buttons[request.index]?.quickReplyButton) return { error: 'button_not_found' };
if (typeof root.WPP?.chat?.replyToButtonMessage !== 'function') return { error: 'unavailable' };
await root.WPP.chat.replyToButtonMessage(raw.id.remote._serialized, request.messageId, {
buttonIndex: request.index,
});
return { sent: true };
}

// "true_<chat>_<id>" or "false_<chat>_<id>", a message of a one-to-one chat.
const validMessage = (value: unknown): value is string =>
typeof value === 'string' && /^(true|false)_\d+@(c\.us|lid)_[A-Za-z0-9]{8,64}$/.test(value);

const SOURCES = ['dynamic', 'native', 'template', 'list'];

const STATUS_OF: Record<string, number> = {
message_not_found: 404, button_not_found: 404, button_used: 409,
};

export async function replyToButton(req: Request, res: Response) {
const { source, index } = req.body || {};
if (!validMessage(req.params.messageId) || !SOURCES.includes(source)
|| !Number.isInteger(index) || index < 0 || index > 11) {
return res.status(400).json({ status: 'error', code: 'invalid_request' });
}
try {
const page: any = (req.client as any)?.page;
if (!page || page.isClosed?.()) throw new Error('unavailable');
const answer = await page.evaluate(replyButtonInPage, { messageId: req.params.messageId, source, index });
if (!answer || typeof answer !== 'object') throw new Error('unavailable');
if (typeof answer.error === 'string') {
const status = STATUS_OF[answer.error];
if (!status) throw new Error('unavailable');
return res.status(status).json({ status: 'error', code: answer.error });
}
return res.status(200).json({ status: 'success', response: { sent: answer.sent === true } });
} catch (_) {
// Never the native error: it can carry a chat id or the message's text.
return res.status(503).json({ status: 'error', code: 'reply_button_unavailable' });
}
}
5 changes: 5 additions & 0 deletions client/api_patches/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as StatusDeleteController from '../controller/statusDeleteController';
import * as CommunityAdminController from '../controller/communityAdminController';
import * as CommerceController from '../controller/commerceController';
import * as OrderConfirmController from '../controller/orderConfirmController';
import * as ButtonReplyController from '../controller/buttonReplyController';
import * as GroupMembershipRequestsController from '../controller/groupMembershipRequestsController';
import * as MiscController from '../controller/miscController';
import * as NewsletterController from '../controller/newsletterController';
Expand Down Expand Up @@ -986,6 +987,10 @@ routes.get('/api/:session/commerce/order-details/:messageId', verifyToken, statu
OrderConfirmController.readBuyerOrder);
routes.post('/api/:session/commerce/order-details/:messageId/confirm', verifyToken, statusConnection,
OrderConfirmController.confirmBuyerOrder);
// A button of a business's message, answered the way WhatsApp Web does
// (buttonReplyController.ts).
routes.post('/api/:session/reply-button/:messageId', verifyToken, statusConnection,
ButtonReplyController.replyToButton);
routes.post(
'/api/:session/set-cart-enabled',
verifyToken,
Expand Down
148 changes: 148 additions & 0 deletions client/api_patches/src/tests/controller/buttonReplyController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { replyButtonInPage, replyToButton } from '../../controller/buttonReplyController';

const ID = 'false_5511900000001@lid_3EB0AAAAAAAAAAAA';

function fakePage({ present = true, extra = {}, withSender = true }: any = {}) {
const chat = { id: 'chat' };
const sendButtonReply = jest.fn(async () => {});
const sendText = jest.fn(async () => {});
const mark = jest.fn(async () => {});
const sendList = jest.fn(async () => {});
const replyToButtonMessage = jest.fn(async () => ({}));
const button = { id: '2', displayText: 'Home' };
const raw: any = {
id: { remote: { _serialized: '5511900000001@lid' } },
replyButtons: { getModelsArray: () => [{ id: '1' }, button] },
interactivePayload: { buttons: [
{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Debt', id: 'd1' }) },
{ name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: 'Site', url: 'https://x.com' }) },
{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Used', id: 'u', disabled: true }) },
] },
list: { sections: [{ rows: [{ title: 'Outage', rowId: 'r1' }] }, { rows: [{ title: 'Bills', description: 'd', rowId: 'r2' }] }] },
hydratedButtons: [{ quickReplyButton: { id: 'q', displayText: 'Yes' } }, { urlButton: { url: 'https://x.com' } }],
...extra,
};
const msg = { unsafe: () => raw, msgContextInfo: (to: any) => ({ quotedStanzaID: 'Q', to }) };
const modules: Record<string, any> = {
WAWebMsgCollection: { MsgCollection: { get: (id: string) => (present && id === ID ? msg : undefined) } },
WAWebFrontendMsgGetters: { getChat: () => chat },
WAWebSendButtonsMsgReplyChatAction: withSender ? sendButtonReply : undefined,
WAWebSendTextMsgChatAction: { sendTextMsgToChat: sendText },
WAWebBizInteractiveMessageQuickReplyAction: { markInteractiveButtonClicked: mark },
WAWebUserPrefsMeUser: { getMePnUserOrThrow_DO_NOT_USE: () => 'me' },
WAWebMsgKey: class { static newId = async () => 'NEWID'; constructor(public parts: any) {} },
WAWebAck: { ACK: { CLOCK: 0 } },
WATimeUtils: { unixTime: () => 1790000000 },
WAWebSendMsgChatAction: { addAndSendMsgToChat: sendList },
WAWebMsgType: { MSG_TYPE: { LIST_RESPONSE: 'list_response' }, MsgKind: { ListResponse: 'listResponse' } },
'WAWebProtobufsE2E.pb': { Message$ListResponseMessage$ListType: { SINGLE_SELECT: 1 } },
};
const require = (name: string) => {
if (!(name in modules)) throw new Error('unknown');
return modules[name];
};
return { require, chat, msg, raw, button, sendButtonReply, sendText, mark, sendList, replyToButtonMessage };
}

async function inPage(fake: any, source: string, index: number, messageId = ID) {
const root = globalThis as any;
const previous = { require: root.require, WPP: root.WPP };
root.require = fake.require;
root.WPP = { chat: { replyToButtonMessage: fake.replyToButtonMessage } };
try {
return await replyButtonInPage({ messageId, source, index });
} finally {
root.require = previous.require;
root.WPP = previous.WPP;
}
}

test('a reply button of a plain message is answered the way WhatsApp Web does', async () => {
const fake = fakePage();
expect(await inPage(fake, 'dynamic', 1)).toEqual({ sent: true });
expect(fake.sendButtonReply).toHaveBeenCalledTimes(1);
expect(fake.sendButtonReply).toHaveBeenCalledWith(fake.msg, fake.button, fake.chat);
});

test('a quick reply of an interactive message quotes it with the button index and id', async () => {
const fake = fakePage();
expect(await inPage(fake, 'native', 0)).toEqual({ sent: true });
expect(fake.sendText).toHaveBeenCalledTimes(1);
expect(fake.sendText).toHaveBeenCalledWith(fake.chat, 'Debt', { quotedMsg: fake.raw, selectedIndex: 0, selectedId: 'd1' });
expect(fake.mark).toHaveBeenCalledWith(fake.raw, 0);
});

test('a quick reply is sent even when marking the button fails', async () => {
const fake = fakePage();
fake.mark.mockRejectedValue(new Error('x'));
expect(await inPage(fake, 'native', 0)).toEqual({ sent: true });
});

test('a button that is not a quick reply, or was used, is never sent', async () => {
const fake = fakePage();
expect(await inPage(fake, 'native', 1)).toEqual({ error: 'button_not_found' });
expect(await inPage(fake, 'native', 2)).toEqual({ error: 'button_used' });
expect(await inPage(fake, 'native', 9)).toEqual({ error: 'button_not_found' });
expect(fake.sendText).not.toHaveBeenCalled();
});

test('a list row is answered with a list response that quotes the list', async () => {
const fake = fakePage();
expect(await inPage(fake, 'list', 1)).toEqual({ sent: true });
expect(fake.sendList).toHaveBeenCalledTimes(1);
const [chat, sent] = fake.sendList.mock.calls[0] as any[];
expect(chat).toBe(fake.chat);
expect(sent).toMatchObject({
type: 'list_response', kind: 'listResponse', from: 'me', local: true, quotedStanzaID: 'Q',
listResponse: { title: 'Bills', description: 'd', listType: 1, singleSelectReply: { selectedRowId: 'r2' } },
body: 'Bills\nd' });
expect(await inPage(fake, 'list', 5)).toEqual({ error: 'button_not_found' });
expect(fake.sendList).toHaveBeenCalledTimes(1);
});

test('a template quick reply is answered by index through WA-JS', async () => {
const fake = fakePage();
expect(await inPage(fake, 'template', 0)).toEqual({ sent: true });
expect(fake.replyToButtonMessage).toHaveBeenCalledWith('5511900000001@lid', ID, { buttonIndex: 0 });
expect(await inPage(fake, 'template', 1)).toEqual({ error: 'button_not_found' });
expect(fake.replyToButtonMessage).toHaveBeenCalledTimes(1);
});

test('a missing message, button or WhatsApp Web function sends nothing', async () => {
expect(await inPage(fakePage({ present: false }), 'dynamic', 0)).toEqual({ error: 'message_not_found' });
const fake = fakePage();
expect(await inPage(fake, 'dynamic', 5)).toEqual({ error: 'button_not_found' });
expect(await inPage(fakePage({ withSender: false }), 'dynamic', 0)).toEqual({ error: 'unavailable' });
expect(fake.sendButtonReply).not.toHaveBeenCalled();
});

function route(body: any, messageId = ID, evaluate: any = jest.fn()) {
const res: any = { status: jest.fn(() => res), json: jest.fn(() => res) };
const page = { evaluate, isClosed: () => false };
return { res, page, run: () => replyToButton({ params: { messageId }, body, client: { page } } as any, res) };
}

test.each([
['an id that is not a message id', { source: 'dynamic', index: 0 }, '../x'],
['an unknown source', { source: 'other', index: 0 }, ID],
['an index past the limit', { source: 'dynamic', index: 12 }, ID],
['an index that is not a number', { source: 'dynamic', index: '0' }, ID],
])('the route refuses %s before touching the page', async (_name, body, messageId) => {
const { res, page, run } = route(body, messageId);
await run();
expect(res.status).toHaveBeenCalledWith(400);
expect(page.evaluate).not.toHaveBeenCalled();
});

test('the route answers with the server code and never the native error', async () => {
const refused = route({ source: 'dynamic', index: 0 }, ID, jest.fn(async () => ({ error: 'button_used' })));
await refused.run();
expect(refused.res.status).toHaveBeenCalledWith(409);
const failed = route({ source: 'dynamic', index: 0 }, ID, jest.fn(async () => { throw new Error('chat 5511900000001'); }));
await failed.run();
expect(failed.res.status).toHaveBeenCalledWith(503);
expect(JSON.stringify(failed.res.json.mock.calls)).not.toContain('5511900000001');
const sent = route({ source: 'dynamic', index: 0 }, ID, jest.fn(async () => ({ sent: true })));
await sent.run();
expect(sent.res.status).toHaveBeenCalledWith(200);
});
2 changes: 2 additions & 0 deletions client/changelog_en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This release brings communities and a reorganized settings window, on top of eve

WHAT'S NEW

- Buttons in business messages can be answered: a reply button sends the answer the way WhatsApp Web does (reply buttons of plain messages, quick replies of interactive messages, template buttons and the rows of lists), a link button opens the link, a copy button copies the code and a call button says the number. The buttons appear under the message list when the message is selected.

- WPPConnect Server 2.10.61 is now offered as an update without waiting for a new WinZapp version. It only updates dependencies, and WinZapp's changes to the server are not affected.

- macOS updates explain whether verification failed because of a connection, certificate, server or GitHub request-limit problem, and whether the verification file or the official release tag could not be checked. Temporary connection and server failures get one retry; request limits show a wait time and do not trigger repeated requests during that wait.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_es-ES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Esta versión trae las comunidades y una ventana de configuración reorganizada,

NOVEDADES

- Ya se pueden usar los botones de los mensajes de empresas: un botón de respuesta envía la respuesta igual que WhatsApp Web (botones de respuesta de mensajes simples, respuestas rápidas de mensajes interactivos, botones de plantilla y filas de listas), un botón de enlace abre el enlace, un botón de copiar copia el código y un botón de llamada dice el número. Los botones aparecen debajo de la lista de mensajes cuando el mensaje está seleccionado.

- WPPConnect Server 2.10.61 ya se ofrece como actualización sin esperar a una nueva versión de WinZapp. Solo actualiza dependencias y no afecta a los cambios que WinZapp hace en el servidor.

- Las actualizaciones de macOS explican si la verificación ha fallado por un problema de conexión, certificado, servidor o límite de solicitudes de GitHub, y si no se ha podido comprobar el archivo de verificación o la etiqueta de la versión oficial. Los fallos temporales de conexión y servidor se reintentan una vez; los límites de solicitudes muestran un tiempo de espera y no generan nuevas solicitudes durante ese período.
Expand Down
2 changes: 2 additions & 0 deletions client/changelog_pl.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ W tej wersji pojawiają się społeczności i zreorganizowane okno ustawień, op

NOWOŚCI

- Przyciski w wiadomościach firm można już używać: przycisk odpowiedzi wysyła odpowiedź tak samo jak WhatsApp Web (przyciski odpowiedzi zwykłych wiadomości, szybkie odpowiedzi wiadomości interaktywnych, przyciski szablonów i wiersze list), przycisk linku otwiera link, przycisk kopiowania kopiuje kod, a przycisk połączenia odczytuje numer. Przyciski pojawiają się pod listą wiadomości, gdy wiadomość jest zaznaczona.

- WPPConnect Server 2.10.61 jest teraz oferowany jako aktualizacja bez czekania na nową wersję WinZapp. Aktualizuje tylko zależności i nie wpływa na zmiany, które WinZapp wprowadza w serwerze.

- Aktualizacje macOS wyjaśniają, czy weryfikacja nie powiodła się z powodu połączenia, certyfikatu, serwera lub limitu żądań GitHub oraz czy nie można sprawdzić pliku weryfikacji czy oficjalnego tagu wydania. Tymczasowe błędy połączenia i serwera powodują jedną ponowną próbę; limity żądań pokazują czas oczekiwania i nie powodują kolejnych żądań w tym czasie.
Expand Down
Loading
Loading