← back to Slack To Steve
slack bridge: #general call mode (@mention/'claude'), staff no-tools path, Sonnet for speed, Slack bold, event logging
2d314941cd5d1e8231e02e1870ca833d7ca8724e · 2026-09-24 14:34:14 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 2d314941cd5d1e8231e02e1870ca833d7ca8724e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 14:34:14 2026 -0700
slack bridge: #general call mode (@mention/'claude'), staff no-tools path, Sonnet for speed, Slack bold, event logging
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
socket.mjs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 57 insertions(+), 5 deletions(-)
diff --git a/socket.mjs b/socket.mjs
index 7f708b2..76a29ea 100644
--- a/socket.mjs
+++ b/socket.mjs
@@ -17,6 +17,14 @@ const AUTO_EXECUTE = (ENV.AUTO_EXECUTE || '0') === '1';
const ARMED_CHANNELS = (ENV.ARMED_CHANNELS || '').split(',').map(s => s.trim()).filter(Boolean);
const armedFor = cid => AUTO_EXECUTE && (ARMED_CHANNELS.length === 0 || ARMED_CHANNELS.includes(cid));
const CLAUDE_CMD = ENV.CLAUDE_CMD || '/Users/macstudio3/.local/bin/claude';
+// CALL channels (e.g. #general): Claude reads the conversation but only answers when CALLED
+// (@mention of the bot, or a message starting "claude"). Anyone may call. Steve's calls get
+// full tools under RAILS; everyone else gets an answer-only Claude with NO tools, so staff
+// can never reach files/secrets/actions on Steve's machine through the bridge.
+const CALL_CHANNELS = new Set((ENV.CALL_CHANNEL_IDS || '').split(',').map(s => s.trim()).filter(Boolean));
+const CONTEXT_MSGS = Number(ENV.CALL_CONTEXT_MSGS || 30);
+let BOT_USER = ''; // resolved via auth.test at startup
+const isCall = t => (BOT_USER && t.includes(`<@${BOT_USER}>`)) || /^claude\b/i.test(t);
const INBOX = path.join(DIR, 'data', 'inbox.jsonl');
const SEEN = new Set(); // dedupe redelivered events by ts
@@ -32,18 +40,61 @@ ${msg}`;
function loadEnv(p) { const o = {}; try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {} return o; }
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 || '(done — no output)'); };
+const MODEL = ENV.CLAUDE_MODEL || 'sonnet'; // faster than the Opus default for Slack round-trips
+const clip = s => { s = String(s || '').trim().replace(/\*\*(.+?)\*\*/g, '*$1*'); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(done — no output)'); };
async function slack(method, params, tok = BOT) {
const r = await fetch('https://slack.com/api/' + method, { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params) });
return r.json();
}
function runClaude(prompt) {
- return new Promise((resolve, reject) => execFile(CLAUDE_CMD, ['-p', RAILS(prompt), '--dangerously-skip-permissions'],
+ return new Promise((resolve, reject) => execFile(CLAUDE_CMD, ['-p', RAILS(prompt), '--model', MODEL, '--dangerously-skip-permissions'],
{ timeout: 600000, maxBuffer: 20 * 1024 * 1024, cwd: DIR }, (e, out, se) => e ? reject(new Error((se || e.message).slice(0, 500))) : resolve(out)));
}
+const STAFF_PROMPT = (ctx, caller, msg) => `You are Claude, answering in the Designer Wallcoverings team Slack channel because a team member (<@${caller}>) called you. You have NO tools — answer only from the conversation below and general knowledge. Never claim to have looked anything up or taken an action. If the request needs a system lookup or an action (orders, stock, pricing changes, emails), say that Steve's Claude can do it and suggest they tag Steve. Keep it short and friendly; it is posted straight into Slack.
+
+Recent channel conversation (oldest first):
+${ctx}
+
+Their message:
+${msg}`;
+
+function runClaudeNoTools(prompt) {
+ return new Promise((resolve, reject) => execFile(CLAUDE_CMD, ['-p', prompt, '--model', MODEL, '--tools', ''],
+ { timeout: 180000, maxBuffer: 5 * 1024 * 1024, cwd: DIR }, (e, out, se) => e ? reject(new Error((se || e.message).slice(0, 500))) : resolve(out)));
+}
+
+async function channelContext(m) {
+ const r = m.thread_ts
+ ? await slack('conversations.replies', { channel: m.channel, ts: m.thread_ts, limit: String(CONTEXT_MSGS) })
+ : await slack('conversations.history', { channel: m.channel, latest: m.ts, inclusive: 'false', limit: String(CONTEXT_MSGS) });
+ const msgs = (r.messages || []).filter(x => x.ts !== m.ts);
+ if (!m.thread_ts) msgs.reverse(); // history is newest-first
+ return msgs.map(x => `<@${x.user || x.bot_id || '?'}>${x.user === STEVE ? ' (Steve)' : ''}: ${(x.text || '').slice(0, 800)}`).join('\n') || '(no earlier messages)';
+}
+
+async function handleCall(m) {
+ if (SEEN.has(m.ts)) return; SEEN.add(m.ts);
+ const text = (m.text || '').replace(`<@${BOT_USER}>`, '').trim(); if (!text) return;
+ const thread_ts = m.thread_ts || m.ts;
+ const steve = m.user === STEVE;
+ fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
+ fs.appendFileSync(INBOX, JSON.stringify({ channel: m.channel, ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), mode: steve ? 'call-steve' : 'call-staff', via: 'socket' }) + '\n');
+ log(`CALL in ${m.channel} by ${m.user}${steve ? ' (Steve)' : ''} · "${text.slice(0, 60)}"`);
+ await slack('chat.postMessage', { channel: m.channel, thread_ts, text: '⏳ On it…' });
+ try {
+ const ctx = await channelContext(m);
+ const out = steve
+ ? await runClaude(`Recent conversation in this Slack channel (for context, oldest first):\n${ctx}\n\n---\n${text}`)
+ : await runClaudeNoTools(STAFF_PROMPT(ctx, m.user, text));
+ await slack('chat.postMessage', { channel: m.channel, thread_ts, text: clip(out) });
+ } catch (e) { await slack('chat.postMessage', { channel: m.channel, thread_ts, text: '⚠️ ' + e.message }); }
+}
+
async function handleMessage(m) {
- if (!m || m.subtype || m.bot_id || m.user !== STEVE) return;
+ if (!m || m.subtype || m.bot_id) return;
+ if (CALL_CHANNELS.has(m.channel)) { if (isCall(m.text || '')) await handleCall(m); return; }
+ if (m.user !== STEVE) return;
if (!CHANNELS.has(m.channel)) return;
if (SEEN.has(m.ts)) return; SEEN.add(m.ts);
const text = (m.text || '').trim(); if (!text) return;
@@ -70,12 +121,13 @@ async function connect() {
if (d.type === 'hello') return;
if (d.type === 'disconnect') { log('slack asked to reconnect (' + d.reason + ')'); try { ws.close(); } catch {} return; }
if (d.envelope_id) ws.send(JSON.stringify({ envelope_id: d.envelope_id })); // ACK immediately (<3s)
+ { const e = d.payload?.event; log(`EVT ${d.type}${e ? ' ' + e.type + ' ch=' + e.channel + ' user=' + (e.user || e.bot_id || '') : ''}`); }
if (d.type === 'events_api' && d.payload?.event?.type === 'message') handleMessage(d.payload.event).catch(e => log('handle error: ' + e.message));
});
ws.addEventListener('close', () => { log('socket closed — reconnecting in 2s'); setTimeout(connect, 2000); });
ws.addEventListener('error', (e) => { log('socket error: ' + (e.message || 'unknown')); });
}
-if (!BOT || !APP || !CHANNELS.size || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_APP_TOKEN / SLACK_CHANNEL_IDS / STEVE_USER_ID)'); process.exit(1); }
+if (!BOT || !APP || !(CHANNELS.size || CALL_CHANNELS.size) || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_APP_TOKEN / SLACK_CHANNEL_IDS / STEVE_USER_ID)'); process.exit(1); }
log('slack socket-mode starting · armed=' + AUTO_EXECUTE);
-connect();
+slack("auth.test",{}).then(a=>{BOT_USER=a.user_id||"";log("bot user "+BOT_USER);connect();});
← e1aa598 slack-to-steve: Socket Mode client (socket.mjs) — Slack push
·
back to Slack To Steve
·
mica catalog grid PNG generator (contact sheet from micawall 8679504 →