← back to Norma
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(),
};
};