// ============================================ // MCP Tools — Typed wrappers for Meta MCP tools // ============================================ import { callMetaTool, type Client } from './client'; // ---- Helper: extract text content from MCP result ---- function extractContent(result: Awaited>): unknown { if (!result.content || !Array.isArray(result.content)) return null; const textPart = result.content.find((c: { type: string }) => c.type === 'text'); if (!textPart || !('text' in textPart)) return null; try { return JSON.parse(textPart.text as string); } catch { return textPart.text; } } function requireId(value: unknown, label: string) { if (!value || typeof value !== 'string') { throw new Error(`${label} obrigatório não informado`); } } function getDefaultOptimizationGoal(objective: string, destination?: string) { if (destination === 'WHATSAPP') { return 'CONVERSATIONS'; } switch (objective) { case 'OUTCOME_TRAFFIC': return 'LINK_CLICKS'; case 'OUTCOME_AWARENESS': return 'REACH'; case 'OUTCOME_ENGAGEMENT': return 'POST_ENGAGEMENT'; case 'OUTCOME_LEADS': return 'LEAD_GENERATION'; case 'OUTCOME_SALES': // OFFSITE_CONVERSIONS requires a pixel_id. Since we don't have it in MVP, fallback to LINK_CLICKS return 'LINK_CLICKS'; default: return 'LINK_CLICKS'; } } function getDefaultBillingEvent(objective: string) { switch (objective) { case 'OUTCOME_TRAFFIC': case 'OUTCOME_AWARENESS': case 'OUTCOME_ENGAGEMENT': case 'OUTCOME_LEADS': case 'OUTCOME_SALES': default: return 'IMPRESSIONS'; } } function normalizeBidCapParams(params: { bid_strategy?: string; bid_amount?: number; }) { if (!params.bid_strategy && !params.bid_amount) return {}; const bidStrategy = params.bid_strategy || 'LOWEST_COST_WITH_BID_CAP'; if (bidStrategy === 'LOWEST_COST_WITH_BID_CAP' && !params.bid_amount) { throw new Error('Para campanha Bid Cap, informe bid_amount em centavos. Exemplo: R$10,00 = 1000.'); } return { bid_strategy: bidStrategy, bid_amount: params.bid_amount, }; } // ============================== // Campaign Management // ============================== export async function listCampaigns(client: Client, accountId?: string, datePreset: string = 'last_30d') { const args: Record = { date_preset: datePreset }; if (accountId) args.ad_account_id = accountId; const result = await callMetaTool(client, 'list_campaigns', args); return extractContent(result); } export async function getCampaignDetails( client: Client, campaignId: string, datePreset: string = 'last_30d' ) { requireId(campaignId, 'campaignId'); const result = await callMetaTool(client, 'get_campaign_details', { campaign_id: campaignId, date_preset: datePreset, }); return extractContent(result); } export async function pauseCampaign(client: Client, campaignId: string) { requireId(campaignId, 'campaignId'); const result = await callMetaTool(client, 'update_campaign', { campaign_id: campaignId, status: 'PAUSED', }); return extractContent(result); } export async function resumeCampaign(client: Client, campaignId: string) { requireId(campaignId, 'campaignId'); const result = await callMetaTool(client, 'update_campaign', { campaign_id: campaignId, status: 'ACTIVE', }); return extractContent(result); } export type DuplicableLevel = 'campaign' | 'adset' | 'ad'; /** * Duplica uma campanha, conjunto (ad set) ou anúncio usando o endpoint nativo * /copies da Meta — o mesmo "Duplicar" do Ads Manager. Campanhas e conjuntos * vêm com cópia completa (deep copy: traz os filhos junto) e tudo entra como * PAUSED, pra revisar antes de ativar. */ export async function duplicateEntity(client: Client, level: DuplicableLevel, id: string) { requireId(id, 'id'); const config: Record = { campaign: { tool: 'copy_campaign', argKey: 'campaign_id' }, adset: { tool: 'copy_adset', argKey: 'adset_id' }, ad: { tool: 'copy_ad', argKey: 'ad_id' }, }; const entry = config[level]; if (!entry) { throw new Error(`Nível inválido para duplicar: ${level}`); } const result = await callMetaTool(client, entry.tool, { [entry.argKey]: id }); return extractContent(result); } /** * Mantido por compatibilidade. * ATENÇÃO: orçamento normalmente é no AdSet, não na campanha. * Para atualizar orçamento real, prefira updateAdSetBudget(). */ export async function updateCampaignBudget( client: Client, campaignId: string, dailyBudget: number ) { requireId(campaignId, 'campaignId'); throw new Error( `updateCampaignBudget não deve atualizar daily_budget direto na campanha. Use listAdSets(client, campaignId), pegue o adset_id e chame updateAdSetBudget(client, adsetId, dailyBudget).` ); } // ============================== // Insights & Analytics // ============================== export async function getCampaignInsights( client: Client, campaignId: string, datePreset: string = 'last_7d' ) { requireId(campaignId, 'campaignId'); const result = await callMetaTool(client, 'get_insights_for_campaign', { campaign_id: campaignId, date_preset: datePreset, }); return extractContent(result); } export async function getAccountInsights( client: Client, datePreset: string = 'last_7d', accountId?: string ) { const args: Record = { date_preset: datePreset, }; if (accountId) args.ad_account_id = accountId; const result = await callMetaTool(client, 'get_insights_for_account', args); return extractContent(result); } export async function getAdAccounts(client: Client) { const result = await callMetaTool(client, 'list_ad_accounts'); return extractContent(result); } export async function getPages(client: Client) { const result = await callMetaTool(client, 'list_pages'); return extractContent(result); } // ============================== // Ad Set Management // ============================== export async function listAdSets(client: Client, campaignId: string) { requireId(campaignId, 'campaignId'); const result = await callMetaTool(client, 'list_adsets', { campaign_id: campaignId, }); return extractContent(result); } export async function updateAdSetBudget( client: Client, adSetId: string, dailyBudget: number ) { requireId(adSetId, 'adSetId'); const result = await callMetaTool(client, 'update_adset', { adset_id: adSetId, daily_budget: dailyBudget, }); return extractContent(result); } export async function pauseAdSet(client: Client, adSetId: string) { requireId(adSetId, 'adSetId'); const result = await callMetaTool(client, 'update_adset', { adset_id: adSetId, status: 'PAUSED', }); return extractContent(result); } export async function resumeAdSet(client: Client, adSetId: string) { requireId(adSetId, 'adSetId'); const result = await callMetaTool(client, 'update_adset', { adset_id: adSetId, status: 'ACTIVE', }); return extractContent(result); } export interface AdSetDefaultCreative { page_id?: string; instagram_actor_id?: string; link?: string; cta?: string; is_whatsapp: boolean; whatsapp_link?: string; whatsapp_phone_number?: string; sample_primary_text?: string; sample_headline?: string; sample_description?: string; } interface AdSetDefaultCreativeApiResponse { ads?: { data?: Array<{ creative?: { object_story_spec?: { page_id?: string; instagram_actor_id?: string; link_data?: { link?: string; message?: string; name?: string; description?: string; call_to_action?: { type?: string }; }; }; asset_feed_spec?: { link_urls?: Array<{ website_url?: string }>; call_to_action_types?: string[]; bodies?: Array<{ text?: string }>; titles?: Array<{ text?: string }>; descriptions?: Array<{ text?: string }>; }; }; }>; }; } interface MetaPageSummary { id: string; instagram_business_account?: { id?: string }; } /** * Extrai page_id/link/instagram_actor_id/cta do criativo de um anúncio já * existente no ad set, para pré-preencher a publicação de novos anúncios no * mesmo destino. Se o ad set ainda não tiver anúncios, cai no fallback de * usar a primeira página conectada à conta (mesmo padrão de campaigns/create). */ export async function getAdSetDefaultCreative( client: Client, adSetId: string ): Promise { requireId(adSetId, 'adSetId'); const result = await callMetaTool(client, 'get_adset_default_creative', { adset_id: adSetId }); const data = extractContent(result) as AdSetDefaultCreativeApiResponse | null; const creative = data?.ads?.data?.[0]?.creative; const storySpec = creative?.object_story_spec; const assetFeed = creative?.asset_feed_spec; if (storySpec) { const linkData = storySpec.link_data; const ctaType = linkData?.call_to_action?.type; const isWhatsapp = ctaType === 'WHATSAPP_MESSAGE'; return { page_id: storySpec.page_id || undefined, instagram_actor_id: storySpec.instagram_actor_id || undefined, link: linkData?.link || undefined, cta: ctaType, is_whatsapp: isWhatsapp, whatsapp_link: isWhatsapp ? linkData?.link || undefined : undefined, whatsapp_phone_number: undefined, sample_primary_text: linkData?.message || undefined, sample_headline: linkData?.name || undefined, sample_description: linkData?.description || undefined, }; } if (assetFeed) { const ctaType = assetFeed.call_to_action_types?.[0]; const isWhatsapp = ctaType === 'WHATSAPP_MESSAGE'; const link = assetFeed.link_urls?.[0]?.website_url; return { page_id: undefined, instagram_actor_id: undefined, link, cta: ctaType, is_whatsapp: isWhatsapp, whatsapp_link: isWhatsapp ? link : undefined, whatsapp_phone_number: undefined, sample_primary_text: assetFeed.bodies?.[0]?.text || undefined, sample_headline: assetFeed.titles?.[0]?.text || undefined, sample_description: assetFeed.descriptions?.[0]?.text || undefined, }; } // Fallback: nenhum anúncio existente — usa a primeira página da conta. const pages = (await getPages(client)) as MetaPageSummary[] | null; const firstPage = Array.isArray(pages) ? pages.find((p) => p?.id) : undefined; return { page_id: firstPage?.id, instagram_actor_id: firstPage?.instagram_business_account?.id, link: undefined, cta: undefined, is_whatsapp: false, }; } // ============================== // Campaign Creation // ============================== export async function createCampaign( client: Client, params: { name: string; objective: string; status?: string; ad_account_id?: string; } ) { const result = await callMetaTool(client, 'create_campaign', { name: params.name, objective: params.objective, status: params.status || 'PAUSED', ad_account_id: params.ad_account_id, }); return extractContent(result); } export async function createAdSet( client: Client, params: { name: string; campaign_id: string; daily_budget: number; targeting: Record; objective: string; optimization_goal?: string; billing_event?: string; status?: string; promoted_object?: Record; destination_type?: string; bid_strategy?: string; bid_amount?: number; start_time?: string; end_time?: string; ad_account_id?: string; } ) { const bidCapParams: any = {}; if (params.bid_strategy) bidCapParams['bid_strategy'] = params.bid_strategy; if (params.bid_amount) bidCapParams['bid_amount'] = params.bid_amount; const result = await callMetaTool(client, 'create_adset', { name: params.name, campaign_id: params.campaign_id, daily_budget: params.daily_budget, targeting: params.targeting, objective: params.objective, optimization_goal: params.optimization_goal || getDefaultOptimizationGoal(params.objective, params.destination_type), billing_event: params.billing_event || getDefaultBillingEvent(params.objective), status: params.status || 'PAUSED', promoted_object: params.promoted_object, destination_type: params.destination_type, start_time: params.start_time, end_time: params.end_time, ad_account_id: params.ad_account_id, ...bidCapParams, }); return extractContent(result); } export async function createAd( client: Client, params: { name: string; adset_id: string; creative_id: string; status?: string; ad_account_id?: string; } ) { const result = await callMetaTool(client, 'create_ad', { name: params.name, adset_id: params.adset_id, creative_id: params.creative_id, status: params.status || 'PAUSED', ad_account_id: params.ad_account_id, }); return extractContent(result); } export interface PlacementImages { square?: string; vertical?: string; horizontal?: string; } export async function createAdCreative( client: Client, params: { name: string; page_id: string; 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; description?: string; call_to_action_type?: string; ad_account_id?: string; } ) { const result = await callMetaTool(client, 'create_ad_creative', { name: params.name, page_id: params.page_id, 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, description: params.description || '', call_to_action_type: params.call_to_action_type || 'LEARN_MORE', ad_account_id: params.ad_account_id, }); return extractContent(result); } export async function createWhatsappAdCreative( client: Client, params: { name: string; page_id: string; instagram_actor_id?: string; image_hash?: string; image_hashes?: string[]; message: string; headline: string; description?: string; link?: string; whatsapp_link?: string; whatsapp_phone_number?: string; ad_account_id?: string; } ) { const result = await callMetaTool(client, 'create_whatsapp_ad_creative', { name: params.name, page_id: params.page_id, instagram_actor_id: params.instagram_actor_id, image_hash: params.image_hash, image_hashes: params.image_hashes, message: params.message, headline: params.headline, description: params.description || '', link: params.link, whatsapp_link: params.whatsapp_link, whatsapp_phone_number: params.whatsapp_phone_number, ad_account_id: params.ad_account_id, }); return extractContent(result); } export async function uploadAdImage( client: Client, imageUrl?: string, imageBytes?: string, adAccountId?: string ) { const params: Record = {}; if (imageUrl) params.image_url = imageUrl; if (imageBytes) params.image_bytes = imageBytes; if (adAccountId) params.ad_account_id = adAccountId; const result = await callMetaTool(client, 'upload_ad_image', params); return extractContent(result); } // ============================== // Creative + Ad Pipeline (reusável: campanha nova ou ad set existente) // ============================== export interface AddAdToAdSetParams { adset_id: string; ad_name: string; page_id: string; instagram_actor_id?: string; image_urls?: string[]; image_bytes?: string[]; link: string; headline: string; primary_text: string; description: string; cta: string; ad_account_id?: string; // Status do ANÚNCIO criado (ACTIVE | PAUSED). Default PAUSED. status?: string; is_whatsapp?: boolean; whatsapp_link?: string; whatsapp_phone_number?: string; } /** * Sobe imagens, cria o ad creative (normal ou whatsapp) e o anúncio dentro de * um ad set já existente. Usado tanto pelo pipeline de criação completa de * campanha quanto para adicionar novos anúncios a uma campanha ativa. */ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) { const imageHashes: string[] = []; const DATA_URI_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/; const processImageUploads = async (urls?: string[], bytes?: string[]) => { const allBytes = [...(bytes || [])]; const realUrls: string[] = []; // gpt-image-2 returns base64 data URIs (data:image/png;base64,...) — Meta's // upload_ad_image expects those as raw base64 via image_bytes, not image_url // (sending a data URI as image_url causes "tipo de arquivo não suportado"). for (const url of urls || []) { const match = DATA_URI_RE.exec(url); if (match) { allBytes.push(match[1]); } else { realUrls.push(url); } } for (const url of realUrls) { const res = await uploadAdImage(client, url, undefined, params.ad_account_id); if ((res as any)?.hash) imageHashes.push((res as any).hash); } for (const b of allBytes) { const res = await uploadAdImage(client, undefined, b, params.ad_account_id); if ((res as any)?.hash) imageHashes.push((res as any).hash); } }; await processImageUploads(params.image_urls, params.image_bytes); if (imageHashes.length === 0) { throw new Error('[Upload] Nenhuma imagem foi enviada ou processada.'); } const creativeName = `${params.ad_name} — Creative`; // 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 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, page_id: params.page_id, instagram_actor_id: instagramActorId, image_hash: primaryImageHash, message: params.primary_text, headline: params.headline, description: params.description, link: params.link, whatsapp_link: params.whatsapp_link, whatsapp_phone_number: params.whatsapp_phone_number, ad_account_id: params.ad_account_id, }); } return createAdCreative(client, { name: creativeName, 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, description: params.description, call_to_action_type: params.cta, ad_account_id: params.ad_account_id, }); }; // Erros ligados ao Instagram em que vale refazer o criativo só com o Facebook // (o anúncio ainda roda no IG pela identidade da página): // - 1815199: a conta de anúncios não tem acesso a esta conta do Instagram. // - "instagram_actor_id must be a valid Instagram account id": o ID vinculado // à página não é um ator de Instagram válido para Ads (ex: conta conectada // pelo fluxo de "login do Instagram" em vez do Business/Página). const isInstagramError = (err: unknown) => err instanceof Error && (/1815199/.test(err.message) || /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; // Imagem única + IG é o caminho comprovado. Só largamos o Instagram se o // próprio criativo de imagem única falhar com erro específico de IG (id // inválido / sem acesso). Prioridade: NUNCA trocar IG por "3 imagens". const buildSingleWithIgFallback = async () => { try { return await buildCreative(params.instagram_actor_id, false); } catch (err) { if (params.instagram_actor_id && isInstagramError(err)) { instagramDropped = true; return await buildCreative(undefined, false); } throw err; } }; if (wantPlacements) { try { // 1) Melhor caso: imagem específica por posicionamento, com IG. creative = await buildCreative(params.instagram_actor_id, true); } catch { // 2) Customização por posicionamento falhou (ex: conflito com a // identidade de IG) → cai pra imagem única mantendo o Instagram. placementsDropped = true; creative = await buildSingleWithIgFallback(); } } else { creative = await buildSingleWithIgFallback(); } const creativeId = (creative as any)?.id; if (!creativeId) { throw new Error(`[Creative] Falha no criativo: ${JSON.stringify(creative)}`); } const ad = await createAd(client, { name: `${params.ad_name} — Ad`, adset_id: params.adset_id, creative_id: creativeId, status: params.status, ad_account_id: params.ad_account_id, }); const adId = (ad as any)?.id; if (!adId) { throw new Error(`[Ad] Falha no anúncio: ${JSON.stringify(ad)}`); } return { adset_id: params.adset_id, creative_id: creativeId, ad_id: adId, instagram_dropped: instagramDropped, placements_dropped: placementsDropped, }; } // ============================== // Full Campaign Creation Pipeline // ============================== export interface FullCampaignParams { campaign_name: string; objective: string; daily_budget: number; targeting: Record; page_id: string; instagram_actor_id?: string; image_urls?: string[]; image_bytes?: string[]; link: string; headline: string; primary_text: string; description: string; cta: string; ad_account_id?: string; // Status aplicado a campanha, conjunto e anúncio (ACTIVE | PAUSED). // Default PAUSED — para um anúncio entregar, os 3 níveis precisam estar ACTIVE. status?: string; bid_strategy?: string; bid_amount?: number; optimization_goal?: string; billing_event?: string; destination_type?: string; promoted_object?: Record; is_whatsapp?: boolean; whatsapp_link?: string; whatsapp_phone_number?: string; } export async function createFullCampaign(client: Client, params: FullCampaignParams) { // Status comum aos 3 níveis. Default PAUSED (revisar antes de ativar). const status = params.status || 'PAUSED'; // Step 1: Campaign const campaign = await createCampaign(client, { name: params.campaign_name, objective: params.objective, status, ad_account_id: params.ad_account_id, }); const campaignId = (campaign as any)?.id; if (!campaignId) { throw new Error(`[Step 1 - Campaign] Falha: ${JSON.stringify(campaign)}`); } // Step 2: Ad Set const destinationType = params.destination_type || (params.is_whatsapp ? 'WHATSAPP' : undefined); let promotedObject = params.promoted_object; if (!promotedObject && params.is_whatsapp) { promotedObject = { page_id: params.page_id, }; } const optimizationGoal = params.optimization_goal || getDefaultOptimizationGoal(params.objective, destinationType); const billingEvent = params.billing_event || getDefaultBillingEvent(params.objective); const adSet = await createAdSet(client, { name: `${params.campaign_name} — Ad Set`, campaign_id: campaignId, daily_budget: params.daily_budget, targeting: params.targeting, objective: params.objective, optimization_goal: optimizationGoal, billing_event: billingEvent, promoted_object: promotedObject, destination_type: destinationType, bid_strategy: params.bid_strategy, bid_amount: params.bid_amount, status, ad_account_id: params.ad_account_id, }); const adSetId = (adSet as any)?.id; if (!adSetId) { throw new Error(`[Step 2 - Ad Set] Falha: ${JSON.stringify(adSet)}`); } // Steps 3-5: Image Upload + Ad Creative + Ad (pipeline compartilhado) const { creative_id: creativeId, ad_id: adId } = await addAdToAdSet(client, { adset_id: adSetId, ad_name: params.campaign_name, page_id: params.page_id, instagram_actor_id: params.instagram_actor_id, image_urls: params.image_urls, image_bytes: params.image_bytes, link: params.link, headline: params.headline, primary_text: params.primary_text, description: params.description, cta: params.cta, status, ad_account_id: params.ad_account_id, is_whatsapp: params.is_whatsapp, whatsapp_link: params.whatsapp_link, whatsapp_phone_number: params.whatsapp_phone_number, }); return { campaign_id: campaignId, adset_id: adSetId, creative_ids: [creativeId], ad_ids: [adId], objective: params.objective, optimization_goal: optimizationGoal, billing_event: billingEvent, bid_strategy: params.bid_strategy, bid_amount: params.bid_amount, is_whatsapp: params.is_whatsapp || false, }; }