← back to Norma Platform

agents/instagram-agent/skills/post.js

125 lines

/**
 * Instagram Post Skill
 *
 * Creates an Instagram feed post via Meta Graph API (Content Publishing API).
 * Two-step flow: create media container, then publish it.
 * Runs in simulation mode when IG_ACCESS_TOKEN is not set.
 *
 * Real endpoints:
 *   Step 1 — Create container:
 *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media
 *     Params: image_url, caption, access_token
 *
 *   Step 2 — Publish:
 *     POST https://graph.facebook.com/v19.0/{ig_user_id}/media_publish
 *     Params: creation_id, access_token
 */

const { loadCredentials } = require('../../shared/credentials');
const ig = require('./_ig-api');
const accounts = require('../accounts');

const AGENT = 'instagram-agent';
const PLATFORM = 'instagram';

/**
 * Resolve credentials. If `account` is given (a handle / page name / ig id),
 * use the multi-account registry + shared META_ACCESS_TOKEN; otherwise fall
 * back to the legacy single-account creds (DB / env).
 */
async function getCredentials(account) {
  if (account) {
    const a = accounts.resolve(account);
    if (!a) throw new Error(`Unknown Instagram account "${account}" (run build-registry.js / see accounts.json)`);
    return { has: a.has_token, userId: a.ig_user_id, accessToken: a.access_token, handle: a.handle };
  }
  const creds = await loadCredentials('instagram');
  return {
    has: !!(creds?.IG_USER_ID && creds?.IG_ACCESS_TOKEN),
    userId: creds?.IG_USER_ID || '',
    accessToken: creds?.IG_ACCESS_TOKEN || '',
  };
}

/**
 * @param {Object} params - Request body
 * @param {string} params.image_url - Public URL of the image to post
 * @param {string} [params.caption] - Post caption text
 * @param {string} [params.petition_url] - Petition URL to include in caption
 * @param {string} [params.pipeline_id] - Pipeline entry ID to link
 * @returns {Promise<Object>}
 */
module.exports = async function post(params) {
  const imageUrl = params.image_url || '';
  const caption = params.caption || params.text || '';
  const { has: hasCreds, userId, accessToken, handle } = await getCredentials(params.account);
  const simulated = !hasCreds;

  console.log(`[${AGENT}] Post skill invoked — account=${handle || params.account || 'default'} simulation=${simulated}`);
  console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
  if (imageUrl) console.log(`[${AGENT}] Image URL: ${imageUrl}`);

  if (simulated) {
    // --- Simulation mode ---
    const simulatedContainerId = `ig_container_${Date.now()}`;
    const simulatedMediaId = `ig_media_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;

    console.log(`[${AGENT}] SIMULATED post created: container=${simulatedContainerId}, media=${simulatedMediaId}`);

    return {
      media_id: simulatedMediaId,
      container_id: simulatedContainerId,
      caption,
      image_url: imageUrl || null,
      permalink: `https://www.instagram.com/p/${simulatedMediaId.substring(0, 11)}/`,
      media_type: 'IMAGE',
      simulated: true,
      posted: false,
      platform: PLATFORM,
      pipeline_id: params.pipeline_id || null,
      created_at: new Date().toISOString(),
    };
  }

  // --- Real API call (Instagram API with Instagram Login) ---
  if (!imageUrl) throw new Error('image_url is required to post to the feed');

  // Step 1: create the image container
  const containerId = await ig.createContainer(userId, accessToken, {
    image_url: imageUrl,
    caption,
  });
  console.log(`[${AGENT}] Post container created: ${containerId}`);

  // Step 2: wait for the container to finish, THEN publish. Image containers
  // are USUALLY ready immediately, but Meta occasionally hasn't finished
  // processing when publish is called and returns "Media ID is not available".
  // Polling status_code -> FINISHED first fixes that race. Publishing only ever
  // fires once, after readiness, so this can never cause a double-post.
  try {
    await ig.waitForContainer(containerId, accessToken, { tries: 12, delayMs: 2000 });
  } catch (e) {
    // A real processing ERROR is surfaced clearly (mirrors the reel path); a
    // bare timeout still falls through to one publish attempt as a last resort.
    if (/processing error/i.test(e.message)) throw e;
    console.log(`[${AGENT}] container wait note: ${e.message} — attempting publish anyway`);
  }
  const mediaId = await ig.publishContainer(userId, accessToken, containerId);
  const permalink = await ig.permalinkOf(mediaId, accessToken);
  console.log(`[${AGENT}] Post published: media=${mediaId} ${permalink || ''}`);

  return {
    media_id: mediaId,
    container_id: containerId,
    caption,
    image_url: imageUrl,
    permalink,
    media_type: 'IMAGE',
    simulated: false,
    posted: true,
    platform: PLATFORM,
    pipeline_id: params.pipeline_id || null,
    created_at: new Date().toISOString(),
  };
};