← back to Dw Chat Analyzer

watch.js

124 lines

#!/usr/bin/env node
'use strict';
/*
 * dw-chat-watch — lightweight new-HOT-lead alerter.
 *
 * Every POLL_MIN minutes: incrementally enumerate NEW Zendesk chat ids since the
 * last check (GET /incremental/chats?start_time=…), fetch each new chat's full
 * record (GET /chats/{id} — cheap, only the handful of new ones), upsert into
 * realestate.dw_chats, and for any that left contact info (HOT lead) fire an alert:
 *   - append to /tmp/dw-chat-hot.log
 *   - POST a CNCP parking-lot card (http://127.0.0.1:3333) — non-gated, local
 * (George email is an opt-in toggle, ALERT_EMAIL=1 — outbound send stays off by default.)
 *
 * Data reality (validated 2026-08-11): the ONLY reliable lead signal in this account
 * is contact-left (email/phone). Search terms / message intent are absent from the
 * bulk feed, so "HOT" == visitor left contact. Cost: $0 (Zendesk API included).
 */
const http = require('http'), fs = require('fs');
const { Pool } = require('pg');
const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'realestate', max: 3 });
pool.on('error', e => console.error('pg', e));

const POLL_MIN = Number(process.env.POLL_MIN || 10);
const STATE = require('path').join(__dirname, '.watch_state');
const LOG = '/tmp/dw-chat-hot.log';
const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333/api/parking-lot';
const ZLINK = process.env.ZENDESK_LINK_BASE || 'https://dashboard.zopim.com/#chats/agent/history';
let TOKEN = null;

function token() {
  if (TOKEN) return TOKEN;
  for (const l of fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8').split('\n'))
    if (l.startsWith('ZENDESK_CHAT_ACCESS_TOKEN=')) TOKEN = l.split('=').slice(1).join('=').trim().replace(/^["']|["']$/g, '');
  return TOKEN;
}
function api(path) {
  return new Promise((res, rej) => {
    const req = require('https').request('https://www.zopim.com/api/v2/' + path,
      { headers: { Authorization: 'Bearer ' + token() } }, r => {
        let b = ''; r.on('data', d => b += d); r.on('end', () => { try { res(JSON.parse(b)); } catch (e) { rej(e); } });
      });
    req.on('error', rej); req.setTimeout(45000, () => req.destroy(new Error('timeout'))); req.end();
  });
}
function post(url, body) {
  return new Promise((res) => {
    const u = new URL(url), data = JSON.stringify(body);
    const req = (u.protocol === 'https:' ? require('https') : http).request(u,
      { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
      r => { let b = ''; r.on('data', d => b += d); r.on('end', () => res({ code: r.statusCode, b })); });
    req.on('error', () => res({ code: 0 })); req.write(data); req.end();
  });
}

function macNotify(title, subtitle, msg, url) {
  // Native macOS on-screen alert — pops over whatever's focused (incl. FileMaker) on macstudio3.
  // NOTIFY=dialog (default here) = clickable popup whose "Open chat" button opens the chat;
  // NOTIFY=banner = a notification-center banner instead.
  const esc = s => String(s || '').replace(/["\\]/g, '\\$&').replace(/[\r\n]+/g, ' ');
  const scpt = (process.env.NOTIFY === 'banner')
    ? `display notification "${esc(msg)}" with title "${esc(title)}" subtitle "${esc(subtitle)}" sound name "Glass"`
    : `tell application "System Events" to set fmUp to exists process "FileMaker Pro"
       if fmUp then tell application "FileMaker Pro" to activate
       delay 0.2
       set r to display dialog "${esc(subtitle)}\n${esc(msg)}" with title "${esc(title)}" buttons {"Dismiss","Open chat"} default button "Open chat" with icon note giving up after 90
       if button returned of r is "Open chat" then open location "${esc(url)}"`;
  try { require('child_process').spawn('osascript', ['-e', scpt], { detached: true, stdio: 'ignore' }).unref(); } catch {}
}

async function alert(c) {
  const v = c.visitor || {};
  const loc = [v.city, v.region, v.country].filter(Boolean).join(', ');
  const contact = v.email || v.phone || '(contact on file)';
  const landing = ((c.webpath || [])[0]?.to || '').replace(/\?.*$/, '');
  const line = `${new Date().toISOString()} HOT ${c.id} | ${contact} | ${loc} | ${landing}`;
  fs.appendFileSync(LOG, line + '\n');
  macNotify('🔥 New chat lead', contact + (loc ? ' · ' + loc : ''), 'Landing: ' + (landing || '?'), ZLINK);
  const r = await post(CNCP, {
    title: `🔥 New HOT chat lead — ${contact}`,
    note: `Zendesk visitor left contact. Loc: ${loc || '?'} · Landing: ${landing || '?'} · Open: ${ZLINK}`,
    project: 'dw-chat-analyzer', tag: 'hot-lead',
  });
  console.log(`ALERT ${c.id} (${contact}) -> log${r.code >= 200 && r.code < 300 ? ' + CNCP' : ''}`);
}

async function poll() {
  try {
    // /chats is NEWEST-first, 40 full records/page, with visitor contact inline —
    // so one call catches new leads; no per-chat enrichment (that would hang on 1000s).
    let last = '';
    try { last = fs.readFileSync(STATE, 'utf8').trim(); } catch {}
    const d = await api('chats');
    const chats = (d.chats || []).filter(c => c.timestamp).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
    if (!chats.length) return;
    const newest = chats[0].timestamp;
    if (!last) {                                    // first run: set baseline, don't alert the backlog
      fs.writeFileSync(STATE, newest);
      console.log(`${new Date().toISOString()} baseline set at ${newest} (no backlog alerts)`);
      return;
    }
    let hot = 0, seenNew = 0;
    for (const c of chats) {
      if (c.timestamp <= last) break;               // reached already-seen (newest-first)
      seenNew++;
      const v = c.visitor || {};
      const isHot = !!(v.email || v.phone);
      await pool.query(
        `INSERT INTO dw_chats (id, started_at, lead_tier, is_lead, visitor_email, visitor_phone,
           visitor_city, visitor_region, visitor_country, landing_page, zendesk_link)
         VALUES ($1, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9, $10, $11)
         ON CONFLICT (id) DO UPDATE SET lead_tier=EXCLUDED.lead_tier, pulled_at=now()`,
        [c.id, c.timestamp, isHot ? 'HOT' : '', isHot, v.email || '', v.phone || '',
         v.city || '', v.region || '', v.country || '', ((c.webpath || [])[0] || {}).to || '', ZLINK]).catch(() => {});
      if (isHot) { await alert(c); hot++; }
    }
    fs.writeFileSync(STATE, newest);
    console.log(`${new Date().toISOString()} ${seenNew} new chats since last poll, ${hot} HOT`);
  } catch (e) { console.error('poll error:', e.message); }
}

console.log(`dw-chat-watch up — polling every ${POLL_MIN}m for new HOT (contact-left) chats`);
poll();
setInterval(poll, POLL_MIN * 60 * 1000);