mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.
New helper
----------
src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.
Auto-fills as much as possible from the template:
* static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
header_media_url (or header_handle when present — preferred)
* URL buttons without {{1}} need no per-send payload
* QUICK_REPLY / PHONE_NUMBER buttons need nothing
* COPY_CODE buttons fall back to the template's example so the
most common "static promo code" case Just Works without UI
Variable-bearing fields require per-send values:
* TEXT header with {{1}} → messageParams.headerText
* Body variables → messageParams.body[]
* URL button with {{1}} → messageParams.buttonParams[i]
* COPY_CODE override → messageParams.buttonParams[i]
19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.
sendTemplateMessage extension
-----------------------------
src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.
Route + UI wiring
-----------------
POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.
src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.
Out of scope
------------
Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.
This is the last PR of the Issue #147 overhaul sequence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8d430f3e9
commit
11475840e1
@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
|
||||||
import {
|
import {
|
||||||
sanitizePhoneForMeta,
|
sanitizePhoneForMeta,
|
||||||
isValidE164,
|
isValidE164,
|
||||||
@ -45,7 +46,15 @@ interface BroadcastResult {
|
|||||||
*/
|
*/
|
||||||
interface NewRecipient {
|
interface NewRecipient {
|
||||||
phone: string
|
phone: string
|
||||||
|
/** Body variable values, one per {{N}}. Legacy field. */
|
||||||
params?: string[]
|
params?: string[]
|
||||||
|
/**
|
||||||
|
* Structured per-send values (header text variable, media URL
|
||||||
|
* override, URL/COPY_CODE button values). When set, takes
|
||||||
|
* precedence over `params` for the body too — see
|
||||||
|
* sendTemplateMessage for the merge rules.
|
||||||
|
*/
|
||||||
|
messageParams?: SendTimeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -125,6 +134,17 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const accessToken = decrypt(config.access_token)
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
// Load the template row once so sendTemplateMessage can build
|
||||||
|
// header + button components on each iteration. Loading inside
|
||||||
|
// the loop would N+1 against Supabase for every recipient.
|
||||||
|
const { data: templateRow } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('name', template_name)
|
||||||
|
.eq('language', template_language || 'en_US')
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
const results: BroadcastResult[] = []
|
const results: BroadcastResult[] = []
|
||||||
let sentCount = 0
|
let sentCount = 0
|
||||||
let failedCount = 0
|
let failedCount = 0
|
||||||
@ -156,6 +176,8 @@ export async function POST(request: Request) {
|
|||||||
to: variant,
|
to: variant,
|
||||||
templateName: template_name,
|
templateName: template_name,
|
||||||
language: template_language || 'en_US',
|
language: template_language || 'en_US',
|
||||||
|
template: templateRow ?? undefined,
|
||||||
|
messageParams: recipient.messageParams,
|
||||||
params: recipient.params ?? [],
|
params: recipient.params ?? [],
|
||||||
})
|
})
|
||||||
sentMessageId = result.messageId
|
sentMessageId = result.messageId
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
rateLimitResponse,
|
rateLimitResponse,
|
||||||
RATE_LIMITS,
|
RATE_LIMITS,
|
||||||
} from '@/lib/rate-limit'
|
} from '@/lib/rate-limit'
|
||||||
|
import type { MessageTemplate } from '@/types'
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -45,7 +46,9 @@ export async function POST(request: Request) {
|
|||||||
content_text,
|
content_text,
|
||||||
media_url,
|
media_url,
|
||||||
template_name,
|
template_name,
|
||||||
|
template_language,
|
||||||
template_params,
|
template_params,
|
||||||
|
template_message_params,
|
||||||
reply_to_message_id,
|
reply_to_message_id,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
@ -177,6 +180,24 @@ export async function POST(request: Request) {
|
|||||||
let waMessageId = ''
|
let waMessageId = ''
|
||||||
let workingPhone = sanitizedPhone
|
let workingPhone = sanitizedPhone
|
||||||
|
|
||||||
|
// For template sends, load the row so sendTemplateMessage can
|
||||||
|
// build header + button components from the template definition.
|
||||||
|
// Match on (user_id, name, language) — same triple the unique
|
||||||
|
// index enforces — so multi-language templates work correctly.
|
||||||
|
// Missing template falls through with `templateRow = null` and
|
||||||
|
// the legacy body-only path runs.
|
||||||
|
let templateRow: MessageTemplate | null = null
|
||||||
|
if (message_type === 'template' && template_name) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('name', template_name)
|
||||||
|
.eq('language', template_language || 'en_US')
|
||||||
|
.maybeSingle()
|
||||||
|
templateRow = (data ?? null) as MessageTemplate | null
|
||||||
|
}
|
||||||
|
|
||||||
const attempt = async (phone: string): Promise<string> => {
|
const attempt = async (phone: string): Promise<string> => {
|
||||||
if (message_type === 'template') {
|
if (message_type === 'template') {
|
||||||
const result = await sendTemplateMessage({
|
const result = await sendTemplateMessage({
|
||||||
@ -184,6 +205,11 @@ export async function POST(request: Request) {
|
|||||||
accessToken,
|
accessToken,
|
||||||
to: phone,
|
to: phone,
|
||||||
templateName: template_name,
|
templateName: template_name,
|
||||||
|
language: template_language || 'en_US',
|
||||||
|
template: templateRow ?? undefined,
|
||||||
|
messageParams: template_message_params ?? undefined,
|
||||||
|
// Legacy body-only fallback — only consulted when
|
||||||
|
// messageParams.body isn't set.
|
||||||
params: template_params || [],
|
params: template_params || [],
|
||||||
contextMessageId,
|
contextMessageId,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -493,10 +493,17 @@ export function MessageThread({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSendTemplate = useCallback(
|
const handleSendTemplate = useCallback(
|
||||||
async (template: MessageTemplate, params: string[]) => {
|
async (
|
||||||
|
template: MessageTemplate,
|
||||||
|
values: {
|
||||||
|
body: string[];
|
||||||
|
headerText?: string;
|
||||||
|
buttonParams?: Record<number, string>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const renderedBody = renderTemplateBody(template.body_text, params);
|
const renderedBody = renderTemplateBody(template.body_text, values.body);
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
|
|
||||||
const optimisticMsg: Message = {
|
const optimisticMsg: Message = {
|
||||||
@ -519,7 +526,17 @@ export function MessageThread({
|
|||||||
conversation_id: conversation.id,
|
conversation_id: conversation.id,
|
||||||
message_type: "template",
|
message_type: "template",
|
||||||
template_name: template.name,
|
template_name: template.name,
|
||||||
template_params: params,
|
template_language: template.language,
|
||||||
|
// Structured params drive the new send-builder path
|
||||||
|
// (header media + URL button substitution). Body values
|
||||||
|
// are mirrored under both shapes so the route can fall
|
||||||
|
// back if the template row isn't found locally.
|
||||||
|
template_message_params: {
|
||||||
|
body: values.body,
|
||||||
|
headerText: values.headerText,
|
||||||
|
buttonParams: values.buttonParams,
|
||||||
|
},
|
||||||
|
template_params: values.body,
|
||||||
content_text: renderedBody,
|
content_text: renderedBody,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import type { MessageTemplate } from "@/types";
|
import type { MessageTemplate } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -21,23 +21,18 @@ import {
|
|||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
|
||||||
|
|
||||||
|
export interface TemplateSendValues {
|
||||||
|
body: string[];
|
||||||
|
headerText?: string;
|
||||||
|
buttonParams?: Record<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface TemplatePickerProps {
|
interface TemplatePickerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSelect: (template: MessageTemplate, params: string[]) => void;
|
onSelect: (template: MessageTemplate, values: TemplateSendValues) => void;
|
||||||
}
|
|
||||||
|
|
||||||
// Meta numbers template placeholders from 1 ({{1}}, {{2}}, …) and the
|
|
||||||
// indices passed to the Graph API must be contiguous starting at 1.
|
|
||||||
// We sort + dedupe here so a body using only {{2}} still drives a single
|
|
||||||
// input slot, and so render-order matches send-order.
|
|
||||||
function extractVariables(body: string): number[] {
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const m of body.matchAll(/\{\{(\d+)\}\}/g)) {
|
|
||||||
ids.add(Number(m[1]));
|
|
||||||
}
|
|
||||||
return Array.from(ids).sort((a, b) => a - b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBodyPreview(body: string, params: string[]): string {
|
function renderBodyPreview(body: string, params: string[]): string {
|
||||||
@ -48,6 +43,36 @@ function renderBodyPreview(body: string, params: string[]): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UrlButtonSlot {
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Templates may need values for: body variables, a text-header
|
||||||
|
* variable, and per-URL-button suffixes. Collect them all so the
|
||||||
|
* send-message path doesn't 400 on missing parameters.
|
||||||
|
*/
|
||||||
|
function collectVariableSlots(template: MessageTemplate): {
|
||||||
|
bodyVars: number[];
|
||||||
|
headerVarCount: number;
|
||||||
|
urlButtonSlots: UrlButtonSlot[];
|
||||||
|
} {
|
||||||
|
const bodyVars = extractVariableIndices(template.body_text);
|
||||||
|
const headerVarCount =
|
||||||
|
template.header_type === "text" && template.header_content
|
||||||
|
? extractVariableIndices(template.header_content).length
|
||||||
|
: 0;
|
||||||
|
const urlButtonSlots: UrlButtonSlot[] = [];
|
||||||
|
(template.buttons ?? []).forEach((b, i) => {
|
||||||
|
if (b.type === "URL" && extractVariableIndices(b.url).length > 0) {
|
||||||
|
urlButtonSlots.push({ index: i, text: b.text, url: b.url });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { bodyVars, headerVarCount, urlButtonSlots };
|
||||||
|
}
|
||||||
|
|
||||||
export function TemplatePicker({
|
export function TemplatePicker({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -57,6 +82,8 @@ export function TemplatePicker({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState<MessageTemplate | null>(null);
|
const [selected, setSelected] = useState<MessageTemplate | null>(null);
|
||||||
const [params, setParams] = useState<string[]>([]);
|
const [params, setParams] = useState<string[]>([]);
|
||||||
|
const [headerText, setHeaderText] = useState<string>("");
|
||||||
|
const [buttonParams, setButtonParams] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -77,9 +104,6 @@ export function TemplatePicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only Approved templates are sendable through Meta — anything else
|
|
||||||
// would 400 on the send route. Hide them rather than letting the
|
|
||||||
// user pick a template that will be rejected.
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("message_templates")
|
.from("message_templates")
|
||||||
.select("*")
|
.select("*")
|
||||||
@ -102,35 +126,60 @@ export function TemplatePicker({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
function handleOpenChange(next: boolean) {
|
function resetSelection() {
|
||||||
if (!next) {
|
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setParams([]);
|
setParams([]);
|
||||||
|
setHeaderText("");
|
||||||
|
setButtonParams({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
if (!next) resetSelection();
|
||||||
onOpenChange(next);
|
onOpenChange(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickTemplate(template: MessageTemplate) {
|
function pickTemplate(template: MessageTemplate) {
|
||||||
const vars = extractVariables(template.body_text);
|
const slots = collectVariableSlots(template);
|
||||||
if (vars.length === 0) {
|
const noInputsNeeded =
|
||||||
onSelect(template, []);
|
slots.bodyVars.length === 0 &&
|
||||||
|
slots.headerVarCount === 0 &&
|
||||||
|
slots.urlButtonSlots.length === 0;
|
||||||
|
if (noInputsNeeded) {
|
||||||
|
onSelect(template, { body: [] });
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelected(template);
|
setSelected(template);
|
||||||
setParams(new Array(vars.length).fill(""));
|
setParams(new Array(slots.bodyVars.length).fill(""));
|
||||||
|
setHeaderText("");
|
||||||
|
setButtonParams({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
onSelect(selected, params);
|
const values: TemplateSendValues = { body: params };
|
||||||
|
if (headerText.trim()) values.headerText = headerText.trim();
|
||||||
|
if (Object.keys(buttonParams).length > 0) {
|
||||||
|
values.buttonParams = Object.fromEntries(
|
||||||
|
Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onSelect(selected, values);
|
||||||
handleOpenChange(false);
|
handleOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const variables = selected ? extractVariables(selected.body_text) : [];
|
const slots = useMemo(
|
||||||
|
() => (selected ? collectVariableSlots(selected) : null),
|
||||||
|
[selected],
|
||||||
|
);
|
||||||
const canConfirm =
|
const canConfirm =
|
||||||
!!selected &&
|
!!selected &&
|
||||||
variables.every((_, i) => (params[i] ?? "").trim().length > 0);
|
!!slots &&
|
||||||
|
slots.bodyVars.every((_, i) => (params[i] ?? "").trim().length > 0) &&
|
||||||
|
(slots.headerVarCount === 0 || headerText.trim().length > 0) &&
|
||||||
|
slots.urlButtonSlots.every(
|
||||||
|
(s) => (buttonParams[s.index] ?? "").trim().length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
@ -207,9 +256,22 @@ export function TemplatePicker({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{variables.map((v, i) => (
|
{slots && slots.headerVarCount > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
{`Header {{1}}`}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={headerText}
|
||||||
|
onChange={(e) => setHeaderText(e.target.value)}
|
||||||
|
placeholder="Value for the header variable"
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slots?.bodyVars.map((v, i) => (
|
||||||
<div key={v} className="space-y-1">
|
<div key={v} className="space-y-1">
|
||||||
<Label className="text-xs text-slate-300">{`Variable {{${v}}}`}</Label>
|
<Label className="text-xs text-slate-300">{`Body {{${v}}}`}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={params[i] ?? ""}
|
value={params[i] ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -222,6 +284,27 @@ export function TemplatePicker({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{slots?.urlButtonSlots.map((slot) => (
|
||||||
|
<div key={slot.index} className="space-y-1">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
{`URL button "${slot.text}" — value for `}{`{{1}}`}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={buttonParams[slot.index] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setButtonParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[slot.index]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="URL suffix value"
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-500 break-all">
|
||||||
|
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -230,10 +313,7 @@ export function TemplatePicker({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={resetSelection}
|
||||||
setSelected(null);
|
|
||||||
setParams([]);
|
|
||||||
}}
|
|
||||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|||||||
@ -113,13 +113,39 @@ export async function sendTextMessage(
|
|||||||
return { messageId: data.messages[0].id }
|
return { messageId: data.messages[0].id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { MessageTemplate } from '@/types'
|
||||||
|
import {
|
||||||
|
buildSendComponents,
|
||||||
|
type SendTimeParams,
|
||||||
|
} from './template-send-builder'
|
||||||
|
|
||||||
export interface SendTemplateMessageArgs {
|
export interface SendTemplateMessageArgs {
|
||||||
phoneNumberId: string
|
phoneNumberId: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
to: string
|
to: string
|
||||||
templateName: string
|
templateName: string
|
||||||
language?: string
|
language?: string
|
||||||
|
/**
|
||||||
|
* Legacy body-only params. Kept for backward compat with callers
|
||||||
|
* that haven't migrated to the structured `template` + `messageParams`
|
||||||
|
* pair below. New callers should pass `template` so media headers
|
||||||
|
* and URL buttons land on the send.
|
||||||
|
*/
|
||||||
params?: string[]
|
params?: string[]
|
||||||
|
/**
|
||||||
|
* The template row from message_templates. When provided, the helper
|
||||||
|
* builds the full components array (header + body + buttons) via
|
||||||
|
* buildSendComponents — that's the only way image/video/document
|
||||||
|
* headers and URL-with-variable buttons actually reach the recipient.
|
||||||
|
*/
|
||||||
|
template?: MessageTemplate
|
||||||
|
/**
|
||||||
|
* Structured per-send values. Body variables go in `body`; header
|
||||||
|
* text variables in `headerText`; media overrides in
|
||||||
|
* `headerMediaUrl` / `headerMediaId`; URL/COPY_CODE button values
|
||||||
|
* in `buttonParams` keyed by index.
|
||||||
|
*/
|
||||||
|
messageParams?: SendTimeParams
|
||||||
/** Meta's message_id of the message being replied to. */
|
/** Meta's message_id of the message being replied to. */
|
||||||
contextMessageId?: string
|
contextMessageId?: string
|
||||||
}
|
}
|
||||||
@ -127,6 +153,13 @@ export interface SendTemplateMessageArgs {
|
|||||||
/**
|
/**
|
||||||
* Send a pre-approved WhatsApp message template. Required outside
|
* Send a pre-approved WhatsApp message template. Required outside
|
||||||
* the 24-hour window and for any first-touch messaging.
|
* the 24-hour window and for any first-touch messaging.
|
||||||
|
*
|
||||||
|
* Caller paths:
|
||||||
|
* - Legacy: pass `params: string[]` (body only). Same behaviour as
|
||||||
|
* before this helper learned about media + buttons.
|
||||||
|
* - Structured: pass `template` (and optionally `messageParams`).
|
||||||
|
* The full components array is built from the row so media
|
||||||
|
* headers + URL buttons land correctly.
|
||||||
*/
|
*/
|
||||||
export async function sendTemplateMessage(
|
export async function sendTemplateMessage(
|
||||||
args: SendTemplateMessageArgs
|
args: SendTemplateMessageArgs
|
||||||
@ -138,17 +171,33 @@ export async function sendTemplateMessage(
|
|||||||
templateName,
|
templateName,
|
||||||
language = 'en_US',
|
language = 'en_US',
|
||||||
params,
|
params,
|
||||||
|
template,
|
||||||
|
messageParams,
|
||||||
contextMessageId,
|
contextMessageId,
|
||||||
} = args
|
} = args
|
||||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||||
|
|
||||||
const template: Record<string, unknown> = {
|
const templatePayload: Record<string, unknown> = {
|
||||||
name: templateName,
|
name: templateName,
|
||||||
language: { code: language },
|
language: { code: language },
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params && params.length > 0) {
|
if (template) {
|
||||||
template.components = [
|
const components = buildSendComponents(template, {
|
||||||
|
// Legacy callers pass body values in `params`; fold them into
|
||||||
|
// `messageParams.body` so the new path covers them too.
|
||||||
|
body: messageParams?.body ?? params,
|
||||||
|
headerText: messageParams?.headerText,
|
||||||
|
headerMediaUrl: messageParams?.headerMediaUrl,
|
||||||
|
headerMediaId: messageParams?.headerMediaId,
|
||||||
|
buttonParams: messageParams?.buttonParams,
|
||||||
|
})
|
||||||
|
if (components.length > 0) {
|
||||||
|
templatePayload.components = components
|
||||||
|
}
|
||||||
|
} else if (params && params.length > 0) {
|
||||||
|
// Legacy body-only path — no template row available.
|
||||||
|
templatePayload.components = [
|
||||||
{
|
{
|
||||||
type: 'body',
|
type: 'body',
|
||||||
parameters: params.map((p) => ({ type: 'text', text: String(p) })),
|
parameters: params.map((p) => ({ type: 'text', text: String(p) })),
|
||||||
@ -161,7 +210,7 @@ export async function sendTemplateMessage(
|
|||||||
recipient_type: 'individual',
|
recipient_type: 'individual',
|
||||||
to,
|
to,
|
||||||
type: 'template',
|
type: 'template',
|
||||||
template,
|
template: templatePayload,
|
||||||
}
|
}
|
||||||
if (contextMessageId) {
|
if (contextMessageId) {
|
||||||
body.context = { message_id: contextMessageId }
|
body.context = { message_id: contextMessageId }
|
||||||
|
|||||||
264
src/lib/whatsapp/template-send-builder.test.ts
Normal file
264
src/lib/whatsapp/template-send-builder.test.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildSendComponents } from './template-send-builder';
|
||||||
|
import type { MessageTemplate } from '@/types';
|
||||||
|
|
||||||
|
function row(overrides: Partial<MessageTemplate> = {}): MessageTemplate {
|
||||||
|
return {
|
||||||
|
id: 'row-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
name: 'order_confirmation',
|
||||||
|
category: 'Utility',
|
||||||
|
language: 'en_US',
|
||||||
|
body_text: 'Your order is on its way.',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSendComponents — body', () => {
|
||||||
|
it('returns [] for a fully-static template (no vars, no media header)', () => {
|
||||||
|
expect(buildSendComponents(row())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a body component when the template has variables', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({ body_text: 'Hi {{1}}, order {{2}} confirmed.' }),
|
||||||
|
{ body: ['John', 'ORD-42'] },
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{
|
||||||
|
type: 'body',
|
||||||
|
parameters: [
|
||||||
|
{ type: 'text', text: 'John' },
|
||||||
|
{ type: 'text', text: 'ORD-42' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when body has variables but caller supplied too few values', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildSendComponents(
|
||||||
|
row({ body_text: 'Hi {{1}} {{2}}' }),
|
||||||
|
{ body: ['just one'] },
|
||||||
|
),
|
||||||
|
).toThrow(/2 variable\(s\) but only 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims extra body values silently (legacy callers may overshoot)', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({ body_text: 'Hi {{1}}' }),
|
||||||
|
{ body: ['John', 'extra', 'extra2'] },
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{ type: 'body', parameters: [{ type: 'text', text: 'John' }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSendComponents — header', () => {
|
||||||
|
it('skips static TEXT headers (template carries them)', () => {
|
||||||
|
expect(
|
||||||
|
buildSendComponents(
|
||||||
|
row({ header_type: 'text', header_content: 'Order Confirmation' }),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a TEXT header component when {{1}} is present', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
|
||||||
|
{ headerText: 'Sara' },
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{ type: 'header', parameters: [{ type: 'text', text: 'Sara' }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when TEXT header has {{1}} but no value was supplied', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildSendComponents(
|
||||||
|
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
|
||||||
|
),
|
||||||
|
).toThrow(/Header text variable \{\{1\}\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-includes IMAGE header from the stored sample URL', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
header_type: 'image',
|
||||||
|
header_media_url: 'https://example.com/sample.jpg',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{
|
||||||
|
type: 'header',
|
||||||
|
parameters: [
|
||||||
|
{ type: 'image', image: { link: 'https://example.com/sample.jpg' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers caller override URL over template sample', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
header_type: 'video',
|
||||||
|
header_media_url: 'https://example.com/default.mp4',
|
||||||
|
}),
|
||||||
|
{ headerMediaUrl: 'https://example.com/custom.mp4' },
|
||||||
|
);
|
||||||
|
expect(components[0]).toEqual({
|
||||||
|
type: 'header',
|
||||||
|
parameters: [
|
||||||
|
{ type: 'video', video: { link: 'https://example.com/custom.mp4' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers media id over url when both are available', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
header_type: 'document',
|
||||||
|
header_handle: '4::aBc',
|
||||||
|
header_media_url: 'https://x.com/doc.pdf',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(components[0]).toEqual({
|
||||||
|
type: 'header',
|
||||||
|
parameters: [{ type: 'document', document: { id: '4::aBc' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on media header with no link OR id available', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildSendComponents(row({ header_type: 'image' })),
|
||||||
|
).toThrow(/requires a media link or id/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSendComponents — buttons', () => {
|
||||||
|
it('omits URL buttons without variables (template carries the URL)', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'URL', text: 'Visit', url: 'https://example.com' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(components).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a URL button component when the URL has {{1}}', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ buttonParams: { 0: 'ORD-42' } },
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'url',
|
||||||
|
index: '0',
|
||||||
|
parameters: [{ type: 'text', text: 'ORD-42' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when URL button has {{1}} but no buttonParam was provided', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow(/URL button #1 uses \{\{1\}\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the correct index when QR buttons precede the URL button', () => {
|
||||||
|
// sub_type:url at index "2" because two QUICK_REPLY buttons came first.
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'QUICK_REPLY', text: 'Yes' },
|
||||||
|
{ type: 'QUICK_REPLY', text: 'No' },
|
||||||
|
{ type: 'URL', text: 'Open', url: 'https://x.com/{{1}}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ buttonParams: { 2: 'ORD-42' } },
|
||||||
|
);
|
||||||
|
const urlBtn = components.find((c) => c.type === 'button');
|
||||||
|
expect(urlBtn).toEqual({
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'url',
|
||||||
|
index: '2',
|
||||||
|
parameters: [{ type: 'text', text: 'ORD-42' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the template example for COPY_CODE buttons', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'COPY_CODE', text: 'Copy', example: 'SUMMER20' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(components).toEqual([
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'copy_code',
|
||||||
|
index: '0',
|
||||||
|
parameters: [{ type: 'coupon_code', coupon_code: 'SUMMER20' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides COPY_CODE code when caller supplies one', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [{ type: 'COPY_CODE', text: 'Copy', example: 'STATIC' }],
|
||||||
|
}),
|
||||||
|
{ buttonParams: { 0: 'PERSONAL_CODE' } },
|
||||||
|
);
|
||||||
|
expect((components[0] as { parameters: { coupon_code: string }[] })
|
||||||
|
.parameters[0].coupon_code).toBe('PERSONAL_CODE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips PHONE_NUMBER buttons entirely (no send-time params allowed)', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
buttons: [
|
||||||
|
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '+15551234567' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(components).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSendComponents — end-to-end mix', () => {
|
||||||
|
it('orders components header → body → buttons and includes all', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({
|
||||||
|
header_type: 'image',
|
||||||
|
header_media_url: 'https://x.com/img.jpg',
|
||||||
|
body_text: 'Hi {{1}}',
|
||||||
|
buttons: [
|
||||||
|
{ type: 'QUICK_REPLY', text: 'Yes' },
|
||||||
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ body: ['John'], buttonParams: { 1: 'abc' } },
|
||||||
|
);
|
||||||
|
expect(components.map((c) => c.type)).toEqual(['header', 'body', 'button']);
|
||||||
|
// QUICK_REPLY at index 0 doesn't need send-time params, so only the
|
||||||
|
// URL button at index 1 emits a component.
|
||||||
|
expect((components[2] as { index: string }).index).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
230
src/lib/whatsapp/template-send-builder.ts
Normal file
230
src/lib/whatsapp/template-send-builder.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Build the Meta `components` array used by POST /{phone_number_id}/messages
|
||||||
|
* when sending an APPROVED template.
|
||||||
|
*
|
||||||
|
* Distinct from `template-components.ts` — that module builds the
|
||||||
|
* `components` for TEMPLATE CREATION (where you describe headers,
|
||||||
|
* footers, buttons, examples). This module builds the per-send
|
||||||
|
* `components` (where you fill in variable values and supply the
|
||||||
|
* actual media link or button URL suffix for THIS specific delivery).
|
||||||
|
*
|
||||||
|
* Auto-fills as much as possible from the template row so callers
|
||||||
|
* only need to supply values for the variable-bearing fields:
|
||||||
|
*
|
||||||
|
* - Static IMAGE/VIDEO/DOCUMENT headers ride along automatically
|
||||||
|
* using the template's `header_media_url` (or `header_handle`).
|
||||||
|
* Meta requires the media component on every send even though
|
||||||
|
* the URL hasn't changed since approval.
|
||||||
|
* - TEXT headers with `{{1}}` need `headerText` from the caller.
|
||||||
|
* - Body variables come in as `body: string[]`, indexed by {{N}}.
|
||||||
|
* - URL buttons with `{{1}}` need `buttonUrlParams[i]` keyed by
|
||||||
|
* button index. URL buttons without variables, plus QUICK_REPLY
|
||||||
|
* and PHONE_NUMBER buttons, don't need send-time parameters.
|
||||||
|
* - COPY_CODE buttons need the actual code to display. We fall
|
||||||
|
* back to the template's `example` value if the caller doesn't
|
||||||
|
* override — that matches the most common use case (a static
|
||||||
|
* promo code) without forcing UI work.
|
||||||
|
*
|
||||||
|
* Validation throws here (not at the Meta API boundary) so a missing
|
||||||
|
* sample surfaces as "Header text variable {{1}} requires a value",
|
||||||
|
* not a 400 from Meta that doesn't say which field broke.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MessageTemplate, TemplateButton } from '@/types';
|
||||||
|
import { extractVariableIndices } from './template-validators';
|
||||||
|
|
||||||
|
export interface SendTimeParams {
|
||||||
|
/** Values for body {{1}}, {{2}}, … indexed by variable position. */
|
||||||
|
body?: string[];
|
||||||
|
/** Value for TEXT-header {{1}}, when the header has a variable. */
|
||||||
|
headerText?: string;
|
||||||
|
/** Override the template's static media URL for this send. */
|
||||||
|
headerMediaUrl?: string;
|
||||||
|
/** Alternative: send the media by Meta media id (from prior upload). */
|
||||||
|
headerMediaId?: string;
|
||||||
|
/**
|
||||||
|
* Per-button overrides keyed by the button's index in the
|
||||||
|
* template's `buttons` array. Used for URL buttons with a {{1}}
|
||||||
|
* suffix and for COPY_CODE buttons whose example you want to
|
||||||
|
* override at send time.
|
||||||
|
*/
|
||||||
|
buttonParams?: Record<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetaSendComponent =
|
||||||
|
| { type: 'header'; parameters: MetaSendParameter[] }
|
||||||
|
| { type: 'body'; parameters: MetaSendParameter[] }
|
||||||
|
| {
|
||||||
|
type: 'button';
|
||||||
|
sub_type: 'url' | 'quick_reply' | 'copy_code';
|
||||||
|
index: string;
|
||||||
|
parameters: MetaSendParameter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetaSendParameter =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'image'; image: { link?: string; id?: string } }
|
||||||
|
| { type: 'video'; video: { link?: string; id?: string } }
|
||||||
|
| { type: 'document'; document: { link?: string; id?: string } }
|
||||||
|
| { type: 'coupon_code'; coupon_code: string }
|
||||||
|
| { type: 'payload'; payload: string };
|
||||||
|
|
||||||
|
function buildHeaderComponent(
|
||||||
|
template: MessageTemplate,
|
||||||
|
params: SendTimeParams,
|
||||||
|
): MetaSendComponent | null {
|
||||||
|
const headerType = template.header_type;
|
||||||
|
if (!headerType) return null;
|
||||||
|
|
||||||
|
if (headerType === 'text') {
|
||||||
|
// TEXT header with {{1}} → need a value. Static text headers
|
||||||
|
// (no variables) just ride along inside the template itself; no
|
||||||
|
// header component required on send.
|
||||||
|
const varCount = extractVariableIndices(template.header_content ?? '').length;
|
||||||
|
if (varCount === 0) return null;
|
||||||
|
const value = params.headerText;
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
'Header text variable {{1}} requires a value — pass headerText.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'header',
|
||||||
|
parameters: [{ type: 'text', text: value }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// image / video / document — Meta requires the media component on
|
||||||
|
// every send. Prefer the caller's explicit override; fall back to
|
||||||
|
// the template's stored sample.
|
||||||
|
const link = params.headerMediaUrl ?? template.header_media_url;
|
||||||
|
const id = params.headerMediaId ?? template.header_handle;
|
||||||
|
if (!link && !id) {
|
||||||
|
throw new Error(
|
||||||
|
`${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mediaPayload: { link?: string; id?: string } = id ? { id } : { link };
|
||||||
|
return {
|
||||||
|
type: 'header',
|
||||||
|
parameters: [
|
||||||
|
headerType === 'image'
|
||||||
|
? { type: 'image', image: mediaPayload }
|
||||||
|
: headerType === 'video'
|
||||||
|
? { type: 'video', video: mediaPayload }
|
||||||
|
: { type: 'document', document: mediaPayload },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBodyComponent(
|
||||||
|
template: MessageTemplate,
|
||||||
|
params: SendTimeParams,
|
||||||
|
): MetaSendComponent | null {
|
||||||
|
const varCount = extractVariableIndices(template.body_text).length;
|
||||||
|
const body = params.body ?? [];
|
||||||
|
if (varCount === 0 && body.length === 0) return null;
|
||||||
|
if (body.length < varCount) {
|
||||||
|
throw new Error(
|
||||||
|
`Body has ${varCount} variable(s) but only ${body.length} value(s) were supplied.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Trim to the variable count — extra values are dropped silently so
|
||||||
|
// a legacy caller that passes too many doesn't error out.
|
||||||
|
const values = body.slice(0, varCount);
|
||||||
|
return {
|
||||||
|
type: 'body',
|
||||||
|
parameters: values.map((text) => ({ type: 'text', text: String(text) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonNeedsSendParam(
|
||||||
|
button: TemplateButton,
|
||||||
|
override: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
switch (button.type) {
|
||||||
|
case 'URL':
|
||||||
|
return extractVariableIndices(button.url).length > 0;
|
||||||
|
case 'COPY_CODE':
|
||||||
|
// We always emit a button param for COPY_CODE so the customer
|
||||||
|
// gets a real code (either the caller's override or the
|
||||||
|
// template's example as a default).
|
||||||
|
return true;
|
||||||
|
case 'QUICK_REPLY':
|
||||||
|
case 'PHONE_NUMBER':
|
||||||
|
return override !== undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButtonComponent(
|
||||||
|
button: TemplateButton,
|
||||||
|
index: number,
|
||||||
|
override: string | undefined,
|
||||||
|
): MetaSendComponent | null {
|
||||||
|
if (!buttonNeedsSendParam(button, override)) return null;
|
||||||
|
|
||||||
|
switch (button.type) {
|
||||||
|
case 'URL': {
|
||||||
|
// Each URL button is its own component with sub_type=url and
|
||||||
|
// the button's index in the template's buttons array.
|
||||||
|
if (!override || !override.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
`URL button #${index + 1} uses {{1}} — requires a buttonParams[${index}] value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'url',
|
||||||
|
index: String(index),
|
||||||
|
parameters: [{ type: 'text', text: override }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'COPY_CODE': {
|
||||||
|
const code = override?.trim() || button.example;
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'copy_code',
|
||||||
|
index: String(index),
|
||||||
|
parameters: [{ type: 'coupon_code', coupon_code: code }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'QUICK_REPLY': {
|
||||||
|
// Only included when the caller explicitly overrides the
|
||||||
|
// payload (rare — usually QR buttons use their default text).
|
||||||
|
return {
|
||||||
|
type: 'button',
|
||||||
|
sub_type: 'quick_reply',
|
||||||
|
index: String(index),
|
||||||
|
parameters: [{ type: 'payload', payload: override! }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'PHONE_NUMBER':
|
||||||
|
// PHONE_NUMBER buttons never accept send-time params per Meta —
|
||||||
|
// return null even if an override snuck through.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full `components` array for the send-message payload.
|
||||||
|
* Returns an empty array when the template is fully static (no
|
||||||
|
* variables, no media header), which is a valid Meta request.
|
||||||
|
*/
|
||||||
|
export function buildSendComponents(
|
||||||
|
template: MessageTemplate,
|
||||||
|
params: SendTimeParams = {},
|
||||||
|
): MetaSendComponent[] {
|
||||||
|
const out: MetaSendComponent[] = [];
|
||||||
|
const header = buildHeaderComponent(template, params);
|
||||||
|
if (header) out.push(header);
|
||||||
|
const body = buildBodyComponent(template, params);
|
||||||
|
if (body) out.push(body);
|
||||||
|
if (template.buttons?.length) {
|
||||||
|
template.buttons.forEach((btn, i) => {
|
||||||
|
const override = params.buttonParams?.[i];
|
||||||
|
const component = buildButtonComponent(btn, i, override);
|
||||||
|
if (component) out.push(component);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user