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