← back to Claude Slack

lib.js

136 lines

'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 };