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
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:
1 parent
522fdba4cd
commit
bfd37967c3
4 files changed
+188
-27
No files matched your search
+55
-3
@@ -13,15 +13,17 @@ const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||
|
||||
(async () => {
|
||||
// ---- fake signal-cli-rest-api
|
||||
const fake = { accounts: [], sends: [], receipts: [], reactions: [], sockets: new Set(), contacts: [{ number: '+15551234567', name: 'Sam Rivera' }], groups: [{ id: 'group.' + Buffer.from('GID123==').toString('base64'), internal_id: 'GID123==', name: 'Family' }] };
|
||||
const fake = { accounts: [], puts: [], sends: [], receipts: [], reactions: [], sockets: new Set(), contacts: [{ number: '+15551234567', name: 'Sam Rivera' }], groups: [{ id: 'group.' + Buffer.from('GID123==').toString('base64'), internal_id: 'GID123==', name: 'Family' }] };
|
||||
const frame = text => { const p = Buffer.from(text), n = p.length; const h = n < 126 ? Buffer.from([0x81, n]) : Buffer.from([0x81, 126, n >> 8, n & 255]); return Buffer.concat([h, p]); };
|
||||
const push = o => { for (const s of fake.sockets) s.write(frame(JSON.stringify(o))); };
|
||||
const pushRaw = text => { for (const s of fake.sockets) s.write(frame(text)); };
|
||||
const push = (o, binary) => { for (const s of fake.sockets) { const f = frame(JSON.stringify(o)); if (binary) f[0] = 0x82; s.write(f); } };
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
|
||||
const srv = http.createServer((rq, rs) => {
|
||||
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
|
||||
const b = Buffer.concat(body).toString(), u = new URL(rq.url, 'http://x'), j = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
||||
if (u.pathname === '/v1/accounts') return j(200, fake.accounts);
|
||||
if (u.pathname === '/v1/qrcodelink') { rs.writeHead(200, { 'content-type': 'image/png' }); return rs.end(png); }
|
||||
if (u.pathname.startsWith('/v1/contacts/') && rq.method === 'PUT') { fake.puts.push(JSON.parse(b)); return j(204, {}); }
|
||||
if (u.pathname.startsWith('/v1/contacts/')) return j(200, fake.contacts);
|
||||
if (u.pathname.startsWith('/v1/groups/')) return j(200, fake.groups);
|
||||
if (u.pathname === '/v2/send') { fake.sends.push(JSON.parse(b)); return j(201, { timestamp: String(1700000000000 + fake.sends.length * 1000) }); }
|
||||
@@ -87,6 +89,31 @@ const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||
r = await call('/api/signal/attachment/att-1'); ok('and it can be fetched through the hub', r.status === 200 && r.headers.get('content-type') === 'image/png' && (await r.arrayBuffer()).byteLength === png.length, r.status);
|
||||
r = await call('/api/signal/attachment/..%2F..%2Fetc%2Fpasswd'); ok('attachment names cannot wander off', r.status === 400 || r.status === 404, r.status);
|
||||
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 3500, dataMessage: { timestamp: T0 + 3500, message: 'sent as a binary frame' } } }, true);
|
||||
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); return t.msgs.some(m => m.text === 'sent as a binary frame') ? t : false; });
|
||||
ok('a message that comes in a binary frame is read too', !!th);
|
||||
const e1 = { account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 3510, dataMessage: { timestamp: T0 + 3510, message: 'glued one' } } };
|
||||
const e2 = { account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 3520, dataMessage: { timestamp: T0 + 3520, message: 'glued two with {braces} and "quotes"' } } };
|
||||
pushRaw(JSON.stringify(e1) + '\n' + JSON.stringify(e2));
|
||||
pushRaw(JSON.stringify([{ ...e1, envelope: { ...e1.envelope, timestamp: T0 + 3530, dataMessage: { timestamp: T0 + 3530, message: 'in an array' } } }]));
|
||||
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); return ['glued one', 'glued two with {braces} and "quotes"', 'in an array'].every(x => t.msgs.some(m => m.text === x)) ? t : false; });
|
||||
ok('messages that arrive glued together in one frame (or in a list) are all kept', !!th);
|
||||
const dbg = await getJ('/api/signal/debug');
|
||||
ok('there is a log of what arrived (kinds only), to find out why a message did not show', dbg.trace.some(x => x.kind === 'envelope' && x.kinds.includes('dataMessage')) && !JSON.stringify(dbg).includes('Hi there'), dbg.trace.slice(-2));
|
||||
r = await call('/api/signal/debug', {}, 'ufriend1'); ok('and only people with the Signal tab can see it', r.status === 403, r.status);
|
||||
|
||||
// the same person by private id only (no number), and a conversation that had been started under that id
|
||||
const UU = '0d1f2a3b-4c5d-6e7f-8091-a2b3c4d5e6f7';
|
||||
push({ account: ME, envelope: { sourceUuid: UU, source: UU, sourceName: 'Lee', timestamp: T0 + 3600, dataMessage: { timestamp: T0 + 3600, message: 'from a private id' } } });
|
||||
await until(async () => (await getJ('/api/signal/convs')).convs.some(c => c.id === UU));
|
||||
push({ account: ME, envelope: { sourceNumber: '+15558889999', sourceUuid: UU, sourceName: 'Lee', timestamp: T0 + 3700, dataMessage: { timestamp: T0 + 3700, message: 'now with the number' } } });
|
||||
const merged = await until(async () => { const c = (await getJ('/api/signal/convs')).convs; return !c.some(x => x.id === UU) && c.some(x => x.id === '+15558889999') ? c : false; });
|
||||
const lee = merged && (await getJ('/api/signal/conv/' + encodeURIComponent('+15558889999'))).msgs.map(m => m.text).join('|');
|
||||
ok('a person seen first by private id and then by number is one conversation, with both messages', lee === 'from a private id|now with the number', lee);
|
||||
push({ account: ME, envelope: { sourceUuid: UU, source: UU, timestamp: T0 + 3800, dataMessage: { timestamp: T0 + 3800, message: 'later, id only' } } });
|
||||
const lee2 = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15558889999')); return t.msgs.some(m => m.text === 'later, id only') ? t : false; });
|
||||
ok('and later messages that carry only the id land in the same place', !!lee2);
|
||||
|
||||
// reactions, receipts, edits, deletes
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 4000, dataMessage: { timestamp: T0 + 4000, reaction: { emoji: '👍', targetAuthorNumber: ME, targetSentTimestamp: T0 + 2000 } } } });
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 5000, editMessage: { targetSentTimestamp: T0, dataMessage: { message: 'Hi there (edited)' } } } });
|
||||
@@ -98,6 +125,7 @@ const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||
r = await post('/api/signal/send', { to: '+15551234567', text: 'On my way' }); let sent = (await r.json()).msg;
|
||||
ok('sending to a person', r.status === 200 && fake.sends.length === 1 && fake.sends[0].number === ME && fake.sends[0].recipients[0] === '+15551234567' && fake.sends[0].message === 'On my way', fake.sends);
|
||||
ok('your sent message appears at once', sent && sent.out && sent.status === 'sent');
|
||||
push({ account: ME, envelope: { sourceUuid: '11111111-2222-3333-4444-555555555555', timestamp: T0 + 6900, receiptMessage: { isDelivery: true, timestamps: [sent.ts] } } }); // (a receipt from someone Signal names only by id)
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 7000, receiptMessage: { isDelivery: true, timestamps: [sent.ts] } } });
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 8000, receiptMessage: { isRead: true, timestamps: [sent.ts] } } });
|
||||
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); const m = t.msgs.find(x => x.ts === sent.ts); return m && m.status === 'read' ? t : false; });
|
||||
@@ -120,6 +148,30 @@ const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||
ok('starting a chat with a new number', r.status === 200 && nc.id === '+15550009999', nc);
|
||||
r = await post('/api/signal/new', { number: 'abc' }); ok('a bad number is refused', r.status === 400);
|
||||
|
||||
// editing contacts and removing chats
|
||||
r = await post('/api/signal/contact', { number: '+15551234567', name: ' Sammy ' });
|
||||
convs = (await getJ('/api/signal/convs')).convs;
|
||||
ok('a contact can be renamed, here and in Signal', r.status === 200 && convs.find(c => c.id === '+15551234567').name === 'Sammy' && fake.puts[0].recipient === '+15551234567' && fake.puts[0].name === 'Sammy', [convs.map(c => c.name), fake.puts]);
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 9500, dataMessage: { timestamp: T0 + 9500, message: 'name should stay' } } });
|
||||
await until(async () => (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs.some(m => m.text === 'name should stay'));
|
||||
ok('your name for them is kept when they write again', (await getJ('/api/signal/convs')).convs.find(c => c.id === '+15551234567').name === 'Sammy');
|
||||
const cl = (await getJ('/api/signal/contacts')).contacts; ok('the contact list shows your name', cl.find(c => c.number === '+15551234567').name === 'Sammy' && cl.find(c => c.number === '+15551234567').mine === true, cl);
|
||||
r = await post('/api/signal/contact', { number: '+15551234567', name: '' });
|
||||
ok('an empty name goes back to the name Signal has', (await getJ('/api/signal/convs')).convs.find(c => c.id === '+15551234567').name === 'Sam Rivera');
|
||||
r = await post('/api/signal/contact', { number: 'nope', name: 'x' }); ok('a bad number is refused', r.status === 400, r.status);
|
||||
r = await post('/api/signal/new', { number: '+15550007777', name: 'Pat' }); ok('a new chat can be started with a name', (await r.json()).conv.name === 'Pat');
|
||||
r = await post('/api/signal/send', { to: '+15550007777', text: 'pic', attachments: [{ name: 'p.png', type: 'image/png', data: png.toString('base64') }] });
|
||||
const outDir = path.join(dataDir, 'signal', 'out'), filesBefore = fs.readdirSync(outDir).length;
|
||||
r = await post('/api/signal/conv/clear', { id: 'g:GID123==' });
|
||||
ok('clearing a chat empties it but keeps it', r.status === 200 && (await getJ('/api/signal/conv/' + encodeURIComponent('g:GID123=='))).msgs.length === 0 && (await getJ('/api/signal/convs')).convs.some(c => c.id === 'g:GID123=='));
|
||||
r = await post('/api/signal/conv/delete', { id: '+15550007777' });
|
||||
ok('removing a chat takes it out of the list', r.status === 200 && !(await getJ('/api/signal/convs')).convs.some(c => c.id === '+15550007777'));
|
||||
ok('and deletes the pictures kept for it', fs.readdirSync(outDir).length === filesBefore - 1, [filesBefore, fs.readdirSync(outDir).length]);
|
||||
ok('the live feed says so', events.some(e => e.t === 'gone' && e.conv === '+15550007777'), events.slice(-3));
|
||||
r = await post('/api/signal/conv/delete', { id: 'nobody' }); ok('removing something unknown is refused', r.status === 404);
|
||||
push({ account: ME, envelope: { sourceNumber: '+15550007777', sourceName: 'Pat', timestamp: T0 + 9800, dataMessage: { timestamp: T0 + 9800, message: 'back again' } } });
|
||||
ok('a new message from a removed chat starts it again', !!(await until(async () => (await getJ('/api/signal/convs')).convs.some(c => c.id === '+15550007777'))));
|
||||
|
||||
// restart: history stays; the socket coming back
|
||||
ac.abort(); child.kill(); await wait(800); const before = JSON.parse(fs.readFileSync(path.join(dataDir, 'signal', 'store.json'), 'utf8'));
|
||||
ok('conversations are saved on disk', Object.keys(before.convs).length >= 3, Object.keys(before.convs));
|
||||
@@ -128,7 +180,7 @@ const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||
ok('after a restart the history is still there', Array.isArray(convs) && convs.some(c => c.id === '+15551234567'), convs);
|
||||
await until(async () => (await getJ('/api/signal/status')).connected);
|
||||
for (const s of fake.sockets) s.destroy(); // the service drops the connection
|
||||
const back = await until(async () => { const s = await getJ('/api/signal/status'); return s.connected && fake.sockets.size > 0; }, 12000);
|
||||
const back = await until(async () => { const s = await getJ('/api/signal/status'); return s.connected && fake.sockets.size > 0; }, 6000);
|
||||
ok('it reconnects by itself if the Signal service drops the link', !!back);
|
||||
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 9000, dataMessage: { timestamp: T0 + 9000, message: 'Still there?' } } });
|
||||
ok('and messages keep arriving', !!(await until(async () => (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs.some(m => m.text === 'Still there?'))));
|
||||
|
||||
Reference in new issue
Block a user