← back to Norma Platform

agents/instagram-agent/skills/reel.js

169 lines

/**
 * Instagram Reel Skill
 *
 * Creates an Instagram Reel (short-form video) via the Instagram API with
 * Instagram Login. Container-then-publish flow with media_type=REELS.
 * Video containers are polled until processing finishes before publishing.
 *
 * Runs in simulation mode when IG_USER_ID + IG_ACCESS_TOKEN are not set.
 * All calls go to graph.instagram.com (see skills/_ig-api.js for why).
 */

const ig = require('./_ig-api');
const accounts = require('../accounts');

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

/**
 * Resolve the IG credential pair for a given account id.
 *
 * Delegates to the shared resolver (accounts.resolveSkillAccount), which tries
 * the handle-registry first (35 accounts on the shared META_ACCESS_TOKEN) and
 * falls back to the dw-marketing-reels env-suffix scheme:
 *
 *   account 'velvetwallpaper'  → registry → shared META token   (NEW)
 *   account 'dw'               → IG_USER_ID + IG_ACCESS_TOKEN    (legacy)
 *   account 'phillipe-romano'  → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
 *
 * Returns { account, userId, accessToken } — userId/accessToken are undefined
 * when that account has no token configured (caller returns no-creds-for-account).
 */
function resolveAccount(account) {
  const r = accounts.resolveSkillAccount(account);
  return { account: r.account, userId: r.userId, accessToken: r.accessToken };
}

/**
 * @param {Object} params - Request body
 * @param {string} params.video_url - Public URL of the video file
 * @param {string} [params.caption] - Reel caption text
 * @param {boolean} [params.share_to_feed=true] - Also share to main feed
 * @param {string} [params.cover_url] - Cover image URL (optional)
 * @param {string} [params.pipeline_id] - Pipeline entry ID to link
 * @param {string} [params.account='dw'] - dw-marketing-reels account id; selects which IG token pair to use
 * @returns {Promise<Object>}
 */
module.exports = async function reel(params) {
  const videoUrl = params.video_url || '';
  const caption = params.caption || params.text || '';
  const shareToFeed = params.share_to_feed !== false;

  // Resolve WHICH account's credentials to use (default 'dw' = legacy behavior).
  const resolved = resolveAccount(params.account);
  const account = resolved.account;
  const igUserId = resolved.userId;
  const accessToken = resolved.accessToken;
  const hasCreds = !!(igUserId && accessToken);
  const simulated = !hasCreds;

  console.log(`[${AGENT}] Reel skill invoked — account=${account} simulation=${simulated}`);
  console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
  if (videoUrl) console.log(`[${AGENT}] Video URL: ${videoUrl}`);

  // CORRECTNESS GATE: a NON-'dw' account that has no token of its own must NOT
  // silently fall through to DW's token — posting brand X's reel from DW's
  // account is a real cross-brand leak. Return a clear structured result.
  if (account !== 'dw' && !hasCreds) {
    const suffix = account.toUpperCase().replace(/-/g, '_');
    console.log(`[${AGENT}] No credentials for account '${account}' — refusing to fall through to DW. (need IG_USER_ID_${suffix} + IG_ACCESS_TOKEN_${suffix})`);
    return {
      status: 'no-creds-for-account',
      account,
      note: `account ${account} has no IG_ACCESS_TOKEN_${suffix} configured`,
      posted: false,
      simulated: false,
      platform: PLATFORM,
      video_url: videoUrl || null,
      pipeline_id: params.pipeline_id || null,
      created_at: new Date().toISOString(),
    };
  }

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

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

    return {
      media_id: simulatedMediaId,
      container_id: simulatedContainerId,
      account,
      caption,
      video_url: videoUrl || null,
      share_to_feed: shareToFeed,
      permalink: `https://www.instagram.com/reel/${simulatedMediaId.substring(0, 11)}/`,
      media_type: 'REELS',
      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) ---
  // igUserId/accessToken were resolved above for `account` (creds guaranteed present here).
  if (!videoUrl) throw new Error('video_url is required to post a reel');

  // Step 1: create the reel container
  const containerBody = {
    media_type: 'REELS',
    video_url: videoUrl,
    caption,
    share_to_feed: shareToFeed,
  };
  if (params.cover_url) containerBody.cover_url = params.cover_url;

  // Steps 1+2: create container + wait for processing, with a bounded retry on
  // transient Meta-side processing failures (IG error 2207076 "Media upload has
  // failed" — Meta advises retry; TK-10395 root-cause). Container creation is
  // PRE-publish, so retrying a FAILED container can never cause a double-post.
  const MAX_ATTEMPTS = 3;
  let containerId;
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    containerId = await ig.createContainer(igUserId, accessToken, containerBody);
    console.log(`[${AGENT}] Reel container created: ${containerId} — waiting for processing... (attempt ${attempt}/${MAX_ATTEMPTS})`);
    try {
      // TK-10395 timing budget: cap the per-attempt poll so the WORST case (3 attempts all
      // poll-timing-out) stays under the reels-repo client timeout (240s). 12 tries × 4s = 48s/attempt
      // ⇒ 3×48s + 5s+10s backoff = 159s < 240s, so the client can't time out MID-retry and mislabel a
      // still-in-flight post as 'posted-unverified' (contrarian find). Short 9:16 reels finish well
      // under 48s; a container still not FINISHED at 48s is the stuck/failed mode we WANT to retry.
      await ig.waitForContainer(containerId, accessToken, { tries: 12, delayMs: 4000 });
      break; // FINISHED — safe to publish
    } catch (e) {
      const transient = /2207076|Media upload has failed|did not finish processing/i.test(e.message || '');
      if (attempt < MAX_ATTEMPTS && transient) {
        console.warn(`[${AGENT}] container processing failed (${e.message}) — retrying in ${5 * attempt}s (attempt ${attempt}/${MAX_ATTEMPTS})`);
        await new Promise((r) => setTimeout(r, 5000 * attempt));
        continue;
      }
      throw e;
    }
  }

  // Step 3: publish (only reached once a container reported FINISHED)
  const mediaId = await ig.publishContainer(igUserId, accessToken, containerId);
  const permalink = await ig.permalinkOf(mediaId, accessToken);
  console.log(`[${AGENT}] Reel published (account=${account}): media=${mediaId} ${permalink || ''}`);

  return {
    media_id: mediaId,
    container_id: containerId,
    account,
    caption,
    video_url: videoUrl,
    share_to_feed: shareToFeed,
    permalink,
    media_type: 'REELS',
    simulated: false,
    posted: true,
    platform: PLATFORM,
    pipeline_id: params.pipeline_id || null,
    created_at: new Date().toISOString(),
  };
};