itself has to present a caller ID this account owns. Fall back to
// From for calls that arrive by any other route.
const from = conn.customParameters?.get?.('CallerNumber') || conn.parameters.From || '';
let who = fmt(from) || 'Unknown caller';
try {
const { contact } = await api('/lookup?phone=' + encodeURIComponent(from));
if (contact) who = contact.name;
} catch {}
if (!S.lines.length) {
const line = addLine({ call: conn, phone: from, name: who, status: 'Incoming — tap Answer', ringing: true });
wireConn(conn, line);
$('cbMute').textContent = 'Answer';
$('cbMute').onclick = () => { conn.accept(); resetMuteButton(); };
setLamp('live ring', 'Incoming');
return;
}
// Already on a call — show who it is and offer the choice.
S.pending = { conn, who, from };
$('waitWho').textContent = who;
$('waitSub').textContent = who === fmt(from) ? 'Incoming call' : `Incoming · ${fmt(from)}`;
$('waiting').classList.add('on');
positionWaiting();
if (navigator.vibrate) navigator.vibrate([120, 80, 120]);
conn.on('cancel', dismissWaiting);
conn.on('disconnect', dismissWaiting);
}
function dismissWaiting() {
$('waiting').classList.remove('on');
S.pending = null;
}
// Sit the incoming-call card directly above the call bar, whatever height the
// call bar happens to be (it grows when a second line adds its chips).
function positionWaiting() {
const bar = $('callbar');
const h = bar.classList.contains('on') ? bar.offsetHeight : 0;
$('waiting').style.bottom = `calc(var(--bar) + ${h + 10}px)`;
}
$('waitHold').onclick = () => {
const p = S.pending;
if (!p) return;
const cur = activeLine();
if (cur) setHeld(cur, true);
p.conn.accept();
const line = addLine({ call: p.conn, phone: p.from, name: p.who, status: 'Connected' });
wireConn(p.conn, line);
dismissWaiting();
};
$('waitEnd').onclick = () => {
const p = S.pending;
if (!p) return;
S.lines.slice().forEach((l) => { try { l.call?.disconnect(); } catch {} dropLine(l.id); });
p.conn.accept();
const line = addLine({ call: p.conn, phone: p.from, name: p.who, status: 'Connected' });
wireConn(p.conn, line);
dismissWaiting();
};
$('waitDecline').onclick = () => {
try { S.pending?.conn.reject(); } catch {}
dismissWaiting();
};
function wireConn(conn, line) {
conn.on('accept', () => {
line.ringing = false;
line.t0 = Date.now();
resetMuteButton();
setLamp('on', 'On a call');
keepAwake();
renderCallBar();
renderPadMode();
});
// Media dropped but the call is still alive. Saying so stops the reflex of
// hanging up and redialling, which is what actually loses the call.
conn.on('reconnecting', (e) => {
line.status = 'Reconnecting…';
setLamp('live ring', 'Reconnecting…');
logFault({ level: 'warn', code: e?.code || 'call-reconnecting', message: e?.message || 'media reconnecting' });
renderCallBar();
});
conn.on('reconnected', () => {
line.status = '';
setLamp('on', 'On a call');
toast('Reconnected');
renderCallBar();
});
conn.on('warning', (name) => {
if (name === 'low-bytes-received' || name === 'ice-connectivity-lost') {
logFault({ level: 'warn', code: `media-${name}`, message: `Media warning: ${name}` });
}
});
conn.on('error', (e) => {
logFault({ code: e?.code, message: e?.message, context: { where: 'call', explanation: e?.explanation } });
toast(describeTwilioError(e), true);
dropLine(line.id);
});
// A call that ends in under two seconds without ever connecting almost
// always failed inside the TwiML, where the SDK cannot see the reason.
// Record it so /api/diag can line it up against Twilio's own alert.
const gone = () => {
if (!line.t0 && !line.ringing) {
logFault({
level: 'warn', code: 'call-ended-immediately',
message: 'Call ended before it connected — check Twilio alerts for the same minute',
context: { phone: line.phone, sid: line.sid || conn?.parameters?.CallSid || null },
});
}
dropLine(line.id);
};
conn.on('disconnect', gone);
conn.on('cancel', gone);
conn.on('reject', gone);
}
// ── Audio routing ─────────────────────────────────────────────────────
// Input selection works everywhere. Output selection depends on setSinkId,
// which desktop Chrome has and Android Chrome does not — on a phone the OS
// owns routing, so no web page can move call audio to the speaker or a
// Bluetooth headset.
async function listAudioDevices() {
const wrap = $('audioPicks');
if (!navigator.mediaDevices?.enumerateDevices) return;
let devices = [];
try { devices = await navigator.mediaDevices.enumerateDevices(); } catch { return; }
const mics = devices.filter((d) => d.kind === 'audioinput');
const spks = devices.filter((d) => d.kind === 'audiooutput');
if (!mics.length) return;
wrap.style.display = 'block';
// Without an active permission grant enumerateDevices() returns entries with
// empty labels, which is why the list reads "Microphone 1" and tells you
// nothing about which headset is which. Offer to fix it rather than
// demanding the microphone before there is a call to use it for.
$('micPerm').style.display = mics.some((d) => !d.label) ? '' : 'none';
const saved = (k) => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
$('micSel').innerHTML = mics.map((d, i) =>
``).join('');
$('micSel').onchange = async () => {
try {
await S.device?.audio?.setInputDevice($('micSel').value);
try { localStorage.setItem('micId', $('micSel').value); } catch {}
toast('Microphone set');
} catch (e) { toast(e.message || 'Could not switch microphone', true); }
};
const outputSelectable = Boolean(S.device?.audio?.isOutputSelectionSupported);
$('spkField').style.display = outputSelectable && spks.length ? 'block' : 'none';
if (outputSelectable && spks.length) {
$('spkSel').innerHTML = spks.map((d, i) =>
``).join('');
$('spkSel').onchange = async () => {
try {
await S.device.audio.speakerDevices.set($('spkSel').value);
try { localStorage.setItem('spkId', $('spkSel').value); } catch {}
toast('Speaker set');
} catch (e) { toast(e.message || 'Could not switch speaker', true); }
};
$('audioNote').textContent = '';
} else {
// Chrome on Android has no setSinkId at all, so this is a platform limit
// rather than a setting: no web page on this browser can move call audio
// to the speaker or a paired headset. Samsung Internet does implement it,
// which is why the picker appears there and not here.
$('audioNote').textContent = spks.length
? 'This browser will not let a web page choose the speaker. Android routes call audio itself, ' +
'so speakerphone and Bluetooth cannot be switched from here — Samsung Internet allows it, Chrome does not. ' +
'Use "Ring my cell" and the call runs through the normal Android dialer, where both work.'
: 'This browser reports no selectable speakers. Use "Ring my cell" for speakerphone and Bluetooth.';
}
// Re-read the list when a headset is plugged in or paired.
if (!S.deviceWatch && navigator.mediaDevices.addEventListener) {
navigator.mediaDevices.addEventListener('devicechange', () => listAudioDevices());
S.deviceWatch = true;
}
}
$('micPerm').onclick = async () => {
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach((t) => t.stop()); // we only wanted the grant
listAudioDevices();
} catch (e) {
toast('Microphone permission denied', true);
logFault({ level: 'warn', code: 'mic-permission-denied', message: e.message });
}
};
// ── Lines ─────────────────────────────────────────────────────────────
// More than one call can be up at once. Exactly one is "active"; the rest are
// held, meaning both audio directions are cut so you neither hear them nor are
// heard by them.
let lineSeq = 0;
const activeLine = () => S.lines.find((l) => l.id === S.activeLine) || null;
const lineById = (id) => S.lines.find((l) => l.id === id) || null;
function addLine({ call, sid, phone, name, status, bridge, ringing }) {
const line = {
id: ++lineSeq, call, sid, phone, name, status: status || '',
bridge: !!bridge, ringing: !!ringing, held: false, muted: false,
t0: ringing ? null : Date.now(),
};
S.lines.push(line);
S.activeLine = line.id;
S.padCompose = false; // a new call takes the keypad back to tones
$('callbar').classList.add('on');
$('cbNote').value = '';
renderCallBar();
renderPadMode();
startTicker();
return line;
}
function dropLine(id) {
const line = lineById(id);
if (line) {
try { line.call?.disconnect(); } catch {}
S.lines = S.lines.filter((l) => l.id !== id);
}
if (S.activeLine === id) {
// Bring a held call back rather than leaving you on a dead line.
const next = S.lines[S.lines.length - 1];
S.activeLine = next ? next.id : null;
if (next) setHeld(next, false);
}
if (!S.lines.length) {
$('callbar').classList.remove('on');
clearInterval(S.timer);
resetMuteButton();
setLamp(S.device ? 'live' : '', S.device ? 'Ready' : 'Idle');
releaseWake();
if (S.tab === 'calls') loadCalls();
}
renderCallBar();
renderPadMode();
}
/**
* Hold by disabling the audio tracks in both directions. Disabling a track
* makes it emit silence no matter who is playing it, which is what keeps a
* second call from talking over the first.
*/
function setHeld(line, held) {
line.held = held;
const toggle = (stream) => {
try { stream?.getAudioTracks().forEach((t) => { t.enabled = !held; }); } catch {}
};
try { toggle(line.call?.getLocalStream?.()); } catch {}
try { toggle(line.call?.getRemoteStream?.()); } catch {}
// Belt and braces: the SDK's own mute covers the outbound direction.
try { line.call?.mute(held || line.muted); } catch {}
renderCallBar();
}
function switchTo(id) {
const target = lineById(id);
if (!target || S.activeLine === id) return;
S.lines.forEach((l) => setHeld(l, l.id !== id));
S.activeLine = id;
renderCallBar();
renderPadMode();
}
function renderCallBar() {
const line = activeLine();
if (!line) return;
$('cbName').textContent = line.name || fmt(line.phone) || 'Unknown';
$('cbStat').textContent = line.ringing ? line.status : (line.held ? 'On hold' : elapsed(line));
$('cbMute').textContent = line.ringing ? 'Answer' : (line.muted ? 'Unmute' : 'Mute');
// Nothing to send tones down on a bridge line: that call is on the cell.
$('cbKeys').style.display = line.call && !line.ringing ? '' : 'none';
$('cbLines').innerHTML = S.lines.length < 2 ? '' : S.lines.map((l) => `
`).join('');
$('cbLines').querySelectorAll('[data-line]').forEach((b) =>
b.onclick = () => switchTo(Number(b.dataset.line)));
if ($('waiting').classList.contains('on')) positionWaiting();
}
function elapsed(line) {
if (!line.t0) return line.status || 'Connecting';
const s = Math.floor((Date.now() - line.t0) / 1000);
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}
function startTicker() {
clearInterval(S.timer);
S.timer = setInterval(() => { if (S.lines.length) renderCallBar(); }, 1000);
}
function resetMuteButton() {
$('cbMute').textContent = 'Mute';
$('cbMute').onclick = toggleMute;
}
function toggleMute() {
const line = activeLine();
if (!line || !line.call) return;
line.muted = !line.muted;
try { line.call.mute(line.muted); } catch {}
renderCallBar();
}
$('cbMute').onclick = toggleMute;
// Jump to the keypad mid-call — the reason the pad exists during a call is
// phone menus, and hunting for the Dial tab while a robot counts down is not
// the moment to go looking.
$('cbKeys').onclick = () => {
S.padCompose = false;
show('dial');
renderPadMode();
$('pad').scrollIntoView({ block: 'center', behavior: 'smooth' });
};
$('cbEnd').onclick = () => {
const line = activeLine();
if (line) dropLine(line.id);
};
// Save a note straight from the call bar — the whole point of keeping it pinned.
$('cbSave').onclick = async () => {
const body = $('cbNote').value.trim();
if (!body) return;
try {
const line = activeLine();
await api('/notes', { method: 'POST', body: { body, phone: line?.phone || S.dial, callSid: line?.sid } });
$('cbNote').value = '';
toast('Note saved');
if (S.tab === 'notes') loadNotes();
} catch (e) { toast(e.message, true); }
};
// Keep the screen from sleeping mid-call on mobile.
async function keepAwake() {
try { S.wakeLock = await navigator.wakeLock?.request('screen'); } catch {}
}
function releaseWake() { try { S.wakeLock?.release(); } catch {} S.wakeLock = null; }
// ── Phonebook ─────────────────────────────────────────────────────────
let bookTimer;
$('bookSearch').oninput = () => { clearTimeout(bookTimer); bookTimer = setTimeout(loadContacts, 200); };
$('addContact').onclick = () => contactSheet({});
async function loadContacts() {
const q = $('bookSearch').value.trim();
S.contacts = await api('/contacts?q=' + encodeURIComponent(q));
const el = $('bookList');
if (!S.contacts.length) {
el.innerHTML = `${q ? 'No one matches that.' : 'Your phonebook is empty.
Tap + to add your first contact.'}
`;
return;
}
const favs = S.contacts.filter((c) => c.favorite);
const rest = S.contacts.filter((c) => !c.favorite);
el.innerHTML =
(favs.length ? `Favorites
` + favs.map(rowContact).join('') : '') +
(rest.length ? `${favs.length ? 'All contacts
' : ''}` + rest.map(rowContact).join('') : '');
el.querySelectorAll('[data-cid]').forEach((n) => n.onclick = () => openContact(Number(n.dataset.cid)));
}
const rowContact = (c) => `
`;
// ── Contact detail — calls, texts, and notes for one person ──────────
async function openContact(id) {
const c = await api('/contacts/' + id);
$('sheetTitle').textContent = c.name;
$('sheetAct').textContent = 'Edit';
$('sheetAct').onclick = () => contactSheet(c);
$('sheetBody').innerHTML = `
${esc(initials(c.name))}
${esc(fmt(c.phone_e164))}
${esc([c.company, c.email].filter(Boolean).join(' · ')) || 'No company on file'}
${c.tags.length ? `${c.tags.map((t) => `${esc(t)}`).join('')}
` : ''}
Add a note
Notes (${c.notes.length})
${c.notes.length ? c.notes.map((n) => noteCard(n, true)).join('') : '
No notes yet.
'}
Recent calls
${c.calls.length ? c.calls.slice(0,12).map(rowCall).join('') : 'No calls yet.
'}
`;
$('dCall').onclick = () => { closeSheet(); show('dial'); S.dial = c.phone_e164; syncReadout(); placeCall(c.phone_e164, c.name); };
$('dText').onclick = () => textSheet(c.phone_e164, c.name);
$('dNoteSave').onclick = async () => {
const body = $('dNoteBody').value.trim();
if (!body) return;
await api('/notes', { method: 'POST', body: { body, contactId: c.id, phone: c.phone_e164 } });
toast('Note saved');
openContact(id);
};
wireNoteCards(() => openContact(id));
// Recent-call rows here need their own wiring — loadCalls() never ran.
$('sheetBody').querySelectorAll('[data-sid]').forEach((n) => n.onclick = () => {
const sid = n.dataset.sid;
callSheet(sid, { ...c.calls.find((x) => x.call_sid === sid), contact_name: c.name });
});
openSheet();
}
// ── Contact editor ────────────────────────────────────────────────────
function contactSheet(c) {
const isNew = !c.id;
$('sheetTitle').textContent = isNew ? 'New contact' : 'Edit contact';
$('sheetAct').textContent = 'Save';
$('sheetBody').innerHTML = `
${isNew ? '' : ``}
`;
$('sheetAct').onclick = async () => {
const body = {
name: $('f-name').value.trim(),
phone: $('f-phone').value.trim(),
company: $('f-company').value.trim(),
email: $('f-email').value.trim(),
tags: $('f-tags').value.split(',').map((t) => t.trim()).filter(Boolean),
favorite: $('f-fav').checked,
};
try {
const saved = isNew
? await api('/contacts', { method: 'POST', body })
: await api('/contacts/' + c.id, { method: 'PATCH', body });
toast('Saved');
openContact(saved.id);
loadContacts();
} catch (e) { toast(e.message, true); }
};
if (!isNew) $('f-del').onclick = async () => {
await api('/contacts/' + c.id, { method: 'DELETE' });
closeSheet(); loadContacts(); toast('Contact deleted');
};
openSheet();
}
// ── Texting ───────────────────────────────────────────────────────────
async function textSheet(phone, name) {
$('sheetTitle').textContent = name || fmt(phone);
$('sheetAct').textContent = 'Send';
const msgs = await api('/messages?phone=' + encodeURIComponent(phone)).catch(() => []);
$('sheetBody').innerHTML = `
${msgs.length ? msgs.map((m) => {
const out = m.direction === 'outbound';
return `
${esc(m.body)}
${m.sent_at ? new Date(m.sent_at).toLocaleString([], {month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : ''}
`;
}).join('') : '
No messages yet. Say something.
'}
`;
$('tCall').onclick = () => { closeSheet(); show('dial'); S.dial = phone; syncReadout(); placeCall(phone, name); };
$('tContact').onclick = async () => {
const { contact } = await api('/lookup?phone=' + encodeURIComponent(phone));
contact ? openContact(contact.id) : contactSheet({ phone_e164: phone });
};
$('sheetAct').onclick = async () => {
const body = $('smsBody').value.trim();
if (!body) return toast('Write a message first', true);
try {
await api('/sms', { method: 'POST', body: { to: phone, body } });
toast('Sent');
textSheet(phone, name);
} catch (e) { toast(e.message, true); }
};
openSheet();
$('sheet').scrollTop = $('sheet').scrollHeight;
}
// ── Call log ──────────────────────────────────────────────────────────
let callTimer;
$('callSearch').oninput = () => { clearTimeout(callTimer); callTimer = setTimeout(loadCalls, 200); };
$('syncBtn').onclick = async () => {
toast('Pulling from Twilio…');
try { const r = await api('/sync', { method: 'POST' }); toast(`Synced ${r.calls} calls, ${r.messages} texts`); loadCalls(); }
catch (e) { toast(e.message, true); }
};
async function loadCalls() {
S.calls = await api('/calls');
const q = $('callSearch').value.trim().toLowerCase();
const list = q
? S.calls.filter((c) => (c.contact_name || '').toLowerCase().includes(q) || (q.replace(/\D/g,'') && (c.counterparty_e164 || '').includes(q.replace(/\D/g,''))))
: S.calls;
const el = $('callList');
el.innerHTML = list.length
? list.map(rowCall).join('')
: `No calls logged yet.
Tap ⟳ to pull your Twilio history.
`;
el.querySelectorAll('[data-sid]').forEach((n) => n.onclick = () => callSheet(n.dataset.sid));
}
function rowCall(c) {
const inbound = String(c.direction || '').startsWith('inbound');
const missed = inbound && !Number(c.duration);
return ``;
}
// `known` lets the contact page open a call sheet without S.calls being loaded.
async function callSheet(sid, known) {
const c = known || S.calls.find((x) => x.call_sid === sid);
if (!c) return;
const notes = await api('/notes?callSid=' + encodeURIComponent(sid)).catch(() => []);
$('sheetTitle').textContent = c.contact_name || fmt(c.counterparty_e164);
$('sheetAct').textContent = '';
$('sheetAct').onclick = null;
$('sheetBody').innerHTML = `
${esc(fmt(c.counterparty_e164))}
${c.started_at ? new Date(c.started_at).toLocaleString() : '—'} · ${dur(c.duration) || 'no answer'}
${c.contact_id ? `` : ``}
Note this call
Notes on this call (${notes.length})
${notes.length ? notes.map((n) => noteCard(n)).join('') : '
No notes on this call.
'}
`;
$('cbCall').onclick = () => { closeSheet(); show('dial'); S.dial = c.counterparty_e164; syncReadout(); placeCall(c.counterparty_e164, c.contact_name); };
if (c.contact_id) $('cbOpen').onclick = () => openContact(c.contact_id);
else $('cbAdd').onclick = () => contactSheet({ phone_e164: c.counterparty_e164 });
$('cNoteSave').onclick = async () => {
const body = $('cNoteBody').value.trim();
if (!body) return;
await api('/notes', { method: 'POST', body: { body, callSid: sid, phone: c.counterparty_e164, contactId: c.contact_id } });
toast('Note saved');
callSheet(sid);
};
wireNoteCards(() => callSheet(sid));
openSheet();
}
// ── Notes ─────────────────────────────────────────────────────────────
let noteTimer;
$('noteSearch').oninput = () => { clearTimeout(noteTimer); noteTimer = setTimeout(loadNotes, 220); };
$('addNote').onclick = () => noteSheet({});
async function loadNotes() {
S.notes = await api('/notes?q=' + encodeURIComponent($('noteSearch').value.trim()));
const el = $('noteList');
el.innerHTML = S.notes.length
? S.notes.map((n) => noteCard(n)).join('')
: `No notes yet.
Tap + to write one, or add notes from any call.
`;
wireNoteCards(loadNotes);
}
// hideWho is used on a contact's own page, where repeating their name on every
// note is noise.
const noteCard = (n, hideWho) => `
${hideWho ? '' : `${esc(n.contact_name || (n.phone_e164 ? fmt(n.phone_e164) : 'General note'))}`}
${n.created_at ? new Date(n.created_at).toLocaleDateString(undefined,{month:'short',day:'numeric'}) + ' · ' : ''}${ago(n.created_at)}
${esc(n.body)}
`;
function wireNoteCards(refresh) {
document.querySelectorAll('[data-nid]').forEach((card) => {
const id = Number(card.dataset.nid);
card.querySelectorAll('[data-act]').forEach((b) => b.onclick = async (e) => {
e.stopPropagation();
const act = b.dataset.act;
if (act === 'del') {
await api('/notes/' + id, { method: 'DELETE' });
toast('Note deleted');
} else if (act === 'pin') {
await api('/notes/' + id, { method: 'PATCH', body: { pinned: b.textContent === 'Pin' } });
} else {
const n = (S.notes.find((x) => x.id === id)) || { id, body: card.querySelector('.note-b').textContent };
return noteSheet(n, refresh);
}
refresh();
});
});
}
function noteSheet(n, refresh) {
const isNew = !n.id;
$('sheetTitle').textContent = isNew ? 'New note' : 'Edit note';
$('sheetAct').textContent = 'Save';
$('sheetBody').innerHTML = `
${isNew ? `` : ''}
`;
$('sheetAct').onclick = async () => {
const body = $('n-body').value.trim();
if (!body) return toast('Write something first', true);
if (isNew) await api('/notes', { method: 'POST', body: { body, phone: $('n-phone').value.trim() } });
else await api('/notes/' + n.id, { method: 'PATCH', body: { body } });
toast('Saved');
closeSheet();
(refresh || loadNotes)();
};
openSheet();
}
// ── Sheet ─────────────────────────────────────────────────────────────
function openSheet() { $('sheet').classList.add('on'); $('sheet').scrollTop = 0; }
function closeSheet() { $('sheet').classList.remove('on'); }
$('sheetBack').onclick = closeSheet;
document.addEventListener('keydown', (e) => { if (e["key"] === 'Escape') closeSheet(); });
$('textBtn').onclick = () => { if (S.dial) textSheet(S.dial); };
// ── Lock ──────────────────────────────────────────────────────────────
$('lockForm').onsubmit = async (e) => {
e.preventDefault();
try {
const r = await fetch('/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: $('pw').value }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Wrong password');
$('lock').classList.remove('on');
$('pw').value = '';
boot();
} catch (err) { $('lockMsg').textContent = err.message; }
};
// ── Boot ──────────────────────────────────────────────────────────────
async function boot() {
try {
S.cfg = await api('/config');
$('fromNum').textContent = fmt(S.cfg.fromNumber);
try { S.mode = localStorage.getItem('mode') || S.mode; } catch {}
if (S.mode === 'webrtc' && !S.cfg.webrtcReady) S.mode = 'bridge';
// Never land in a mode that cannot place a call.
if (S.mode === 'bridge' && !S.cfg.hasCell) S.mode = 'webrtc';
setMode(S.mode);
setLamp('', 'Idle');
syncReadout();
renderPadMode();
show('dial');
refreshBadges();
listAudioDevices();
flushFaults();
clearInterval(S.badgeTimer);
S.badgeTimer = setInterval(refreshBadges, 60000);
await handoffFromCRM();
} catch (e) { /* 401 already raised the lock screen */ }
}
// ── Hand-off from the CRM ─────────────────────────────────────────────
// The CRM's "push to phone" button (Lead detail) navigates
// here with the contact on the query string. We save it to the phonebook and cue
// the number.
//
// WHY A NAVIGATION AND NOT THE CRM CALLING OUR API. Different origin, and this
// app sets no Access-Control-Allow-Origin, so a cross-origin POST is blocked. The
// alternative — letting the CRM server push on our behalf — would mean giving it
// this app's password. Arriving as a navigation means the write happens on THIS
// origin under the operator's OWN session: no CORS, no shared secret.
//
// WE DO NOT PLACE THE CALL. The number is cued and the operator presses call.
// That is explicit in the request and is the safer default: an accidental
// outbound call costs real money and the do-not-call state lives in the CRM.
async function handoffFromCRM() {
const q = new URLSearchParams(location.search);
const to = (q.get('to') || '').trim();
if (!to) return;
// Strip the params before doing anything else, so a refresh or a tab the
// browser restores days later cannot silently re-add a contact and re-cue a
// number the operator has long moved past.
history.replaceState(null, '', location.pathname);
const contact = {
name: (q.get('name') || '').trim() || fmt(to),
phone: to,
company: (q.get('company') || '').trim() || null,
email: (q.get('email') || '').trim() || null,
tags: (q.get('tags') || '').split(',').map((t) => t.trim()).filter(Boolean),
};
// Raw fetch, not api(): api() throws on a non-2xx and 409 is an expected,
// meaningful answer here rather than an error.
try {
const r = await fetch('/api/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contact),
});
if (r.status === 409) {
// phone_e164 is UNIQUE and the conflict carries the existing id, so update
// in place. Pushing the same lead twice must not strand stale details.
const { id } = await r.json().catch(() => ({}));
if (id) {
await fetch('/api/contacts/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contact),
});
}
toast('Contact updated · number cued');
} else if (r.ok) {
toast('Contact added · number cued');
} else {
toast('Could not save contact — number still cued', true);
}
} catch {
toast('Could not save contact — number still cued', true);
}
// Cue regardless of whether the phonebook write succeeded: the operator came
// here to call this number, and failing to save a contact is no reason to make
// them retype it.
S.dial = to;
syncReadout();
show('dial');
}
(async () => {
const s = await fetch('/api/session').then((r) => r.json()).catch(() => ({ authed: false }));
if (s.authed) boot();
else $('lock').classList.add('on');
})();
// Coming back to a suspended tab: the token may have expired while the timers
// were frozen, so always re-issue it before trying to register again.
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return;
flushFaults();
if (S.mode !== 'webrtc' || !S.device) return;
const stale = !S.tokenAt || Date.now() - S.tokenAt > 30 * 60 * 1000;
if (stale) await refreshToken('resume');
if (S.device.state !== 'registered') { S.regBackoff = 0; scheduleReregister('resume'); }
});
// Anything that escapes a handler is a fault worth seeing later, not just a
// line in a console nobody has open on a phone.
window.addEventListener('error', (e) => {
logFault({ code: 'js-error', message: e.message, context: { src: `${e.filename}:${e.lineno}` } });
});
window.addEventListener('unhandledrejection', (e) => {
const r = e.reason;
if (r?.message === 'Locked') return; // the lock screen already says so
logFault({ code: r?.code || 'unhandled-rejection', message: r?.message || String(r) });
});
/** Twilio's raw errors are opaque; say what actually went wrong. */
function describeTwilioError(e) {
const code = Number(e && (e.code || (e.causes && e.code)));
const map = {
13224: 'Twilio would not dial that number. Check the digits and try again.',
31000: 'The call could not be set up. Try again.',
31003: 'Could not reach Twilio media servers — the network may be blocking them.',
31005: 'Connection to Twilio dropped. Check your network and try again.',
31009: 'Lost the connection to Twilio. Reconnecting…',
31201: 'No microphone available.',
31202: 'Microphone is blocked. Allow mic access for this site.',
31204: 'Session expired. Reconnecting…',
31205: 'Session expired. Reconnecting…',
31207: 'This account is not allowed to place that call.',
31402: 'Microphone permission denied. Allow it in your browser settings.',
31403: 'Microphone permission denied. Allow it in your browser settings.',
31480: 'The person you called is unavailable.',
31486: 'The line is busy.',
31603: 'The call was declined.',
53000: 'Signalling connection lost. Reconnecting…',
53405: 'Media connection failed — this network may be blocking call audio.',
};
if (code === 31204 || code === 31205) scheduleReregister('token-error');
if (e?.name === 'InvalidStateError') return 'Already on a call.';
return map[code] || (e && (e.message || e.explanation)) || 'Call error';
}
// ── Diagnostics ───────────────────────────────────────────────────────
// Everything needed to answer "why did that call misbehave" without opening
// the Twilio console: what this browser saw, what the server saw, and what
// Twilio itself recorded.
$('diagRefresh').onclick = async () => {
const out = $('diagOut');
out.innerHTML = 'Loading…
';
try {
await flushFaults();
const d = await api('/diag?limit=60');
const rows = [];
const r = Array.isArray(d.routing) ? d.routing[0] : null;
if (r) {
rows.push(row('info', 'routing',
r.inboundReachesThisApp
? `Inbound for ${r.number} reaches this app`
: `Inbound for ${r.number} does NOT reach this app — it goes to ${r.voiceApplicationSid || r.voiceUrl || 'nowhere'}`,
''));
}
if (Array.isArray(d.twilio)) {
for (const a of d.twilio.slice(0, 12)) {
rows.push(row(a.level === 'warning' ? 'warn' : '', `twilio ${a.code}`, a.text || '(no detail)', ago(a.at)));
}
} else if (d.twilio?.error) {
rows.push(row('warn', 'twilio', 'Could not read Twilio alerts: ' + d.twilio.error, ''));
}
for (const e of d.events || []) {
rows.push(row(e.level === 'error' ? '' : e.level, `${e.source} ${e.code || ''}`.trim(), e.message || '', ago(e.at)));
}
out.innerHTML = rows.length ? rows.join('') : 'Nothing logged.
';
} catch (e) {
out.innerHTML = `Could not load: ${esc(e.message)}
`;
}
renderNetLine();
function row(level, code, msg, when) {
return `${esc(code)}` +
`${esc(msg)}${esc(when)}
`;
}
};
// A test call rings the browser like any other call, so answering it goes
// through exactly the path a real one does — including the call bar.
async function testCall(kind, to) {
try {
const r = await api('/test-call', { method: 'POST', body: { kind, to } });
toast(kind === 'browser' ? 'Ringing this browser…' : `Calling ${r.describe}…`);
} catch (e) {
toast(e.message, true);
}
}
$('testBrowser').onclick = () => testCall('browser');
$('testInbound').onclick = () => testCall('inbound');
$('testDial').onclick = () => {
const n = $('testNumber').value.trim();
if (!n) return toast('Enter a number first', true);
testCall('number', n);
};
function renderNetLine() {
const q = readQueue().length;
$('diagNet').textContent =
`${navigator.onLine ? 'Online' : 'Offline'} · device ${S.device?.state || 'not started'} · ` +
`${S.lines.length} line(s)${q ? ` · ${q} fault(s) waiting to upload` : ''}`;
}
$('diagBox').addEventListener('toggle', () => { if ($('diagBox').open) renderNetLine(); });