metaads-pro/src/lib/mcp/tools.ts

805 lines
22 KiB
TypeScript
Raw Normal View History

// ============================================
// 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<ReturnType<typeof callMetaTool>>): 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<string, unknown> = { 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<string, unknown> = {
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
* 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<AdSetDefaultCreative> {
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<string, unknown>;
objective: string;
optimization_goal?: string;
billing_event?: string;
status?: string;
promoted_object?: Record<string, unknown>;
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<string, string> = {};
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 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<string, unknown>;
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<string, unknown>;
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,
};
}