diff --git a/src/lib/mcp/client.ts b/src/lib/mcp/client.ts index dc79e4b..cabf97c 100644 --- a/src/lib/mcp/client.ts +++ b/src/lib/mcp/client.ts @@ -281,8 +281,68 @@ export async function callMetaTool( case 'create_ad_creative': requireFields(args, ['name', 'page_id', 'link', 'message'], toolName); - - if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) { + + // Placement Asset Customization: imagem específica por posicionamento + // (1:1 no feed, 9:16 em stories/reels, 1.91:1 na coluna direita). Roda + // em ad set normal (não-dinâmico). Mantém object_story_spec com page_id + // (e IG) + asset_feed_spec com regras de customização por placement. + if (args.placement_images && (args.placement_images.vertical || args.placement_images.horizontal)) { + const pi = args.placement_images as { square?: string; vertical?: string; horizontal?: string }; + // O branch garante que ao menos vertical/horizontal existe. + const squareHash = (pi.square || pi.vertical || pi.horizontal) as string; + + const images: Array<{ hash: string; adlabels: Array<{ name: string }> }> = [ + { hash: squareHash, adlabels: [{ name: 'sq' }] }, + ]; + const rules: Array> = [ + // Regra padrão (mais ampla): cobre todos os posicionamentos. + { + customization_spec: { publisher_platforms: ['facebook', 'instagram', 'audience_network', 'messenger'] }, + image_label: { name: 'sq' }, + }, + ]; + + if (pi.vertical && pi.vertical !== squareHash) { + images.push({ hash: pi.vertical, adlabels: [{ name: 'vt' }] }); + rules.push({ + customization_spec: { + publisher_platforms: ['facebook', 'instagram'], + facebook_positions: ['story', 'facebook_reels'], + instagram_positions: ['story', 'reels'], + }, + image_label: { name: 'vt' }, + }); + } + + if (pi.horizontal && pi.horizontal !== squareHash) { + images.push({ hash: pi.horizontal, adlabels: [{ name: 'hz' }] }); + rules.push({ + customization_spec: { + publisher_platforms: ['facebook'], + facebook_positions: ['right_hand_column'], + }, + image_label: { name: 'hz' }, + }); + } + + const objectStorySpec: any = { page_id: args.page_id }; + if (args.instagram_actor_id) objectStorySpec.instagram_actor_id = args.instagram_actor_id; + + resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, { + name: args.name, + object_story_spec: objectStorySpec, + asset_feed_spec: { + images, + bodies: [{ text: args.message }], + titles: [{ text: args.headline || 'Headline' }], + descriptions: [{ text: args.description || '' }], + link_urls: [{ website_url: args.link }], + call_to_action_types: [args.call_to_action_type || 'LEARN_MORE'], + ad_formats: ['SINGLE_IMAGE'], + asset_customization_rules: rules, + }, + }); + } else if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) { // Asset Customization / Dynamic Creative const assetFeedSpec = { images: args.image_hashes.map((hash: any) => ({ hash })), diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index e0c6bf4..132819b 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -474,6 +474,12 @@ export async function createAd( return extractContent(result); } +export interface PlacementImages { + square?: string; + vertical?: string; + horizontal?: string; +} + export async function createAdCreative( client: Client, params: { @@ -482,6 +488,9 @@ export async function createAdCreative( instagram_actor_id?: string; image_hash?: string; image_hashes?: string[]; + // Imagens por posicionamento (1:1 / 9:16 / 1.91:1). Quando presente, o + // criativo usa Placement Asset Customization em vez de uma imagem única. + placement_images?: PlacementImages; link: string; message: string; headline: string; @@ -496,6 +505,7 @@ export async function createAdCreative( instagram_actor_id: params.instagram_actor_id, image_hash: params.image_hash, image_hashes: params.image_hashes, + placement_images: params.placement_images, link: params.link, message: params.message, headline: params.headline, @@ -626,19 +636,19 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) { const creativeName = `${params.ad_name} — Creative`; - // Use a single primary image via the standard object_story_spec path — - // asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad - // set to be created with is_dynamic_creative=true, which we don't set here, - // and combining it with degrees_of_freedom_spec triggers Meta error subcode - // 2490433 ("unexpected error"). The first generated image (1080x1080) is - // the most broadly compatible across placements. + // A imagem quadrada (1080x1080, a primeira gerada) é a mais compatível e vira + // o fallback universal. Quando há ≥2 imagens, tentamos também usar cada uma no + // seu posicionamento (1:1 feed / 9:16 stories-reels / 1.91:1 coluna direita) + // via Placement Asset Customization (roda em ad set normal, não-dinâmico). const primaryImageHash = imageHashes[0]; + const placementImages: PlacementImages | undefined = + !params.is_whatsapp && imageHashes.length >= 2 + ? { square: imageHashes[0], vertical: imageHashes[1], horizontal: imageHashes[2] } + : undefined; - // Monta o criativo com (ou sem) o Instagram. Quando a conta de anúncios não - // tem permissão pra usar a conta de IG vinculada à página, a Meta recusa o - // criativo inteiro (erro 200 / subcode 1815199). Nesse caso refazemos o - // criativo só com o Facebook, em vez de barrar a publicação. - const buildCreative = (instagramActorId?: string) => { + // Monta o criativo com (ou sem) o Instagram e com (ou sem) customização por + // posicionamento — cada um tem seu próprio fallback (ver cascata abaixo). + const buildCreative = (instagramActorId?: string, usePlacements = false) => { if (params.is_whatsapp) { return createWhatsappAdCreative(client, { name: creativeName, @@ -659,6 +669,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) { page_id: params.page_id, instagram_actor_id: instagramActorId, image_hash: primaryImageHash, + placement_images: usePlacements ? placementImages : undefined, link: params.link, message: params.primary_text, headline: params.headline, @@ -680,14 +691,40 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) { /instagram_actor_id/i.test(err.message) || /valid instagram account/i.test(err.message)); + const wantPlacements = !!placementImages; let creative; let instagramDropped = false; + let placementsDropped = false; + try { - creative = await buildCreative(params.instagram_actor_id); + creative = await buildCreative(params.instagram_actor_id, wantPlacements); } catch (err) { if (params.instagram_actor_id && isInstagramError(err)) { - creative = await buildCreative(undefined); + // Problema no Instagram: refaz sem IG (mantendo a customização se houver). instagramDropped = true; + try { + creative = await buildCreative(undefined, wantPlacements); + } catch (err2) { + if (wantPlacements) { + placementsDropped = true; + creative = await buildCreative(undefined, false); + } else { + throw err2; + } + } + } else if (wantPlacements) { + // Erro provável da customização por posicionamento → cai pra imagem única. + placementsDropped = true; + try { + creative = await buildCreative(params.instagram_actor_id, false); + } catch (err2) { + if (params.instagram_actor_id && isInstagramError(err2)) { + instagramDropped = true; + creative = await buildCreative(undefined, false); + } else { + throw err2; + } + } } else { throw err; } @@ -715,6 +752,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) { creative_id: creativeId, ad_id: adId, instagram_dropped: instagramDropped, + placements_dropped: placementsDropped, }; }