[object Object]

← back to Norma

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

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  ·  IG: robust 48h Phase-1 check reminder (launchd one-shot Aug 86d4ac6 →