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
11 changes: 10 additions & 1 deletion src/api/abstract/abstract.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,17 @@ export abstract class RouterBroker {

const isInstanceCreate = request.originalUrl.includes('/instance/create');

// On instance-scoped routes the URL param (:instanceName) is the
// authenticated identity, so query/body must not override it (CVE-2435,
// #2549). On param-less routes (e.g. GET /instance/fetchInstances) there
// is no URL-derived identity β€” there the instanceId/instanceName query
// params ARE the legitimate filter and must be preserved, otherwise the
// controller falls through to returning ALL instances.
const hasUrlInstance = Boolean(request.params?.instanceName);

if (request?.query && Object.keys(request.query).length > 0) {
Object.assign(instance, sanitizeUntrustedInput(request.query as Record<string, any>));
const query = request.query as Record<string, any>;
Object.assign(instance, hasUrlInstance ? sanitizeUntrustedInput(query) : query);
}

if (isInstanceCreate) {
Expand Down
14 changes: 13 additions & 1 deletion src/api/dto/sendMessage.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,14 @@ export class SendAudioDto extends Metadata {
audio: string;
}

export type TypeButton = 'reply' | 'copy' | 'url' | 'call' | 'pix';
export type TypeButton = 'reply' | 'copy' | 'url' | 'call' | 'pix' | 'flow';

export type KeyType = 'phone' | 'email' | 'cpf' | 'cnpj' | 'random';

export type FlowAction = 'navigate' | 'data_exchange';

export type FlowMode = 'published' | 'draft';

export class Button {
type: TypeButton;
displayText?: string;
Expand All @@ -116,6 +120,14 @@ export class Button {
name?: string;
keyType?: KeyType;
key?: string;
// WhatsApp Flows (galaxy_message) fields
flowId?: string;
flowToken?: string;
flowCta?: string;
flowAction?: FlowAction;
flowActionPayload?: Record<string, any>;
flowMessageVersion?: string;
flowMode?: FlowMode;
}

export class SendButtonsDto extends Metadata {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3788,6 +3788,16 @@ export class BaileysStartupService extends ChannelStartupService {
],
share_payment_status: false,
}),
flow: () =>
toString({
flow_message_version: button.flowMessageVersion ?? '3',
flow_token: button.flowToken ?? this.generateRandomId(),
flow_id: button.flowId,
flow_cta: button.flowCta ?? button.displayText,
flow_action: button.flowAction ?? 'navigate',
...(button.flowActionPayload ? { flow_action_payload: button.flowActionPayload } : {}),
mode: button.flowMode ?? 'published',
}),
};

return json[button.type]?.() || '';
Expand All @@ -3799,6 +3809,7 @@ export class BaileysStartupService extends ChannelStartupService {
['url', 'cta_url'],
['call', 'cta_call'],
['pix', 'payment_info'],
['flow', 'galaxy_message'],
]);

private readonly mapKeyType = new Map<KeyType, string>([
Expand All @@ -3820,16 +3831,75 @@ export class BaileysStartupService extends ChannelStartupService {
).length;
const hasReplyButtons = replyCount > 0;
const hasPixButton = data.buttons.some((btn) => btn.type === 'pix');
const hasFlowButton = data.buttons.some((btn) => btn.type === 'flow');
const hasCTAButtons = ctaCount > 0;

/* =========================
* REGRAS DE VALIDAÇÃO
*
* WhatsApp's native_flow with name="mixed" (see buildInteractiveBizNode)
* renders quick_reply + cta_url + cta_call + cta_copy together. PIX
* (`payment_info`) uses a different template and must travel alone.
* (`payment_info`) and Flows (`galaxy_message`) use their own templates
* and must travel alone.
* ========================= */

// WhatsApp Flows (galaxy_message) β€” renders its own interactive form and
// must be the only button in the message.
if (hasFlowButton) {
if (data.buttons.length > 1) {
throw new BadRequestException('Only one flow button is allowed');
}
if (hasReplyButtons || hasCTAButtons || hasPixButton) {
throw new BadRequestException('Flow button cannot be mixed with other button types');
}

const flowButton = data.buttons[0];
if (!flowButton.flowId) {
throw new BadRequestException('flowId is required for a flow button');
}

const message: proto.IMessage = {
interactiveMessage: {
body: {
text: (() => {
let text = `*${data.title}*`;
if (data?.description) {
text += `\n\n${data.description}`;
}
return text;
})(),
},
footer: data?.footer ? { text: data.footer } : undefined,
nativeFlowMessage: {
buttons: [
{
name: this.mapType.get('flow'),
buttonParamsJson: this.toJSONString(flowButton),
},
],
messageParamsJson: JSON.stringify({
from: 'api',
templateId: v4(),
}),
},
},
};

return await this.sendMessageWithTyping(
data.number,
message,
{
delay: data?.delay,
presence: 'composing',
quoted: data?.quoted,
mentionsEveryOne: data?.mentionsEveryOne,
mentioned: data?.mentioned,
},
false,
[buildInteractiveBizNode()],
);
}

// Per-type ceilings on a mixed message β€” keep within WhatsApp's
// rendered limits (quick_reply ≀ 3, CTA ≀ 2) and cap the total so the
// UI doesn't truncate.
Expand Down
9 changes: 8 additions & 1 deletion src/validate/message.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ export const buttonsMessageSchema: JSONSchema7 = {
properties: {
type: {
type: 'string',
enum: ['reply', 'copy', 'url', 'call', 'pix'],
enum: ['reply', 'copy', 'url', 'call', 'pix', 'flow'],
},
displayText: { type: 'string' },
id: { type: 'string' },
Expand All @@ -432,6 +432,13 @@ export const buttonsMessageSchema: JSONSchema7 = {
name: { type: 'string' },
keyType: { type: 'string', enum: ['phone', 'email', 'cpf', 'cnpj', 'random'] },
key: { type: 'string' },
flowId: { type: 'string' },
flowToken: { type: 'string' },
flowCta: { type: 'string' },
flowAction: { type: 'string', enum: ['navigate', 'data_exchange'] },
flowActionPayload: { type: 'object' },
flowMessageVersion: { type: 'string' },
flowMode: { type: 'string', enum: ['published', 'draft'] },
},
required: ['type'],
...isNotEmpty('id', 'url', 'phoneNumber'),
Expand Down
Loading