diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 09:00:22 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 09:00:22 -0700 |
| commit | 2e897103836a6814a39dbb905d6a7ed1417344d7 (patch) | |
| tree | 3d3c8d497682802508d524cd42bab43e61bc8a33 /nwcpush | |
| parent | 265fc554840455c87b7e46a2f42bcb51e22318c1 (diff) | |
| download | coinosv3-server-2e897103836a6814a39dbb905d6a7ed1417344d7.tar.gz coinosv3-server-2e897103836a6814a39dbb905d6a7ed1417344d7.zip | |
nwcpush: calls wake a closed app. Kind 21059 wraps to a registered pubkey are watched; one big enough to be a call offer (an SDP) pushes 'call' at high urgency, a minute's TTL, naming the wrap, which the device fetches from GET /wrap/<id> (kept two minutes; a push can't carry it) and opens with its own key. ICE candidates and hang-ups are too small to wake anyone. A 'call' opt-out like the other reasons.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'nwcpush')
| -rw-r--r-- | nwcpush/server.js | 45 |
1 files changed, 41 insertions, 4 deletions
diff --git a/nwcpush/server.js b/nwcpush/server.js index 6d56f84..198757c 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -139,7 +139,8 @@ function resubscribeNotify() { const filters = []; // DMs / direct invites / zap receipts land p-tagged at the user // ...as do replies and mentions (kind 1 naming the user) and reactions (7) - if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#p': ptags, since: Math.floor(Date.now() / 1000) }); + // ...and calls ringing in (NIP-AC: ephemeral gift wraps, kind 21059) + if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7, CALL_WRAP], '#p': ptags, since: Math.floor(Date.now() / 1000) }); // community chat: wraps authored by a channel's derived stream key if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) }); // one sub per filter — the pool API takes a single filter object @@ -152,7 +153,27 @@ function resubscribeNotify() { // DMs are throttled barely at all now: the device drops the ones it decides // are noise (a stranger, or our own sent-copy), and a server-side cooldown // would let a discarded push swallow the friend's message that followed it. -const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 }; +const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000, call: 3_000 }; + +// Calls (NIP-AC). Every setup message of a call is a kind 21059 wrap to the +// person — the offer that rings them, then a stream of ICE candidates and +// the hang-up — and none can be read here. An offer carries a whole SDP, so +// its wrap is several kilobytes; candidates and hang-ups are a few hundred +// bytes. Only a wrap big enough to be an offer wakes the device (a push the +// device then shows nothing for counts against the site in Chrome), and a +// whole offer is too big for a push (~4 KB), so the push says which wrap and +// the device fetches it from here (GET /wrap/<id>) and opens it itself. +const CALL_WRAP = 21059; +const CALL_OFFER_MIN = 2000; // wrap content length: an offer's is ~4-10 KB, a candidate's ~1 KB +const CALL_KEEP_MS = 120_000; +const callWraps = new Map(); // wrap id -> { ev, at } +function keepCallWrap(ev) { + callWraps.set(ev.id, { ev, at: Date.now() }); + if (callWraps.size > 500) { + const cut = Date.now() - CALL_KEEP_MS; + for (const [k, v] of callWraps) if (v.at < cut || callWraps.size > 500) callWraps.delete(k); + } +} const lastPush = new Map(); // endpointId:reason -> ts const seenNotifyEvents = new Map(); // event id -> ts function onNotifyEvent(ev) { @@ -189,6 +210,14 @@ function onNotifyEvent(ev) { } return; } + if (ev.kind === CALL_WRAP) { + if (!p || String(ev.content || '').length < CALL_OFFER_MIN) return; + keepCallWrap(ev); + for (const [id, r] of Object.entries(notifyRegs)) { + if ((r.ptags || []).includes(p)) pushNotify(id, r, 'call', { wrapId: ev.id }).catch(() => {}); + } + return; + } const zap = ev.kind === 9735 || ev.kind === 9737; const reason = zap ? 'payment' : p && notifyPtags().includes(p) ? 'dm' : 'chat'; @@ -224,7 +253,8 @@ async function pushNotify(id, r, reason, extra) { // dozes, so it got them all). Money and people talking to you go 'high' // and keep for a day; reactions and community chatter can wait. const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason); - const opts = loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' }; + // a call rings now or not at all: a minute, then it's a missed call + const opts = reason === 'call' ? { TTL: 60, urgency: 'high' } : loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' }; try { try { await deliver(r.sub, payload, opts); @@ -384,6 +414,13 @@ const server = Bun.serve({ // The browser needs this to create a PushSubscription. if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey }); + // a call's offer wrap, for the device a push woke (see CALL_WRAP above): + // still encrypted to them, as every relay that carried it saw it + const wm = /^\/wrap\/([0-9a-f]{64})$/.exec(url.pathname); + if (wm && req.method === 'GET') { + const w = callWraps.get(wm[1]); + return w && Date.now() - w.at < CALL_KEEP_MS ? json(w.ev) : json({ error: 'gone' }, 404); + } // Publish-back for the auto-answering service worker: it cannot hold a // relay socket, so it hands us its (encrypted, service-key-signed) reply. @@ -463,7 +500,7 @@ const server = Bun.serve({ const id = Bun.hash(sub_.endpoint).toString(36); // per-category opt-outs; absent means everything on (older clients) const reasons = {}; - for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) { + for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction', 'call']) { if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k]; } notifyRegs[id] = { |
