← back to Claude Slack
claude-slack: two-way Slack chat channel (poller + self-heal), send-side ready, gated on channels:history scope + bot invite
eefb03314b588e4947fd1ceca3d1221853d46848 · 2026-06-25 17:30:04 -0700 · Steve
Files touched
A .gitignoreA README.mdA com.steve.claude-slack.plistA health.jsA lib.jsA package.jsonA poller.jsA probe.jsA run.sh
Diff
commit eefb03314b588e4947fd1ceca3d1221853d46848
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 25 17:30:04 2026 -0700
claude-slack: two-way Slack chat channel (poller + self-heal), send-side ready, gated on channels:history scope + bot invite
---
.gitignore | 7 +++
README.md | 41 +++++++++++++
com.steve.claude-slack.plist | 39 +++++++++++++
health.js | 84 +++++++++++++++++++++++++++
lib.js | 135 +++++++++++++++++++++++++++++++++++++++++++
package.json | 12 ++++
poller.js | 103 +++++++++++++++++++++++++++++++++
probe.js | 27 +++++++++
run.sh | 4 ++
9 files changed, 452 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a56c19b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.env
+.env.*
+.cursors.json
+.health.json
+*.log
+node_modules/
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..60122e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# claude-slack
+
+Two-way Slack chat channel for Claude — the Slack sibling of `claude-mail`.
+A launchd job polls configured channels every 10s, runs the local `claude` CLI
+on any message addressed to the bot, and posts the reply **in-thread**.
+
+- **Bot:** `dw_reports_bot` (`U0BC1PWB1SS`) in the Designerwallcoverings workspace.
+- **Channels** (`SLACK_CHANNELS` in `.env`):
+ - `slack-steve` → `#claude-programming` (`C09QUHKM20L`), `requireMention:false` — your 1:1 line; every message gets answered.
+ - `slack-general` → `#general` (`C03T1SQNJBH`), `requireMention:true` — shared team channel; Claude only answers messages that say `claude …` / `@claude`.
+
+## How it works
+`poller.js` (one pass per launchd fire):
+1. `auth.test` → resolve the bot's own user id (loop guard).
+2. For each channel: `conversations.history` since a saved per-channel cursor.
+ On **first sight** of a channel the cursor is anchored to "now" — it never
+ replies to backlog.
+3. `gate()` drops bot/own/system messages, off-allowlist authors, and (in
+ `requireMention` channels) anything not addressed to Claude.
+4. `runClaude()` runs `claude -p` headless with Slack-tuned rails.
+5. Reply posted with `chat.postMessage` + `thread_ts`.
+
+`health.js` pages Steve (CNCP + macOS notification + George email — all
+independent of the Slack token) after 3 consecutive failed passes, and again
+when it recovers.
+
+## Go-live blockers (one-time, Steve)
+The bot token is currently **send-only**. To enable two-way chat:
+1. **Add scopes** at api.slack.com → your app → OAuth & Permissions → Bot Token Scopes:
+ `channels:history` (public), `groups:history` (private), optionally `users:read`.
+2. **Reinstall** the app to the workspace (re-issues the token). If the token
+ changes, re-run the secrets route so `~/.claude.json` + this `.env` update.
+3. **Invite the bot** into each channel: `/invite @dw_reports_bot` in
+ `#claude-programming` and `#general` (reading history requires membership).
+4. Verify: `node --env-file=.env probe.js` — every channel should read "OK".
+5. Go live: `cp com.steve.claude-slack.plist ~/Library/LaunchAgents/ && \
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.claude-slack.plist`
+
+## Commands
+- `npm run probe` — read-only auth + per-channel history/membership check.
+- `npm run poll` — run one pass by hand.
diff --git a/com.steve.claude-slack.plist b/com.steve.claude-slack.plist
new file mode 100644
index 0000000..dde9335
--- /dev/null
+++ b/com.steve.claude-slack.plist
@@ -0,0 +1,39 @@
+<?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.claude-slack</string>
+
+ <key>ProgramArguments</key>
+ <array>
+ <string>/bin/zsh</string>
+ <string>/Users/stevestudio2/Projects/claude-slack/run.sh</string>
+ </array>
+
+ <key>WorkingDirectory</key>
+ <string>/Users/stevestudio2/Projects/claude-slack</string>
+
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/usr/local/bin:/Users/stevestudio2/.npm-global/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+ </dict>
+
+ <!-- Poll every 10s. launchd never starts a 2nd instance of this label while
+ one is still running, so a long claude run just defers the next fire. -->
+ <key>StartInterval</key>
+ <integer>10</integer>
+
+ <key>RunAtLoad</key>
+ <true/>
+
+ <key>KeepAlive</key>
+ <false/>
+
+ <key>StandardOutPath</key>
+ <string>/Users/stevestudio2/Projects/claude-slack/poller.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/stevestudio2/Projects/claude-slack/poller.log</string>
+</dict>
+</plist>
diff --git a/health.js b/health.js
new file mode 100644
index 0000000..01134b1
--- /dev/null
+++ b/health.js
@@ -0,0 +1,84 @@
+'use strict';
+// claude-slack self-heal / alert layer (mirrors claude-mail/health.js).
+// poller calls recordFailure(msg) when a pass throws, recordSuccess() when one
+// completes. We count CONSECUTIVE failures and, past a threshold, page Steve
+// over channels that DO NOT depend on the (possibly-broken) Slack token:
+// CNCP (local HTTP), a native macOS notification, and George/Gmail email.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { execFile } = require('node:child_process');
+
+const STATE = path.join(__dirname, '.health.json');
+const THRESHOLD = Number(process.env.HEALTH_FAIL_THRESHOLD || 3); // consecutive fails before alerting
+const REALERT_MS = Number(process.env.HEALTH_REALERT_MS || 30 * 60 * 1000); // re-page at most every 30 min
+const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333';
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const ALERT_TO = process.env.HEALTH_ALERT_EMAIL || 'steveabramsdesigns@gmail.com';
+
+function read() {
+ try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); }
+ catch { return { consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null }; }
+}
+function write(s) { try { fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); } catch {} }
+const log = (...a) => console.log(new Date().toISOString(), '[health]', ...a);
+
+function macNotify(title, msg) {
+ return new Promise((res) => {
+ const safe = (s) => String(s).replace(/["\\]/g, ' ').slice(0, 240);
+ execFile('osascript', ['-e', `display notification "${safe(msg)}" with title "${safe(title)}" sound name "Basso"`], () => res());
+ });
+}
+async function cncpAlert(title, note) {
+ for (const ep of ['/api/parking-lot', '/api/parkinglot', '/api/wins']) {
+ try {
+ const body = ep.includes('wins')
+ ? { project: 'claude-slack', title, summary: note }
+ : { title, note, source: 'claude-slack-health' };
+ const r = await fetch(CNCP + ep, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ if (r.ok) { log(`CNCP alert posted via ${ep}`); return true; }
+ } catch {}
+ }
+ return false;
+}
+async function georgeAlert(subject, text) {
+ try {
+ const r = await fetch(GEORGE + '/api/send', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from('admin:').toString('base64') },
+ body: JSON.stringify({ account: 'steve-office', to: ALERT_TO, subject, body: `<pre>${text}</pre>` }),
+ });
+ if (r.ok) { log('George alert email sent'); return true; }
+ } catch {}
+ return false;
+}
+
+async function fireAlert(kind, detail) {
+ const title = kind === 'down' ? '🔴 claude-slack poller is FAILING' : '🟢 claude-slack poller recovered';
+ const note = kind === 'down'
+ ? `The claude-slack poller has failed ${detail.count}x in a row since ${detail.since}. Last error: ${detail.error}. The Slack chat channel is NOT reading messages. Check: cd ~/Projects/claude-slack && node --env-file=.env probe.js`
+ : `claude-slack poller is back: it failed ${detail.count}x but a pass just succeeded. The Slack channel is reading again.`;
+ log(`FIRING ${kind} alert: ${note}`);
+ await Promise.all([macNotify(title, note), cncpAlert(title, note), georgeAlert(title, note)]);
+}
+
+async function recordFailure(errMsg) {
+ const s = read();
+ s.consecutiveFailures += 1;
+ s.lastError = String(errMsg || 'unknown');
+ if (!s.firstFailureAt) s.firstFailureAt = new Date().toISOString();
+ const now = Date.now();
+ const due = !s.lastAlertAt || (now - new Date(s.lastAlertAt).getTime()) > REALERT_MS;
+ if (s.consecutiveFailures >= THRESHOLD && due) {
+ s.lastAlertAt = new Date().toISOString();
+ write(s);
+ await fireAlert('down', { count: s.consecutiveFailures, since: s.firstFailureAt, error: s.lastError });
+ } else { write(s); }
+}
+async function recordSuccess() {
+ const s = read();
+ if (s.consecutiveFailures >= THRESHOLD) await fireAlert('recovered', { count: s.consecutiveFailures });
+ if (s.consecutiveFailures !== 0 || s.lastAlertAt) write({ consecutiveFailures: 0, firstFailureAt: null, lastAlertAt: null, lastError: null });
+}
+
+module.exports = { recordFailure, recordSuccess };
diff --git a/lib.js b/lib.js
new file mode 100644
index 0000000..08ac046
--- /dev/null
+++ b/lib.js
@@ -0,0 +1,135 @@
+'use strict';
+// claude-slack shared lib: config, Slack Web API client, message gate, claude runner.
+// Env is loaded by `node --env-file=.env` (Node 22+). No external deps — Node 22's
+// global fetch talks to Slack, and child_process spawns the local `claude` CLI.
+
+const { spawn } = require('node:child_process');
+
+// ---- Config ----------------------------------------------------------------
+// CHANNELS is a JSON array in .env: each entry { key, id, requireMention }.
+// - requireMention:true → Claude only answers messages that address it
+// ("claude …", "@claude", "<@BOT>") — correct for shared/team channels.
+// - requireMention:false → Claude answers every human message in the channel —
+// correct for a 1:1 line. Pair with ALLOWED_SLACK_USERS to keep it private.
+function parseChannels() {
+ try { return JSON.parse(process.env.SLACK_CHANNELS || '[]'); }
+ catch (e) { console.error('SLACK_CHANNELS is not valid JSON:', e.message); return []; }
+}
+
+const cfg = {
+ token: process.env.SLACK_BOT_TOKEN,
+ botUserId: process.env.SLACK_BOT_USER_ID || '', // resolved at runtime via auth.test if empty
+ channels: parseChannels(),
+ // Optional author allowlist (Slack user IDs, comma-separated). Empty = any
+ // human in the workspace may invoke Claude (the private workspace is the
+ // auth boundary). Resolving names→IDs needs the users:read scope.
+ allowedUsers: (process.env.ALLOWED_SLACK_USERS || '')
+ .split(',').map((s) => s.trim()).filter(Boolean),
+ // Regex that marks a message as "addressed to Claude" in requireMention channels.
+ mentionRe: new RegExp(process.env.MENTION_PATTERN || '(^|[^a-z])@?claude\\b', 'i'),
+ maxReply: Number(process.env.SLACK_MAX_REPLY_CHARS || 3500), // Slack hard cap ~4000/block
+ claudeBin: process.env.CLAUDE_BIN || 'claude',
+ workdir: process.env.CLAUDE_WORKDIR || process.env.HOME,
+ permissionMode: process.env.CLAUDE_PERMISSION_MODE || 'acceptEdits',
+ timeoutMs: Number(process.env.CLAUDE_TIMEOUT_MS || 900000),
+};
+
+// ---- Slack Web API client (token-auth, JSON) -------------------------------
+const SLACK = 'https://slack.com/api/';
+
+async function slack(method, params = {}, post = false) {
+ const headers = { Authorization: `Bearer ${cfg.token}` };
+ let url = SLACK + method, opts = { method: post ? 'POST' : 'GET', headers };
+ if (post) {
+ headers['Content-Type'] = 'application/json; charset=utf-8';
+ opts.body = JSON.stringify(params);
+ } else {
+ const q = new URLSearchParams(params).toString();
+ if (q) url += '?' + q;
+ }
+ const r = await fetch(url, opts);
+ const j = await r.json();
+ if (!j.ok) {
+ const err = new Error(`slack ${method} failed: ${j.error}` + (j.needed ? ` (needs ${j.needed})` : ''));
+ err.slackError = j.error; err.needed = j.needed; err.provided = j.provided;
+ throw err;
+ }
+ return j;
+}
+
+const api = {
+ authTest: () => slack('auth.test'),
+ history: (channel, oldest, limit = 50) =>
+ slack('conversations.history', oldest ? { channel, oldest, limit } : { channel, limit }),
+ post: (channel, text, thread_ts) =>
+ slack('chat.postMessage', { channel, text, thread_ts, unfurl_links: false, unfurl_media: false }, true),
+};
+
+// ---- Message gate ----------------------------------------------------------
+// Returns { ok, reason, prompt }. Drops the bot's own/loop messages, system
+// subtypes, off-allowlist authors, and (in requireMention channels) anything
+// not addressed to Claude. `prompt` is the cleaned text handed to claude.
+function gate(msg, channel, botUserId) {
+ if (!msg || typeof msg.ts !== 'string') return { ok: false, reason: 'no ts' };
+ // Skip anything that isn't a plain human message: joins/leaves/edits/bot posts.
+ if (msg.subtype) return { ok: false, reason: `subtype ${msg.subtype}` };
+ if (msg.bot_id) return { ok: false, reason: 'bot message (loop guard)' };
+ if (botUserId && msg.user === botUserId) return { ok: false, reason: 'own message (loop guard)' };
+
+ const text = (msg.text || '').trim();
+ if (!text) return { ok: false, reason: 'empty text' };
+
+ if (cfg.allowedUsers.length && !cfg.allowedUsers.includes(msg.user)) {
+ return { ok: false, reason: `author not allowlisted: ${msg.user}` };
+ }
+
+ let prompt = text;
+ if (channel.requireMention) {
+ if (!cfg.mentionRe.test(text) && !(botUserId && text.includes(`<@${botUserId}>`))) {
+ return { ok: false, reason: 'not addressed to claude' };
+ }
+ // Strip the address token so the prompt is just the actual request.
+ prompt = text
+ .replace(new RegExp(`<@${botUserId}>`, 'g'), '')
+ .replace(/^\s*(hey\s+|hi\s+|ok\s+)?@?claude\b[:,]?\s*/i, '')
+ .trim() || text;
+ }
+ return { ok: true, reason: 'ok', prompt };
+}
+
+// ---- Claude headless runner ------------------------------------------------
+const RAILS = [
+ 'You are answering a Slack message from Steve (or an allowlisted teammate) in the Designer Wallcoverings workspace, as the bot dw_reports_bot.',
+ 'You have full local access to this Mac and its tools, and Steve\'s global CLAUDE.md standing rules apply.',
+ 'AUTONOMY: Do safe, reversible, local, read-only work directly and report the result.',
+ 'GATED: For anything gated — production writes, sending messages to third parties, DNS/domain changes, spending money, deleting/overwriting, customer-facing changes, pushing to remotes — DO NOT execute it. Describe exactly what you would do and ask the person to confirm in a live session.',
+ 'FORMAT: Reply in concise Slack-friendly plain text. Lead with the answer. Use Slack mrkdwn sparingly (*bold*, `code`); avoid long markdown, headers, or big code fences. Keep it short — this is a chat message, not an email.',
+].join(' ');
+
+function runClaude(prompt) {
+ return new Promise((resolve) => {
+ const env = { ...process.env };
+ // Force Max-plan OAuth on the home network (mirror Steve's `claude` shell fn).
+ delete env.ANTHROPIC_API_KEY;
+ delete env.ANTHROPIC_AUTH_TOKEN;
+ const args = [
+ '-p', prompt,
+ '--append-system-prompt', RAILS,
+ '--permission-mode', cfg.permissionMode,
+ '--output-format', 'text',
+ ];
+ const child = spawn(cfg.claudeBin, args, { cwd: cfg.workdir, env });
+ let out = '', err = '';
+ const timer = setTimeout(() => { child.kill('SIGKILL'); }, cfg.timeoutMs);
+ child.stdout.on('data', (d) => { out += d; });
+ child.stderr.on('data', (d) => { err += d; });
+ child.on('close', (code) => {
+ clearTimeout(timer);
+ if (code === 0 && out.trim()) return resolve({ ok: true, text: out.trim() });
+ resolve({ ok: false, text: (out.trim() || err.trim() || `claude exited ${code}`) });
+ });
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, text: `spawn error: ${e.message}` }); });
+ });
+}
+
+module.exports = { cfg, api, gate, runClaude };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f82fa64
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "claude-slack",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Two-way Slack chat channel for Claude — poll configured channels, answer messages addressed to the bot, reply in-thread. Sibling of claude-mail.",
+ "main": "poller.js",
+ "engines": { "node": ">=22" },
+ "scripts": {
+ "probe": "node --env-file=.env probe.js",
+ "poll": "node --env-file=.env poller.js"
+ }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..265d412
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,103 @@
+'use strict';
+// claude-slack poller — one pass: for each configured channel, read messages
+// newer than our saved cursor, run claude on the ones addressed to it, post the
+// reply in-thread, and advance the cursor. Run on an interval by launchd
+// (com.steve.claude-slack). Idempotent: a per-channel `latest ts` cursor means
+// no message is ever handled twice, and on FIRST sight of a channel we only set
+// the cursor to "now" (we never reply to backlog).
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { cfg, api, gate, runClaude } = require('./lib');
+const { recordSuccess, recordFailure } = require('./health');
+
+const CURSORS = path.join(__dirname, '.cursors.json');
+const log = (...a) => console.log(new Date().toISOString(), ...a);
+
+function readCursors() {
+ try { return JSON.parse(fs.readFileSync(CURSORS, 'utf8')); } catch { return {}; }
+}
+function writeCursors(c) {
+ try { fs.writeFileSync(CURSORS, JSON.stringify(c, null, 2)); } catch (e) { log('cursor write failed:', e.message); }
+}
+
+async function processChannel(ch, botUserId, cursors) {
+ const prev = cursors[ch.id];
+ // conversations.history returns newest-first; `oldest` is inclusive, so we
+ // filter strictly greater than the cursor below.
+ const res = await api.history(ch.id, prev, 50);
+ const msgs = (res.messages || []).filter((m) => m.ts && (!prev || Number(m.ts) > Number(prev)));
+
+ if (!prev) {
+ // First time we've seen this channel: anchor the cursor to the latest
+ // message so we answer only what arrives AFTER now — never the backlog.
+ const newest = (res.messages || [])[0]?.ts;
+ if (newest) cursors[ch.id] = newest;
+ log(`[${ch.key}] first run — anchored cursor at ${newest || '(empty channel)'}`);
+ return;
+ }
+ if (msgs.length === 0) { log(`[${ch.key}] no new messages`); return; }
+
+ // Oldest → newest so the conversation is answered in order.
+ msgs.sort((a, b) => Number(a.ts) - Number(b.ts));
+ log(`[${ch.key}] ${msgs.length} new message(s)`);
+
+ for (const msg of msgs) {
+ const d = gate(msg, ch, botUserId);
+ if (!d.ok) {
+ log(`[${ch.key}] skip ts=${msg.ts} user=${msg.user || '?'} reason="${d.reason}"`);
+ cursors[ch.id] = msg.ts; // advance past anything we deliberately ignore
+ writeCursors(cursors);
+ continue;
+ }
+
+ log(`[${ch.key}] ANSWER ts=${msg.ts} user=${msg.user} -> running claude`);
+ const res2 = await runClaude(d.prompt);
+ let reply = res2.ok ? res2.text : `I hit a problem running that locally:\n\n${res2.text}`;
+ if (reply.length > cfg.maxReply) reply = reply.slice(0, cfg.maxReply) + '\n…(truncated)';
+
+ try {
+ // Thread the reply under the original (or its parent thread) so shared
+ // channels stay tidy and the exchange is self-contained.
+ const threadTs = msg.thread_ts || msg.ts;
+ await api.post(ch.id, reply, threadTs);
+ log(`[${ch.key}] REPLIED in thread ${threadTs}`);
+ } catch (e) {
+ // Posting failed (network/scope) — leave the cursor BEFORE this message so
+ // the next pass retries instead of dropping Steve's question on the floor.
+ log(`[${ch.key}] POST FAILED ts=${msg.ts}: ${e.message}`);
+ return;
+ }
+ cursors[ch.id] = msg.ts;
+ writeCursors(cursors);
+ }
+}
+
+async function main() {
+ if (!cfg.token) throw new Error('SLACK_BOT_TOKEN missing');
+ if (!cfg.channels.length) throw new Error('SLACK_CHANNELS is empty');
+
+ // Resolve the bot's own user id once so the loop guard can skip its own posts.
+ let botUserId = cfg.botUserId;
+ if (!botUserId) { try { botUserId = (await api.authTest()).user_id; } catch (e) { log('auth.test failed:', e.message); throw e; } }
+
+ const cursors = readCursors();
+ for (const ch of cfg.channels) {
+ try { await processChannel(ch, botUserId, cursors); }
+ catch (e) {
+ // A per-channel failure (e.g. not invited, missing history scope) must not
+ // sink the other channels. Surface it; the pass still counts as degraded.
+ log(`[${ch.key}] ERROR: ${e.message}`);
+ throw e; // bubble so health records a failure when a scope/auth issue is global
+ }
+ }
+ writeCursors(cursors);
+}
+
+main()
+ .then(async () => { await recordSuccess(); process.exit(0); })
+ .catch(async (e) => {
+ log('FATAL', e.message);
+ try { await recordFailure(e.message); } catch (he) { log('health.recordFailure errored:', he.message); }
+ process.exit(1);
+ });
diff --git a/probe.js b/probe.js
new file mode 100644
index 0000000..c9639aa
--- /dev/null
+++ b/probe.js
@@ -0,0 +1,27 @@
+'use strict';
+// READ-ONLY diagnostic: can the bot authenticate, READ history, and is it a
+// member of each configured channel? Surfaces the exact missing scope / missing
+// invite so the go-live blockers are unambiguous. Run:
+// node --env-file=.env probe.js
+const { cfg, api } = require('./lib');
+
+(async () => {
+ if (!cfg.token) { console.log('SLACK_BOT_TOKEN: MISSING'); process.exit(1); }
+ let botId = '';
+ try { const a = await api.authTest(); botId = a.user_id; console.log(`auth.test: OK — bot @${a.user} (${a.user_id}) team ${a.team}`); }
+ catch (e) { console.log('auth.test: FAIL —', e.message); process.exit(1); }
+
+ if (!cfg.channels.length) { console.log('SLACK_CHANNELS: empty'); process.exit(1); }
+ for (const ch of cfg.channels) {
+ try {
+ const r = await api.history(ch.id, undefined, 1);
+ console.log(`[${ch.key}] ${ch.id} read history: OK (${(r.messages || []).length} msg sampled, requireMention=${!!ch.requireMention})`);
+ } catch (e) {
+ let hint = '';
+ if (e.slackError === 'missing_scope') hint = ` → ADD SCOPE: ${e.needed} (reinstall app)`;
+ else if (e.slackError === 'not_in_channel') hint = ` → INVITE the bot: /invite @bot in that channel`;
+ else if (e.slackError === 'channel_not_found') hint = ' → wrong channel id, or private + bot not a member';
+ console.log(`[${ch.key}] ${ch.id} read history: FAIL — ${e.slackError || e.message}${hint}`);
+ }
+ }
+})();
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..41cc78a
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,4 @@
+#!/bin/zsh
+# launchd entrypoint — one poll pass, env from .env (Node 22 --env-file).
+cd "$(dirname "$0")" || exit 1
+exec /usr/local/bin/node --env-file=.env poller.js
(oldest)
·
back to Claude Slack
·
chore: macstudio3 migration — reconcile from mac2 + repoint 59d33fd →