← back to Slack To Steve

socket.mjs

134 lines

// socket.mjs — Slack Socket Mode client: Slack PUSHES each message over a
// websocket the instant Steve hits send (no polling, no rate limits). Same rails +
// armed `claude -p` logic as ingest.mjs. Needs an app-level token (SLACK_APP_TOKEN
// xapp-…, connections:write) + Socket Mode enabled + message.channels/message.groups
// event subscriptions. The bot token (xoxb-) still does the reading/posting.
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';

const DIR = process.cwd();
const ENV = loadEnv(path.join(DIR, '.env'));
const BOT = ENV.SLACK_BOT_TOKEN;
const APP = ENV.SLACK_APP_TOKEN;                    // xapp-… app-level token
const CHANNELS = new Set((ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || '').split(',').map(s => s.trim()).filter(Boolean));
const STEVE = ENV.STEVE_USER_ID;
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

const RAILS = (msg) => `You are acting on a message Steve just sent you in his Slack channel — this is him talking to Claude to get work done. Do what he asks NOW, using all your tools/skills.

RAILS (the real protection — honor them even though this runs with --dangerously-skip-permissions):
- SAFE / reversible / local / read-only work → just do it and report the result.
- GATED action — anything customer-facing, a prod or dw_unified or Shopify write, a send-to-list, DNS/domain, money/spend, secrets, a deploy, a remote push, a bulk delete/archive, or installing a scheduled job → DO NOT perform it. Instead say exactly what you would do, why it's gated, and that it needs his explicit in-session go.
Keep your FINAL output short and concrete — it gets posted straight back to him in Slack.

Steve's message:
${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 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), '--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) 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;
  fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
  const armed = armedFor(m.channel);
  fs.appendFileSync(INBOX, JSON.stringify({ channel: m.channel, ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), armed, via: 'socket' }) + '\n');
  log(`msg from Steve in ${m.channel} · armed=${armed} · "${text.slice(0, 60)}"`);
  if (armed) {
    await slack('chat.postMessage', { channel: m.channel, thread_ts: m.ts, text: '⏳ On it — working on this now…' });
    try { const out = await runClaude(text); await slack('chat.postMessage', { channel: m.channel, thread_ts: m.ts, text: clip(out) }); }
    catch (e) { await slack('chat.postMessage', { channel: m.channel, thread_ts: m.ts, text: '⚠️ ' + e.message }); }
  } else {
    await slack('chat.postMessage', { channel: m.channel, thread_ts: m.ts, text: '📥 Got it — queued for Claude. (auto-execute is off; a session will action this.)' });
  }
}

async function connect() {
  const open = await slack('apps.connections.open', {}, APP);
  if (!open.ok) { log('apps.connections.open failed: ' + open.error + ' — retry in 5s'); return setTimeout(connect, 5000); }
  const ws = new WebSocket(open.url);
  ws.addEventListener('open', () => log('socket connected · listening on ' + [...CHANNELS].join(',') + ' · armed=' + AUTO_EXECUTE));
  ws.addEventListener('message', (ev) => {
    let d; try { d = JSON.parse(ev.data); } catch { return; }
    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 || 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);
slack("auth.test",{}).then(a=>{BOT_USER=a.user_id||"";log("bot user "+BOT_USER);connect();});