diff --git a/slab/bin/instagram-mcp.mjs b/slab/bin/instagram-mcp.mjs index 40e580acae..85e73247bb 100755 --- a/slab/bin/instagram-mcp.mjs +++ b/slab/bin/instagram-mcp.mjs @@ -65,13 +65,13 @@ const TOOLS = [ { name: "instagram_quota", description: "Read an account's official 24-hour Instagram content publishing quota.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(accounts), default: "oskiewar" } } } }, { name: "instagram_insights", description: "Read official Reel insights for one media ID.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(accounts), default: "oskiewar" }, mediaId: { type: "string" } }, required: ["mediaId"] } }, { name: "instagram_refresh", description: "Refresh and persist an account's long-lived token in its vault file. SIDE EFFECT: rewrites only the token line. Requires confirm:true.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(accounts), default: "oskiewar" }, confirm: { type: "boolean" } }, required: ["confirm"] } }, - { name: "instagram_post_reel", description: "Publish a verified local MP4 through the official Instagram Graph API: public Spaces upload, container poll, publish, and receipt. SIDE EFFECT: creates a live Instagram Reel. Requires confirm:true.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(accounts), default: "oskiewar" }, videoPath: { type: "string" }, coverPath: { type: "string" }, caption: { type: "string", maxLength: 2200 }, confirm: { type: "boolean" } }, required: ["videoPath", "caption", "confirm"] } }, + { name: "instagram_post_reel", description: "Publish a verified local MP4 through the official Instagram Graph API: public Spaces upload, container poll, publish, and receipt. SIDE EFFECT: creates a live Instagram Reel. Requires confirm:true.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(accounts), default: "oskiewar" }, videoPath: { type: "string" }, coverPath: { type: "string" }, caption: { type: "string", maxLength: 2200 }, trial: { type: "string", enum: ["manual", "auto"], description: "Post as a trial reel (non-followers first). manual = graduate in the app; auto = Instagram graduates it if it performs." }, confirm: { type: "boolean" } }, required: ["videoPath", "caption", "confirm"] } }, { name: "oskiewar_reel_queue", description: "List staged Oskiewar Reel artifacts and their verification state.", inputSchema: { type: "object", properties: {} } }, { name: "oskiewar_reel_render", description: "Render and verify an Oskiewar Reel into tmp/oskiewar-reels/queue. This may take several minutes but does not publish.", inputSchema: { type: "object", properties: { day: { type: "string", description: "YYYY-MM-DD" }, index: { type: "integer", minimum: 0 }, segment: { type: "string" }, seconds: { type: "number", minimum: 8, maximum: 120 }, door: { type: "string", enum: ["survival", "fight"], description: "Force the lava climb or the versus fight; omitted, the slot's seed deals it (the oven's oskiewaroll)." } } } }, { name: "oskiewar_reel_publish", description: "Run the Oskiewar queue publisher. live:false writes/returns the dry-run payload; live:true uploads and posts. SIDE EFFECT when live:true. Requires confirm:true for live publication.", inputSchema: { type: "object", properties: { id: { type: "string" }, live: { type: "boolean", default: false }, confirm: { type: "boolean" } }, required: ["id"] } }, { name: "instagram_reel_app_queue", description: "List staged Reel artifacts for the Whistlegraph or Aesthetic Computer Instagram app.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(reelApps) } }, required: ["account"] } }, { name: "instagram_reel_app_build", description: "Build and verify one deterministic Whistlegraph or Aesthetic Computer Reel slot. Stages locally; never publishes.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(reelApps) }, day: { type: "string", description: "YYYY-MM-DD" }, index: { type: "integer", minimum: 0 }, redo: { type: "boolean", default: false } }, required: ["account"] } }, - { name: "instagram_reel_app_publish", description: "Publish or dry-run one staged Whistlegraph/Aesthetic Reel. live:false writes the payload only; live:true creates a live Instagram Reel and requires confirm:true.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(reelApps) }, id: { type: "string" }, live: { type: "boolean", default: false }, confirm: { type: "boolean" } }, required: ["account", "id"] } }, + { name: "instagram_reel_app_publish", description: "Publish or dry-run one staged Whistlegraph/Aesthetic Reel. live:false writes the payload only; live:true creates a live Instagram Reel and requires confirm:true.", inputSchema: { type: "object", properties: { account: { type: "string", enum: Object.keys(reelApps) }, id: { type: "string" }, live: { type: "boolean", default: false }, trial: { type: "string", enum: ["manual", "auto"], description: "Post as a trial reel (non-followers first). manual = graduate in the app; auto = Instagram graduates it if it performs." }, confirm: { type: "boolean" } }, required: ["account", "id"] } }, ]; async function callTool(name, args = {}) { @@ -88,6 +88,7 @@ async function callTool(name, args = {}) { if (args.confirm !== true) throw new Error("confirm:true is required to publish a live Reel"); const argv = ["--as", as, "post", resolve(args.videoPath), "--caption", args.caption]; if (args.coverPath) argv.push("--cover", resolve(args.coverPath)); + if (args.trial) argv.push("--trial", args.trial); return text(await run(ig, argv)); } if (name === "oskiewar_reel_queue") return text(await run(reel, ["--queue"])); @@ -118,7 +119,7 @@ async function callTool(name, args = {}) { const app = reelApp(args.account); if (args.live && args.confirm !== true) throw new Error("confirm:true is required for live publication"); return text(await run(reelApps[app], ["--publish", args.id, - ...(args.live ? ["--live"] : [])], 600_000)); + ...(args.live ? ["--live"] : []), ...(args.trial ? ["--trial", args.trial] : [])], 600_000)); } throw new Error(`unknown tool ${name}`); } diff --git a/toolchain/instagram/ig.mjs b/toolchain/instagram/ig.mjs index 783f205877..56affd6d85 100644 --- a/toolchain/instagram/ig.mjs +++ b/toolchain/instagram/ig.mjs @@ -23,6 +23,7 @@ // node toolchain/instagram/ig.mjs refresh --all # monthly cron // node toolchain/instagram/ig.mjs --as oskiewar post reel.mp4 \ // [--caption "..."] [--cover cover.jpg] [--audio-name "..."] +// [--trial [manual|auto]] // node toolchain/instagram/ig.mjs --as oskiewar insights // node toolchain/instagram/ig.mjs --as oskiewar snapshot // @@ -303,6 +304,13 @@ async function doPost(creds) { : null; if (collaborators && collaborators.length > 3) die(`Instagram allows at most 3 collaborators`); + // Trial reel: shown to non-followers first. "manual" (the default) waits + // for a human to graduate it in the app; "auto" lets Instagram graduate it + // to followers if it performs (SS_PERFORMANCE). + const TRIAL = { manual: "MANUAL", auto: "SS_PERFORMANCE" }; + const trial = flags.trial ? TRIAL[flags.trial === true ? "manual" : String(flags.trial)] : null; + if (flags.trial && !trial) die(`--trial takes manual or auto`); + // Quota first — a spent budget should stop us before any bytes move. const quota = await fetchQuota(creds); console.log(`▸ quota ${quota.used}/${quota.total} used in the last 24h`); @@ -323,6 +331,7 @@ async function doPost(creds) { if (urls.cover) body.cover_url = urls.cover; if (audioName) body.audio_name = audioName; if (collaborators?.length) body.collaborators = collaborators; + if (trial) body.trial_params = { graduation_strategy: trial }; const created = await call(api(`${creds.igUserId}/media?access_token=${creds.token}`), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -390,6 +399,7 @@ async function doPost(creds) { caption, audioName, collaborators, + trial, file: videoPath, }; const sidecar = videoPath.replace(/\.[^.]+$/, "") + ".instagram.json"; @@ -405,6 +415,7 @@ async function doPost(creds) { source: relativeToRoot(videoPath), audioName, collaborators, + trial, urls: { reel: urls.reel, ...(urls.cover ? { cover: urls.cover } : {}) }, insights: null, }); @@ -607,6 +618,7 @@ commands: refresh refresh the 60-day token, rewrite the vault env file in place refresh --all refresh every provisioned account (monthly cron; no --as needed) post [--caption "..."] [--cover img.jpg] [--audio-name "..."] + [--trial [manual|auto]] trial reel: non-followers first; auto = SS_PERFORMANCE publish a reel: Spaces upload → container → poll → publish insights per-reel metrics (views, reach, skip rate, …; --json for machines) insights --refresh pull metrics for every post in the account's ledger diff --git a/toolchain/instagram/reel-app.mjs b/toolchain/instagram/reel-app.mjs index f733a594d1..3a70c6c2fb 100644 --- a/toolchain/instagram/reel-app.mjs +++ b/toolchain/instagram/reel-app.mjs @@ -195,10 +195,12 @@ function finishRecord(record, dir) { return record; } -async function publish(account, record, live, allowRepeat = false) { +// trial: null, or "manual"/"auto" — see ig.mjs --trial. +async function publish(account, record, live, allowRepeat = false, trial = null) { if (!record.meta?.ok) throw new Error(`${record.id} failed the media gate`); if (!live) { dryRun(record, record.files, { igUserId: `{${account}-ig-user-id}` }); + if (trial) console.log(` → as a trial reel (${trial}): non-followers first`); console.log(" → add --live to publish; nothing left this machine"); return; } @@ -207,16 +209,24 @@ async function publish(account, record, live, allowRepeat = false) { if (prior && !allowRepeat) throw new Error(`${record.sourceId} already published as ${prior.mediaId || prior.id}; pass --allow-repeat deliberately`); await runAsync(process.execPath, [IG, "--as", account, "post", record.files.reel, - "--caption", record.caption, "--cover", record.files.cover]); + "--caption", record.caption, "--cover", record.files.cover, + ...(trial ? ["--trial", trial] : [])]); const receipt = readJson(record.files.reel.replace(/\.[^.]+$/, ".instagram.json"), null); if (!receipt?.mediaId) throw new Error("Instagram returned without a publish receipt"); annotateLedger(account, { id: record.id, sourceId: record.sourceId, day: record.day, index: record.index, slot: record.slot, segment: record.segment, publishedAt: receipt.publishedAt, mediaId: receipt.mediaId, - containerId: receipt.containerId, insights: null }); + containerId: receipt.containerId, trial: receipt.trial || null, insights: null }); console.log(`📤 ${record.id} → ${receipt.mediaId}`); } +function trialFlag(flags) { + if (!flags.trial) return null; + const trial = flags.trial === true ? "manual" : String(flags.trial); + if (!["manual", "auto"].includes(trial)) throw new Error("--trial takes manual or auto"); + return trial; +} + export async function runReelApp(account, argv = process.argv.slice(2)) { const config = REEL_APPS[account]; if (!config) throw new Error(`unknown Reel app ${account}`); @@ -239,7 +249,8 @@ export async function runReelApp(account, argv = process.argv.slice(2)) { if (flags.publish) { const record = readJson(join(staging, String(flags.publish), "reel.json"), null); if (!record) throw new Error(`staged reel not found: ${flags.publish}`); - await publish(account, record, flags.live === true, flags["allow-repeat"] === true); return; + await publish(account, record, flags.live === true, flags["allow-repeat"] === true, + trialFlag(flags)); return; } const day = String(flags.day || new Date().toISOString().slice(0, 10)); @@ -264,7 +275,7 @@ export async function runReelApp(account, argv = process.argv.slice(2)) { if (!record.meta?.ok) throw new Error(`${record.id} held: media gate failed`); if (!autoEnabled(config)) throw new Error(`${config.prefix}_IG_AUTO=1 is not set in ${envFile(account)}; staged but not posted`); - await publish(account, record, true, flags["allow-repeat"] === true); + await publish(account, record, true, flags["allow-repeat"] === true, trialFlag(flags)); } else { console.log(`review ${record.files.reel}`); console.log(`then: node toolchain/instagram/${account === "aesthetic" ? "aesthetic-ig" : "whistlegraph-ig"}.mjs --publish ${record.id}`);