← back to Rc Fm Callnotes
sync-calls.mjs
155 lines
// RingCentral -> FileMaker call-notes sync.
// Every run: pull completed external calls since the last checkpoint, and for each
// 1. match the external number against Clients (phone-format variants)
// 2. matched -> stamp a dated call note into the client's Notes
// no match -> create a new client carrying all call info in Notes
// 3. any invoice numbers found in note text -> stamp the invoice's Internal notes
// 4. push the caller into the RC address book so their name shows on future calls
// Append-only against FileMaker; a kill file stops the loop.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { callLog, listContacts, createContact } from './lib/rc.js';
import { enrichWithTranscript } from './lib/transcribe.mjs';
import {
findClientByPhone, appendClientNote, createClientFromCall,
findInvoiceByNumber, appendInvoiceInternalNote, invoiceNotesOf, normPhone,
} from './lib/fm.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const STATE = join(__dir, 'data', 'state.json');
const KILL = join(__dir, 'STOP');
const LOG = (...a) => console.log(new Date().toISOString(), ...a);
if (existsSync(KILL)) { LOG('STOP file present — exiting.'); process.exit(0); }
const state = existsSync(STATE)
? JSON.parse(readFileSync(STATE, 'utf8'))
: { lastSync: new Date(Date.now() - 60 * 60 * 1000).toISOString(), seen: [] };
const seen = new Set(state.seen || []);
const OWN_NUMBERS = new Set(['8883734564', '2133149912']); // DW main + showroom lines
const fmtWhen = (iso) => new Date(iso).toLocaleString('en-US', {
timeZone: 'America/Los_Angeles',
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
});
const fmtDur = (s) => (s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`);
const fmtPhone = (d) => (d.length === 10 ? `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}` : d);
function externalParty(rec) {
const side = rec.direction === 'Inbound' ? rec.from : rec.to;
const num = side?.phoneNumber || '';
return { number: num, name: side?.name || '' };
}
function buildNote(rec, party) {
const lines = [
`[${fmtWhen(rec.startTime)}] ${rec.direction} call ${rec.direction === 'Inbound' ? 'from' : 'to'} ${fmtPhone(normPhone(party.number))}` +
(party.name ? ` (${party.name})` : ''),
`Result: ${rec.result} · Duration: ${fmtDur(rec.duration || 0)}`,
];
if (rec.result === 'Missed') lines.push('MISSED CALL — needs follow-up');
if (rec.result === 'Voicemail') lines.push('Went to voicemail');
if (rec.recording) lines.push('Call was recorded');
if (rec._noteText) lines.push(`Notes: ${rec._noteText}`);
lines.push('(auto-logged from RingCentral)');
return lines.join('\n');
}
// Invoice numbers referenced in note text, e.g. "invoice 102387" / "inv# 102387".
function invoiceRefs(text) {
const out = new Set();
for (const m of String(text || '').matchAll(/\b(?:invoice|inv)\W{0,3}(\d{5,6})\b/gi)) out.add(m[1]);
return [...out];
}
async function ensureRcContact(party, client) {
const digits = normPhone(party.number);
if (digits.length !== 10) return 'skip (non-US number)';
// Already in the address book?
let page = 1;
for (;;) {
const res = await listContacts({ page, perPage: 250 });
const hit = (res.records || []).find((c) =>
[c.businessPhone, c.mobilePhone, c.homePhone].some((p) => normPhone(p) === digits));
if (hit) return `already present (#${hit.id})`;
if (!res.navigation?.nextPage) break;
page += 1;
if (page > 40) break; // personal book cap safety
}
const f = client?.fieldData || {};
const company = f.Company?.trim();
const personal = (f.Name || party.name || '').trim();
const [firstName, ...rest] = (personal || company || 'Unknown caller').split(/\s+/);
const created = await createContact({
firstName, lastName: rest.join(' ') || undefined,
company: company || undefined,
businessPhone: `+1${digits}`,
notes: f['Account Number'] ? `DW account ${f['Account Number']}` : 'Added by call-notes sync',
});
return `created (#${created.id})`;
}
export async function processCall(rec, { dryRun = false } = {}) {
const party = externalParty(rec);
const digits = normPhone(party.number);
const out = { id: rec.id, when: rec.startTime, direction: rec.direction, number: fmtPhone(digits), actions: [] };
if (!digits || OWN_NUMBERS.has(digits) || digits.length < 7) {
out.actions.push('skipped (internal/anonymous)');
return out;
}
const note = buildNote(rec, party);
const client = await findClientByPhone(party.number);
if (client) {
out.client = `${client.fieldData.Company || client.fieldData.Name} (acct ${client.fieldData['Account Number']})`;
if (!dryRun) await appendClientNote(client.recordId, client.fieldData.Notes, note);
out.actions.push('note appended to existing client');
} else {
if (!dryRun) {
const created = await createClientFromCall({ name: party.name, phone: fmtPhone(digits), note });
out.client = `NEW client created (recordId ${created.recordId})`;
} else out.client = 'NEW client would be created';
out.actions.push('new client created with call info in Notes');
}
for (const inv of invoiceRefs(rec._noteText)) {
const invoice = await findInvoiceByNumber(inv);
if (invoice) {
if (!dryRun) await appendInvoiceInternalNote(invoice.recordId, invoiceNotesOf(invoice), note.split('\n').slice(0, 2).join(' — ') + (rec._noteText ? ` — ${rec._noteText}` : ''));
out.actions.push(`invoice ${inv}: internal note stamped`);
} else out.actions.push(`invoice ${inv}: not found, skipped`);
}
try {
const c = await ensureRcContact(party, client);
out.actions.push(`RC caller-ID contact: ${c}`);
} catch (e) { out.actions.push(`RC contact sync failed: ${e.message.slice(0, 120)}`); }
return out;
}
// ---- main ----
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) {
const dateFrom = state.lastSync;
const res = await callLog({ dateFrom, perPage: 100 });
const cutoff = Date.now() - 2 * 60 * 1000; // leave in-flight calls for next pass
const records = (res.records || []).filter((r) =>
r.result && r.result !== 'In Progress' &&
new Date(r.startTime).getTime() + (r.duration || 0) * 1000 < cutoff &&
!seen.has(r.id));
LOG(`window from ${dateFrom}: ${records.length} new completed call(s)`);
let newest = state.lastSync;
for (const rec of records.sort((a, b) => a.startTime.localeCompare(b.startTime))) {
try {
const r = await processCall(rec);
LOG(JSON.stringify(r));
seen.add(rec.id);
if (rec.startTime > newest) newest = rec.startTime;
} catch (e) { LOG(`ERROR on call ${rec.id}: ${e.message}`); }
}
writeFileSync(STATE, JSON.stringify({ lastSync: newest, seen: [...seen].slice(-500) }, null, 2));
LOG('done.');
}