← back to Norma Platform
IG fleet interaction: phased reshare-fleet + comment auto-reply (positivity-filtered, spam+self skip) + reply-fleet + staged launchd plists (phase 1: 5 accounts)
ed1358fb0954d37e81880d0c3d88c5ab24f52365 · 2026-08-14 14:21:45 -0700 · Steve
Files touched
A agents/instagram-agent/comment-reply.jsA agents/instagram-agent/deploy/com.steve.dw-ig-reply-fleet.plistA agents/instagram-agent/deploy/com.steve.dw-ig-reshare-fleet.plistA agents/instagram-agent/reply-fleet.jsA agents/instagram-agent/reshare-fleet.js
Diff
commit ed1358fb0954d37e81880d0c3d88c5ab24f52365
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 14 14:21:45 2026 -0700
IG fleet interaction: phased reshare-fleet + comment auto-reply (positivity-filtered, spam+self skip) + reply-fleet + staged launchd plists (phase 1: 5 accounts)
---
agents/instagram-agent/comment-reply.js | 68 ++++++++++++++++++++++
.../deploy/com.steve.dw-ig-reply-fleet.plist | 17 ++++++
.../deploy/com.steve.dw-ig-reshare-fleet.plist | 17 ++++++
agents/instagram-agent/reply-fleet.js | 16 +++++
agents/instagram-agent/reshare-fleet.js | 26 +++++++++
5 files changed, 144 insertions(+)
diff --git a/agents/instagram-agent/comment-reply.js b/agents/instagram-agent/comment-reply.js
new file mode 100644
index 0000000..1cd962c
--- /dev/null
+++ b/agents/instagram-agent/comment-reply.js
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/**
+ * comment-reply.js — the ONE API-allowed "interaction": warmly reply to comments on OUR OWN
+ * posts (Meta has no endpoint to like/comment on other accounts' media). Builds vendor/designer
+ * relationships off the reshare-credit strategy — a vendor comments on their feature, we reply.
+ *
+ * node comment-reply.js <account> # DRAFT: show comments + drafted replies, send nothing
+ * node comment-reply.js <account> --go # LIVE: send replies
+ *
+ * SAFETY — only auto-replies to clearly POSITIVE engagement. Skips questions (need a real
+ * answer), anything negative, and spam — those are logged as "needs a human" instead of a
+ * canned reply. Dedups via data/comment-replies-<account>.jsonl so we never double-reply.
+ */
+const accounts = require('./accounts');
+const fs = require('fs');
+const path = require('path');
+
+const args = process.argv.slice(2);
+const ACCOUNT = (args.find((a) => !a.startsWith('--')) || 'fabric_fridays').replace(/^@/, '');
+const GO = args.includes('--go');
+const acct = accounts.resolve(ACCOUNT);
+if (!acct) { console.error(`Unknown account "${ACCOUNT}".`); process.exit(1); }
+const { ig_user_id: ID, username: UNAME, access_token: T, graph_host: H, graph_version: V } = acct;
+
+const REPLIES = [
+ 'Thank you so much! 🙌', 'So glad you love it too! ✨', 'Right?? Obsessed. 🧵',
+ 'Appreciate you! 💛', 'Thank you — made our day. 🙏', 'Couldn\'t agree more! 👏',
+ 'You get it. 😍 Thanks for the love!', 'Thank you! Swoon-worthy, right? ✨',
+];
+// Skip anything that needs a real human: a question, a negative, or spam.
+const SKIP = /\?|price|cost|how much|where.*(buy|get)|available|shipping|dm|link|follow me|check my|send me|send us|send this|repost|can we|collab|hate|ugly|awful|worst|scam|fake|spam|http/i;
+const NEG = /hate|ugly|awful|worst|scam|fake|trash|disgusting/i;
+
+const g = async (url) => (await fetch(url)).json();
+
+(async () => {
+ const LOG = path.join(__dirname, 'data', `comment-replies-${ACCOUNT}.jsonl`);
+ const replied = new Set(fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l).comment_id; } catch { return null; } }) : []);
+
+ const media2 = await g(`${H}/${V}/${ID}/media?fields=id,permalink,comments_count&limit=20&access_token=${encodeURIComponent(T)}`);
+ const withComments = (media2.data || []).filter((m) => (m.comments_count || 0) > 0);
+ console.log(`@${ACCOUNT} — ${GO ? 'LIVE' : 'DRAFT'} — ${withComments.length} post(s) with comments\n`);
+
+ let sent = 0, skipped = 0; const needsHuman = [];
+ for (const m of (media2.data || [])) {
+ if (!(m.comments_count > 0)) continue;
+ const cj = await g(`${H}/${V}/${m.id}/comments?fields=id,text,username,timestamp&access_token=${encodeURIComponent(T)}`);
+ for (const c of (cj.data || [])) {
+ if (replied.has(c.id)) continue;
+ const cu = (c.username || '').toLowerCase();
+ if (cu === (UNAME || '').toLowerCase() || cu === ACCOUNT.toLowerCase()) continue; // never reply to ourselves
+ const text = c.text || '';
+ if (SKIP.test(text)) { needsHuman.push({ post: m.permalink, from: c.username, text, why: NEG.test(text) ? 'negative' : 'question/spam' }); skipped++; continue; }
+ const reply = REPLIES[(c.id.charCodeAt(c.id.length - 1) + sent) % REPLIES.length];
+ console.log(`↳ @${c.username}: "${text.slice(0, 60)}"\n reply: "${reply}" (${m.permalink})`);
+ if (GO) {
+ try {
+ const r = await (await fetch(`${H}/${V}/${c.id}/replies`, { method: 'POST', body: new URLSearchParams({ message: reply, access_token: T }) })).json();
+ if (r.id) { fs.appendFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), comment_id: c.id, from: c.username, reply, reply_id: r.id }) + '\n'); console.log(' ✓ sent'); sent++; }
+ else console.log(' ✗ ' + JSON.stringify(r).slice(0, 120));
+ } catch (e) { console.log(' ✗ ' + e.message); }
+ await new Promise((res) => setTimeout(res, 3000));
+ } else sent++;
+ }
+ }
+ if (needsHuman.length) { console.log(`\n⚠ ${needsHuman.length} comment(s) need a HUMAN (question/negative/spam) — not auto-replied:`); needsHuman.forEach((n) => console.log(` @${n.from} [${n.why}]: "${n.text.slice(0, 70)}" ${n.post}`)); }
+ console.log(`\n@${ACCOUNT}: ${GO ? 'sent' : 'would send'} ${sent} · skipped ${skipped}.`);
+})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
diff --git a/agents/instagram-agent/deploy/com.steve.dw-ig-reply-fleet.plist b/agents/instagram-agent/deploy/com.steve.dw-ig-reply-fleet.plist
new file mode 100644
index 0000000..a27d688
--- /dev/null
+++ b/agents/instagram-agent/deploy/com.steve.dw-ig-reply-fleet.plist
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+ <key>Label</key><string>com.steve.dw-ig-reply-fleet</string>
+ <key>ProgramArguments</key><array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/Norma/agents/instagram-agent/reply-fleet.js</string>
+ <string>--go</string></array>
+ <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/Norma/agents/instagram-agent</string>
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string></dict>
+ <key>StartCalendarInterval</key><array>
+ <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>
+ <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>30</integer></dict>
+ <dict><key>Hour</key><integer>17</integer><key>Minute</key><integer>30</integer></dict></array>
+ <key>StandardOutPath</key><string>/tmp/dw-ig-reply-fleet.log</string>
+ <key>StandardErrorPath</key><string>/tmp/dw-ig-reply-fleet.err</string>
+</dict></plist>
diff --git a/agents/instagram-agent/deploy/com.steve.dw-ig-reshare-fleet.plist b/agents/instagram-agent/deploy/com.steve.dw-ig-reshare-fleet.plist
new file mode 100644
index 0000000..4e7efdc
--- /dev/null
+++ b/agents/instagram-agent/deploy/com.steve.dw-ig-reshare-fleet.plist
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+ <key>Label</key><string>com.steve.dw-ig-reshare-fleet</string>
+ <key>ProgramArguments</key><array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/Norma/agents/instagram-agent/reshare-fleet.js</string>
+ <string>--go</string></array>
+ <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/Norma/agents/instagram-agent</string>
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string></dict>
+ <key>StartCalendarInterval</key><array>
+ <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>
+ <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>30</integer></dict>
+ <dict><key>Hour</key><integer>17</integer><key>Minute</key><integer>30</integer></dict></array>
+ <key>StandardOutPath</key><string>/tmp/dw-ig-reshare-fleet.log</string>
+ <key>StandardErrorPath</key><string>/tmp/dw-ig-reshare-fleet.err</string>
+</dict></plist>
diff --git a/agents/instagram-agent/reply-fleet.js b/agents/instagram-agent/reply-fleet.js
new file mode 100644
index 0000000..c3442b4
--- /dev/null
+++ b/agents/instagram-agent/reply-fleet.js
@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+// reply-fleet.js — run comment auto-reply across the current phase's accounts, paced.
+const { execFileSync } = require('child_process');
+const path = require('path'); const fs = require('fs');
+const GO = process.argv.includes('--go');
+const { phase, accounts } = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'reshare-phase.json'), 'utf8'));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+(async () => {
+ console.log(`Reply fleet — phase ${phase} — ${accounts.length} accounts — ${GO ? 'LIVE' : 'DRY'}`);
+ for (let i = 0; i < accounts.length; i++) {
+ try { const out = execFileSync(process.execPath, [path.join(__dirname, 'comment-reply.js'), accounts[i], ...(GO ? ['--go'] : [])], { encoding: 'utf8' });
+ console.log(out.trim().split('\n').filter((l) => /sent|would send|needs a HUMAN|↳/.test(l)).slice(0, 4).join('\n')); }
+ catch (e) { console.log(`@${accounts[i]}: ${String(e.message).split('\n')[0]}`); }
+ if (GO && i < accounts.length - 1) await sleep(60000);
+ }
+})();
diff --git a/agents/instagram-agent/reshare-fleet.js b/agents/instagram-agent/reshare-fleet.js
new file mode 100644
index 0000000..982419b
--- /dev/null
+++ b/agents/instagram-agent/reshare-fleet.js
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+/**
+ * reshare-fleet.js — run the per-account reshare across the CURRENT PHASE's accounts, paced.
+ * Phase list in data/reshare-phase.json (grow it as accounts prove safe). Each account is
+ * capped at 6/day by hashtag-reshare.js itself; this just cycles the phase, spaced ~90s apart
+ * so a fleet run never bursts (velocity safety).
+ * node reshare-fleet.js # DRY (each account dry)
+ * node reshare-fleet.js --go # LIVE
+ */
+const { execFileSync } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+const GO = process.argv.includes('--go');
+const { phase, accounts } = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'reshare-phase.json'), 'utf8'));
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+(async () => {
+ console.log(`Reshare fleet — phase ${phase} — ${accounts.length} accounts — ${GO ? 'LIVE' : 'DRY'}`);
+ for (let i = 0; i < accounts.length; i++) {
+ const a = accounts[i];
+ try {
+ const out = execFileSync(process.execPath, [path.join(__dirname, 'hashtag-reshare.js'), a, '--n', '2', ...(GO ? ['--go'] : [])], { encoding: 'utf8' });
+ console.log(out.trim().split('\n').filter((l) => /reshared|pick|✓|✗|cap|Skip/.test(l)).join('\n'));
+ } catch (e) { console.log(`@${a}: ${String(e.message).split('\n')[0]}`); }
+ if (GO && i < accounts.length - 1) await sleep(90000); // 90s between accounts
+ }
+})();
← 1079fad IG: generic per-account hashtag-reshare engine (all 32 niche
·
back to Norma Platform
·
IG: robust 48h Phase-1 check reminder (launchd one-shot Aug 86d4ac6 →