← 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
M .env.exampleM ingest.mjs
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 →