← back to Rc Fm Callnotes
initial scaffold: RC->FM call notes sync
270c5467b826db9b8714d12ff29b9beb2bd63787 · 2026-07-03 04:19:50 -0700 · Steve Abrams
Files touched
A .gitignoreA lib/fm-test-helpers.jsA lib/fm.jsA lib/rc.jsA package.jsonA sync-calls.mjsA test-run.mjs
Diff
commit 270c5467b826db9b8714d12ff29b9beb2bd63787
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 3 04:19:50 2026 -0700
initial scaffold: RC->FM call notes sync
---
.gitignore | 7 +++
lib/fm-test-helpers.js | 10 ++++
lib/fm.js | 84 +++++++++++++++++++++++++++
lib/rc.js | 71 +++++++++++++++++++++++
package.json | 6 ++
sync-calls.mjs | 153 +++++++++++++++++++++++++++++++++++++++++++++++++
test-run.mjs | 83 +++++++++++++++++++++++++++
7 files changed, 414 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..607f4cd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+data/rc-config.json
+data/state.json
+tmp/
+*.log
+.DS_Store
diff --git a/lib/fm-test-helpers.js b/lib/fm-test-helpers.js
new file mode 100644
index 0000000..f0d194c
--- /dev/null
+++ b/lib/fm-test-helpers.js
@@ -0,0 +1,10 @@
+// Direct pass-throughs for the test harness (create/get against whitelisted files).
+import { readFileSync } from 'node:fs';
+
+const FM_DIR = '/Users/stevestudio2/Projects/filemaker-mcp';
+for (const line of readFileSync(`${FM_DIR}/.env`, 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+}
+const fm = await import(`${FM_DIR}/src/fm-client.js`);
+export const { createRecord, findRecords, getRecord, deleteRecord: deleteRecordSafe } = fm;
diff --git a/lib/fm.js b/lib/fm.js
new file mode 100644
index 0000000..f848598
--- /dev/null
+++ b/lib/fm.js
@@ -0,0 +1,84 @@
+// Thin wrapper over the filemaker-mcp Data API client (whitelisted files only).
+// Loads that project's .env so Claris credentials stay in one place.
+import { readFileSync } from 'node:fs';
+
+const FM_DIR = '/Users/stevestudio2/Projects/filemaker-mcp';
+for (const line of readFileSync(`${FM_DIR}/.env`, 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
+}
+
+const fm = await import(`${FM_DIR}/src/fm-client.js`);
+export const { findRecords, createRecord, updateRecord, listRecords, getRecord } = fm;
+
+export const CLIENTS_LAYOUT = 'Main Menu Copy'; // exposes Phone + Notes (writable)
+export const INVOICE_LAYOUT = 'PACKING LIST !!'; // exposes Invoice + Internal notes
+
+// Digits-only canonical form, US numbers reduced to 10 digits.
+export function normPhone(raw) {
+ let d = String(raw || '').replace(/\D/g, '');
+ if (d.length === 11 && d.startsWith('1')) d = d.slice(1);
+ return d;
+}
+
+// Common ways a 10-digit US number appears in the Clients file.
+export function phoneVariants(digits10) {
+ if (digits10.length !== 10) return [`*${digits10}*`];
+ const a = digits10.slice(0, 3), b = digits10.slice(3, 6), c = digits10.slice(6);
+ return [
+ `*${a}-${b}-${c}*`,
+ `*(${a}) ${b}-${c}*`,
+ `*(${a})${b}-${c}*`,
+ `*${a}.${b}.${c}*`,
+ `*${a} ${b} ${c}*`,
+ `*${a}${b}${c}*`,
+ ];
+}
+
+// Find a client by phone number; returns first match or null.
+export async function findClientByPhone(rawNumber) {
+ const digits = normPhone(rawNumber);
+ if (digits.length < 7) return null;
+ const query = phoneVariants(digits).map((v) => ({ Phone: v }));
+ try {
+ const res = await findRecords('Clients', CLIENTS_LAYOUT, query, { limit: 3 });
+ return res.data?.[0] || null;
+ } catch (e) {
+ if (e.fmCode === '401') return null; // 401 = no records match
+ throw e;
+ }
+}
+
+// Append text to a client's Notes (read-modify-write; newest entry on top).
+export async function appendClientNote(recordId, existingNotes, entry) {
+ const merged = entry + (existingNotes ? `\n\n${existingNotes}` : '');
+ return updateRecord('Clients', CLIENTS_LAYOUT, recordId, { Notes: merged }, { dryRun: false });
+}
+
+export async function createClientFromCall({ name, phone, note }) {
+ const fieldData = {
+ Company: name || 'Unknown caller',
+ Name: name || '',
+ Phone: phone,
+ Notes: note,
+ 'Date Added': new Date().toLocaleDateString('en-US'),
+ };
+ // 'Date Added' isn't on Main Menu Copy — only send fields the layout exposes.
+ delete fieldData['Date Added'];
+ return createRecord('Clients', CLIENTS_LAYOUT, fieldData, { dryRun: false });
+}
+
+export async function findInvoiceByNumber(invoiceNo) {
+ try {
+ const res = await findRecords('invoice', INVOICE_LAYOUT, { Invoice: `==${invoiceNo}` }, { limit: 2 });
+ return res.data?.[0] || null;
+ } catch (e) {
+ if (e.fmCode === '401') return null;
+ throw e;
+ }
+}
+
+export async function appendInvoiceInternalNote(recordId, existingNotes, entry) {
+ const merged = entry + (existingNotes ? `\n${existingNotes}` : '');
+ return updateRecord('invoice', INVOICE_LAYOUT, recordId, { 'Internal notes': merged }, { dryRun: false });
+}
diff --git a/lib/rc.js b/lib/rc.js
new file mode 100644
index 0000000..5e748d9
--- /dev/null
+++ b/lib/rc.js
@@ -0,0 +1,71 @@
+// RingCentral REST client — JWT grant (same app as /root/DW-Agents/ringcentral-agent).
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const CFG = JSON.parse(readFileSync(join(__dir, '..', 'data', 'rc-config.json'), 'utf8'));
+
+let session = null; // { token, exp }
+
+export async function getToken() {
+ const now = Date.now();
+ if (session && now < session.exp) return session.token;
+ const res = await fetch(`${CFG.serverUrl}/restapi/oauth/token`, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Basic ' + Buffer.from(`${CFG.clientId}:${CFG.clientSecret}`).toString('base64'),
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: CFG.jwtToken,
+ }),
+ });
+ const json = await res.json();
+ if (!res.ok) throw new Error(`RC auth failed: ${JSON.stringify(json).slice(0, 300)}`);
+ session = { token: json.access_token, exp: now + (json.expires_in - 60) * 1000 };
+ return session.token;
+}
+
+export async function rc(path, { method = 'GET', body, query } = {}) {
+ const token = await getToken();
+ const url = new URL(`${CFG.serverUrl}${path}`);
+ for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
+ const res = await fetch(url, {
+ method,
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (res.status === 204) return {};
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(`RC ${method} ${path} -> ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
+ err.status = res.status;
+ throw err;
+ }
+ return json;
+}
+
+// Company-wide call log (detailed view carries per-leg info).
+export function callLog({ dateFrom, dateTo, perPage = 100 } = {}) {
+ const query = { view: 'Detailed', perPage: String(perPage) };
+ if (dateFrom) query.dateFrom = dateFrom;
+ if (dateTo) query.dateTo = dateTo;
+ return rc('/restapi/v1.0/account/~/call-log', { query });
+}
+
+// Personal address-book contacts on the authorized extension (caller-ID source).
+export function listContacts({ page = 1, perPage = 100 } = {}) {
+ return rc('/restapi/v1.0/account/~/extension/~/address-book/contact', {
+ query: { page: String(page), perPage: String(perPage) },
+ });
+}
+export function createContact(contact) {
+ return rc('/restapi/v1.0/account/~/extension/~/address-book/contact', {
+ method: 'POST', body: contact,
+ });
+}
+export function deleteContact(id) {
+ return rc(`/restapi/v1.0/account/~/extension/~/address-book/contact/${id}`, { method: 'DELETE' });
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2f495ad
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "rc-fm-callnotes",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "RingCentral -> FileMaker call notes sync + lazy caller-ID contact sync"
+}
diff --git a/sync-calls.mjs b/sync-calls.mjs
new file mode 100644
index 0000000..b05e72f
--- /dev/null
+++ b/sync-calls.mjs
@@ -0,0 +1,153 @@
+// 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 {
+ findClientByPhone, appendClientNote, createClientFromCall,
+ findInvoiceByNumber, appendInvoiceInternalNote, 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, invoice.fieldData['Internal notes'], note.split('\n').slice(0, 2).join(' — '));
+ 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.');
+}
diff --git a/test-run.mjs b/test-run.mjs
new file mode 100644
index 0000000..af63e68
--- /dev/null
+++ b/test-run.mjs
@@ -0,0 +1,83 @@
+// End-to-end test with TEST DATA ONLY (all records marked "TEST — DELETE ME").
+// Exercises: client match -> note append, no-match -> client create,
+// invoice reference -> Internal notes stamp, RC caller-ID contact create/verify/delete.
+import { createRecord, findRecords, getRecord, deleteRecordSafe } from './lib/fm-test-helpers.js';
+import { processCall } from './sync-calls.mjs';
+import { listContacts, deleteContact } from './lib/rc.js';
+import { normPhone } from './lib/fm.js';
+
+const results = [];
+const say = (s) => { console.log(s); results.push(s); };
+
+// --- setup: TEST client + TEST invoice ---
+say('1) Creating TEST client (Clients file)…');
+const testClient = await createRecord('Clients', 'Main Menu Copy', {
+ Company: 'TEST — DELETE ME (Claude call-notes)',
+ Name: 'Test Client',
+ Phone: '(818) 555-0142',
+ Notes: '',
+}, { dryRun: false });
+say(` created recordId ${testClient.recordId}`);
+
+say('2) Creating TEST invoice 999998 (invoice file)…');
+const testInvoice = await createRecord('invoice', 'PACKING LIST !!', {
+ Invoice: '999998',
+ 'SOLD NAME': 'TEST — DELETE ME (Claude call-notes)',
+ 'Internal notes': '',
+}, { dryRun: false });
+say(` created recordId ${testInvoice.recordId}`);
+
+// --- test A: known caller -> note appended to existing client ---
+say('3) Simulating inbound call from the TEST client number (818-555-0142)…');
+const callA = {
+ id: 'TEST-CALL-A', direction: 'Inbound', result: 'Accepted', duration: 245,
+ startTime: new Date().toISOString(),
+ from: { phoneNumber: '+18185550142', name: 'TEST CALLER' },
+ to: { phoneNumber: '+18883734564' },
+};
+say(' ' + JSON.stringify(await processCall(callA)));
+
+// --- test B: unknown caller -> new client created ---
+say('4) Simulating inbound call from an UNKNOWN number (818-555-0177)…');
+const callB = {
+ id: 'TEST-CALL-B', direction: 'Inbound', result: 'Missed', duration: 14,
+ startTime: new Date().toISOString(),
+ from: { phoneNumber: '+18185550177', name: 'TEST UNKNOWN DELETE ME' },
+ to: { phoneNumber: '+18883734564' },
+};
+say(' ' + JSON.stringify(await processCall(callB)));
+
+// --- test C: call note mentioning the TEST invoice -> Internal notes stamped ---
+say('5) Simulating call with a note that references invoice 999998…');
+const callC = {
+ id: 'TEST-CALL-C', direction: 'Inbound', result: 'Accepted', duration: 180,
+ startTime: new Date().toISOString(),
+ from: { phoneNumber: '+18185550142', name: 'TEST CALLER' },
+ to: { phoneNumber: '+18883734564' },
+ _noteText: 'Client called about invoice 999998 — asked for ship date and tracking.',
+};
+say(' ' + JSON.stringify(await processCall(callC)));
+
+// --- verify what landed ---
+say('6) Reading back what landed…');
+const clientBack = await getRecord('Clients', 'Main Menu Copy', testClient.recordId);
+say(' TEST client Notes now:\n' + clientBack.fieldData.Notes.split('\n').map((l) => ' | ' + l).join('\n'));
+const invBack = await getRecord('invoice', 'PACKING LIST !!', testInvoice.recordId);
+say(' TEST invoice Internal notes now:\n' + String(invBack.fieldData['Internal notes']).split('\n').map((l) => ' | ' + l).join('\n'));
+
+// --- clean up RC test contacts (leave FM records for Steve to eyeball) ---
+say('7) Cleaning TEST contacts out of the RingCentral address book…');
+let cleaned = 0;
+for (let page = 1; page <= 40; page++) {
+ const res = await listContacts({ page, perPage: 250 });
+ for (const c of res.records || []) {
+ const nums = [c.businessPhone, c.mobilePhone, c.homePhone].map(normPhone);
+ if (nums.includes('8185550142') || nums.includes('8185550177')) {
+ await deleteContact(c.id); cleaned++;
+ say(` deleted RC test contact #${c.id} (${c.firstName || ''} ${c.lastName || ''})`);
+ }
+ }
+ if (!res.navigation?.nextPage) break;
+}
+say(` ${cleaned} RC test contact(s) removed — live address book left untouched.`);
+say('DONE — FileMaker TEST records left in place for review (all marked TEST — DELETE ME).');
(oldest)
·
back to Rc Fm Callnotes
·
call-notes sync live: client match/create, invoice notes, RC 300fc6f →