[object Object]

← back to Slack To Steve

slack-to-steve: multi-channel poller — watch both #claude-chat and #claude-to-steve

0bd337e159053f0a382071fef8ef1c2e6ed1bc44 · 2026-07-13 00:30:11 -0700 · Steve Abrams

Files touched

Diff

commit 0bd337e159053f0a382071fef8ef1c2e6ed1bc44
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 00:30:11 2026 -0700

    slack-to-steve: multi-channel poller — watch both #claude-chat and #claude-to-steve
---
 .env.example |  1 +
 ingest.mjs   | 83 ++++++++++++++++++++++++++++++------------------------------
 2 files changed, 42 insertions(+), 42 deletions(-)

diff --git a/.env.example b/.env.example
index 3ecca1e..ef240b8 100644
--- a/.env.example
+++ b/.env.example
@@ -3,6 +3,7 @@
 SLACK_BOT_TOKEN=xoxb-...
 # #claude-to-steve channel ID (starts with C...). Get it: right-click the channel
 # in Slack → View channel details → bottom, or run: node whoami.mjs
+SLACK_CHANNEL_IDS=C0BGQ2QLR35,C09SW7VQ0RK   # comma-separated; watches all of them
 SLACK_CHANNEL_ID=
 # Steve's Slack user ID (starts with U...). Sender allowlist — only his messages act.
 STEVE_USER_ID=
diff --git a/ingest.mjs b/ingest.mjs
index 40343e7..c74123f 100644
--- a/ingest.mjs
+++ b/ingest.mjs
@@ -1,17 +1,17 @@
-// ingest.mjs — the #claude-to-steve two-way poller (george-reply-ingest for Slack).
+// ingest.mjs — the two-way poller (george-reply-ingest for Slack).
 //
-// Reads NEW messages Steve posts in #claude-to-steve, verifies the sender,
-// posts a threaded acknowledgement, and QUEUES the instruction to inbox.jsonl.
+// Watches one OR MORE channels (SLACK_CHANNEL_IDS, comma-separated). For each,
+// reads NEW messages Steve posts, verifies the sender, posts a threaded
+// acknowledgement, and QUEUES the instruction to inbox.jsonl.
 //
 // SAFETY RAILS (match george-reply-ingest):
-//   • It does NOT autonomously execute instructions by default. Auto-execute via
-//     `claude -p` is the ARMED step — enabled only when AUTO_EXECUTE=1 AND a live
-//     session/operator has approved it. Until then this captures + acks + queues,
-//     so a human actions the queue. Gated/irreversible asks ALWAYS stop for approval.
-//   • Sender allowlist: only messages from STEVE_USER_ID are processed.
-//   • Cursor (last-ts) is persisted so a message is never processed twice.
+//   • Does NOT autonomously execute by default. Auto-execute via `claude -p` is the
+//     ARMED step — only when AUTO_EXECUTE=1 AND Steve has approved it. Until then it
+//     captures + acks + queues; a human actions the queue.
+//   • Sender allowlist: only STEVE_USER_ID messages are processed.
+//   • Per-channel cursor (data/last-ts-<cid>.txt) so a message is never processed twice.
 //
-// Needs scopes: channels:history (read) + chat:write (reply). $0 — Slack Web API only.
+// Scopes: channels:history (public) + groups:history (private) + chat:write. $0.
 import fs from 'fs';
 import path from 'path';
 import { execFile } from 'child_process';
@@ -19,11 +19,11 @@ import { execFile } from 'child_process';
 const DIR = process.cwd();
 const ENV = loadEnv(path.join(DIR, '.env'));
 const TOKEN = ENV.SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;
-const CHANNEL = ENV.SLACK_CHANNEL_ID || process.env.SLACK_CHANNEL_ID;
+const CHANNELS = (ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || process.env.SLACK_CHANNEL_IDS || '')
+  .split(',').map(s => s.trim()).filter(Boolean);
 const STEVE = ENV.STEVE_USER_ID || process.env.STEVE_USER_ID;
 const AUTO_EXECUTE = (ENV.AUTO_EXECUTE || process.env.AUTO_EXECUTE || '0') === '1';
 const CLAUDE_CMD = ENV.CLAUDE_CMD || process.env.CLAUDE_CMD || 'claude';
-const CURSOR = path.join(DIR, 'data', 'last-ts.txt');
 const INBOX = path.join(DIR, 'data', 'inbox.jsonl');
 
 function loadEnv(p) {
@@ -32,54 +32,53 @@ function loadEnv(p) {
   return o;
 }
 async function slack(method, params) {
-  const body = new URLSearchParams(params);
   const r = await fetch('https://slack.com/api/' + method, {
     method: 'POST',
     headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/x-www-form-urlencoded' },
-    body,
+    body: new URLSearchParams(params),
   });
   return r.json();
 }
 const log = m => console.log(new Date().toISOString() + ' ' + m);
+const clip = s => { s = String(s || '').trim(); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(no output)'); };
+function runClaude(prompt) {
+  return new Promise((resolve, reject) =>
+    execFile(CLAUDE_CMD, ['-p', prompt], { timeout: 240000, maxBuffer: 10 * 1024 * 1024 }, (err, out, se) =>
+      err ? reject(new Error((se || err.message).slice(0, 400))) : resolve(out)));
+}
 
-(async () => {
-  if (!TOKEN || !CHANNEL || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_CHANNEL_ID / STEVE_USER_ID) — see .env.example'); process.exit(1); }
-  fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
-  const oldest = fs.existsSync(CURSOR) ? fs.readFileSync(CURSOR, 'utf8').trim() : '0';
-
-  const hist = await slack('conversations.history', { channel: CHANNEL, oldest: oldest || '0', limit: '50', inclusive: 'false' });
-  if (!hist.ok) { log('history error: ' + hist.error + (hist.error === 'missing_scope' ? ' (run preflight — channels:history not granted yet)' : '')); process.exit(1); }
+async function processChannel(cid) {
+  const cursorFile = path.join(DIR, 'data', `last-ts-${cid}.txt`);
+  const oldest = fs.existsSync(cursorFile) ? fs.readFileSync(cursorFile, 'utf8').trim() : '0';
+  const hist = await slack('conversations.history', { channel: cid, oldest: oldest || '0', limit: '50', inclusive: 'false' });
+  if (!hist.ok) { log(`[${cid}] history error: ${hist.error}`); return 0; }
 
-  // Slack returns newest-first; process oldest-first so the cursor advances monotonically.
   const msgs = (hist.messages || []).filter(m => m.type === 'message' && !m.subtype).reverse();
-  let newest = oldest;
-  let acted = 0;
+  let newest = oldest, acted = 0;
   for (const m of msgs) {
     if (parseFloat(m.ts) <= parseFloat(oldest || '0')) continue;
     newest = m.ts;
-    if (m.user !== STEVE) continue;                       // sender allowlist
+    if (m.user !== STEVE) continue;
     const text = (m.text || '').trim();
     if (!text) continue;
-
-    fs.appendFileSync(INBOX, JSON.stringify({ ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), executed: false }) + '\n');
+    fs.appendFileSync(INBOX, JSON.stringify({ channel: cid, ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), executed: false }) + '\n');
     acted++;
-
     if (AUTO_EXECUTE) {
-      await slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '⏳ On it — working on this now.' });
-      runClaude(text).then(out => slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: clip(out) }))
-                     .catch(e => slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '⚠️ ' + e.message }));
+      await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⏳ On it — working on this now.' });
+      runClaude(text).then(o => slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: clip(o) }))
+                     .catch(e => slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '⚠️ ' + e.message }));
     } else {
-      await slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '📥 Got it — queued for Claude. (auto-execute is off; a session will action this.)' });
+      await slack('chat.postMessage', { channel: cid, thread_ts: m.ts, text: '📥 Got it — queued for Claude. (auto-execute is off; a session will action this.)' });
     }
   }
-  if (parseFloat(newest) > parseFloat(oldest || '0')) fs.writeFileSync(CURSOR, newest);
-  log(`processed ${acted} new message(s) from Steve; cursor=${newest}`);
-})();
-
-function clip(s) { s = String(s || '').trim(); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(no output)'); }
-function runClaude(prompt) {
-  return new Promise((resolve, reject) => {
-    execFile(CLAUDE_CMD, ['-p', prompt], { timeout: 240000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) =>
-      err ? reject(new Error((stderr || err.message).slice(0, 400))) : resolve(stdout));
-  });
+  if (parseFloat(newest) > parseFloat(oldest || '0')) fs.writeFileSync(cursorFile, newest);
+  return acted;
 }
+
+(async () => {
+  if (!TOKEN || !CHANNELS.length || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_CHANNEL_IDS / STEVE_USER_ID) — see .env.example'); process.exit(1); }
+  fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
+  let total = 0;
+  for (const cid of CHANNELS) total += await processChannel(cid);
+  log(`processed ${total} new message(s) from Steve across ${CHANNELS.length} channel(s): ${CHANNELS.join(', ')}`);
+})();

← de634c1 slack-to-steve: pre-build two-way #claude-to-steve poller (D  ·  back to Slack To Steve  ·  auto-save: 2026-07-13T00:51:43 (1 files) — exports/ 27adc1d →