libp2r2p 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- package/web-storage-queue/index.js +652 -0
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
// Expected use:
|
|
2
|
+
// const messenger = await createPrivateMessenger({
|
|
3
|
+
// userSigner,
|
|
4
|
+
// contentKeySigner, // optional when userSigner handles content keys internally
|
|
5
|
+
// nymSigner: optionalDefaultNymSigner,
|
|
6
|
+
// channels: [{ signer: privateChannelSigner, relays, mode: 'leecher', seeders: optionalSeederPubkeys }],
|
|
7
|
+
// onContentKeyChange: event => reviewContentKeyUse(event),
|
|
8
|
+
// onError: err => reportPrivateMessengerError(err)
|
|
9
|
+
// })
|
|
10
|
+
//
|
|
11
|
+
// Channel roles:
|
|
12
|
+
// - Default channel: { signer } signs, publishes, and decrypts the outer router with the same channel key.
|
|
13
|
+
// - Split reader channel: { signer, readerPubkey } signs as the channel key but encrypts/decrypts the outer router with the reader pubkey.
|
|
14
|
+
// - Reader-secret channel: { signer, readerSigner } is also valid; the reader signer decrypts the router.
|
|
15
|
+
// - Reader-only channel: { pubkey, readerSigner } can watch/fetch/drain messages but cannot send or seed recovery replies.
|
|
16
|
+
// for await (const msg of messenger.messages()) handlePrivateMessage(msg)
|
|
17
|
+
// await messenger.ask({ receiverPubkey, payload: { ping: true } })
|
|
18
|
+
// await messenger.reply({ question: msg.question, payload: { ok: true } })
|
|
19
|
+
// await messenger.tell({ receiverPubkey, payload: { note: 'hello' } })
|
|
20
|
+
// await messenger.yell({ receiverPubkeys, payload: { notice: 'hello all' } })
|
|
21
|
+
// await messenger.broadcastRumor({ receiverPubkeys, rumor: { kind, tags: [], content } })
|
|
22
|
+
// await messenger.broadcastEvent({ receiverPubkeys, event: signedNostrEvent })
|
|
23
|
+
// await messenger.broadcastNymRumor({ rumor: { kind, tags: [], content } })
|
|
24
|
+
// await messenger.broadcastNymEvent({ event: signedNostrEvent })
|
|
25
|
+
// await messenger.update({ channels: [{ signer: privateChannelSigner, relays, seeders: nextOptionalSeederPubkeys }] })
|
|
26
|
+
// await messenger.clearChannel(channelPubkey)
|
|
27
|
+
//
|
|
28
|
+
// Missed-message recovery:
|
|
29
|
+
// - Each watched channel stores lastSeenAt/lastWatchedAt in localStorage.
|
|
30
|
+
// - Re-watching after reload fetches the gap from lastSeenAt to now.
|
|
31
|
+
// - Browser offline/online events add explicit offline ranges with a small skew.
|
|
32
|
+
// - Ranges older than 7 days are ignored; channel state not watched for 45 days is pruned.
|
|
33
|
+
// - Seeders announce presence every 10min and are used for the relay-uncovered left edge of a missed range.
|
|
34
|
+
// - Configured seeders are all asked; auto-discovered seeders are capped to the 8 most recently active.
|
|
35
|
+
// - Seeder/watchtower channels store reconstructed router events in a separate IndexedDB queue and auto-reply to recovery asks.
|
|
36
|
+
// - Seeder replies stream compact routers with createMissingMessageReplyPacker({ messenger, question }).update(seed), then finalize(optionalLastSeed).
|
|
37
|
+
// - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
|
|
38
|
+
|
|
39
|
+
import * as privateMessage from '../private-message/index.js'
|
|
40
|
+
import { bytesToBase64 } from '../base64/index.js'
|
|
41
|
+
import { getRelaysByPubkey, pickRelaysForPubkeys, subscribeRelayListUpdates } from '../relay/index.js'
|
|
42
|
+
import * as privateChannel from '../private-channel/index.js'
|
|
43
|
+
import { cleanupTemporaryStorage as cleanupTemporaryChannelStorage } from '../temporary-storage/index.js'
|
|
44
|
+
import { createQueue } from '../idb-queue/index.js'
|
|
45
|
+
import { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
|
|
46
|
+
import {
|
|
47
|
+
compactSeedNymCarriers,
|
|
48
|
+
compactSeedRouterRows,
|
|
49
|
+
createEventReplyPacker,
|
|
50
|
+
createMissingMessageReplyPacker,
|
|
51
|
+
MISSING_MESSAGES_ASK_CODE,
|
|
52
|
+
MISSING_MESSAGES_REPLY_CODE,
|
|
53
|
+
NYM_CARRIER_SEED_RECORD_TYPE,
|
|
54
|
+
ROUTER_SEED_RECORD_TYPE,
|
|
55
|
+
routerSeedRowKey,
|
|
56
|
+
SEEDER_PRESENCE_CODE
|
|
57
|
+
} from './recovery/index.js'
|
|
58
|
+
|
|
59
|
+
export { DEFAULT_STALE_CHANNEL_SECONDS } from './constants/index.js'
|
|
60
|
+
export {
|
|
61
|
+
compactSeedNymCarriers,
|
|
62
|
+
compactSeedRouterRows,
|
|
63
|
+
createEventReplyPacker,
|
|
64
|
+
createMissingMessageReplyPacker,
|
|
65
|
+
MISSING_MESSAGES_ASK_CODE,
|
|
66
|
+
MISSING_MESSAGES_REPLY_CODE,
|
|
67
|
+
NYM_CARRIER_SEED_RECORD_TYPE,
|
|
68
|
+
ROUTER_SEED_RECORD_TYPE,
|
|
69
|
+
routerSeedRowKey,
|
|
70
|
+
SEEDER_PRESENCE_CODE
|
|
71
|
+
} from './recovery/index.js'
|
|
72
|
+
|
|
73
|
+
const DEFAULT_OFFLINE_RECOVERY_SECONDS = 7 * 24 * 60 * 60
|
|
74
|
+
const DEFAULT_OFFLINE_SKEW_SECONDS = 30
|
|
75
|
+
const DEFAULT_RELOAD_GAP_DELAY_MS = 500
|
|
76
|
+
const DEFAULT_SEEDER_PRESENCE_INTERVAL_MS = 10 * 60 * 1000
|
|
77
|
+
const DEFAULT_SEEDER_ONLINE_SECONDS = 20 * 60
|
|
78
|
+
const DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS = 8
|
|
79
|
+
const DEFAULT_MESSAGE_QUEUE_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
|
|
80
|
+
const DEFAULT_SEED_QUEUE_MAX_BYTES = 64 * 1024 * 1024 // 64 MiB
|
|
81
|
+
const SEED_KEY = '__p2r2pSeedKey'
|
|
82
|
+
const SEED_TIME = '__p2r2pSeedTime'
|
|
83
|
+
const MESSAGE_QUEUE_INDEXES = {
|
|
84
|
+
byChannel: 'channelPubkey',
|
|
85
|
+
byChannelTypeEventId: {
|
|
86
|
+
keyPath: ['channelPubkey', 'type', 'event.id'],
|
|
87
|
+
unique: true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const SEED_QUEUE_INDEXES = {
|
|
91
|
+
byChannel: 'channelPubkey',
|
|
92
|
+
bySeedKey: { keyPath: SEED_KEY, unique: true },
|
|
93
|
+
byChannelTime: ['channelPubkey', SEED_TIME],
|
|
94
|
+
byTime: SEED_TIME
|
|
95
|
+
}
|
|
96
|
+
const encoder = new TextEncoder()
|
|
97
|
+
const noContentKeys = async () => ({})
|
|
98
|
+
|
|
99
|
+
function textToBase64 (text) {
|
|
100
|
+
return bytesToBase64(encoder.encode(text))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defaultOnError (err) {
|
|
104
|
+
console.warn('private-messenger failed', err?.message ?? err)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nowSeconds () {
|
|
108
|
+
return Math.floor(Date.now() / 1000)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function uniq (values) {
|
|
112
|
+
return [...new Set((values || []).filter(Boolean))]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isPlainObject (value) {
|
|
116
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function storesRecoverySeeds (mode) {
|
|
120
|
+
return mode === 'seeder' || mode === 'watchtower'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJson (raw, fallback) {
|
|
124
|
+
try { return JSON.parse(raw || '') } catch { return fallback }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class PrivateMessenger {
|
|
128
|
+
static cleanupTemporaryStorage ({ storageArea = globalThis.sessionStorage } = {}) {
|
|
129
|
+
cleanupTemporaryChannelStorage({ storageArea })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
constructor ({
|
|
133
|
+
offlineRecoverySeconds = DEFAULT_OFFLINE_RECOVERY_SECONDS,
|
|
134
|
+
staleChannelSeconds = DEFAULT_STALE_CHANNEL_SECONDS,
|
|
135
|
+
offlineSkewSeconds = DEFAULT_OFFLINE_SKEW_SECONDS,
|
|
136
|
+
reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
|
|
137
|
+
seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
|
|
138
|
+
seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
|
|
139
|
+
maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
|
|
140
|
+
messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
|
|
141
|
+
seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
|
|
142
|
+
temporaryStorageArea = globalThis.sessionStorage,
|
|
143
|
+
_indexedDB = globalThis.indexedDB,
|
|
144
|
+
useContentKeys = true,
|
|
145
|
+
onContentKeyChange,
|
|
146
|
+
onMessageQueued,
|
|
147
|
+
onDebug,
|
|
148
|
+
onError = defaultOnError,
|
|
149
|
+
_privateMessage = privateMessage,
|
|
150
|
+
_privateChannel = privateChannel,
|
|
151
|
+
_getRelaysByPubkey = getRelaysByPubkey,
|
|
152
|
+
_pickRelaysForPubkeys = pickRelaysForPubkeys,
|
|
153
|
+
_subscribeRelayListUpdates = subscribeRelayListUpdates,
|
|
154
|
+
_setTimeout = globalThis.setTimeout.bind(globalThis),
|
|
155
|
+
_setInterval = globalThis.setInterval.bind(globalThis),
|
|
156
|
+
_clearInterval = globalThis.clearInterval.bind(globalThis)
|
|
157
|
+
} = {}) {
|
|
158
|
+
this.offlineRecoverySeconds = offlineRecoverySeconds
|
|
159
|
+
this.staleChannelSeconds = staleChannelSeconds
|
|
160
|
+
this.offlineSkewSeconds = offlineSkewSeconds
|
|
161
|
+
this.reloadGapDelayMs = reloadGapDelayMs
|
|
162
|
+
this.seederPresenceIntervalMs = seederPresenceIntervalMs
|
|
163
|
+
this.seederOnlineSeconds = seederOnlineSeconds
|
|
164
|
+
this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
|
|
165
|
+
this.messageQueueMaxBytes = messageQueueMaxBytes
|
|
166
|
+
this.seedQueueMaxBytes = seedQueueMaxBytes
|
|
167
|
+
this.temporaryStorageArea = temporaryStorageArea
|
|
168
|
+
this._indexedDB = _indexedDB
|
|
169
|
+
this.useContentKeys = useContentKeys
|
|
170
|
+
this.onContentKeyChange = onContentKeyChange
|
|
171
|
+
this.onMessageQueued = onMessageQueued
|
|
172
|
+
this.onDebug = onDebug
|
|
173
|
+
this.onError = onError
|
|
174
|
+
this._privateMessage = _privateMessage
|
|
175
|
+
this._privateChannel = _privateChannel
|
|
176
|
+
this._getRelaysByPubkey = _getRelaysByPubkey
|
|
177
|
+
this._pickRelaysForPubkeys = _pickRelaysForPubkeys
|
|
178
|
+
this._subscribeRelayListUpdates = _subscribeRelayListUpdates
|
|
179
|
+
this._setTimeout = _setTimeout
|
|
180
|
+
this._setInterval = _setInterval
|
|
181
|
+
this._clearInterval = _clearInterval
|
|
182
|
+
|
|
183
|
+
this.userSigner = null
|
|
184
|
+
this.contentKeySigner = null
|
|
185
|
+
this.nymSigner = null
|
|
186
|
+
this.userPubkey = ''
|
|
187
|
+
this.contentKeyPubkey = ''
|
|
188
|
+
this.prefix = ''
|
|
189
|
+
this.queue = null
|
|
190
|
+
this.seedQueue = null
|
|
191
|
+
this.channels = new Map()
|
|
192
|
+
this.stopByChannel = new Map()
|
|
193
|
+
this.presenceTimers = new Map()
|
|
194
|
+
this.stopRelayListWatcher = null
|
|
195
|
+
this.relayListWatcherPubkey = ''
|
|
196
|
+
this.relayListRefreshPromise = null
|
|
197
|
+
this.stopOnline = null
|
|
198
|
+
this.stopOffline = null
|
|
199
|
+
this.queueOperationTail = Promise.resolve()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async init ({ userSigner, contentKeySigner, nymSigner, channels = [], relays = [], mode = 'leecher' }) {
|
|
203
|
+
if (!userSigner?.getPublicKey) throw new Error('USER_SIGNER_REQUIRED')
|
|
204
|
+
PrivateMessenger.cleanupTemporaryStorage({ storageArea: this.temporaryStorageArea })
|
|
205
|
+
this.userSigner = userSigner
|
|
206
|
+
this.contentKeySigner = contentKeySigner || null
|
|
207
|
+
this.nymSigner = nymSigner || null
|
|
208
|
+
this.userPubkey = await userSigner.getPublicKey()
|
|
209
|
+
this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
|
|
210
|
+
this.prefix = `libp2r2p:private-messenger:${this.userPubkey}`
|
|
211
|
+
this.queue = await createQueue({
|
|
212
|
+
prefix: this.prefix,
|
|
213
|
+
indexes: MESSAGE_QUEUE_INDEXES,
|
|
214
|
+
maxBytes: this.messageQueueMaxBytes,
|
|
215
|
+
evictionPolicy: 'fifo',
|
|
216
|
+
indexedDB: this._indexedDB
|
|
217
|
+
})
|
|
218
|
+
this.seedQueue = await createQueue({
|
|
219
|
+
prefix: `${this.prefix}:seeds`,
|
|
220
|
+
indexes: SEED_QUEUE_INDEXES,
|
|
221
|
+
maxBytes: this.seedQueueMaxBytes,
|
|
222
|
+
evictionPolicy: 'fifo',
|
|
223
|
+
indexedDB: this._indexedDB
|
|
224
|
+
})
|
|
225
|
+
await this.cleanupStaleChannels()
|
|
226
|
+
await this.update({ userSigner, contentKeySigner, nymSigner: this.nymSigner, channels, relays, mode })
|
|
227
|
+
return this
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
runQueueOperation (operation) {
|
|
231
|
+
const run = this.queueOperationTail.then(operation)
|
|
232
|
+
this.queueOperationTail = run.catch(err => {
|
|
233
|
+
try { this.onError?.(err) } catch {}
|
|
234
|
+
})
|
|
235
|
+
return run
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
queueIncoming (operation) {
|
|
239
|
+
return this.runQueueOperation(operation).catch(() => undefined)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
debug (action, detail = {}) {
|
|
243
|
+
try {
|
|
244
|
+
this.onDebug?.({ source: 'private-messenger', action, ...detail })
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.onError?.(err)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
debugSend (method, channelPubkey, detail = {}) {
|
|
251
|
+
const receiverPubkeys = uniq(detail.receiverPubkeys || (detail.receiverPubkey ? [detail.receiverPubkey] : []))
|
|
252
|
+
this.debug('send', {
|
|
253
|
+
method,
|
|
254
|
+
type: method,
|
|
255
|
+
code: detail.code || '',
|
|
256
|
+
channelPubkey,
|
|
257
|
+
senderPubkey: this.userPubkey,
|
|
258
|
+
receiverPubkey: detail.receiverPubkey || '',
|
|
259
|
+
receiverPubkeys,
|
|
260
|
+
receiverCount: receiverPubkeys.length
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async update ({ userSigner = this.userSigner, contentKeySigner = this.contentKeySigner, nymSigner = this.nymSigner, channels = [...this.channels.values()], relays = [], mode = 'leecher' } = {}) {
|
|
265
|
+
if (userSigner) {
|
|
266
|
+
const userPubkey = await userSigner.getPublicKey?.()
|
|
267
|
+
if (!userPubkey) throw new Error('USER_SIGNER_REQUIRED')
|
|
268
|
+
if (this.userPubkey && userPubkey !== this.userPubkey) throw new Error('USER_SIGNER_MISMATCH')
|
|
269
|
+
this.userSigner = userSigner
|
|
270
|
+
}
|
|
271
|
+
this.contentKeySigner = contentKeySigner || null
|
|
272
|
+
this.nymSigner = nymSigner || null
|
|
273
|
+
this.contentKeyPubkey = await this.contentKeySigner?.getPublicKey?.() || ''
|
|
274
|
+
const nextChannels = await this.normalizeChannels(channels, { relays, mode })
|
|
275
|
+
const nextPubkeys = new Set(nextChannels.map(channel => channel.pubkey))
|
|
276
|
+
|
|
277
|
+
for (const pubkey of [...this.channels.keys()]) {
|
|
278
|
+
if (!nextPubkeys.has(pubkey)) {
|
|
279
|
+
this.unwatch(pubkey)
|
|
280
|
+
this.channels.delete(pubkey)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const channel of nextChannels) this.channels.set(channel.pubkey, channel)
|
|
284
|
+
|
|
285
|
+
await this.cleanupStaleChannels()
|
|
286
|
+
await this.watch()
|
|
287
|
+
await this.reconcilePresencePublishers()
|
|
288
|
+
return this
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async normalizeChannels (channels, defaults) {
|
|
292
|
+
const out = []
|
|
293
|
+
for (const entry of channels || []) {
|
|
294
|
+
const channel = typeof entry === 'string' ? { pubkey: entry } : entry
|
|
295
|
+
const signer = channel.signer || channel.privateChannelSigner || null
|
|
296
|
+
const readerSigner = channel.readerSigner || channel.privateChannelReaderSigner || signer || null
|
|
297
|
+
const nymSigner = channel.nymSigner || null
|
|
298
|
+
const hasChannelRelays = Boolean(channel.relays?.length)
|
|
299
|
+
const hasChannelSendRelays = Boolean(channel.sendRelays?.length)
|
|
300
|
+
const hasDefaultRelays = Boolean(defaults.relays?.length)
|
|
301
|
+
const pubkey = channel.pubkey || await signer?.getPublicKey?.()
|
|
302
|
+
if (!pubkey) throw new Error('CHANNEL_PUBKEY_REQUIRED')
|
|
303
|
+
if (!signer && !readerSigner) throw new Error('CHANNEL_SIGNER_REQUIRED')
|
|
304
|
+
const mode = channel.mode || defaults.mode || 'leecher'
|
|
305
|
+
if (!signer && storesRecoverySeeds(mode)) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
306
|
+
const readerPubkey = channel.readerPubkey || channel.privateChannelReaderPubkey || await readerSigner?.getPublicKey?.() || pubkey
|
|
307
|
+
out.push({
|
|
308
|
+
pubkey,
|
|
309
|
+
signer,
|
|
310
|
+
readerSigner,
|
|
311
|
+
readerPubkey,
|
|
312
|
+
nymSigner,
|
|
313
|
+
relays: uniq(hasChannelRelays ? channel.relays : defaults.relays),
|
|
314
|
+
sendRelays: uniq(hasChannelSendRelays ? channel.sendRelays : []),
|
|
315
|
+
usesNip65WatchRelays: !hasChannelRelays && !hasDefaultRelays,
|
|
316
|
+
mode,
|
|
317
|
+
seeders: uniq(channel.seeders)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
return out
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async readRelayToReceivers (receiverPubkeys) {
|
|
324
|
+
const pubkeys = uniq(receiverPubkeys)
|
|
325
|
+
if (!pubkeys.length) return new Map()
|
|
326
|
+
const relaysByPubkey = await this._getRelaysByPubkey(pubkeys)
|
|
327
|
+
return this._pickRelaysForPubkeys(pubkeys, relaysByPubkey, { relayType: 'read' })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async readRelaysForPubkey (pubkey) {
|
|
331
|
+
const relaysByPubkey = await this._getRelaysByPubkey([pubkey])
|
|
332
|
+
const readRelays = uniq(relaysByPubkey?.[pubkey]?.read)
|
|
333
|
+
if (readRelays.length) return readRelays
|
|
334
|
+
return relayMapRelays(this._pickRelaysForPubkeys([pubkey], relaysByPubkey, { relayType: 'read' }))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async recoveryMirrorRelays (channelPubkey) {
|
|
338
|
+
const seeders = this.recoverySeeders(channelPubkey)
|
|
339
|
+
if (!seeders.length) return []
|
|
340
|
+
try {
|
|
341
|
+
return relayMapRelays(await this.readRelayToReceivers(seeders))
|
|
342
|
+
} catch (err) {
|
|
343
|
+
this.onError?.(err)
|
|
344
|
+
return []
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async resolveWatchRelays (channel) {
|
|
349
|
+
if (!channel.usesNip65WatchRelays && channel.relays.length) return channel.relays
|
|
350
|
+
return this.readRelaysForPubkey(this.userPubkey)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers }) {
|
|
354
|
+
const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
|
|
355
|
+
if (relayToReceivers) return { relayToReceivers, recoveryRelays }
|
|
356
|
+
if (relays?.length) return { relays: uniq(relays), recoveryRelays }
|
|
357
|
+
if (channel.sendRelays.length) return { relays: channel.sendRelays, recoveryRelays }
|
|
358
|
+
if (channel.relays.length) return { relays: channel.relays, recoveryRelays }
|
|
359
|
+
const derived = await this.readRelayToReceivers(receiverPubkeys)
|
|
360
|
+
if (!relayMapRelays(derived).length) throw new Error('NO_RELAYS')
|
|
361
|
+
return { relayToReceivers: derived, recoveryRelays }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
stateKey () {
|
|
365
|
+
return `${this.prefix}:state`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
readState () {
|
|
369
|
+
const state = parseJson(localStorage.getItem(this.stateKey()), { channels: {} })
|
|
370
|
+
if (!isPlainObject(state.channels)) state.channels = {}
|
|
371
|
+
return state
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
writeState (state) {
|
|
375
|
+
localStorage.setItem(this.stateKey(), JSON.stringify(state))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
updateChannelState (pubkey, patch) {
|
|
379
|
+
const state = this.readState()
|
|
380
|
+
const current = state.channels[pubkey] || {}
|
|
381
|
+
state.channels[pubkey] = { ...current, ...patch }
|
|
382
|
+
this.writeState(state)
|
|
383
|
+
return state.channels[pubkey]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
removeChannelState (pubkey) {
|
|
387
|
+
const state = this.readState()
|
|
388
|
+
delete state.channels[pubkey]
|
|
389
|
+
this.writeState(state)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
markSeen (pubkey, createdAt = nowSeconds()) {
|
|
393
|
+
const state = this.readState()
|
|
394
|
+
const current = state.channels[pubkey] || {}
|
|
395
|
+
current.lastSeenAt = Math.max(current.lastSeenAt || 0, createdAt || 0)
|
|
396
|
+
state.channels[pubkey] = current
|
|
397
|
+
this.writeState(state)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
knownSeeders (pubkey) {
|
|
401
|
+
const channel = this.channels.get(pubkey)
|
|
402
|
+
if (channel?.seeders?.length) return channel.seeders
|
|
403
|
+
const activity = this.readState().channels[pubkey]?.seederActivity || {}
|
|
404
|
+
return Object.keys(activity)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
recoverySeeders (pubkey) {
|
|
408
|
+
const channel = this.channels.get(pubkey)
|
|
409
|
+
const configuredSeeders = channel?.seeders || []
|
|
410
|
+
if (configuredSeeders.length) return configuredSeeders.filter(seeder => seeder !== this.userPubkey)
|
|
411
|
+
|
|
412
|
+
const activity = this.readState().channels[pubkey]?.seederActivity || {}
|
|
413
|
+
const cutoff = nowSeconds() - this.seederOnlineSeconds
|
|
414
|
+
return Object.entries(activity)
|
|
415
|
+
.filter(([seeder, entry]) => seeder !== this.userPubkey && (entry.lastActiveAt || 0) >= cutoff)
|
|
416
|
+
.sort((a, b) => (b[1].lastActiveAt || 0) - (a[1].lastActiveAt || 0))
|
|
417
|
+
.slice(0, this.maxDynamicRecoverySeeders)
|
|
418
|
+
.map(([seeder]) => seeder)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
markSeederActive (channelPubkey, seederPubkey, { announced = false, at = nowSeconds() } = {}) {
|
|
422
|
+
const state = this.readState()
|
|
423
|
+
const current = state.channels[channelPubkey] || {}
|
|
424
|
+
const activity = current.seederActivity || {}
|
|
425
|
+
const entry = activity[seederPubkey] || {}
|
|
426
|
+
activity[seederPubkey] = {
|
|
427
|
+
...entry,
|
|
428
|
+
firstSeenAt: entry.firstSeenAt || at,
|
|
429
|
+
lastActiveAt: Math.max(entry.lastActiveAt || 0, at)
|
|
430
|
+
}
|
|
431
|
+
if (announced) activity[seederPubkey].announcedAt = at
|
|
432
|
+
current.seederActivity = activity
|
|
433
|
+
state.channels[channelPubkey] = current
|
|
434
|
+
this.writeState(state)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
trackSeederActivity (channelPubkey, message) {
|
|
438
|
+
const senderPubkey = message.event?.pubkey
|
|
439
|
+
if (!senderPubkey) return false
|
|
440
|
+
|
|
441
|
+
const channel = this.channels.get(channelPubkey)
|
|
442
|
+
if (!channel) return false
|
|
443
|
+
|
|
444
|
+
const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
|
|
445
|
+
const isPresence = messageCode(message) === SEEDER_PRESENCE_CODE
|
|
446
|
+
const configuredSeeders = channel.seeders || []
|
|
447
|
+
const isConfiguredSeeder = configuredSeeders.includes(senderPubkey)
|
|
448
|
+
const isKnownDynamicSeeder = Boolean(activity[senderPubkey])
|
|
449
|
+
const at = messageTime(message)
|
|
450
|
+
|
|
451
|
+
if (isPresence) {
|
|
452
|
+
if (configuredSeeders.length && !isConfiguredSeeder) return false
|
|
453
|
+
this.markSeederActive(channelPubkey, senderPubkey, { announced: true, at })
|
|
454
|
+
return true
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!isConfiguredSeeder && !isKnownDynamicSeeder) return false
|
|
458
|
+
this.markSeederActive(channelPubkey, senderPubkey, { at })
|
|
459
|
+
return true
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
contentKeyStatus (contentKeyPubkey) {
|
|
463
|
+
if (!contentKeyPubkey) return 'none'
|
|
464
|
+
return contentKeyPubkey === this.contentKeyPubkey ? 'known' : 'unknown'
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
handleContentKeyUsage (channelPubkey, usage) {
|
|
468
|
+
const direction = usage.direction === 'sent' ? 'sent' : 'received'
|
|
469
|
+
const contentKeyPubkey = usage.contentKeyPubkey || ''
|
|
470
|
+
const state = this.readState()
|
|
471
|
+
const current = state.channels[channelPubkey] || {}
|
|
472
|
+
const contentKeyUsage = current.contentKeyUsage || {}
|
|
473
|
+
const previous = contentKeyUsage[direction] || null
|
|
474
|
+
|
|
475
|
+
const contentKeyStatus = this.contentKeyStatus(contentKeyPubkey)
|
|
476
|
+
if (
|
|
477
|
+
previous &&
|
|
478
|
+
(previous.contentKeyPubkey || '') === contentKeyPubkey &&
|
|
479
|
+
previous.contentKeyStatus === contentKeyStatus
|
|
480
|
+
) {
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
const event = {
|
|
484
|
+
type: 'content-key-change',
|
|
485
|
+
channelPubkey,
|
|
486
|
+
direction,
|
|
487
|
+
keyRole: usage.keyRole || (direction === 'sent' ? 'sender' : 'receiver'),
|
|
488
|
+
contentKeyPubkey,
|
|
489
|
+
hasContentKey: Boolean(contentKeyPubkey),
|
|
490
|
+
contentKeyStatus,
|
|
491
|
+
previousContentKeyPubkey: previous?.contentKeyPubkey ?? null,
|
|
492
|
+
previousContentKeyStatus: previous?.contentKeyStatus ?? null,
|
|
493
|
+
senderPubkey: usage.senderPubkey || '',
|
|
494
|
+
receiverPubkey: usage.receiverPubkey || '',
|
|
495
|
+
receiverPubkeys: usage.receiverPubkeys || [],
|
|
496
|
+
counterpartyPubkey: direction === 'sent' ? (usage.receiverPubkey || '') : (usage.senderPubkey || ''),
|
|
497
|
+
isBroadcast: Boolean(usage.isBroadcast),
|
|
498
|
+
outerId: usage.outer?.id || '',
|
|
499
|
+
outerCreatedAt: usage.outer?.created_at || 0,
|
|
500
|
+
routerPubkey: usage.router?.pubkey || '',
|
|
501
|
+
routerCreatedAt: usage.router?.created_at || 0
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
contentKeyUsage[direction] = {
|
|
505
|
+
contentKeyPubkey,
|
|
506
|
+
contentKeyStatus,
|
|
507
|
+
changedAt: nowSeconds(),
|
|
508
|
+
senderPubkey: event.senderPubkey,
|
|
509
|
+
receiverPubkey: event.receiverPubkey,
|
|
510
|
+
isBroadcast: event.isBroadcast
|
|
511
|
+
}
|
|
512
|
+
current.contentKeyUsage = contentKeyUsage
|
|
513
|
+
state.channels[channelPubkey] = current
|
|
514
|
+
this.writeState(state)
|
|
515
|
+
this.onContentKeyChange?.(event)
|
|
516
|
+
return true
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
addOfflineRange (pubkey, start, end) {
|
|
520
|
+
const now = nowSeconds()
|
|
521
|
+
const minStart = now - this.offlineRecoverySeconds
|
|
522
|
+
const normalized = {
|
|
523
|
+
start: Math.max(0, Math.floor(start)),
|
|
524
|
+
end: Math.floor(end)
|
|
525
|
+
}
|
|
526
|
+
if (normalized.end <= normalized.start || normalized.end < minStart) return
|
|
527
|
+
normalized.start = Math.max(normalized.start, minStart)
|
|
528
|
+
|
|
529
|
+
const state = this.readState()
|
|
530
|
+
const current = state.channels[pubkey] || {}
|
|
531
|
+
const ranges = (current.offlineRanges || [])
|
|
532
|
+
.filter(range => range.end >= minStart)
|
|
533
|
+
.concat([normalized])
|
|
534
|
+
.sort((a, b) => a.start - b.start)
|
|
535
|
+
current.offlineRanges = mergeRanges(ranges)
|
|
536
|
+
state.channels[pubkey] = current
|
|
537
|
+
this.writeState(state)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
closeOpenOfflineRanges () {
|
|
541
|
+
const state = this.readState()
|
|
542
|
+
const end = nowSeconds()
|
|
543
|
+
const minStart = end - this.offlineRecoverySeconds
|
|
544
|
+
for (const pubkey of Object.keys(state.channels)) {
|
|
545
|
+
const current = state.channels[pubkey]
|
|
546
|
+
if (!current.openOfflineStart) continue
|
|
547
|
+
const start = Math.max(minStart, Math.max(0, current.openOfflineStart))
|
|
548
|
+
if (end > start) {
|
|
549
|
+
current.offlineRanges = mergeRanges((current.offlineRanges || []).concat([{ start, end }]))
|
|
550
|
+
}
|
|
551
|
+
delete current.openOfflineStart
|
|
552
|
+
state.channels[pubkey] = current
|
|
553
|
+
}
|
|
554
|
+
this.writeState(state)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async watch (channels = [...this.channels.keys()], { scheduleReloadGap = true } = {}) {
|
|
558
|
+
const channelPubkeys = uniq(channels)
|
|
559
|
+
for (const pubkey of channelPubkeys) {
|
|
560
|
+
const channel = this.channels.get(pubkey)
|
|
561
|
+
if (!channel) throw new Error('UNKNOWN_CHANNEL')
|
|
562
|
+
const watchRelays = await this.resolveWatchRelays(channel)
|
|
563
|
+
const stop = await this._privateMessage.watch({
|
|
564
|
+
channels: [pubkey],
|
|
565
|
+
relays: watchRelays,
|
|
566
|
+
receiverSigner: this.userSigner,
|
|
567
|
+
iykcSigner: this.contentKeySigner,
|
|
568
|
+
privateChannelSigner: channel.signer,
|
|
569
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
570
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
571
|
+
mode: channel.mode,
|
|
572
|
+
onAsk: message => this.queueIncoming(() => this.handleAsk(pubkey, message)),
|
|
573
|
+
onReply: message => this.queueIncoming(() => this.handleReply(pubkey, message)),
|
|
574
|
+
onTell: message => this.queueIncoming(() => this.handleTell(pubkey, message)),
|
|
575
|
+
onYell: message => this.queueIncoming(() => this.handleYell(pubkey, message)),
|
|
576
|
+
onNym: message => this.queueIncoming(() => this.handleNym(pubkey, message)),
|
|
577
|
+
onMessage: message => this.queueIncoming(() => this.handleMessage(pubkey, message)),
|
|
578
|
+
onSeed: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
|
|
579
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
580
|
+
receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
|
|
581
|
+
onError: err => this.onError?.(err)
|
|
582
|
+
})
|
|
583
|
+
this.stopByChannel.set(pubkey, stop)
|
|
584
|
+
this.updateChannelState(pubkey, {
|
|
585
|
+
lastWatchedAt: nowSeconds(),
|
|
586
|
+
mode: channel.mode,
|
|
587
|
+
relays: watchRelays,
|
|
588
|
+
seeders: channel.seeders
|
|
589
|
+
})
|
|
590
|
+
this.debug('watch', {
|
|
591
|
+
channelPubkey: pubkey,
|
|
592
|
+
relays: watchRelays,
|
|
593
|
+
mode: channel.mode,
|
|
594
|
+
seeders: channel.seeders,
|
|
595
|
+
seederCount: channel.seeders.length
|
|
596
|
+
})
|
|
597
|
+
if (scheduleReloadGap) this.scheduleReloadGap(pubkey)
|
|
598
|
+
}
|
|
599
|
+
this.ensureNetworkWatchers()
|
|
600
|
+
this.ensureRelayListWatcher()
|
|
601
|
+
return this
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
unwatch (channels) {
|
|
605
|
+
const channelPubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.stopByChannel.keys()]
|
|
606
|
+
for (const pubkey of channelPubkeys) {
|
|
607
|
+
this.stopByChannel.get(pubkey)?.()
|
|
608
|
+
this.stopByChannel.delete(pubkey)
|
|
609
|
+
this.stopPresencePublisher(pubkey)
|
|
610
|
+
}
|
|
611
|
+
this.ensureRelayListWatcher()
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
nip65WatchChannelPubkeys () {
|
|
615
|
+
return [...this.channels.values()]
|
|
616
|
+
.filter(channel => channel.usesNip65WatchRelays && this.stopByChannel.has(channel.pubkey))
|
|
617
|
+
.map(channel => channel.pubkey)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
ensureRelayListWatcher () {
|
|
621
|
+
const channelPubkeys = this.nip65WatchChannelPubkeys()
|
|
622
|
+
if (!channelPubkeys.length) {
|
|
623
|
+
this.stopRelayListWatcher?.()
|
|
624
|
+
this.stopRelayListWatcher = null
|
|
625
|
+
this.relayListWatcherPubkey = ''
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if (this.stopRelayListWatcher && this.relayListWatcherPubkey === this.userPubkey) return
|
|
629
|
+
this.stopRelayListWatcher?.()
|
|
630
|
+
if (typeof window === 'undefined' && this._subscribeRelayListUpdates === subscribeRelayListUpdates) return
|
|
631
|
+
this.relayListWatcherPubkey = this.userPubkey
|
|
632
|
+
this.stopRelayListWatcher = this._subscribeRelayListUpdates([this.userPubkey], {
|
|
633
|
+
relayType: 'read',
|
|
634
|
+
onChange: () => this.refreshNip65WatchRelays()
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
refreshNip65WatchRelays () {
|
|
639
|
+
if (!this.relayListRefreshPromise) {
|
|
640
|
+
this.relayListRefreshPromise = Promise.resolve()
|
|
641
|
+
.then(() => this.refreshNip65WatchRelaysNow())
|
|
642
|
+
.catch(err => this.onError?.(err))
|
|
643
|
+
.finally(() => { this.relayListRefreshPromise = null })
|
|
644
|
+
}
|
|
645
|
+
return this.relayListRefreshPromise
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async refreshNip65WatchRelaysNow () {
|
|
649
|
+
const channelPubkeys = this.nip65WatchChannelPubkeys()
|
|
650
|
+
if (!channelPubkeys.length) {
|
|
651
|
+
this.ensureRelayListWatcher()
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
const until = nowSeconds()
|
|
655
|
+
for (const pubkey of channelPubkeys) {
|
|
656
|
+
const lastSeenAt = this.readState().channels[pubkey]?.lastSeenAt
|
|
657
|
+
if (lastSeenAt) this.addOfflineRange(pubkey, Math.max(0, lastSeenAt - this.offlineSkewSeconds), until)
|
|
658
|
+
}
|
|
659
|
+
await this.watch(channelPubkeys, { scheduleReloadGap: false })
|
|
660
|
+
await this.recoverOfflineRanges(channelPubkeys)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async handleAsk (channelPubkey, message) {
|
|
664
|
+
this.trackSeederActivity(channelPubkey, message)
|
|
665
|
+
if (storesRecoverySeeds(this.channels.get(channelPubkey)?.mode) && messageCode(message) === MISSING_MESSAGES_ASK_CODE) {
|
|
666
|
+
await this.replyWithStoredSeeds(channelPubkey, message)
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
await this.enqueueRumor('ask', channelPubkey, message)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async handleReply (channelPubkey, message) {
|
|
673
|
+
this.trackSeederActivity(channelPubkey, message)
|
|
674
|
+
if (messageCode(message) === MISSING_MESSAGES_REPLY_CODE) {
|
|
675
|
+
await this.consumeMissingMessagesReply(channelPubkey, message)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
await this.enqueueRumor('reply', channelPubkey, message)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async handleTell (channelPubkey, message) {
|
|
682
|
+
this.trackSeederActivity(channelPubkey, message)
|
|
683
|
+
await this.enqueueRumor('tell', channelPubkey, message)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async handleYell (channelPubkey, message) {
|
|
687
|
+
this.trackSeederActivity(channelPubkey, message)
|
|
688
|
+
if (messageCode(message) === SEEDER_PRESENCE_CODE) return
|
|
689
|
+
await this.enqueueRumor('yell', channelPubkey, message)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async handleNym (channelPubkey, message) {
|
|
693
|
+
await this.enqueueRumor('nym', channelPubkey, message)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async handleMessage (channelPubkey, message) {
|
|
697
|
+
if (eventType(message.event) !== 'message') return
|
|
698
|
+
this.trackSeederActivity(channelPubkey, message)
|
|
699
|
+
await this.enqueueRumor('message', channelPubkey, message)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async enqueueRumor (type, channelPubkey, message) {
|
|
703
|
+
const channel = this.channels.get(channelPubkey)
|
|
704
|
+
if (channel?.mode === 'watchtower' && type !== 'ask') return
|
|
705
|
+
this.markSeen(channelPubkey, message.outer?.created_at || message.event?.created_at || nowSeconds())
|
|
706
|
+
const eventId = message.event?.id || ''
|
|
707
|
+
const dedupeKey = eventId ? [channelPubkey, type, eventId] : null
|
|
708
|
+
if (dedupeKey && await this.queue.someBy('byChannelTypeEventId', dedupeKey)) {
|
|
709
|
+
this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
await this.queue.enqueue({
|
|
714
|
+
type,
|
|
715
|
+
channelPubkey,
|
|
716
|
+
receivedAt: nowSeconds(),
|
|
717
|
+
event: message.event,
|
|
718
|
+
payload: message.payload,
|
|
719
|
+
question: message.question || null,
|
|
720
|
+
questionId: message.questionId || null,
|
|
721
|
+
outer: message.outer || null,
|
|
722
|
+
meta: message.meta || null
|
|
723
|
+
})
|
|
724
|
+
} catch (err) {
|
|
725
|
+
// The unique index closes the small cross-instance race after `someBy`.
|
|
726
|
+
if (dedupeKey && err?.name === 'ConstraintError') {
|
|
727
|
+
this.debug('dedupe', debugMessageInfo(type, channelPubkey, message))
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
throw err
|
|
731
|
+
}
|
|
732
|
+
this.debug('enqueue', debugMessageInfo(type, channelPubkey, message))
|
|
733
|
+
this.onMessageQueued?.()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async enqueueSeed (channelPubkey, seed) {
|
|
737
|
+
const receivedAt = nowSeconds()
|
|
738
|
+
if (seed.recordType === NYM_CARRIER_SEED_RECORD_TYPE || seed.carriers?.length) {
|
|
739
|
+
const carriers = compactSeedNymCarriers(seed.carriers)
|
|
740
|
+
const recordTime = nymCarrierRecordTime({ carriers }) || seed.outer?.created_at || receivedAt
|
|
741
|
+
this.markSeen(channelPubkey, recordTime)
|
|
742
|
+
const key = nymCarrierSeedKey({ channelPubkey, carriers })
|
|
743
|
+
const seedKey = key ? `nym:${key}` : ''
|
|
744
|
+
if (seedKey && await this.seedQueue.someBy('bySeedKey', seedKey)) return
|
|
745
|
+
await this.seedQueue.enqueue({
|
|
746
|
+
type: 'seed',
|
|
747
|
+
recordType: NYM_CARRIER_SEED_RECORD_TYPE,
|
|
748
|
+
channelPubkey,
|
|
749
|
+
receivedAt,
|
|
750
|
+
carriers,
|
|
751
|
+
meta: { channelPubkey: seed.channelPubkey },
|
|
752
|
+
[SEED_KEY]: seedKey || undefined,
|
|
753
|
+
[SEED_TIME]: recordTime
|
|
754
|
+
})
|
|
755
|
+
await this.pruneStoredSeeds(channelPubkey)
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const rows = compactSeedRouterRows(seed)
|
|
760
|
+
let newest = seed.outer?.created_at || receivedAt
|
|
761
|
+
for (const row of rows) {
|
|
762
|
+
const rowTime = row.lastSeenAt || row.router?.created_at || receivedAt
|
|
763
|
+
newest = Math.max(newest, rowTime)
|
|
764
|
+
const rowKey = routerSeedRowKey({ ...row, channelPubkey })
|
|
765
|
+
const seedKey = `router:${rowKey}`
|
|
766
|
+
const [previous] = await this.seedQueue.removeBy('bySeedKey', seedKey)
|
|
767
|
+
const firstSeenAt = Math.min(previous?.firstSeenAt ?? rowTime, row.firstSeenAt ?? rowTime)
|
|
768
|
+
const lastSeenAt = Math.max(previous?.lastSeenAt ?? rowTime, row.lastSeenAt ?? rowTime)
|
|
769
|
+
await this.seedQueue.enqueue({
|
|
770
|
+
...row,
|
|
771
|
+
type: 'seed',
|
|
772
|
+
recordType: ROUTER_SEED_RECORD_TYPE,
|
|
773
|
+
channelPubkey,
|
|
774
|
+
receivedAt,
|
|
775
|
+
firstSeenAt,
|
|
776
|
+
lastSeenAt,
|
|
777
|
+
meta: { channelPubkey: seed.channelPubkey },
|
|
778
|
+
[SEED_KEY]: seedKey,
|
|
779
|
+
[SEED_TIME]: lastSeenAt || rowTime
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
this.markSeen(channelPubkey, newest)
|
|
783
|
+
await this.pruneStoredSeeds(channelPubkey)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async * messages () {
|
|
787
|
+
// seedQueue is retained replay material for recovery replies, not an app-message stream.
|
|
788
|
+
for await (const item of this.queue.items()) yield withoutQueueMetadata(item)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async nextMessage () {
|
|
792
|
+
// seedQueue is retained replay material for recovery replies, not an app-message stream.
|
|
793
|
+
await this.queueOperationTail
|
|
794
|
+
return withoutQueueMetadata(await this.queue.shift())
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async ask ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
|
|
798
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
799
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
|
|
800
|
+
this.debugSend('ask', channelPubkey, { code, receiverPubkey })
|
|
801
|
+
return this._privateMessage.ask({
|
|
802
|
+
senderSigner: this.userSigner,
|
|
803
|
+
imkcSigner: this.contentKeySigner,
|
|
804
|
+
privateChannelSigner: channel.signer,
|
|
805
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
806
|
+
receiverPubkey,
|
|
807
|
+
...routing,
|
|
808
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
809
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
810
|
+
message,
|
|
811
|
+
code,
|
|
812
|
+
payload,
|
|
813
|
+
error,
|
|
814
|
+
content,
|
|
815
|
+
deletionPubkey,
|
|
816
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async reply ({ channelPubkey = this.defaultChannelPubkey(), question, receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
|
|
821
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
822
|
+
const resolvedReceiverPubkey = receiverPubkey || question?.pubkey || ''
|
|
823
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [resolvedReceiverPubkey], relays, relayToReceivers })
|
|
824
|
+
this.debugSend('reply', channelPubkey, { code, receiverPubkey: receiverPubkey || question?.pubkey || '' })
|
|
825
|
+
return this._privateMessage.reply({
|
|
826
|
+
senderSigner: this.userSigner,
|
|
827
|
+
imkcSigner: this.contentKeySigner,
|
|
828
|
+
privateChannelSigner: channel.signer,
|
|
829
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
830
|
+
question,
|
|
831
|
+
receiverPubkey,
|
|
832
|
+
...routing,
|
|
833
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
834
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
835
|
+
message,
|
|
836
|
+
code,
|
|
837
|
+
payload,
|
|
838
|
+
error,
|
|
839
|
+
content,
|
|
840
|
+
deletionPubkey,
|
|
841
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async tell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkey, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
|
|
846
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
847
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys: [receiverPubkey], relays, relayToReceivers })
|
|
848
|
+
this.debugSend('tell', channelPubkey, { code, receiverPubkey })
|
|
849
|
+
return this._privateMessage.tell({
|
|
850
|
+
senderSigner: this.userSigner,
|
|
851
|
+
imkcSigner: this.contentKeySigner,
|
|
852
|
+
privateChannelSigner: channel.signer,
|
|
853
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
854
|
+
receiverPubkey,
|
|
855
|
+
...routing,
|
|
856
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
857
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
858
|
+
message,
|
|
859
|
+
code,
|
|
860
|
+
payload,
|
|
861
|
+
error,
|
|
862
|
+
content,
|
|
863
|
+
deletionPubkey,
|
|
864
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
|
|
869
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
870
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
871
|
+
this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
|
|
872
|
+
return this._privateMessage.yell({
|
|
873
|
+
senderSigner: this.userSigner,
|
|
874
|
+
imkcSigner: this.contentKeySigner,
|
|
875
|
+
privateChannelSigner: channel.signer,
|
|
876
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
877
|
+
receiverPubkeys,
|
|
878
|
+
...routing,
|
|
879
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
880
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
881
|
+
message,
|
|
882
|
+
code,
|
|
883
|
+
payload,
|
|
884
|
+
error,
|
|
885
|
+
content,
|
|
886
|
+
deletionPubkey,
|
|
887
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
888
|
+
})
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey }) {
|
|
892
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
893
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
894
|
+
this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
|
|
895
|
+
return this._privateMessage.broadcastRumor({
|
|
896
|
+
senderSigner: this.userSigner,
|
|
897
|
+
imkcSigner: this.contentKeySigner,
|
|
898
|
+
privateChannelSigner: channel.signer,
|
|
899
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
900
|
+
receiverPubkeys,
|
|
901
|
+
...routing,
|
|
902
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
903
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
904
|
+
rumor,
|
|
905
|
+
deletionPubkey,
|
|
906
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
907
|
+
})
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey }) {
|
|
911
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
912
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
913
|
+
this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
|
|
914
|
+
return this._privateMessage.broadcastEvent({
|
|
915
|
+
senderSigner: this.userSigner,
|
|
916
|
+
imkcSigner: this.contentKeySigner,
|
|
917
|
+
privateChannelSigner: channel.signer,
|
|
918
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
919
|
+
receiverPubkeys,
|
|
920
|
+
...routing,
|
|
921
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
922
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
923
|
+
event,
|
|
924
|
+
deletionPubkey,
|
|
925
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async broadcastNymRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, nymSigner, deletionPubkey }) {
|
|
930
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
931
|
+
const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
|
|
932
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
933
|
+
this.debugSend('broadcastNymRumor', channelPubkey, { receiverPubkeys })
|
|
934
|
+
return this._privateMessage.broadcastNymRumor({
|
|
935
|
+
nymSigner: resolvedNymSigner,
|
|
936
|
+
privateChannelSigner: channel.signer,
|
|
937
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
938
|
+
...routing,
|
|
939
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
940
|
+
rumor,
|
|
941
|
+
deletionPubkey
|
|
942
|
+
})
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async broadcastNymEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, nymSigner, deletionPubkey }) {
|
|
946
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
947
|
+
const resolvedNymSigner = this.requireNymSigner(channel, nymSigner)
|
|
948
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
|
|
949
|
+
this.debugSend('broadcastNymEvent', channelPubkey, { receiverPubkeys })
|
|
950
|
+
return this._privateMessage.broadcastNymEvent({
|
|
951
|
+
nymSigner: resolvedNymSigner,
|
|
952
|
+
privateChannelSigner: channel.signer,
|
|
953
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
954
|
+
...routing,
|
|
955
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
956
|
+
event,
|
|
957
|
+
deletionPubkey
|
|
958
|
+
})
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async publishSeederPresence (channelPubkey = this.defaultChannelPubkey()) {
|
|
962
|
+
const channel = this.requireWritableChannel(channelPubkey)
|
|
963
|
+
const receiverPubkeys = uniq([...this.knownSeeders(channelPubkey), this.userPubkey])
|
|
964
|
+
const routing = await this.resolveSendRouting({ channel, receiverPubkeys })
|
|
965
|
+
this.debugSend('yell', channelPubkey, { code: SEEDER_PRESENCE_CODE, receiverPubkeys })
|
|
966
|
+
return this._privateMessage.yell({
|
|
967
|
+
senderSigner: this.userSigner,
|
|
968
|
+
imkcSigner: this.contentKeySigner,
|
|
969
|
+
privateChannelSigner: channel.signer,
|
|
970
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
971
|
+
receiverPubkeys,
|
|
972
|
+
...routing,
|
|
973
|
+
expirationSeconds: this.offlineRecoverySeconds,
|
|
974
|
+
temporaryStorageArea: this.temporaryStorageArea,
|
|
975
|
+
code: SEEDER_PRESENCE_CODE,
|
|
976
|
+
payload: {},
|
|
977
|
+
_getIykcProofs: this.contentKeyLookup()
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async startPresencePublisher (channelPubkey) {
|
|
982
|
+
if (this.presenceTimers.has(channelPubkey)) return
|
|
983
|
+
try {
|
|
984
|
+
await this.publishSeederPresence(channelPubkey)
|
|
985
|
+
} catch (err) {
|
|
986
|
+
console.warn('private-messenger seeder presence failed', err?.message ?? err)
|
|
987
|
+
}
|
|
988
|
+
const timer = this._setInterval(() => {
|
|
989
|
+
return this.publishSeederPresence(channelPubkey).catch(err => {
|
|
990
|
+
console.warn('private-messenger seeder presence failed', err?.message ?? err)
|
|
991
|
+
})
|
|
992
|
+
}, this.seederPresenceIntervalMs)
|
|
993
|
+
timer?.unref?.()
|
|
994
|
+
this.presenceTimers.set(channelPubkey, timer)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
stopPresencePublisher (channelPubkey) {
|
|
998
|
+
const timer = this.presenceTimers.get(channelPubkey)
|
|
999
|
+
if (timer) this._clearInterval(timer)
|
|
1000
|
+
this.presenceTimers.delete(channelPubkey)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async reconcilePresencePublishers () {
|
|
1004
|
+
const starts = []
|
|
1005
|
+
for (const pubkey of [...this.presenceTimers.keys()]) {
|
|
1006
|
+
if (!storesRecoverySeeds(this.channels.get(pubkey)?.mode)) this.stopPresencePublisher(pubkey)
|
|
1007
|
+
}
|
|
1008
|
+
for (const [pubkey, channel] of this.channels) {
|
|
1009
|
+
if (storesRecoverySeeds(channel.mode)) starts.push(this.startPresencePublisher(pubkey))
|
|
1010
|
+
else this.stopPresencePublisher(pubkey)
|
|
1011
|
+
}
|
|
1012
|
+
await Promise.all(starts)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
createMissingMessageReplyPacker (options) {
|
|
1016
|
+
return createMissingMessageReplyPacker({ messenger: this, ...options })
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
createEventReplyPacker (options) {
|
|
1020
|
+
return createEventReplyPacker({ messenger: this, ...options })
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
defaultChannelPubkey () {
|
|
1024
|
+
return this.channels.keys().next().value
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
requireChannel (pubkey) {
|
|
1028
|
+
const channel = this.channels.get(pubkey)
|
|
1029
|
+
if (!channel) throw new Error('UNKNOWN_CHANNEL')
|
|
1030
|
+
return channel
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
requireWritableChannel (pubkey) {
|
|
1034
|
+
const channel = this.requireChannel(pubkey)
|
|
1035
|
+
if (!channel.signer) throw new Error('PRIVATE_CHANNEL_WRITER_REQUIRED')
|
|
1036
|
+
return channel
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
requireNymSigner (channel, override) {
|
|
1040
|
+
const signer = override || channel?.nymSigner || this.nymSigner
|
|
1041
|
+
if (!signer?.getPublicKey) throw new Error('NYM_SIGNER_REQUIRED')
|
|
1042
|
+
return signer
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
contentKeyLookup () {
|
|
1046
|
+
return this.useContentKeys ? undefined : noContentKeys
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
scheduleReloadGap (pubkey) {
|
|
1050
|
+
const current = this.readState().channels[pubkey]
|
|
1051
|
+
const start = current?.openOfflineStart || current?.lastSeenAt
|
|
1052
|
+
if (!start) return
|
|
1053
|
+
this._setTimeout(async () => {
|
|
1054
|
+
this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
|
|
1055
|
+
await this.recoverOfflineRanges([pubkey])
|
|
1056
|
+
}, this.reloadGapDelayMs)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Browser-offline recovery owns durable gaps. Stop only the child live reads;
|
|
1060
|
+
// unwatch() would also stop seeder-presence publishing and alter channel state.
|
|
1061
|
+
#pauseLiveWatches () {
|
|
1062
|
+
for (const stop of this.stopByChannel.values()) stop?.()
|
|
1063
|
+
this.stopByChannel.clear()
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async #resumeLiveWatches () {
|
|
1067
|
+
const channelPubkeys = [...this.channels.keys()]
|
|
1068
|
+
await this.watch(channelPubkeys, { scheduleReloadGap: false })
|
|
1069
|
+
return channelPubkeys
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
ensureNetworkWatchers () {
|
|
1073
|
+
if (typeof window === 'undefined') return
|
|
1074
|
+
if (!this.stopOffline) {
|
|
1075
|
+
const offline = () => {
|
|
1076
|
+
const state = this.readState()
|
|
1077
|
+
const start = Math.max(0, nowSeconds() - this.offlineSkewSeconds)
|
|
1078
|
+
for (const pubkey of this.channels.keys()) {
|
|
1079
|
+
const current = state.channels[pubkey] || {}
|
|
1080
|
+
current.openOfflineStart ||= start
|
|
1081
|
+
state.channels[pubkey] = current
|
|
1082
|
+
}
|
|
1083
|
+
this.writeState(state)
|
|
1084
|
+
this.#pauseLiveWatches()
|
|
1085
|
+
}
|
|
1086
|
+
window.addEventListener('offline', offline)
|
|
1087
|
+
this.stopOffline = () => window.removeEventListener('offline', offline)
|
|
1088
|
+
}
|
|
1089
|
+
if (!this.stopOnline) {
|
|
1090
|
+
const online = async () => {
|
|
1091
|
+
this.closeOpenOfflineRanges()
|
|
1092
|
+
const channelPubkeys = await this.#resumeLiveWatches()
|
|
1093
|
+
await this.recoverOfflineRanges(channelPubkeys)
|
|
1094
|
+
}
|
|
1095
|
+
window.addEventListener('online', online)
|
|
1096
|
+
this.stopOnline = () => window.removeEventListener('online', online)
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async askSeedersForMissingRange (channelPubkey, since, until) {
|
|
1101
|
+
if (!this.channels.get(channelPubkey)?.signer) return []
|
|
1102
|
+
const seeders = this.recoverySeeders(channelPubkey)
|
|
1103
|
+
if (!seeders.length || until < since) return []
|
|
1104
|
+
|
|
1105
|
+
const asks = []
|
|
1106
|
+
for (const seeder of seeders) {
|
|
1107
|
+
try {
|
|
1108
|
+
asks.push(await this.ask({
|
|
1109
|
+
channelPubkey,
|
|
1110
|
+
receiverPubkey: seeder,
|
|
1111
|
+
code: MISSING_MESSAGES_ASK_CODE,
|
|
1112
|
+
payload: { since, until }
|
|
1113
|
+
}))
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
console.warn('private-messenger seeder recovery ask failed', seeder, err?.message ?? err)
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return asks
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async askSeedersForRelayLeftEdge (channelPubkey, range, fetchedEvents) {
|
|
1122
|
+
const oldest = oldestCreatedAt(fetchedEvents)
|
|
1123
|
+
const until = oldest == null ? range.end : Math.min(range.end, oldest)
|
|
1124
|
+
if (until < range.start) return []
|
|
1125
|
+
return this.askSeedersForMissingRange(channelPubkey, range.start, until)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async replyWithStoredSeeds (channelPubkey, message) {
|
|
1129
|
+
const payload = isPlainObject(message.payload?.payload) ? message.payload.payload : {}
|
|
1130
|
+
const since = Number.isFinite(payload.since) ? payload.since : undefined
|
|
1131
|
+
const until = Number.isFinite(payload.until) ? payload.until : undefined
|
|
1132
|
+
const packer = this.createMissingMessageReplyPacker({
|
|
1133
|
+
channelPubkey,
|
|
1134
|
+
question: message.event,
|
|
1135
|
+
receiverPubkey: message.event?.pubkey,
|
|
1136
|
+
since,
|
|
1137
|
+
until
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
for await (const seed of this.seedQueue.storedItemsBy('byChannel', channelPubkey)) {
|
|
1141
|
+
await packer.update(seed)
|
|
1142
|
+
}
|
|
1143
|
+
await packer.finalize()
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async consumeMissingMessagesReply (channelPubkey, message) {
|
|
1147
|
+
const payload = message.payload?.payload
|
|
1148
|
+
const jsonl = typeof payload?.jsonl === 'string' ? payload.jsonl : ''
|
|
1149
|
+
if (!jsonl) return
|
|
1150
|
+
|
|
1151
|
+
for (const line of splitJsonl(jsonl)) {
|
|
1152
|
+
const record = parseJson(line, null)
|
|
1153
|
+
if (!record) continue
|
|
1154
|
+
const recovered = await this.messageFromBackfillRecord(channelPubkey, record)
|
|
1155
|
+
if (!recovered) continue
|
|
1156
|
+
await this.enqueueRumor(recovered.type, channelPubkey, {
|
|
1157
|
+
event: recovered.event,
|
|
1158
|
+
outer: recovered.outer,
|
|
1159
|
+
meta: { ...(recovered.meta || {}), channelPubkey, recoveredFromSeeder: message.event?.pubkey || '' },
|
|
1160
|
+
payload: recovered.payload
|
|
1161
|
+
})
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async messageFromBackfillRecord (channelPubkey, record) {
|
|
1166
|
+
if (record?.recordType === NYM_CARRIER_SEED_RECORD_TYPE) {
|
|
1167
|
+
const event = this._privateChannel.eventFromNymCarriers(record.carriers)
|
|
1168
|
+
return {
|
|
1169
|
+
type: 'nym',
|
|
1170
|
+
event,
|
|
1171
|
+
outer: { id: '', created_at: nymCarrierRecordTime(record) },
|
|
1172
|
+
meta: { channelPubkey, carriers: record.carriers },
|
|
1173
|
+
payload: parseEventContent(event)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const routerRecord = record?.recordType === ROUTER_SEED_RECORD_TYPE ? record.router : null
|
|
1178
|
+
if (!isPrivateChannelRouter(routerRecord)) return null
|
|
1179
|
+
if (!this._privateChannel.unwrapEvent) throw new Error('PRIVATE_CHANNEL_UNWRAP_UNSUPPORTED')
|
|
1180
|
+
|
|
1181
|
+
const channel = this.requireChannel(channelPubkey)
|
|
1182
|
+
const router = {
|
|
1183
|
+
kind: privateChannel.ROUTER_KIND,
|
|
1184
|
+
pubkey: routerRecord.pubkey,
|
|
1185
|
+
created_at: routerRecord.created_at || nowSeconds(),
|
|
1186
|
+
tags: (routerRecord.tags || []).filter(tag => tag[0] !== 'c').concat([['c', '0', '1']]),
|
|
1187
|
+
content: routerRecord.content
|
|
1188
|
+
}
|
|
1189
|
+
const encryptSigner = channel.readerSigner && channel.readerSigner !== channel.signer ? channel.readerSigner : channel.signer
|
|
1190
|
+
const encryptPeerPubkey = encryptSigner === channel.signer ? channel.readerPubkey : channelPubkey
|
|
1191
|
+
const outer = {
|
|
1192
|
+
kind: privateChannel.PRIVATE_BROADCAST_KIND,
|
|
1193
|
+
pubkey: channelPubkey,
|
|
1194
|
+
created_at: router.created_at,
|
|
1195
|
+
tags: [],
|
|
1196
|
+
content: await encryptSigner.nip44v3Encrypt(
|
|
1197
|
+
encryptPeerPubkey,
|
|
1198
|
+
privateChannel.PRIVATE_BROADCAST_KIND,
|
|
1199
|
+
'',
|
|
1200
|
+
textToBase64(JSON.stringify(router))
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
const event = await this._privateChannel.unwrapEvent({
|
|
1204
|
+
receiverSigner: this.userSigner,
|
|
1205
|
+
iykcSigner: this.contentKeySigner,
|
|
1206
|
+
privateChannelSigner: channel.signer,
|
|
1207
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
1208
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
1209
|
+
event: outer,
|
|
1210
|
+
receiverPubkey: this.userPubkey
|
|
1211
|
+
})
|
|
1212
|
+
if (!event) return null
|
|
1213
|
+
return {
|
|
1214
|
+
type: eventType(event),
|
|
1215
|
+
event,
|
|
1216
|
+
outer,
|
|
1217
|
+
meta: { channelPubkey },
|
|
1218
|
+
payload: parseEventContent(event)
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
|
|
1223
|
+
const state = this.readState()
|
|
1224
|
+
const now = nowSeconds()
|
|
1225
|
+
const minStart = now - this.offlineRecoverySeconds
|
|
1226
|
+
|
|
1227
|
+
for (const pubkey of uniq(channels)) {
|
|
1228
|
+
const channel = this.channels.get(pubkey)
|
|
1229
|
+
const current = state.channels[pubkey]
|
|
1230
|
+
if (!channel || !current?.offlineRanges?.length) continue
|
|
1231
|
+
|
|
1232
|
+
const remaining = []
|
|
1233
|
+
for (const range of current.offlineRanges) {
|
|
1234
|
+
if (range.end < minStart) continue
|
|
1235
|
+
try {
|
|
1236
|
+
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
1237
|
+
const fetchedEvents = await this._privateChannel.fetch({
|
|
1238
|
+
receiverSigner: this.userSigner,
|
|
1239
|
+
iykcSigner: this.contentKeySigner,
|
|
1240
|
+
privateChannelSigner: channel.signer,
|
|
1241
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
1242
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
1243
|
+
privateChannelPubkeys: [pubkey],
|
|
1244
|
+
receiverPubkey: this.userPubkey,
|
|
1245
|
+
relays: fetchRelays,
|
|
1246
|
+
since: Math.max(0, range.start),
|
|
1247
|
+
until: range.end,
|
|
1248
|
+
mode: channel.mode,
|
|
1249
|
+
modeByPubkey: { [pubkey]: channel.mode },
|
|
1250
|
+
receivedChunkTtlMs: this.offlineRecoverySeconds * 1000,
|
|
1251
|
+
onEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) })),
|
|
1252
|
+
onNymEvent: (event, outer, meta) => this.queueIncoming(() => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) })),
|
|
1253
|
+
onSeedEvent: seed => this.queueIncoming(() => this.enqueueSeed(pubkey, seed)),
|
|
1254
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
1255
|
+
onError: err => { throw err }
|
|
1256
|
+
}) || []
|
|
1257
|
+
await this.askSeedersForRelayLeftEdge(pubkey, range, fetchedEvents)
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
this.onError?.(err)
|
|
1260
|
+
remaining.push(range)
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const fresh = this.readState()
|
|
1264
|
+
fresh.channels[pubkey] = {
|
|
1265
|
+
...(fresh.channels[pubkey] || {}),
|
|
1266
|
+
offlineRanges: remaining
|
|
1267
|
+
}
|
|
1268
|
+
this.writeState(fresh)
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async clearChannel (pubkey) {
|
|
1273
|
+
return this.runQueueOperation(async () => {
|
|
1274
|
+
this.unwatch(pubkey)
|
|
1275
|
+
this._privateMessage.clearChannelState?.(pubkey)
|
|
1276
|
+
this.channels.delete(pubkey)
|
|
1277
|
+
this.removeChannelState(pubkey)
|
|
1278
|
+
await this.queue.removeBy('byChannel', pubkey)
|
|
1279
|
+
await this.seedQueue.removeBy('byChannel', pubkey)
|
|
1280
|
+
this.ensureRelayListWatcher()
|
|
1281
|
+
})
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async clearQueue () {
|
|
1285
|
+
return this.runQueueOperation(() => this.queue.clear())
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async cleanupStaleChannels () {
|
|
1289
|
+
if (!this.prefix) return
|
|
1290
|
+
return this.runQueueOperation(async () => {
|
|
1291
|
+
const state = this.readState()
|
|
1292
|
+
const cutoff = nowSeconds() - this.staleChannelSeconds
|
|
1293
|
+
for (const [pubkey, channel] of Object.entries(state.channels)) {
|
|
1294
|
+
if ((channel.lastWatchedAt || 0) >= cutoff) continue
|
|
1295
|
+
delete state.channels[pubkey]
|
|
1296
|
+
await this.queue?.removeBy('byChannel', pubkey)
|
|
1297
|
+
await this.seedQueue?.removeBy('byChannel', pubkey)
|
|
1298
|
+
}
|
|
1299
|
+
this.writeState(state)
|
|
1300
|
+
})
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
async pruneStoredSeeds (channelPubkey) {
|
|
1304
|
+
if (!this.seedQueue) return
|
|
1305
|
+
const cutoff = nowSeconds() - this.offlineRecoverySeconds
|
|
1306
|
+
const keyRange = globalThis.IDBKeyRange
|
|
1307
|
+
if (channelPubkey && keyRange?.bound) {
|
|
1308
|
+
await this.seedQueue.removeBy('byChannelTime', keyRange.bound([channelPubkey, 0], [channelPubkey, cutoff], false, true))
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
if (!channelPubkey && keyRange?.upperBound) {
|
|
1312
|
+
await this.seedQueue.removeBy('byTime', keyRange.upperBound(cutoff, true))
|
|
1313
|
+
return
|
|
1314
|
+
}
|
|
1315
|
+
await this.seedQueue.removeWhere(item => {
|
|
1316
|
+
if (channelPubkey && item.channelPubkey !== channelPubkey) return false
|
|
1317
|
+
return (seedRecordTime(item) || item.receivedAt || 0) < cutoff
|
|
1318
|
+
})
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
close () {
|
|
1322
|
+
this.unwatch()
|
|
1323
|
+
for (const pubkey of [...this.presenceTimers.keys()]) this.stopPresencePublisher(pubkey)
|
|
1324
|
+
this.stopRelayListWatcher?.()
|
|
1325
|
+
this.stopRelayListWatcher = null
|
|
1326
|
+
this.relayListWatcherPubkey = ''
|
|
1327
|
+
this.stopOffline?.()
|
|
1328
|
+
this.stopOnline?.()
|
|
1329
|
+
this.stopOffline = null
|
|
1330
|
+
this.stopOnline = null
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function mergeRanges (ranges) {
|
|
1335
|
+
const out = []
|
|
1336
|
+
for (const range of ranges) {
|
|
1337
|
+
const last = out[out.length - 1]
|
|
1338
|
+
if (!last || range.start > last.end + 1) out.push({ ...range })
|
|
1339
|
+
else last.end = Math.max(last.end, range.end)
|
|
1340
|
+
}
|
|
1341
|
+
return out
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function eventType (event) {
|
|
1345
|
+
if (event.kind === privateMessage.ASK_KIND) return 'ask'
|
|
1346
|
+
if (event.kind === privateMessage.REPLY_KIND) return 'reply'
|
|
1347
|
+
if (event.kind === privateMessage.TELL_KIND) return event.tags?.some(t => t[0] === 'r') ? 'tell' : 'yell'
|
|
1348
|
+
return 'message'
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function parseEventContent (event) {
|
|
1352
|
+
return privateMessage.parseRumorContent(event)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function messageCode (message) {
|
|
1356
|
+
return isPlainObject(message.payload) && Object.prototype.hasOwnProperty.call(message.payload, 'code')
|
|
1357
|
+
? message.payload.code
|
|
1358
|
+
: null
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function debugMessageInfo (type, channelPubkey, message) {
|
|
1362
|
+
return {
|
|
1363
|
+
type,
|
|
1364
|
+
code: messageCode(message) || '',
|
|
1365
|
+
channelPubkey,
|
|
1366
|
+
senderPubkey: message.event?.pubkey || '',
|
|
1367
|
+
eventId: message.event?.id || '',
|
|
1368
|
+
outerId: message.outer?.id || '',
|
|
1369
|
+
outerCreatedAt: message.outer?.created_at || message.event?.created_at || 0
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function messageTime (message) {
|
|
1374
|
+
return message.outer?.created_at || message.event?.created_at || nowSeconds()
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function oldestCreatedAt (events) {
|
|
1378
|
+
let oldest = null
|
|
1379
|
+
for (const event of events || []) {
|
|
1380
|
+
if (!Number.isFinite(event?.created_at)) continue
|
|
1381
|
+
oldest = oldest == null ? event.created_at : Math.min(oldest, event.created_at)
|
|
1382
|
+
}
|
|
1383
|
+
return oldest
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function relayMapRelays (relayToReceivers) {
|
|
1387
|
+
if (!relayToReceivers) return []
|
|
1388
|
+
const entries = relayToReceivers instanceof Map ? relayToReceivers.entries() : Object.entries(relayToReceivers)
|
|
1389
|
+
return uniq([...entries].map(([relay]) => relay))
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function isPrivateChannelRouter (event) {
|
|
1393
|
+
return event?.kind === privateChannel.ROUTER_KIND &&
|
|
1394
|
+
typeof event.content === 'string' &&
|
|
1395
|
+
event.tags?.some(tag => tag[0] === 'c')
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function nymCarrierRecordTime (record) {
|
|
1399
|
+
return record?.carriers?.reduce((max, carrier) => Math.max(max, carrier.created_at || 0), 0) || 0
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function nymCarrierSeedKey (record) {
|
|
1403
|
+
const carriers = record?.carriers || []
|
|
1404
|
+
if (!carriers.length) return ''
|
|
1405
|
+
const ids = carriers.map(carrier => carrier.id || '').join(',')
|
|
1406
|
+
return `${record.channelPubkey || ''}:${carriers[0]?.pubkey || ''}:${ids}`
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function withoutQueueMetadata (item) {
|
|
1410
|
+
if (!item) return null
|
|
1411
|
+
const {
|
|
1412
|
+
[SEED_KEY]: ignoredSeedKey,
|
|
1413
|
+
[SEED_TIME]: ignoredSeedTime,
|
|
1414
|
+
...value
|
|
1415
|
+
} = item
|
|
1416
|
+
return value
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function seedRecordTime (record) {
|
|
1420
|
+
if (record?.recordType === NYM_CARRIER_SEED_RECORD_TYPE || record?.carriers?.length) return nymCarrierRecordTime(record)
|
|
1421
|
+
if (record?.recordType === ROUTER_SEED_RECORD_TYPE) return record.lastSeenAt || record.router?.created_at || 0
|
|
1422
|
+
return record?.router?.created_at || 0
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function splitJsonl (jsonl) {
|
|
1426
|
+
return String(jsonl || '').split('\n').filter(Boolean)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
export async function createPrivateMessenger (options) {
|
|
1430
|
+
return new PrivateMessenger(options).init(options)
|
|
1431
|
+
}
|