mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #264 from ArnasDon/feat/template-image-upload
feat(templates): upload image for template headers; fix image-URL submit (#230)
This commit is contained in:
commit
3b55b3f1d9
@ -72,10 +72,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
|
|||||||
# See docs/automations-and-cron.md.
|
# See docs/automations-and-cron.md.
|
||||||
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
|
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
|
||||||
|
|
||||||
# Meta App ID (Meta for Developers → App Settings → Basic). Required
|
# Meta App ID (Meta for Developers → App Settings → Basic). Required to
|
||||||
# only for the (planned) Resumable Upload flow that lets users attach
|
# create/edit message templates with an IMAGE header: Meta only accepts a
|
||||||
# local media files as template header samples. Submitting templates
|
# Resumable-Upload media handle (not a plain URL) as the header sample, and
|
||||||
# with a public sample URL works without it. Pair with META_APP_SECRET.
|
# that upload is app-scoped. Without it, image-header template submission
|
||||||
|
# returns a clear error; text/body-only templates are unaffected. Pair
|
||||||
|
# with META_APP_SECRET.
|
||||||
# META_APP_ID=your-meta-app-id
|
# META_APP_ID=your-meta-app-id
|
||||||
|
|
||||||
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
|
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
type TemplatePayload,
|
type TemplatePayload,
|
||||||
} from '@/lib/whatsapp/template-validators'
|
} from '@/lib/whatsapp/template-validators'
|
||||||
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
||||||
|
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-template lifecycle endpoint.
|
* Per-template lifecycle endpoint.
|
||||||
@ -136,8 +137,6 @@ export async function PATCH(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaPayload = buildMetaTemplatePayload(payload)
|
|
||||||
|
|
||||||
if (!isDryRun()) {
|
if (!isDryRun()) {
|
||||||
const { data: config, error: configError } = await supabase
|
const { data: config, error: configError } = await supabase
|
||||||
.from('whatsapp_config')
|
.from('whatsapp_config')
|
||||||
@ -151,6 +150,19 @@ export async function PATCH(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const accessToken = decrypt(config.access_token)
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
// Image headers need a fresh Resumable-Upload handle on every edit
|
||||||
|
// (Meta replaces components wholesale). Derive from header_media_url.
|
||||||
|
try {
|
||||||
|
await ensureImageHeaderHandle(payload, accessToken)
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaPayload = buildMetaTemplatePayload(payload)
|
||||||
try {
|
try {
|
||||||
await editMessageTemplate({
|
await editMessageTemplate({
|
||||||
metaTemplateId: existing.meta_template_id,
|
metaTemplateId: existing.meta_template_id,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
type TemplatePayload,
|
type TemplatePayload,
|
||||||
} from '@/lib/whatsapp/template-validators'
|
} from '@/lib/whatsapp/template-validators'
|
||||||
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
||||||
|
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
|
||||||
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
|
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,8 +138,6 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaPayload = buildMetaTemplatePayload(payload)
|
|
||||||
|
|
||||||
const dryRun =
|
const dryRun =
|
||||||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
|
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
|
||||||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
|
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
|
||||||
@ -175,6 +174,21 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = decrypt(config.access_token)
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
// Image headers need a Resumable-Upload handle (Meta rejects a
|
||||||
|
// plain URL at creation). Derive it from header_media_url before
|
||||||
|
// building the payload. Surfaces a 400 with an actionable message
|
||||||
|
// (missing META_APP_ID, unreachable URL, wrong type/size).
|
||||||
|
try {
|
||||||
|
await ensureImageHeaderHandle(payload, accessToken)
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaPayload = buildMetaTemplatePayload(payload)
|
||||||
try {
|
try {
|
||||||
const meta = await submitMessageTemplate({
|
const meta = await submitMessageTemplate({
|
||||||
wabaId: config.waba_id,
|
wabaId: config.waba_id,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@ -11,8 +11,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import {
|
||||||
|
uploadAccountMedia,
|
||||||
|
MEDIA_MAX_BYTES_BY_KIND,
|
||||||
|
} from '@/lib/storage/upload-media';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -137,6 +142,11 @@ export function TemplateManager() {
|
|||||||
// doesn't take the template off Meta as well as locally.
|
// doesn't take the template off Meta as well as locally.
|
||||||
const [templateToDelete, setTemplateToDelete] =
|
const [templateToDelete, setTemplateToDelete] =
|
||||||
useState<MessageTemplate | null>(null);
|
useState<MessageTemplate | null>(null);
|
||||||
|
// Header-image upload (issue #230). Uploads to the account-scoped
|
||||||
|
// chat-media bucket and stores the public URL in header_media_url; the
|
||||||
|
// submit route turns that into a Meta Resumable-Upload handle.
|
||||||
|
const [uploadingHeader, setUploadingHeader] = useState(false);
|
||||||
|
const headerFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We
|
// Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We
|
||||||
// re-run the extractor on every render to keep the sample-value rows
|
// re-run the extractor on every render to keep the sample-value rows
|
||||||
@ -445,6 +455,29 @@ export function TemplateManager() {
|
|||||||
const headerNeedsMedia =
|
const headerNeedsMedia =
|
||||||
form.header_format !== 'none' && form.header_format !== 'text';
|
form.header_format !== 'none' && form.header_format !== 'text';
|
||||||
|
|
||||||
|
async function handleHeaderImageFile(file: File) {
|
||||||
|
if (!['image/jpeg', 'image/png'].includes(file.type)) {
|
||||||
|
toast.error('Header image must be a JPEG or PNG.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) {
|
||||||
|
toast.error(
|
||||||
|
`Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadingHeader(true);
|
||||||
|
try {
|
||||||
|
const { publicUrl } = await uploadAccountMedia('chat-media', file);
|
||||||
|
setForm((f) => ({ ...f, header_media_url: publicUrl }));
|
||||||
|
toast.success('Image uploaded.');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Upload failed.');
|
||||||
|
} finally {
|
||||||
|
setUploadingHeader(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
@ -777,24 +810,62 @@ export function TemplateManager() {
|
|||||||
|
|
||||||
{headerNeedsMedia && (
|
{headerNeedsMedia && (
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
|
{form.header_format === 'image' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={headerFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleHeaderImageFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingHeader}
|
||||||
|
onClick={() => headerFileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploadingHeader ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Upload image
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
JPEG or PNG, ≤5 MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={`https://… (public link to a sample ${form.header_format})`}
|
placeholder={`https://… (or paste a public ${form.header_format} link)`}
|
||||||
value={form.header_media_url}
|
value={form.header_media_url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, header_media_url: e.target.value })
|
setForm({ ...form, header_media_url: e.target.value })
|
||||||
}
|
}
|
||||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
{form.header_format === 'image' && form.header_media_url && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={form.header_media_url}
|
||||||
|
alt="Header sample"
|
||||||
|
className="max-h-28 rounded-md border border-border object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
Must be publicly accessible HTTPS. Meta fetches it once
|
{form.header_format === 'image'
|
||||||
during review, so the file needs to stay live for ~24 hrs.
|
? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.'
|
||||||
{form.header_format === 'image' &&
|
: 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'}
|
||||||
' Recommended: JPEG or PNG, ≥800×418 px, ≤5 MB.'}
|
|
||||||
{form.header_format === 'video' &&
|
{form.header_format === 'video' &&
|
||||||
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
|
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
|
||||||
{form.header_format === 'document' &&
|
{form.header_format === 'document' &&
|
||||||
' Recommended: PDF, ≤100 MB.'}
|
' Recommended: PDF, ≤100 MB.'}
|
||||||
{' '}Direct file upload is coming in a follow-up.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
91
src/lib/whatsapp/meta-api.resumable.test.ts
Normal file
91
src/lib/whatsapp/meta-api.resumable.test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { uploadResumableMedia } from './meta-api';
|
||||||
|
|
||||||
|
// Capture the two requests the resumable upload makes.
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uploadResumableMedia', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
calls.length = 0;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a session then uploads the bytes and returns the handle', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
if (url.includes('/uploads?')) {
|
||||||
|
return jsonResponse({ id: 'upload:SESSION123' });
|
||||||
|
}
|
||||||
|
return jsonResponse({ h: '2:HANDLE' });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handle } = await uploadResumableMedia({
|
||||||
|
appId: 'app-1',
|
||||||
|
accessToken: 'tok',
|
||||||
|
fileName: 'header.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handle).toBe('2:HANDLE');
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
|
||||||
|
// Step 1: app-scoped session with file metadata + access_token query.
|
||||||
|
expect(calls[0].url).toContain('/app-1/uploads?');
|
||||||
|
expect(calls[0].url).toContain('file_length=4');
|
||||||
|
expect(calls[0].url).toContain('file_type=image%2Fjpeg');
|
||||||
|
expect(calls[0].url).toContain('access_token=tok');
|
||||||
|
|
||||||
|
// Step 2: posts to the returned session id with OAuth + file_offset.
|
||||||
|
expect(calls[1].url).toContain('/upload:SESSION123');
|
||||||
|
const headers = calls[1].init?.headers as Record<string, string>;
|
||||||
|
expect(headers.Authorization).toBe('OAuth tok');
|
||||||
|
expect(headers.file_offset).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if the session response has no id', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({})));
|
||||||
|
await expect(
|
||||||
|
uploadResumableMedia({
|
||||||
|
appId: 'a',
|
||||||
|
accessToken: 't',
|
||||||
|
fileName: 'h.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
bytes: new Uint8Array([0]),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/session id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if the upload response has no handle', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string) =>
|
||||||
|
url.includes('/uploads?')
|
||||||
|
? jsonResponse({ id: 'upload:S' })
|
||||||
|
: jsonResponse({}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
uploadResumableMedia({
|
||||||
|
appId: 'a',
|
||||||
|
accessToken: 't',
|
||||||
|
fileName: 'h.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
bytes: new Uint8Array([0]),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/file handle/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -443,6 +443,83 @@ export async function sendTemplateMessage(
|
|||||||
return { messageId: data.messages[0].id }
|
return { messageId: data.messages[0].id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Resumable Upload (media handles for template headers)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Creating a message template with a media HEADER (image/video/
|
||||||
|
// document) requires an `example.header_handle` — Meta does NOT accept
|
||||||
|
// a plain public URL at creation time. The handle comes from the
|
||||||
|
// two-step Resumable Upload API, which is keyed on the Meta APP id (not
|
||||||
|
// the phone number / WABA):
|
||||||
|
//
|
||||||
|
// 1. POST /{app_id}/uploads?file_name&file_length&file_type&access_token
|
||||||
|
// → { id: "upload:<session>" }
|
||||||
|
// 2. POST /{id} (Authorization: OAuth <token>, file_offset: 0, raw bytes)
|
||||||
|
// → { h: "<handle>" }
|
||||||
|
//
|
||||||
|
// See https://developers.facebook.com/docs/graph-api/guides/upload
|
||||||
|
|
||||||
|
export interface UploadResumableMediaArgs {
|
||||||
|
/** Meta App id (env META_APP_ID) — resumable upload is app-scoped. */
|
||||||
|
appId: string
|
||||||
|
accessToken: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
bytes: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file via the Resumable Upload API and return the media
|
||||||
|
* handle to use as `example.header_handle` when creating/editing a
|
||||||
|
* template with a media header.
|
||||||
|
*/
|
||||||
|
export async function uploadResumableMedia(
|
||||||
|
args: UploadResumableMediaArgs,
|
||||||
|
): Promise<{ handle: string }> {
|
||||||
|
const { appId, accessToken, fileName, mimeType, bytes } = args
|
||||||
|
|
||||||
|
// Step 1 — open an upload session.
|
||||||
|
const startParams = new URLSearchParams({
|
||||||
|
file_name: fileName,
|
||||||
|
file_length: String(bytes.byteLength),
|
||||||
|
file_type: mimeType,
|
||||||
|
access_token: accessToken,
|
||||||
|
})
|
||||||
|
const startRes = await fetch(
|
||||||
|
`${META_API_BASE}/${appId}/uploads?${startParams.toString()}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
if (!startRes.ok) {
|
||||||
|
await throwMetaError(startRes, `Resumable upload start failed: ${startRes.status}`)
|
||||||
|
}
|
||||||
|
const startData = (await startRes.json()) as { id?: string }
|
||||||
|
if (!startData.id) {
|
||||||
|
throw new Error('Resumable upload did not return a session id.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — upload the bytes. Note the `OAuth` auth scheme (not Bearer)
|
||||||
|
// and the file_offset header, both required by this endpoint.
|
||||||
|
const uploadRes = await fetch(`${META_API_BASE}/${startData.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `OAuth ${accessToken}`,
|
||||||
|
file_offset: '0',
|
||||||
|
},
|
||||||
|
// Uint8Array is a valid BodyInit at runtime; cast around the
|
||||||
|
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
|
||||||
|
body: bytes as unknown as BodyInit,
|
||||||
|
})
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
await throwMetaError(uploadRes, `Resumable upload failed: ${uploadRes.status}`)
|
||||||
|
}
|
||||||
|
const uploadData = (await uploadRes.json()) as { h?: string }
|
||||||
|
if (!uploadData.h) {
|
||||||
|
throw new Error('Resumable upload did not return a file handle.')
|
||||||
|
}
|
||||||
|
return { handle: uploadData.h }
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Template submission (Business Management API)
|
// Template submission (Business Management API)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
81
src/lib/whatsapp/template-header-handle.test.ts
Normal file
81
src/lib/whatsapp/template-header-handle.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Stub the Meta resumable upload so the helper is tested in isolation.
|
||||||
|
vi.mock('./meta-api', () => ({
|
||||||
|
uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ensureImageHeaderHandle } from './template-header-handle';
|
||||||
|
import { uploadResumableMedia } from './meta-api';
|
||||||
|
import type { TemplatePayload } from './template-validators';
|
||||||
|
|
||||||
|
function payload(over: Partial<TemplatePayload> = {}): TemplatePayload {
|
||||||
|
return {
|
||||||
|
name: 't',
|
||||||
|
category: 'Utility',
|
||||||
|
language: 'en_US',
|
||||||
|
body_text: 'hi',
|
||||||
|
header_type: 'image',
|
||||||
|
header_media_url: 'https://x.test/img.jpg',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? type : null) },
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(size),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensureImageHeaderHandle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(uploadResumableMedia).mockClear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for non-image headers', async () => {
|
||||||
|
const p = payload({ header_type: 'text', header_content: 'Hi' });
|
||||||
|
await ensureImageHeaderHandle(p, 'tok');
|
||||||
|
expect(uploadResumableMedia).not.toHaveBeenCalled();
|
||||||
|
expect(p.header_handle).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when a handle already exists', async () => {
|
||||||
|
const p = payload({ header_handle: 'existing' });
|
||||||
|
await ensureImageHeaderHandle(p, 'tok');
|
||||||
|
expect(uploadResumableMedia).not.toHaveBeenCalled();
|
||||||
|
expect(p.header_handle).toBe('existing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an actionable error when META_APP_ID is unset', async () => {
|
||||||
|
const p = payload();
|
||||||
|
await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/META_APP_ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives + sets header_handle from a valid image URL', async () => {
|
||||||
|
vi.stubEnv('META_APP_ID', 'app-1');
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/jpeg', 2048)));
|
||||||
|
const p = payload();
|
||||||
|
await ensureImageHeaderHandle(p, 'tok');
|
||||||
|
expect(uploadResumableMedia).toHaveBeenCalledOnce();
|
||||||
|
expect(p.header_handle).toBe('HANDLE123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-image content type', async () => {
|
||||||
|
vi.stubEnv('META_APP_ID', 'app-1');
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('text/html')));
|
||||||
|
await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/JPEG or PNG/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an image over 5 MB', async () => {
|
||||||
|
vi.stubEnv('META_APP_ID', 'app-1');
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/png', 6 * 1024 * 1024)));
|
||||||
|
await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/5 MB/);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/whatsapp/template-header-handle.ts
Normal file
74
src/lib/whatsapp/template-header-handle.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { uploadResumableMedia } from '@/lib/whatsapp/meta-api'
|
||||||
|
import type { TemplatePayload } from '@/lib/whatsapp/template-validators'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta requires an `example.header_handle` (from the Resumable Upload
|
||||||
|
* API) to create/edit a template with an IMAGE header — a plain public
|
||||||
|
* URL is not accepted at creation time. This helper turns the template's
|
||||||
|
* `header_media_url` (whether the user uploaded a file or pasted a link)
|
||||||
|
* into a handle and writes it onto the payload, so both the upload path
|
||||||
|
* and the legacy URL path actually succeed.
|
||||||
|
*
|
||||||
|
* No-op unless the header is an image that has a URL but no handle yet.
|
||||||
|
* Image-only for now (the #230 scope); video/document handles can follow
|
||||||
|
* the same shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Meta's image-header sample limits.
|
||||||
|
const IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png']
|
||||||
|
|
||||||
|
export async function ensureImageHeaderHandle(
|
||||||
|
payload: TemplatePayload,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (payload.header_type !== 'image') return
|
||||||
|
if (payload.header_handle) return // already have one
|
||||||
|
if (!payload.header_media_url) return // validator already requires url-or-handle
|
||||||
|
|
||||||
|
const appId = process.env.META_APP_ID
|
||||||
|
if (!appId) {
|
||||||
|
throw new Error(
|
||||||
|
'Image-header templates need META_APP_ID set (used for Meta’s Resumable Upload). Add it to your environment, or remove the image header.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the sample image bytes (works for our uploaded chat-media URL
|
||||||
|
// and for a manually-pasted public link).
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(payload.header_media_url)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Header image URL returned ${res.status}. It must be publicly reachable.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
||||||
|
if (contentType && !ALLOWED_IMAGE_TYPES.includes(contentType)) {
|
||||||
|
throw new Error(`Header image must be JPEG or PNG (got ${contentType}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await res.arrayBuffer())
|
||||||
|
if (bytes.byteLength === 0) {
|
||||||
|
throw new Error('Header image is empty.')
|
||||||
|
}
|
||||||
|
if (bytes.byteLength > IMAGE_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Header image is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = ALLOWED_IMAGE_TYPES.includes(contentType) ? contentType : 'image/jpeg'
|
||||||
|
const fileName = mimeType === 'image/png' ? 'header.png' : 'header.jpg'
|
||||||
|
|
||||||
|
const { handle } = await uploadResumableMedia({
|
||||||
|
appId,
|
||||||
|
accessToken,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
payload.header_handle = handle
|
||||||
|
}
|
||||||
@ -116,7 +116,9 @@ describe('buildSendComponents — header', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers media id over url when both are available', () => {
|
it('does NOT use the resumable header_handle at send — falls back to the stored link', () => {
|
||||||
|
// header_handle is a template-CREATION sample handle, invalid as a
|
||||||
|
// send-time media id. The send must use the public link instead.
|
||||||
const components = buildSendComponents(
|
const components = buildSendComponents(
|
||||||
row({
|
row({
|
||||||
header_type: 'document',
|
header_type: 'document',
|
||||||
@ -126,7 +128,18 @@ describe('buildSendComponents — header', () => {
|
|||||||
);
|
);
|
||||||
expect(components[0]).toEqual({
|
expect(components[0]).toEqual({
|
||||||
type: 'header',
|
type: 'header',
|
||||||
parameters: [{ type: 'document', document: { id: '4::aBc' } }],
|
parameters: [{ type: 'document', document: { link: 'https://x.com/doc.pdf' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an explicit headerMediaId override as the media id', () => {
|
||||||
|
const components = buildSendComponents(
|
||||||
|
row({ header_type: 'image', header_media_url: 'https://x.com/s.jpg' }),
|
||||||
|
{ headerMediaId: '9:realMediaId' },
|
||||||
|
);
|
||||||
|
expect(components[0]).toEqual({
|
||||||
|
type: 'header',
|
||||||
|
parameters: [{ type: 'image', image: { id: '9:realMediaId' } }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -95,10 +95,16 @@ function buildHeaderComponent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// image / video / document — Meta requires the media component on
|
// image / video / document — Meta requires the media component on
|
||||||
// every send. Prefer the caller's explicit override; fall back to
|
// every send. Prefer the caller's explicit override; fall back to the
|
||||||
// the template's stored sample.
|
// template's stored public URL.
|
||||||
|
//
|
||||||
|
// NOTE: `template.header_handle` is intentionally NOT used here. It's a
|
||||||
|
// Resumable-Upload handle that's only valid as the *creation-time*
|
||||||
|
// sample (`example.header_handle`); it is NOT a reusable send-time
|
||||||
|
// media id, and passing it as `{ id }` makes Meta reject the send. Only
|
||||||
|
// an explicit `headerMediaId` (a real /media upload id) is honored.
|
||||||
const link = params.headerMediaUrl ?? template.header_media_url;
|
const link = params.headerMediaUrl ?? template.header_media_url;
|
||||||
const id = params.headerMediaId ?? template.header_handle;
|
const id = params.headerMediaId;
|
||||||
if (!link && !id) {
|
if (!link && !id) {
|
||||||
throw new Error(
|
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.`,
|
`${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user