// ============================================ // 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); } /** * 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 async function createAdCreative( client: Client, params: { name: string; page_id: string; instagram_actor_id?: string; image_hash?: string; image_hashes?: string[]; 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, 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; 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`; // 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. const primaryImageHash = imageHashes[0]; // 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) => { 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, 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, }); }; // 1815199: "A conta de anúncios não tem acesso a esta conta do Instagram". const isInstagramPermissionError = (err: unknown) => err instanceof Error && /1815199/.test(err.message); let creative; let instagramDropped = false; try { creative = await buildCreative(params.instagram_actor_id); } catch (err) { if (params.instagram_actor_id && isInstagramPermissionError(err)) { creative = await buildCreative(undefined); instagramDropped = true; } else { throw err; } } 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, 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, }; } // ============================== // 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; 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) { // Step 1: Campaign const campaign = await createCampaign(client, { name: params.campaign_name, objective: params.objective, 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, 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, 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, }; }