← back to Norma

agents/instagram-agent/skills/reel.js

155 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 AGENT = 'instagram-agent';
const PLATFORM = 'instagram';

/**
 * Resolve the IG credential pair for a given account id.
 *
 * The dw-marketing-reels publisher (scripts/publish-social.mjs) tags every
 * reel with an `account` id ('dw', 'phillipe-romano', …) and names its own
 * per-account auth header env as IG_AGENT_AUTH_<ID> where
 * <ID> = account.toUpperCase().replace(/-/g, '_'). We MIRROR that exact
 * naming here for the IG token pair so the two sides agree:
 *
 *   account 'dw'              → IG_USER_ID           + IG_ACCESS_TOKEN  (existing)
 *   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 acct = (account || 'dw').toString().trim() || 'dw';
  if (acct === 'dw') {
    return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN };
  }
  const suffix = acct.toUpperCase().replace(/-/g, '_');
  return {
    account: acct,
    userId: process.env[`IG_USER_ID_${suffix}`],
    accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
  };
}

/**
 * @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;

  const containerId = await ig.createContainer(igUserId, accessToken, containerBody);
  console.log(`[${AGENT}] Reel container created: ${containerId} — waiting for processing...`);

  // Step 2: video must finish processing before it can be published
  await ig.waitForContainer(containerId, accessToken);

  // Step 3: publish
  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(),
  };
};