← back to SmokeShop

lib/instagram-api.js

160 lines

/**
 * Instagram Graph API — Meta OAuth + posting
 * Handles authentication and media publishing for Instagram Business accounts
 */

const GRAPH_API_VERSION = 'v18.0';
const GRAPH_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`;

/**
 * Build the OAuth authorization URL for Meta login
 */
function getAuthUrl(appId, redirectUri) {
  const scopes = [
    'instagram_basic',
    'instagram_content_publish',
    'pages_show_list',
    'pages_read_engagement',
  ].join(',');

  return `https://www.facebook.com/${GRAPH_API_VERSION}/dialog/oauth?` +
    `client_id=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}` +
    `&scope=${scopes}&response_type=code`;
}

/**
 * Exchange authorization code for access token
 */
async function exchangeCodeForToken(code, appId, appSecret, redirectUri) {
  const url = `${GRAPH_BASE}/oauth/access_token?` +
    `client_id=${appId}&client_secret=${appSecret}` +
    `&redirect_uri=${encodeURIComponent(redirectUri)}&code=${code}`;

  const res = await fetch(url);
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`Token exchange failed: ${JSON.stringify(err)}`);
  }
  return res.json();
}

/**
 * Exchange short-lived token for long-lived token (60 days)
 */
async function getLongLivedToken(shortToken, appId, appSecret) {
  const url = `${GRAPH_BASE}/oauth/access_token?` +
    `grant_type=fb_exchange_token&client_id=${appId}` +
    `&client_secret=${appSecret}&fb_exchange_token=${shortToken}`;

  const res = await fetch(url);
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`Long-lived token exchange failed: ${JSON.stringify(err)}`);
  }
  return res.json();
}

/**
 * Get Instagram Business Account ID from Facebook Page
 */
async function getIGBusinessAccount(accessToken) {
  // Get pages
  const pagesRes = await fetch(`${GRAPH_BASE}/me/accounts?access_token=${accessToken}`);
  const pages = await pagesRes.json();

  if (!pages.data || pages.data.length === 0) {
    throw new Error('No Facebook Pages found. You need a Facebook Page linked to an Instagram Business account.');
  }

  // Get IG account from first page
  const pageId = pages.data[0].id;
  const igRes = await fetch(
    `${GRAPH_BASE}/${pageId}?fields=instagram_business_account&access_token=${accessToken}`
  );
  const igData = await igRes.json();

  if (!igData.instagram_business_account) {
    throw new Error('No Instagram Business account linked to this Facebook Page.');
  }

  return {
    igAccountId: igData.instagram_business_account.id,
    pageId,
    pageName: pages.data[0].name,
  };
}

/**
 * Publish an image to Instagram
 * Step 1: Create media container
 * Step 2: Publish the container
 */
async function publishPost(igAccountId, accessToken, imageUrl, caption) {
  // Step 1: Create media container
  const containerRes = await fetch(
    `${GRAPH_BASE}/${igAccountId}/media`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        image_url: imageUrl,
        caption,
        access_token: accessToken,
      }),
    }
  );
  const container = await containerRes.json();

  if (container.error) {
    throw new Error(`Media container error: ${JSON.stringify(container.error)}`);
  }

  // Step 2: Publish
  const publishRes = await fetch(
    `${GRAPH_BASE}/${igAccountId}/media_publish`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        creation_id: container.id,
        access_token: accessToken,
      }),
    }
  );
  const result = await publishRes.json();

  if (result.error) {
    throw new Error(`Publish error: ${JSON.stringify(result.error)}`);
  }

  return { mediaId: result.id, containerId: container.id };
}

/**
 * Get insights for a media object
 */
async function getMediaInsights(mediaId, accessToken) {
  const metrics = 'impressions,reach,engagement,saved';
  const res = await fetch(
    `${GRAPH_BASE}/${mediaId}/insights?metric=${metrics}&access_token=${accessToken}`
  );
  const data = await res.json();

  if (data.error) return null;

  const insights = {};
  if (data.data) {
    data.data.forEach(metric => {
      insights[metric.name] = metric.values?.[0]?.value || 0;
    });
  }
  return insights;
}

module.exports = {
  getAuthUrl,
  exchangeCodeForToken,
  getLongLivedToken,
  getIGBusinessAccount,
  publishPost,
  getMediaInsights,
};