Signal: keep messages that arrive glued together, one chat per person, receipts by time, faster reconnect; edit contacts, clear and remove chats; debug list
build-container / build (push) Successful in 7m31s

- read every JSON value in a frame (lines, arrays), binary frames too
- a person seen by number and by private id is one conversation (merged)
- delivery/read receipts match on message time, not on who sent them
- reconnect every 1-5 s; the chat list says when the link was down
- /api/signal/debug: kinds of messages seen lately (no text)
- edit a contact (kept in the app and sent to Signal), clear messages, remove a chat; right-click menu in the list; name for a new number
- tests: 54 checks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 11:00:34 -04:00
1 parent 522fdba4cd
commit bfd37967c3
4 files changed
+188 -27

No files matched your search

+98 -16
View File
@@ -7,6 +7,9 @@ const crypto = require('crypto');
const { Readable } = require('stream');
const MAX_PER_CONV = 1500;
const trace = []; // what has been seen lately (kinds of messages and connection changes, never the text), for working out why a message did not show
const note = (kind, extra) => { trace.push({ at: new Date().toISOString(), kind, ...extra }); if (trace.length > 300) trace.shift(); };
const ids = () => (state.db.ids = state.db.ids || {}); // a contact can show up by phone number or only by Signal's private id (uuid); this remembers which is whose
const state = { api: '', dir: '', account: '', db: { account: '', convs: {}, msgs: {} }, ws: null, wsUp: false, backoff: 1000, clients: new Set(), saveT: null,
contacts: { at: 0, map: new Map() }, groups: { at: 0, list: [] }, started: false };
@@ -45,9 +48,9 @@ async function loadContacts(force) {
try {
const list = await apiJson('GET', '/v1/contacts/' + encodeURIComponent(state.account));
const m = new Map();
for (const c of list || []) { const n = c.name || c.nickname && c.nickname.name || [c.given_name, c.family_name].filter(Boolean).join(' ') || c.profile_name || (c.profile && c.profile.given_name) || ''; if (c.number) m.set(c.number, n); }
for (const c of list || []) { const n = c.name || c.nickname && c.nickname.name || [c.given_name, c.family_name].filter(Boolean).join(' ') || c.profile_name || (c.profile && c.profile.given_name) || ''; if (c.number) m.set(c.number, n); if (c.number && c.uuid && ids()[c.uuid] !== c.number) { ids()[c.uuid] = c.number; mergeConv(c.uuid, c.number); } }
state.contacts.map = m;
for (const c of Object.values(state.db.convs)) if (c.type === 'direct' && m.get(c.id) && c.name !== m.get(c.id)) { c.name = m.get(c.id); }
for (const c of Object.values(state.db.convs)) if (c.type === 'direct') { const n = displayName(c.id, c.name !== c.id ? c.name : ''); if (n && c.name !== n) c.name = n; }
saveSoon();
} catch (e) { state.contacts.at = Date.now() - 9 * 60e3; /* try again in a minute */ }
}
@@ -62,7 +65,18 @@ async function loadGroups(force) {
} catch (e) { state.groups.at = Date.now() - 4 * 60e3; }
}
const groupApiId = internal => { const g = state.groups.list.find(x => x.internal_id === internal); return g ? g.id : 'group.' + Buffer.from(internal).toString('base64'); };
const displayName = (id, hint) => state.contacts.map.get(id) || hint || id;
const nicks = () => (state.db.nick = state.db.nick || {}); // names you chose for contacts in this app
const displayName = (id, hint) => nicks()[id] || state.contacts.map.get(id) || hint || id;
function setNick(number, name) {
name = name.trim().slice(0, 80);
if (name) nicks()[number] = name; else delete nicks()[number];
const c = state.db.convs[number]; if (c) { c.name = displayName(number, ''); emit({ t: 'update', conv: number }); }
saveSoon(); return name;
}
function dropFiles(cid) { // pictures you sent that were kept here
for (const m of state.db.msgs[cid] || []) for (const a of m.att || []) if (a.local && /^local-[0-9a-f]+$/.test(a.id)) { try { fs.unlinkSync(path.join(state.dir, 'out', a.id)); } catch { /* already gone */ } }
}
// ---- keeping the conversations
function emit(ev) {
@@ -95,6 +109,22 @@ function addMessage(cid, c, m) {
return true;
}
// One person, one conversation, whether Signal names them by number or only by private id.
function peer(number, uuid) {
const map = ids();
if (number && uuid && map[uuid] !== number) { map[uuid] = number; mergeConv(uuid, number); saveSoon(); }
return number || (uuid && map[uuid]) || uuid || '';
}
function mergeConv(from, to) {
const a = state.db.convs[from]; if (!a || from === to) return;
const b = state.db.convs[to] || (state.db.convs[to] = { id: to, type: 'direct', name: a.name, ts: 0, preview: '', unread: 0 });
const list = (state.db.msgs[to] = state.db.msgs[to] || []);
for (const m of state.db.msgs[from] || []) if (!list.some(x => x.id === m.id || (x.ts === m.ts && x.out === m.out))) list.push(m);
list.sort((x, y) => x.ts - y.ts); b.unread = (b.unread || 0) + (a.unread || 0);
const last = list[list.length - 1]; if (last) { b.ts = last.ts; b.preview = (last.out ? 'You: ' : '') + previewOf(last); }
if (b.name === to && a.name !== from) b.name = a.name;
delete state.db.convs[from]; delete state.db.msgs[from]; emit({ t: 'update', conv: to });
}
function ingestData(dm, from, fromName, ts, out, dest) {
const gid = dm.groupInfo && dm.groupInfo.groupId;
const cid = gid ? 'g:' + gid : (out ? dest : from);
@@ -130,7 +160,6 @@ function ingestReceipt(r, who) {
if (!status) return;
let changed = null;
for (const [cid, list] of Object.entries(state.db.msgs)) {
if (cid !== who && !cid.startsWith('g:')) continue;
for (const m of list) if (m.out && (r.timestamps || []).includes(m.ts) && (m.status !== 'read')) { m.status = status; changed = cid; }
}
if (changed) { saveSoon(); emit({ t: 'update', conv: changed }); }
@@ -138,35 +167,65 @@ function ingestReceipt(r, who) {
function ingest(env) {
if (!env) return;
const ts = env.timestamp || Date.now(), who = env.sourceNumber || env.source || env.sourceUuid || '', name = env.sourceName || '';
const ts = env.timestamp || Date.now(), who = peer(env.sourceNumber, env.sourceUuid || (/^[0-9a-f-]{36}$/i.test(env.source || '') ? env.source : '')) || env.source || '', name = env.sourceName || '';
if (env.dataMessage) ingestData(env.dataMessage, who, name, env.dataMessage.timestamp || ts, false);
else if (env.editMessage) ingestEdit(env.editMessage, who, ts, false);
else if (env.syncMessage && env.syncMessage.sentMessage) {
const sm = env.syncMessage.sentMessage;
if (sm.editMessage) ingestEdit(sm.editMessage, state.account, ts, true, sm.destinationNumber || sm.destination);
else ingestData(sm, state.account, '', sm.timestamp || ts, true, sm.destinationNumber || sm.destination || sm.destinationUuid);
if (sm.editMessage) ingestEdit(sm.editMessage, state.account, ts, true, peer(sm.destinationNumber, sm.destinationUuid) || sm.destination);
else ingestData(sm, state.account, '', sm.timestamp || ts, true, peer(sm.destinationNumber, sm.destinationUuid) || sm.destination);
} else if (env.receiptMessage) ingestReceipt(env.receiptMessage, who);
else if (env.typingMessage) emit({ t: 'typing', conv: env.typingMessage.groupId ? 'g:' + env.typingMessage.groupId : who, stop: env.typingMessage.action === 'STOPPED' });
}
// A frame normally holds one message, but messages that arrive close together could come glued together (one JSON per line, an array, or
// back to back), so read every JSON value in it instead of giving up when the whole thing is not one value.
function jsonValues(text) {
try { const v = JSON.parse(text); return Array.isArray(v) ? v : [v]; } catch { /* more than one value, or damaged */ }
const out = []; let depth = 0, start = -1, inStr = false, esc = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true;
else if (c === '{') { if (depth++ === 0) start = i; }
else if (c === '}' && depth > 0 && --depth === 0) { try { out.push(JSON.parse(text.slice(start, i + 1))); } catch { /* skip this one */ } }
}
return out;
}
function handleRaw(text) {
let m; try { m = JSON.parse(text); } catch { return; }
const env = m.envelope || (m.params && m.params.envelope) || (m.result && m.result.envelope);
try { ingest(env); } catch (e) { console.error('[signal] could not read a message:', e.message); }
const values = jsonValues(text);
if (!values.length) { note('unreadable', { bytes: String(text).length }); return; }
if (values.length > 1) note('several-in-one-frame', { count: values.length });
for (const m of values) {
const env = m && (m.envelope || (m.params && m.params.envelope) || (m.result && m.result.envelope));
const kinds = env ? Object.keys(env).filter(k => /Message$/.test(k)) : ['no-envelope:' + Object.keys(m || {}).join(',')];
note('envelope', { kinds, from: env && (env.sourceNumber || env.source || '').slice(-4), group: !!(env && env.dataMessage && env.dataMessage.groupInfo), text: !!(env && ((env.dataMessage && env.dataMessage.message) || (env.syncMessage && env.syncMessage.sentMessage && env.syncMessage.sentMessage.message))), att: !!(env && env.dataMessage && (env.dataMessage.attachments || []).length) });
try { ingest(env); } catch (e) { note('error', { message: e.message }); console.error('[signal] could not read a message:', e.message); }
}
}
// ---- the link to signal-cli
// signal-cli hands a message only to whoever is listening at that moment, so the hub reconnects quickly and says so when it was away.
function connect() {
if (!state.account || state.ws || typeof WebSocket === 'undefined') return;
const url = state.api.replace(/^http/, 'ws') + '/v1/receive/' + encodeURIComponent(state.account);
let ws;
try { ws = new WebSocket(url); } catch (e) { return setTimeout(connect, state.backoff = Math.min(state.backoff * 2, 30000)); }
try { ws = new WebSocket(url); } catch (e) { return retrySoon(); }
state.ws = ws;
ws.onopen = () => { state.wsUp = true; state.backoff = 1000; emit({ t: 'status' }); };
ws.onmessage = e => handleRaw(typeof e.data === 'string' ? e.data : String(e.data));
const gone = () => { if (state.ws !== ws) return; state.ws = null; state.wsUp = false; emit({ t: 'status' }); const t = setTimeout(connect, state.backoff = Math.min(state.backoff * 2, 30000)); t.unref && t.unref(); };
ws.onopen = () => {
state.wsUp = true; state.backoff = 1000; note('connected');
if (state.downSince) { const from = state.downSince; state.downSince = 0; if (Date.now() - from > 2500) state.gap = { from, to: Date.now() }; console.log(`[signal] connected again after ${Math.round((Date.now() - from) / 1000)} s`); } else console.log('[signal] connected');
emit({ t: 'status' });
};
const text = d => typeof d === 'string' ? handleRaw(d) : d && typeof d.text === 'function' ? d.text().then(handleRaw) : d instanceof ArrayBuffer ? handleRaw(Buffer.from(d).toString('utf8')) : handleRaw(String(d)); // (a binary frame is read as text too)
ws.onmessage = e => text(e.data);
const gone = ev => {
if (state.ws !== ws) return; state.ws = null; state.wsUp = false; if (!state.downSince) state.downSince = Date.now();
note('disconnected', { code: ev && ev.code, reason: ev && ev.reason }); console.log('[signal] connection lost' + (ev && ev.code ? ' (code ' + ev.code + ')' : '')); emit({ t: 'status' }); retrySoon();
};
ws.onclose = gone; ws.onerror = gone;
}
function retrySoon() { const t = setTimeout(connect, state.backoff); state.backoff = Math.min(state.backoff + 1000, 5000); t.unref && t.unref(); } // 1 s, 2 s ... never longer than 5 s
async function refreshAccount() {
try {
const list = await apiJson('GET', '/v1/accounts');
@@ -218,7 +277,8 @@ async function send(cid, text, atts, quoteTs) {
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.replace(/^\/api\/signal/, '') || '/';
try {
if (p === '/status') return json(res, 200, { linked: !!state.account, number: state.account, connected: state.wsUp, reachable: state.reachable !== false, unread: totalUnread() });
if (p === '/status') return json(res, 200, { linked: !!state.account, number: state.account, connected: state.wsUp, reachable: state.reachable !== false, unread: totalUnread(), gap: state.gap || null });
if (p === '/debug') return json(res, 200, { account: state.account ? '…' + state.account.slice(-4) : '', connected: state.wsUp, gap: state.gap || null, convs: Object.keys(state.db.convs).length, trace }); // what arrived lately: kinds only, no message text
if (p === '/qr') { // a new link code each time; scanned in Signal -> Settings -> Linked devices
const r = await api('GET', '/v1/qrcodelink?device_name=' + encodeURIComponent('NE Hub'), null, 60000);
if (!r.ok) return json(res, 502, { error: 'Could not get a link code from the Signal service' });
@@ -232,7 +292,12 @@ async function handle(req, res) {
}
if (!state.account) return json(res, 409, { error: 'Signal is not linked yet' });
if (p === '/convs') return json(res, 200, { convs: Object.values(state.db.convs).sort((a, b) => b.ts - a.ts), unread: totalUnread() });
if (p === '/contacts') { await loadContacts(false); return json(res, 200, { contacts: [...state.contacts.map].map(([number, name]) => ({ number, name })).sort((a, b) => (a.name || a.number).localeCompare(b.name || b.number)) }); }
if (p === '/contacts') {
await loadContacts(false);
const all = new Map(state.contacts.map); for (const n of Object.keys(nicks())) if (!all.has(n)) all.set(n, '');
for (const c of Object.values(state.db.convs)) if (c.type === 'direct' && /^\+/.test(c.id) && !all.has(c.id)) all.set(c.id, '');
return json(res, 200, { contacts: [...all].map(([number, name]) => ({ number, name: nicks()[number] || name, mine: !!nicks()[number] })).sort((a, b) => (a.name || a.number).localeCompare(b.name || b.number)) });
}
let m;
if ((m = /^\/conv\/(.+)$/.exec(p)) && req.method === 'GET') {
const id = decodeURIComponent(m[1]), c = state.db.convs[id]; if (!c) return json(res, 404, { error: 'Unknown conversation' });
@@ -243,8 +308,25 @@ async function handle(req, res) {
if (p === '/new' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 10000)), n = String(b.number || '').replace(/[\s()-]/g, '');
if (!/^\+\d{6,15}$/.test(n)) return json(res, 400, { error: 'Enter a phone number with its country code, like +15551234567' });
if (String(b.name || '').trim()) setNick(n, String(b.name));
const c = conv(n, 'direct', ''); saveSoon(); return json(res, 200, { conv: c });
}
if (p === '/contact' && req.method === 'POST') { // rename a contact (an empty name goes back to the name Signal has)
const b = JSON.parse(await readBody(req, 10000)), n = String(b.number || '').replace(/[\s()-]/g, '');
if (!/^\+\d{6,15}$/.test(n) && !/^[0-9a-f-]{36}$/i.test(n)) return json(res, 400, { error: 'That is not a phone number' });
const name = setNick(n, String(b.name || ''));
let bridge = false;
if (name && /^\+/.test(n)) { try { await apiJson('PUT', '/v1/contacts/' + encodeURIComponent(state.account), { recipient: n, name }); bridge = true; } catch { /* kept in this app anyway */ } }
return json(res, 200, { ok: true, name: displayName(n, ''), bridge });
}
if (p === '/conv/delete' && req.method === 'POST') { // remove a chat from this app (it stays on your phone; a new message starts it again)
const b = JSON.parse(await readBody(req, 10000)); if (!state.db.convs[b.id]) return json(res, 404, { error: 'Unknown conversation' });
dropFiles(b.id); delete state.db.convs[b.id]; delete state.db.msgs[b.id]; saveSoon(); emit({ t: 'gone', conv: b.id }); return json(res, 200, { ok: true });
}
if (p === '/conv/clear' && req.method === 'POST') { // empty a chat but keep it in the list
const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.id]; if (!c) return json(res, 404, { error: 'Unknown conversation' });
dropFiles(b.id); state.db.msgs[b.id] = []; c.preview = ''; c.unread = 0; saveSoon(); emit({ t: 'update', conv: b.id }); return json(res, 200, { ok: true });
}
if (p === '/read' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.id]; if (!c) return json(res, 404, { error: 'Unknown conversation' });
c.unread = 0; saveSoon(); emit({ t: 'read', conv: c.id });