← back to Butlr
mac2-contacts-service/server.js
187 lines
// Mac2-resident contacts service. Reads macOS Contacts via the SQLite DBs
// at ~/Library/Application Support/AddressBook/Sources/*/AddressBook-v22.abcddb
// — bypasses macOS TCC privacy prompts (which gate the AppleScript / Contacts
// API). Direct file reads work because the privacy gate is on the API, not
// the file itself.
//
// Listens on Tailscale + localhost so Butlr prod (Kamatera) can search
// contacts without bulk-exporting anything. Per /search request returns
// only matching name+phone, max 25 rows. Every read is audited.
//
// Endpoints:
// GET /health → { ok, count, sources }
// GET /search?q=<query> → { ok, contacts: [{ name, phones:[E.164] }] }
//
// Auth: X-Butlr-Token header == CONTACTS_SHARED_TOKEN.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const PORT = parseInt(process.env.CONTACTS_PORT || '9937', 10);
const BIND = process.env.CONTACTS_BIND || '0.0.0.0';
const SHARED_TOKEN = process.env.CONTACTS_SHARED_TOKEN || 'CHANGE_ME_BEFORE_USE';
const LOG_PATH = path.join(os.homedir(), 'Library/Logs/butlr-contacts-access.log');
const AB_SOURCES_DIR = path.join(os.homedir(), 'Library/Application Support/AddressBook/Sources');
const OVERLAY_PATH = path.join(os.homedir(), '.butlr-contacts.json');
function audit(...args) {
const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`;
try { fs.appendFileSync(LOG_PATH, line); } catch {}
process.stdout.write(line);
}
function findSourceDbs() {
try {
return fs.readdirSync(AB_SOURCES_DIR)
.map(s => path.join(AB_SOURCES_DIR, s, 'AddressBook-v22.abcddb'))
.filter(p => fs.existsSync(p));
} catch { return []; }
}
function normalize(rawPhone) {
const digits = String(rawPhone || '').replace(/\D/g, '');
if (!digits) return null;
if (digits.length === 10) return '+1' + digits;
if (digits.length === 11 && digits[0] === '1') return '+' + digits;
if (digits.length >= 8 && digits.length <= 15) return '+' + digits;
return null;
}
const SOURCES = findSourceDbs();
audit(`found ${SOURCES.length} contacts DBs`);
let CACHE = null;
let CACHE_AT = 0;
const CACHE_TTL = 5 * 60 * 1000;
function loadAll() {
const t0 = Date.now();
const all = new Map(); // dedupe key = name|firstPhone
for (const db of SOURCES) {
try {
const sql = `SELECT IFNULL(r.ZFIRSTNAME,'') AS fn, IFNULL(r.ZLASTNAME,'') AS ln, IFNULL(r.ZORGANIZATION,'') AS org, p.ZFULLNUMBER
FROM ZABCDRECORD r JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK
WHERE p.ZFULLNUMBER IS NOT NULL`;
const out = execFileSync('/usr/bin/sqlite3', ['-separator', '\t', db, sql], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
for (const line of out.split('\n')) {
if (!line.trim()) continue;
const [fn, ln, org, phone] = line.split('\t');
const name = ([fn, ln].filter(Boolean).join(' ').trim()) || org || '';
if (!name) continue;
const norm = normalize(phone);
if (!norm) continue;
const key = name.toLowerCase();
if (!all.has(key)) all.set(key, { name, phones: new Set() });
all.get(key).phones.add(norm);
}
} catch (e) {
audit(`source read ERROR ${path.basename(path.dirname(db))}: ${e.message}`);
}
}
// Merge in Butlr-overlay file (~/.butlr-contacts.json) — contacts you add
// via the Butlr admin UI that aren't in macOS Contacts. Overlay entries
// shadow same-name entries from SQLite, so you can correct / extend.
let overlayCount = 0;
try {
if (fs.existsSync(OVERLAY_PATH)) {
const overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8'));
if (Array.isArray(overlay)) {
for (const o of overlay) {
if (!o || !o.name) continue;
const key = String(o.name).toLowerCase();
const phones = (Array.isArray(o.phones) ? o.phones : [o.phone]).map(normalize).filter(Boolean);
if (!phones.length) continue;
const existing = all.get(key);
if (existing) {
for (const p of phones) existing.phones.add(p);
} else {
all.set(key, { name: String(o.name), phones: new Set(phones) });
}
overlayCount++;
}
}
}
} catch (e) {
audit(`overlay read ERROR: ${e.message}`);
}
const list = Array.from(all.values()).map(c => ({ name: c.name, phones: Array.from(c.phones) }));
audit(`loaded ${list.length} contacts in ${Date.now() - t0}ms (${SOURCES.length} SQLite sources + ${overlayCount} overlay entries)`);
CACHE = list;
CACHE_AT = Date.now();
return list;
}
// Add a contact to the overlay file. Idempotent — merges by lowercase name.
function addOverlayContact(name, phone) {
const norm = normalize(phone);
if (!name || !norm) return { ok: false, error: 'name + valid phone required' };
let overlay = [];
try { if (fs.existsSync(OVERLAY_PATH)) overlay = JSON.parse(fs.readFileSync(OVERLAY_PATH, 'utf8')) || []; } catch {}
const idx = overlay.findIndex(o => String(o.name).toLowerCase() === name.toLowerCase());
if (idx >= 0) {
if (!Array.isArray(overlay[idx].phones)) overlay[idx].phones = overlay[idx].phone ? [overlay[idx].phone] : [];
if (!overlay[idx].phones.includes(norm)) overlay[idx].phones.push(norm);
} else {
overlay.push({ name: String(name), phones: [norm] });
}
fs.writeFileSync(OVERLAY_PATH, JSON.stringify(overlay, null, 2));
// Invalidate cache so next read picks up the new entry
CACHE = null;
CACHE_AT = 0;
audit(`overlay ADD name="${name}" phone="${norm}"`);
return { ok: true, name, phone: norm };
}
function getContacts() {
if (!CACHE || (Date.now() - CACHE_AT) > CACHE_TTL) return loadAll();
return CACHE;
}
const app = express();
app.use((req, res, next) => {
const token = req.headers['x-butlr-token'] || req.query.t;
if (token !== SHARED_TOKEN) {
audit(`AUTH FAIL ${req.ip} ${req.method} ${req.url}`);
return res.status(403).json({ ok: false, error: 'bad_token' });
}
next();
});
app.get('/health', (req, res) => {
try {
const c = getContacts();
res.json({ ok: true, count: c.length, sources: SOURCES.length, cached_age_ms: Date.now() - CACHE_AT });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
app.get('/search', (req, res) => {
const q = String(req.query.q || '').trim().toLowerCase();
if (!q || q.length < 2) return res.json({ ok: true, contacts: [] });
try {
const all = getContacts();
const matches = all.filter(c => (c.name || '').toLowerCase().includes(q)).slice(0, 25);
audit(`search q="${q}" returned ${matches.length} of ${all.length}`);
res.json({ ok: true, contacts: matches });
} catch (e) {
audit(`search ERROR q="${q}": ${e.message}`);
res.status(500).json({ ok: false, error: e.message });
}
});
app.post('/add', express.json(), (req, res) => {
const r = addOverlayContact(req.body && req.body.name, req.body && req.body.phone);
if (!r.ok) return res.status(400).json(r);
res.json(r);
});
app.listen(PORT, BIND, () => {
audit(`Butlr contacts service (SQLite-direct) listening on ${BIND}:${PORT}`);
});