← back to Dw Chat Analyzer
Chat analyzer: new-HOT-lead watcher (watch.js) — polls newest /chats page (no per-chat hang), alerts new contact-left chats to log+CNCP; pm2 dw-chat-watch. FileMaker-screen alert + per-chat link pending Steve inputs
ef41f56ef237d014f63dbb5ae10e5327d53fe2fa · 2026-08-11 08:21:02 -0700 · steve
Files touched
Diff
commit ef41f56ef237d014f63dbb5ae10e5327d53fe2fa
Author: steve <steve@designerwallcoverings.com>
Date: Tue Aug 11 08:21:02 2026 -0700
Chat analyzer: new-HOT-lead watcher (watch.js) — polls newest /chats page (no per-chat hang), alerts new contact-left chats to log+CNCP; pm2 dw-chat-watch. FileMaker-screen alert + per-chat link pending Steve inputs
---
.watch_state | 1 +
watch.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 108 insertions(+)
diff --git a/.watch_state b/.watch_state
new file mode 100644
index 0000000..4da3d8a
--- /dev/null
+++ b/.watch_state
@@ -0,0 +1 @@
+2026-08-11T04:44:53Z
\ No newline at end of file
diff --git a/watch.js b/watch.js
new file mode 100644
index 0000000..dfcc065
--- /dev/null
+++ b/watch.js
@@ -0,0 +1,107 @@
+#!/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();
+ });
+}
+
+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');
+ 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);
← 89c8889 Chat analyzer: DTD verdict C — two-tier HOT/WARM lead flaggi
·
back to Dw Chat Analyzer
·
Chat analyzer: /api/new-hot endpoint for FileMaker to pull ( 47a430f →