← 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);