← back to Claude Slack

health.js

85 lines

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