most-box 0.1.9 → 0.2.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/README.md +16 -3
- package/out/admin/index.html +0 -0
- package/out/app/index.html +0 -0
- package/out/assets/AppShell-OiOEqXPr.js +1 -0
- package/out/assets/ChatUi-Cif5LRF3.js +1 -0
- package/out/assets/CopyButton-Dm7krgbq.js +1 -0
- package/out/assets/LanguageToggle-B4ZNuBCV.js +1 -0
- package/out/assets/LogoIcon-B2fFe0l1.js +1 -0
- package/out/assets/MarketingHeader-yIZuQP7m.js +1 -0
- package/out/assets/MarketingLayout-DVH0Nx7S.js +1 -0
- package/out/assets/MarketingThemeToggle-qlwCZU1o.js +1 -0
- package/out/assets/MilkdownEditor-_TGlDyA_.js +1054 -0
- package/out/assets/MoveModal-BVr4Q7-b.js +1 -0
- package/out/assets/Nav-5xeettNJ.js +1 -0
- package/out/assets/NoteSidebar-DpniUKmy.js +1 -0
- package/out/assets/OpenSidebarButton-BfgG2HIT.js +1 -0
- package/out/assets/PemBlock-CxwIepth.js +1 -0
- package/out/assets/SidebarAccount-Zg5DZblE.js +1 -0
- package/out/assets/arrow-right-CL9YSDVS.js +1 -0
- package/out/assets/channelApi-DNdJfsJ-.js +1 -0
- package/out/assets/chevron-down-CnLh_-aO.js +1 -0
- package/out/assets/{circle-alert-CqEQz6P4.js → circle-alert-oiiRDvhx.js} +1 -1
- package/out/assets/cloud-BEe2N89j.js +1 -0
- package/out/assets/code-9LB8QqxL.js +1 -0
- package/out/assets/{copy-CM-qWlbv.js → copy-giX4rmFJ.js} +1 -1
- package/out/assets/{dist-CvatM4u8.js → dist-8QDHqrPN.js} +1 -1
- package/out/assets/{dist-BmtYO1GG.js → dist-B0JrbG7f.js} +5 -5
- package/out/assets/{dist-DBpw-A8y.js → dist-BFSyuOuw.js} +1 -1
- package/out/assets/{dist-D5Cf0hK8.js → dist-BPs0Xns9.js} +2 -2
- package/out/assets/{dist-BQV5zYG_.js → dist-Br_5lLVO.js} +9 -9
- package/out/assets/{dist-CrzjJUOw.js → dist-BzqqCPi2.js} +1 -1
- package/out/assets/dist-C3lbe8SW.js +9 -0
- package/out/assets/{dist-CWJ3323z.js → dist-CfU9fwWz.js} +1 -1
- package/out/assets/{dist-BdmTLuCI.js → dist-DCX0ws1K.js} +1 -1
- package/out/assets/{dist-DrumcFOX.js → dist-DJdv-Ma3.js} +1 -1
- package/out/assets/{dist-C5HibLEW.js → dist-chOCTzB2.js} +1 -1
- package/out/assets/{download-0rM8xVCe.js → download-D0oMEYQZ.js} +1 -1
- package/out/assets/downloadValidation-Bk1VsBBo.js +1 -0
- package/out/assets/external-link-Cm2WCUxv.js +1 -0
- package/out/assets/{filePreview-BvbHWUTG.js → filePreview-BZ50vZZf.js} +1 -1
- package/out/assets/format-CR8oUWq6.js +1 -0
- package/out/assets/game-Bvz4dspe.js +1 -0
- package/out/assets/{hard-drive-CCdIvSap.js → hard-drive-B3CQbcp2.js} +1 -1
- package/out/assets/index-BkZvz4WA.css +1 -0
- package/out/assets/index-WCK14Vja.js +34 -0
- package/out/assets/index.lazy-5Q6GuMNT.js +1 -0
- package/out/assets/index.lazy-5jq6EFXa.js +3 -0
- package/out/assets/index.lazy-7n1Q-NrA.js +3 -0
- package/out/assets/index.lazy-BFnOyQFj.js +1 -0
- package/out/assets/index.lazy-B_oPp6qK.js +1 -0
- package/out/assets/index.lazy-BvY50KVz.js +1 -0
- package/out/assets/index.lazy-C0Kn_amZ.js +1 -0
- package/out/assets/index.lazy-C3cek3Gn.js +1 -0
- package/out/assets/index.lazy-CLpPkdy1.js +1 -0
- package/out/assets/index.lazy-Cpr1kApf.js +2 -0
- package/out/assets/index.lazy-CuwLZiUK.js +1 -0
- package/out/assets/index.lazy-DDc3Ylgf.js +2 -0
- package/out/assets/index.lazy-Dg3aqOss.js +1 -0
- package/out/assets/{key-round-tIqGrtt_.js → key-round-CzuljhND.js} +1 -1
- package/out/assets/lock-D2NhNoJW.js +1 -0
- package/out/assets/message-square-DwBq_Go5.js +1 -0
- package/out/assets/mp-Bln2MB9G.js +1 -0
- package/out/assets/{music-BkZKq879.js → music-CB73K5Gz.js} +1 -1
- package/out/assets/{notebook-pen-B4VSbweh.js → notebook-pen-Up7r5zoI.js} +1 -1
- package/out/assets/play-OszVgROb.js +1 -0
- package/out/assets/plus-BbxQG_Ai.js +1 -0
- package/out/assets/{save-BzjzC3eV.js → save-CiqyiifY.js} +1 -1
- package/out/assets/search-gqAPOsgS.js +1 -0
- package/out/assets/{send-DtQInX0y.js → send-vwCWsZGP.js} +1 -1
- package/out/assets/shield-check-CxWxsNLc.js +1 -0
- package/out/assets/{trash-2-BhMrUgGM.js → trash-2-DNGr8IgF.js} +1 -1
- package/out/assets/triangle-alert-B_1BlX1b.js +1 -0
- package/out/assets/upload-Dxl7GUzb.js +1 -0
- package/out/assets/useChannelMessages-7bYKXU_R.js +3 -0
- package/out/assets/useGameRoom-DqA1mkfk.js +1 -0
- package/out/assets/wallet-DlkawdPJ.js +1 -0
- package/out/assets/{wifi-v3JpPCNm.js → wifi-sBOKcPFM.js} +1 -1
- package/out/avatars/default/LICENSE.md +7 -0
- package/out/avatars/default/dusk.svg +100 -0
- package/out/avatars/default/ember.svg +55 -0
- package/out/avatars/default/mint.svg +29 -0
- package/out/avatars/default/ocean.svg +25 -0
- package/out/avatars/default/sage.svg +34 -0
- package/out/avatars/default/violet.svg +60 -0
- package/out/chat/index.html +0 -0
- package/out/chat/join/index.html +0 -0
- package/out/demo/index.html +0 -0
- package/out/download/index.html +7 -2
- package/out/game/gandengyan/index.html +0 -0
- package/out/game/index.html +0 -0
- package/out/game/zhajinhua/index.html +0 -0
- package/out/index.html +7 -2
- package/out/note/index.html +0 -0
- package/out/ping/index.html +7 -2
- package/out/{web3/ed25519 → profile}/index.html +0 -0
- package/out/web3/index.html +0 -0
- package/package.json +2 -2
- package/public/avatars/default/LICENSE.md +7 -0
- package/public/avatars/default/dusk.svg +100 -0
- package/public/avatars/default/ember.svg +55 -0
- package/public/avatars/default/mint.svg +29 -0
- package/public/avatars/default/ocean.svg +25 -0
- package/public/avatars/default/sage.svg +34 -0
- package/public/avatars/default/violet.svg +60 -0
- package/server/index.js +9 -0
- package/server/src/core/channelAttachment.js +7 -3
- package/server/src/core/channelIdentity.js +39 -0
- package/server/src/core/cid.js +6 -1
- package/server/src/core/cidTopic.js +18 -4
- package/server/src/core/displayPath.js +10 -0
- package/server/src/core/gameRoom.js +2 -2
- package/server/src/core/mostLink.js +45 -25
- package/server/src/core/ownerMetadata.js +34 -0
- package/server/src/core/userSyncKeys.js +36 -0
- package/server/src/http/app.js +68 -148
- package/server/src/http/dataPath.js +26 -0
- package/server/src/http/errors.js +8 -4
- package/server/src/http/nodeStatus.js +13 -13
- package/server/src/http/rateLimit.js +39 -0
- package/server/src/http/routePolicy.js +43 -0
- package/server/src/index.js +2010 -823
- package/server/src/node/offlineSwarm.js +20 -0
- package/server/src/utils/api.js +1 -15
- package/server/src/utils/avatar.js +44 -3
- package/server/src/utils/downloadMessages.js +17 -18
- package/server/src/utils/errors.js +3 -1
- package/server/src/utils/noteUtils.js +27 -3
- package/out/assets/AppShell-DmZQwVA9.js +0 -1
- package/out/assets/ChatUi-CVGqjFdx.js +0 -1
- package/out/assets/LogoIcon-CYQ7cHd5.js +0 -1
- package/out/assets/MarketingLayout-BTIbv4fW.js +0 -1
- package/out/assets/MilkdownEditor-BL8xE7u9.js +0 -1054
- package/out/assets/MoveModal-BKkVBvrS.js +0 -1
- package/out/assets/Nav-BDGeJnbC.js +0 -1
- package/out/assets/NoteSidebar-BIJ8_m5K.js +0 -1
- package/out/assets/OpenSidebarButton-Di62DGiu.js +0 -1
- package/out/assets/PemBlock-Dxx6k9MH.js +0 -1
- package/out/assets/SidebarAccount-CCHZLGdP.js +0 -1
- package/out/assets/admin-BepWGXWG.js +0 -2
- package/out/assets/app-3D79fY3w.js +0 -1
- package/out/assets/arrow-right-D0sGC8QA.js +0 -1
- package/out/assets/channelApi-CL7YsIQ-.js +0 -1
- package/out/assets/chat-B56sk6od.js +0 -1
- package/out/assets/check-DdfnsLKm.js +0 -1
- package/out/assets/chevron-down-Xlb3wTxd.js +0 -1
- package/out/assets/circle-check-CwAH4dgJ.js +0 -1
- package/out/assets/cloud-CcPRoob1.js +0 -1
- package/out/assets/code-Dr6STnCn.js +0 -1
- package/out/assets/database-DQ7ZtUT9.js +0 -1
- package/out/assets/dateTime-D1koKRQU.js +0 -1
- package/out/assets/demo-B_6rlIjn.js +0 -3
- package/out/assets/dist-BGtXa07s.js +0 -9
- package/out/assets/download-BLPU-Kzq.js +0 -1
- package/out/assets/downloadMessages-7Xbd-HhS.js +0 -1
- package/out/assets/ed25519-BEctXF0E.js +0 -1
- package/out/assets/folder-CcbCxm-k.js +0 -1
- package/out/assets/game-B0zuqnOh.js +0 -1
- package/out/assets/gandengyan-DbQC7hCK.js +0 -1
- package/out/assets/index-BLhmAher.css +0 -1
- package/out/assets/index-Cf23WD2V.js +0 -29
- package/out/assets/join-DQHXjlfH.js +0 -1
- package/out/assets/note-DmWqGSS2.js +0 -2
- package/out/assets/ping-JILckfMu.js +0 -1
- package/out/assets/play-BIl5vwqS.js +0 -1
- package/out/assets/plus-DHvLpuuw.js +0 -1
- package/out/assets/routes-Dyckj88f.js +0 -1
- package/out/assets/search-C-EpsDNl.js +0 -1
- package/out/assets/sun-C3IUQTpa.js +0 -1
- package/out/assets/tools-BEctXF0E.js +0 -1
- package/out/assets/triangle-alert-DUODU79n.js +0 -1
- package/out/assets/upload-CpDM23UH.js +0 -1
- package/out/assets/useChannelMessages-Bs1hEJyd.js +0 -3
- package/out/assets/useGameRoom-C6UgmIGG.js +0 -1
- package/out/assets/wallet-YxbxCi7C.js +0 -1
- package/out/assets/web3-CRX1YFmw.js +0 -3
- package/out/assets/zhajinhua-QDmSZbOp.js +0 -1
- package/out/web3/tools/index.html +0 -0
- /package/out/assets/{gandengyan-8eWJAjpY.css → index-8eWJAjpY.css} +0 -0
- /package/out/assets/{zhajinhua-BZc4blbW.css → index-BZc4blbW.css} +0 -0
package/server/src/index.js
CHANGED
|
@@ -16,10 +16,41 @@ import b4a from 'b4a'
|
|
|
16
16
|
import crypto from 'node:crypto'
|
|
17
17
|
import fs from 'node:fs'
|
|
18
18
|
import path from 'node:path'
|
|
19
|
+
import { Duplex } from 'node:stream'
|
|
19
20
|
|
|
20
|
-
import { calculateCid, parseMostLink } from './core/cid.js'
|
|
21
|
+
import { calculateCid, parseMostLink, buildMostLink } from './core/cid.js'
|
|
21
22
|
import { normalizeChannelAttachment } from './core/channelAttachment.js'
|
|
22
23
|
import { getCidInfo } from './core/cidTopic.js'
|
|
24
|
+
import {
|
|
25
|
+
CHAT_FILE_ROOT,
|
|
26
|
+
TRANSIENT_CHANNEL_TYPES,
|
|
27
|
+
CHANNEL_DISCOVERY_TIMEOUT,
|
|
28
|
+
CHANNEL_CANDIDATE_TTL,
|
|
29
|
+
normalizeChannelDisplayName,
|
|
30
|
+
normalizeChannelAvatar,
|
|
31
|
+
normalizeChannelId,
|
|
32
|
+
createChannelWriterId,
|
|
33
|
+
buildChannelKey,
|
|
34
|
+
normalizeChannelKey,
|
|
35
|
+
uniqueStrings,
|
|
36
|
+
} from './core/channelIdentity.js'
|
|
37
|
+
import { getPathBaseName, getDisplayPathFolder } from './core/displayPath.js'
|
|
38
|
+
import {
|
|
39
|
+
normalizeOwnerAddress,
|
|
40
|
+
getOwnerBucketKey,
|
|
41
|
+
normalizeMetadataBuckets,
|
|
42
|
+
cloneMetadataRecord,
|
|
43
|
+
} from './core/ownerMetadata.js'
|
|
44
|
+
import {
|
|
45
|
+
USER_SYNC_SCHEMA_VERSION,
|
|
46
|
+
USER_SYNC_NAMESPACE_PREFIX,
|
|
47
|
+
normalizeUserSyncKey,
|
|
48
|
+
deriveUserSyncId,
|
|
49
|
+
getUserSyncName,
|
|
50
|
+
getSyncTimestamp,
|
|
51
|
+
getNextSyncTimestamp,
|
|
52
|
+
} from './core/userSyncKeys.js'
|
|
53
|
+
import { createOfflineSwarm } from './node/offlineSwarm.js'
|
|
23
54
|
import {
|
|
24
55
|
sanitizeFilename,
|
|
25
56
|
validateAndSanitizePath,
|
|
@@ -66,89 +97,43 @@ import {
|
|
|
66
97
|
} from './config.js'
|
|
67
98
|
|
|
68
99
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
69
|
-
const CHAT_FILE_ROOT = 'chat-file'
|
|
70
|
-
const TRANSIENT_CHANNEL_TYPES = new Set(['game'])
|
|
71
|
-
const DEFAULT_OWNER_BUCKET = '__local__'
|
|
72
|
-
const USER_DATA_SCHEMA_VERSION = 1
|
|
73
|
-
const IMPORT_CHECK_TTL_MS = 10 * 60 * 1000
|
|
74
|
-
const IMPORT_CHECK_MAX_FILES = 5000
|
|
75
|
-
|
|
76
|
-
function normalizeOwnerAddress(address) {
|
|
77
|
-
const value = String(address || '').trim()
|
|
78
|
-
return /^0x[a-fA-F0-9]{40}$/.test(value) ? value.toLowerCase() : ''
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function getOwnerBucketKey(address) {
|
|
82
|
-
return normalizeOwnerAddress(address) || DEFAULT_OWNER_BUCKET
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizeMetadataBuckets(input) {
|
|
86
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
87
|
-
return {}
|
|
88
|
-
}
|
|
89
|
-
const buckets = {}
|
|
90
|
-
for (const [rawOwner, records] of Object.entries(input)) {
|
|
91
|
-
const ownerKey =
|
|
92
|
-
rawOwner === DEFAULT_OWNER_BUCKET
|
|
93
|
-
? DEFAULT_OWNER_BUCKET
|
|
94
|
-
: normalizeOwnerAddress(rawOwner)
|
|
95
|
-
if (!ownerKey || !Array.isArray(records)) continue
|
|
96
|
-
buckets[ownerKey] = records.map(record => ({ ...record }))
|
|
97
|
-
}
|
|
98
|
-
return buckets
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function cloneMetadataRecord(record, ownerAddress = '') {
|
|
102
|
-
return {
|
|
103
|
-
...record,
|
|
104
|
-
ownerAddress:
|
|
105
|
-
ownerAddress && ownerAddress !== DEFAULT_OWNER_BUCKET ? ownerAddress : '',
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function getPathBaseName(fileName) {
|
|
110
|
-
const parts = String(fileName || '').split('/').filter(Boolean)
|
|
111
|
-
return parts[parts.length - 1] || 'unnamed_file'
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function getDisplayPathFolder(fileName) {
|
|
115
|
-
const parts = String(fileName || '').split('/').filter(Boolean)
|
|
116
|
-
parts.pop()
|
|
117
|
-
return parts.join('/')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function buildMostLink(cid, fileName) {
|
|
121
|
-
return `most://${cid}?filename=${encodeURIComponent(fileName)}`
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function normalizeChannelDisplayName(input, fallbackAddress = '') {
|
|
125
|
-
const value = String(input || '').trim()
|
|
126
|
-
if (value) return value.slice(0, 50)
|
|
127
|
-
return fallbackAddress ? fallbackAddress.slice(0, 10) : ''
|
|
128
|
-
}
|
|
129
100
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
101
|
+
function createMemoryDuplexPair() {
|
|
102
|
+
let left
|
|
103
|
+
let right
|
|
134
104
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
105
|
+
left = new Duplex({
|
|
106
|
+
read() {},
|
|
107
|
+
write(chunk, _encoding, callback) {
|
|
108
|
+
if (!right.destroyed) right.push(chunk)
|
|
109
|
+
callback()
|
|
140
110
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
111
|
+
final(callback) {
|
|
112
|
+
if (!right.destroyed) right.push(null)
|
|
113
|
+
callback()
|
|
144
114
|
},
|
|
145
|
-
|
|
146
|
-
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
right = new Duplex({
|
|
118
|
+
read() {},
|
|
119
|
+
write(chunk, _encoding, callback) {
|
|
120
|
+
if (!left.destroyed) left.push(chunk)
|
|
121
|
+
callback()
|
|
147
122
|
},
|
|
148
|
-
|
|
149
|
-
|
|
123
|
+
final(callback) {
|
|
124
|
+
if (!left.destroyed) left.push(null)
|
|
125
|
+
callback()
|
|
150
126
|
},
|
|
151
|
-
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
left.on('close', () => {
|
|
130
|
+
if (!right.destroyed) right.destroy()
|
|
131
|
+
})
|
|
132
|
+
right.on('close', () => {
|
|
133
|
+
if (!left.destroyed) left.destroy()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return [left, right]
|
|
152
137
|
}
|
|
153
138
|
|
|
154
139
|
export class MostBoxEngine extends EventEmitter {
|
|
@@ -161,7 +146,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
161
146
|
#initialized = false
|
|
162
147
|
#options = null
|
|
163
148
|
#activeDownloads = new Map()
|
|
164
|
-
#importChecks = new Map()
|
|
165
149
|
#drivePromises = new Map()
|
|
166
150
|
#fileDiscoveries = new Map()
|
|
167
151
|
#fileMonitors = new Map()
|
|
@@ -173,7 +157,16 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
173
157
|
#channelLocalCoreKey = new Map()
|
|
174
158
|
#channelDiscoveries = new Map()
|
|
175
159
|
#channelChatDiscoveries = new Map()
|
|
160
|
+
#channelIdDiscoveries = new Map()
|
|
176
161
|
#channelPeers = new Map()
|
|
162
|
+
#channelCandidateCache = new Map()
|
|
163
|
+
#channelStreams = new Set()
|
|
164
|
+
|
|
165
|
+
#userSyncSessions = new Map()
|
|
166
|
+
#userSyncCores = new Map()
|
|
167
|
+
#userSyncCoreOffsets = new Map()
|
|
168
|
+
#userSyncDiscoveries = new Map()
|
|
169
|
+
#userSyncMetadata = { sessions: {}, clocks: {} }
|
|
177
170
|
|
|
178
171
|
#chatSwarm = null
|
|
179
172
|
|
|
@@ -360,52 +353,19 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
360
353
|
|
|
361
354
|
for (const channel of this.#channels) {
|
|
362
355
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
valueEncoding: 'json',
|
|
367
|
-
})
|
|
368
|
-
await core.ready()
|
|
369
|
-
const coreKeyHex = b4a.toString(core.key, 'hex')
|
|
370
|
-
if (!this.#channelCores.has(channel.name)) {
|
|
371
|
-
this.#channelCores.set(channel.name, new Map())
|
|
372
|
-
}
|
|
373
|
-
this.#channelCores.get(channel.name).set(coreKeyHex, core)
|
|
374
|
-
this.#channelLocalCoreKey.set(channel.name, coreKeyHex)
|
|
375
|
-
this.#channelPeers.set(channel.name, new Map())
|
|
376
|
-
this.#setupChannelAppendListener(core, channel.name)
|
|
377
|
-
const remoteCoreKeys = Array.isArray(channel.remoteCoreKeys)
|
|
378
|
-
? channel.remoteCoreKeys
|
|
379
|
-
: []
|
|
380
|
-
for (const remoteCoreKey of remoteCoreKeys) {
|
|
381
|
-
if (remoteCoreKey && remoteCoreKey !== coreKeyHex) {
|
|
382
|
-
await this.#openRemoteChannelCore(channel.name, remoteCoreKey)
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
|
|
387
|
-
const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
|
|
388
|
-
channel.name
|
|
389
|
-
)
|
|
390
|
-
const appDiscovery = this.#swarm.join(discoveryKey, {
|
|
391
|
-
server: true,
|
|
392
|
-
client: true,
|
|
393
|
-
})
|
|
394
|
-
this.#channelDiscoveries.set(channel.name, appDiscovery)
|
|
395
|
-
const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
|
|
396
|
-
server: true,
|
|
397
|
-
client: true,
|
|
398
|
-
})
|
|
399
|
-
this.#channelChatDiscoveries.set(channel.name, chatDiscovery)
|
|
400
|
-
console.log(`[MostBox] Rejoined channel: ${channel.name}`)
|
|
356
|
+
await this.#openChannelRuntime(channel)
|
|
357
|
+
await this.#joinChannelDiscoveryTopics(channel)
|
|
358
|
+
console.log(`[MostBox] Rejoined channel: ${channel.channelKey}`)
|
|
401
359
|
} catch (err) {
|
|
402
360
|
console.warn(
|
|
403
|
-
`[MostBox] Failed to rejoin channel ${channel.
|
|
361
|
+
`[MostBox] Failed to rejoin channel ${channel.channelKey}:`,
|
|
404
362
|
err.message
|
|
405
363
|
)
|
|
406
364
|
}
|
|
407
365
|
}
|
|
408
366
|
|
|
367
|
+
this.#userSyncMetadata = this.#loadUserSyncMetadata()
|
|
368
|
+
|
|
409
369
|
this.#initialized = true
|
|
410
370
|
console.log(`[MostBox] Engine initialized successfully`)
|
|
411
371
|
this.emit('ready')
|
|
@@ -462,9 +422,25 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
462
422
|
this.#channelLocalCoreKey.clear()
|
|
463
423
|
this.#channelDiscoveries.clear()
|
|
464
424
|
this.#channelChatDiscoveries.clear()
|
|
425
|
+
this.#channelIdDiscoveries.clear()
|
|
465
426
|
this.#channelPeers.clear()
|
|
427
|
+
this.#channelCandidateCache.clear()
|
|
428
|
+
this.#channelStreams.clear()
|
|
466
429
|
this.#channels = []
|
|
467
|
-
|
|
430
|
+
|
|
431
|
+
for (const [, coresMap] of this.#userSyncCores) {
|
|
432
|
+
for (const [, core] of coresMap) {
|
|
433
|
+
try {
|
|
434
|
+
await core.close()
|
|
435
|
+
} catch (err) {
|
|
436
|
+
console.warn('[MostBox] Failed to close user sync core:', err.message)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
this.#userSyncSessions.clear()
|
|
441
|
+
this.#userSyncCores.clear()
|
|
442
|
+
this.#userSyncCoreOffsets.clear()
|
|
443
|
+
this.#userSyncDiscoveries.clear()
|
|
468
444
|
|
|
469
445
|
if (this.#store) {
|
|
470
446
|
await this.#store.close()
|
|
@@ -644,13 +620,18 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
644
620
|
}
|
|
645
621
|
|
|
646
622
|
// 存储 displayName(用户看到的文件夹路径),不存储 drivePath
|
|
647
|
-
|
|
623
|
+
const now = Date.now()
|
|
624
|
+
const fileRecord = {
|
|
648
625
|
fileName: safeFileName,
|
|
649
626
|
cid: cidString,
|
|
650
627
|
driveName: name,
|
|
651
|
-
|
|
628
|
+
size: fileSize,
|
|
629
|
+
source: 'published',
|
|
630
|
+
publishedAt: new Date(now).toISOString(),
|
|
652
631
|
starred: false,
|
|
653
|
-
|
|
632
|
+
syncUpdatedAt: now,
|
|
633
|
+
}
|
|
634
|
+
publishedBucket.push(fileRecord)
|
|
654
635
|
this.#savePublishedMetadata()
|
|
655
636
|
this.#upsertHolding({
|
|
656
637
|
cid: cidString,
|
|
@@ -667,6 +648,9 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
667
648
|
}
|
|
668
649
|
|
|
669
650
|
this.emit('publish:success', result)
|
|
651
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
652
|
+
file: this.#formatFileForSync(fileRecord, 'active'),
|
|
653
|
+
})
|
|
670
654
|
return result
|
|
671
655
|
}
|
|
672
656
|
|
|
@@ -697,8 +681,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
697
681
|
|
|
698
682
|
try {
|
|
699
683
|
const parsed = parseMostLink(link)
|
|
700
|
-
if (parsed.
|
|
701
|
-
throw new ValidationError(
|
|
684
|
+
if (parsed.errorCode) {
|
|
685
|
+
throw new ValidationError(
|
|
686
|
+
parsed.errorCode,
|
|
687
|
+
parsed.errorCode,
|
|
688
|
+
parsed.details
|
|
689
|
+
)
|
|
702
690
|
}
|
|
703
691
|
const cidString = parsed.cid
|
|
704
692
|
console.log(`[MostBox] Parsed CID: ${cidString}`)
|
|
@@ -994,22 +982,32 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
994
982
|
ownerAddress,
|
|
995
983
|
excludeCid: cidString,
|
|
996
984
|
})
|
|
985
|
+
const savedSize = totalBytes || fs.statSync(savePath).size
|
|
986
|
+
const syncUpdatedAt =
|
|
987
|
+
existingIndex !== -1
|
|
988
|
+
? getNextSyncTimestamp(publishedBucket[existingIndex].syncUpdatedAt)
|
|
989
|
+
: Date.now()
|
|
997
990
|
if (existingIndex !== -1) {
|
|
998
991
|
const existing = publishedBucket[existingIndex]
|
|
999
992
|
existing.fileName = sanitizedFileName
|
|
1000
993
|
existing.driveName = name
|
|
1001
|
-
existing.
|
|
994
|
+
existing.size = savedSize
|
|
995
|
+
existing.source = 'downloaded'
|
|
996
|
+
existing.publishedAt = new Date(syncUpdatedAt).toISOString()
|
|
997
|
+
existing.syncUpdatedAt = syncUpdatedAt
|
|
1002
998
|
} else {
|
|
1003
999
|
publishedBucket.push({
|
|
1004
1000
|
fileName: sanitizedFileName,
|
|
1005
1001
|
cid: cidString,
|
|
1006
1002
|
driveName: name,
|
|
1007
|
-
|
|
1003
|
+
size: savedSize,
|
|
1004
|
+
source: 'downloaded',
|
|
1005
|
+
publishedAt: new Date(syncUpdatedAt).toISOString(),
|
|
1008
1006
|
starred: false,
|
|
1007
|
+
syncUpdatedAt,
|
|
1009
1008
|
})
|
|
1010
1009
|
}
|
|
1011
1010
|
this.#savePublishedMetadata()
|
|
1012
|
-
const savedSize = totalBytes || fs.statSync(savePath).size
|
|
1013
1011
|
this.#upsertHolding({
|
|
1014
1012
|
cid: cidString,
|
|
1015
1013
|
fileName: sanitizedFileName,
|
|
@@ -1019,6 +1017,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1019
1017
|
})
|
|
1020
1018
|
|
|
1021
1019
|
this.emit('download:success', result)
|
|
1020
|
+
const syncedFile = publishedBucket.find(file => file.cid === cidString)
|
|
1021
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1022
|
+
file: this.#formatFileForSync(syncedFile, 'active'),
|
|
1023
|
+
})
|
|
1022
1024
|
return result
|
|
1023
1025
|
}
|
|
1024
1026
|
} finally {
|
|
@@ -1033,8 +1035,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1033
1035
|
this.#ensureInitialized()
|
|
1034
1036
|
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
1035
1037
|
const parsed = parseMostLink(link)
|
|
1036
|
-
if (parsed.
|
|
1037
|
-
throw new ValidationError(
|
|
1038
|
+
if (parsed.errorCode) {
|
|
1039
|
+
throw new ValidationError(
|
|
1040
|
+
parsed.errorCode,
|
|
1041
|
+
parsed.errorCode,
|
|
1042
|
+
parsed.details
|
|
1043
|
+
)
|
|
1038
1044
|
}
|
|
1039
1045
|
|
|
1040
1046
|
const localContent = await this.#getLocalCidContent(parsed.cid, {
|
|
@@ -1068,8 +1074,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1068
1074
|
|
|
1069
1075
|
const timeout = options.timeout || DRIVE_ENTRY_TIMEOUT
|
|
1070
1076
|
const parsed = parseMostLink(link)
|
|
1071
|
-
if (parsed.
|
|
1072
|
-
throw new ValidationError(
|
|
1077
|
+
if (parsed.errorCode) {
|
|
1078
|
+
throw new ValidationError(
|
|
1079
|
+
parsed.errorCode,
|
|
1080
|
+
parsed.errorCode,
|
|
1081
|
+
parsed.details
|
|
1082
|
+
)
|
|
1073
1083
|
}
|
|
1074
1084
|
|
|
1075
1085
|
const cidString = parsed.cid
|
|
@@ -1155,8 +1165,14 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1155
1165
|
cid: f.cid,
|
|
1156
1166
|
link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
|
|
1157
1167
|
publishedAt: f.publishedAt,
|
|
1168
|
+
size: Number(f.size) || 0,
|
|
1158
1169
|
starred: f.starred || false,
|
|
1159
1170
|
ownerAddress: ownerAddress || '',
|
|
1171
|
+
localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
|
|
1172
|
+
seedStatus: this.#seedStates.get(f.cid)?.status || '',
|
|
1173
|
+
holdingSize:
|
|
1174
|
+
Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
|
|
1175
|
+
0,
|
|
1160
1176
|
}))
|
|
1161
1177
|
}
|
|
1162
1178
|
|
|
@@ -1174,7 +1190,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1174
1190
|
throw new Error('File not found')
|
|
1175
1191
|
}
|
|
1176
1192
|
files[index].starred = !files[index].starred
|
|
1193
|
+
files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
|
|
1177
1194
|
this.#savePublishedMetadata()
|
|
1195
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1196
|
+
file: this.#formatFileForSync(files[index], 'active'),
|
|
1197
|
+
})
|
|
1178
1198
|
return {
|
|
1179
1199
|
cid,
|
|
1180
1200
|
starred: files[index].starred,
|
|
@@ -1195,8 +1215,9 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1195
1215
|
if (index !== -1) {
|
|
1196
1216
|
const fileRecord = files[index]
|
|
1197
1217
|
const holding = this.#holdings.find(item => item.cid === fileRecord.cid)
|
|
1218
|
+
const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
|
|
1198
1219
|
|
|
1199
|
-
|
|
1220
|
+
const trashRecord = {
|
|
1200
1221
|
fileName: fileRecord.fileName,
|
|
1201
1222
|
cid: fileRecord.cid,
|
|
1202
1223
|
driveName:
|
|
@@ -1205,13 +1226,18 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1205
1226
|
source: holding?.source || 'published',
|
|
1206
1227
|
publishedAt: fileRecord.publishedAt,
|
|
1207
1228
|
starred: fileRecord.starred || false,
|
|
1208
|
-
deletedAt: new Date().toISOString(),
|
|
1209
|
-
|
|
1229
|
+
deletedAt: new Date(syncUpdatedAt).toISOString(),
|
|
1230
|
+
syncUpdatedAt,
|
|
1231
|
+
}
|
|
1232
|
+
trashFiles.push(trashRecord)
|
|
1210
1233
|
this.#saveTrashMetadata()
|
|
1211
1234
|
|
|
1212
1235
|
files.splice(index, 1)
|
|
1213
1236
|
this.#setPublishedBucket(ownerAddress, files)
|
|
1214
1237
|
this.#savePublishedMetadata()
|
|
1238
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:trash', {
|
|
1239
|
+
file: this.#formatFileForSync(trashRecord, 'trash'),
|
|
1240
|
+
})
|
|
1215
1241
|
|
|
1216
1242
|
if (!this.#hasPublishedReference(fileRecord.cid)) {
|
|
1217
1243
|
await this.#leaveCidTopic(fileRecord.cid)
|
|
@@ -1237,9 +1263,15 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1237
1263
|
cid: f.cid,
|
|
1238
1264
|
link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
|
|
1239
1265
|
publishedAt: f.publishedAt,
|
|
1266
|
+
size: Number(f.size) || 0,
|
|
1240
1267
|
starred: f.starred || false,
|
|
1241
1268
|
ownerAddress: ownerAddress || '',
|
|
1242
1269
|
deletedAt: f.deletedAt,
|
|
1270
|
+
localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
|
|
1271
|
+
seedStatus: this.#seedStates.get(f.cid)?.status || '',
|
|
1272
|
+
holdingSize:
|
|
1273
|
+
Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
|
|
1274
|
+
0,
|
|
1243
1275
|
}))
|
|
1244
1276
|
}
|
|
1245
1277
|
|
|
@@ -1269,6 +1301,9 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1269
1301
|
trashFiles.splice(index, 1)
|
|
1270
1302
|
this.#setTrashBucket(ownerAddress, trashFiles)
|
|
1271
1303
|
this.#saveTrashMetadata()
|
|
1304
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1305
|
+
file: this.#formatFileForSync(publishedFiles[existingIndex], 'active'),
|
|
1306
|
+
})
|
|
1272
1307
|
return this.listPublishedFiles({ ownerAddress })
|
|
1273
1308
|
}
|
|
1274
1309
|
|
|
@@ -1277,29 +1312,43 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1277
1312
|
excludeCid: fileRecord.cid,
|
|
1278
1313
|
})
|
|
1279
1314
|
|
|
1280
|
-
|
|
1315
|
+
const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
|
|
1316
|
+
const publishedRecord = {
|
|
1281
1317
|
fileName: fileRecord.fileName,
|
|
1282
1318
|
cid: fileRecord.cid,
|
|
1283
1319
|
driveName,
|
|
1284
1320
|
publishedAt: fileRecord.publishedAt,
|
|
1285
1321
|
starred: fileRecord.starred || false,
|
|
1286
|
-
|
|
1322
|
+
size: Number(fileRecord.size) || 0,
|
|
1323
|
+
source: fileRecord.source || 'synced',
|
|
1324
|
+
syncUpdatedAt,
|
|
1325
|
+
}
|
|
1326
|
+
publishedFiles.push(publishedRecord)
|
|
1287
1327
|
this.#savePublishedMetadata()
|
|
1288
1328
|
|
|
1289
1329
|
trashFiles.splice(index, 1)
|
|
1290
1330
|
this.#setTrashBucket(ownerAddress, trashFiles)
|
|
1291
1331
|
this.#saveTrashMetadata()
|
|
1292
1332
|
|
|
1293
|
-
await this.#
|
|
1294
|
-
|
|
1295
|
-
|
|
1333
|
+
const localContent = await this.#getLocalCidContent(fileRecord.cid, {
|
|
1334
|
+
ownerAddress,
|
|
1335
|
+
allowHoldingFallback: true,
|
|
1296
1336
|
})
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1337
|
+
if (localContent) {
|
|
1338
|
+
await this.#joinCidTopicInternal(fileRecord.cid, {
|
|
1339
|
+
server: true,
|
|
1340
|
+
client: false,
|
|
1341
|
+
})
|
|
1342
|
+
this.#upsertHolding({
|
|
1343
|
+
cid: fileRecord.cid,
|
|
1344
|
+
fileName: fileRecord.fileName,
|
|
1345
|
+
size: localContent.size || Number(fileRecord.size) || 0,
|
|
1346
|
+
driveName,
|
|
1347
|
+
source: fileRecord.source || 'published',
|
|
1348
|
+
})
|
|
1349
|
+
}
|
|
1350
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1351
|
+
file: this.#formatFileForSync(publishedRecord, 'active'),
|
|
1303
1352
|
})
|
|
1304
1353
|
|
|
1305
1354
|
return this.listPublishedFiles({ ownerAddress })
|
|
@@ -1323,6 +1372,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1323
1372
|
trashFiles.splice(index, 1)
|
|
1324
1373
|
this.#setTrashBucket(ownerAddress, trashFiles)
|
|
1325
1374
|
this.#saveTrashMetadata()
|
|
1375
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
|
|
1376
|
+
cid: fileRecord.cid,
|
|
1377
|
+
syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
|
|
1378
|
+
})
|
|
1326
1379
|
|
|
1327
1380
|
if (!this.#hasAnyUserReference(fileRecord.cid)) {
|
|
1328
1381
|
try {
|
|
@@ -1349,8 +1402,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1349
1402
|
const removedTrash = [...this.#getTrashBucket(ownerAddress)]
|
|
1350
1403
|
this.#setTrashBucket(ownerAddress, [])
|
|
1351
1404
|
this.#saveTrashMetadata()
|
|
1352
|
-
|
|
1353
1405
|
for (const fileRecord of removedTrash) {
|
|
1406
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
|
|
1407
|
+
cid: fileRecord.cid,
|
|
1408
|
+
syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
|
|
1409
|
+
})
|
|
1354
1410
|
if (this.#hasAnyUserReference(fileRecord.cid)) continue
|
|
1355
1411
|
const driveName =
|
|
1356
1412
|
fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
|
|
@@ -1455,8 +1511,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1455
1511
|
excludeCid: cid,
|
|
1456
1512
|
})
|
|
1457
1513
|
files[index].fileName = safeFileName
|
|
1458
|
-
files[index].
|
|
1514
|
+
files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
|
|
1515
|
+
files[index].publishedAt = new Date(files[index].syncUpdatedAt).toISOString()
|
|
1459
1516
|
this.#savePublishedMetadata()
|
|
1517
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1518
|
+
file: this.#formatFileForSync(files[index], 'active'),
|
|
1519
|
+
})
|
|
1460
1520
|
return {
|
|
1461
1521
|
cid,
|
|
1462
1522
|
fileName: safeFileName,
|
|
@@ -1497,7 +1557,8 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1497
1557
|
|
|
1498
1558
|
const updatedFiles = updates.map(({ file, newFileName }) => {
|
|
1499
1559
|
file.fileName = newFileName
|
|
1500
|
-
file.
|
|
1560
|
+
file.syncUpdatedAt = getNextSyncTimestamp(file.syncUpdatedAt)
|
|
1561
|
+
file.publishedAt = new Date(file.syncUpdatedAt).toISOString()
|
|
1501
1562
|
return {
|
|
1502
1563
|
cid: file.cid,
|
|
1503
1564
|
fileName: file.fileName,
|
|
@@ -1507,6 +1568,11 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1507
1568
|
|
|
1508
1569
|
if (updatedFiles.length > 0) {
|
|
1509
1570
|
this.#savePublishedMetadata()
|
|
1571
|
+
for (const { file } of updates) {
|
|
1572
|
+
this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
|
|
1573
|
+
file: this.#formatFileForSync(file, 'active'),
|
|
1574
|
+
})
|
|
1575
|
+
}
|
|
1510
1576
|
}
|
|
1511
1577
|
|
|
1512
1578
|
return { files: updatedFiles }
|
|
@@ -1606,253 +1672,6 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1606
1672
|
}
|
|
1607
1673
|
}
|
|
1608
1674
|
|
|
1609
|
-
exportUserMetadata(ownerAddressInput) {
|
|
1610
|
-
this.#ensureInitialized()
|
|
1611
|
-
const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
|
|
1612
|
-
if (!ownerAddress) {
|
|
1613
|
-
throw new ValidationError('valid owner address is required')
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
const files = this.#getPublishedBucket(ownerAddress).map(file => ({
|
|
1617
|
-
fileName: file.fileName,
|
|
1618
|
-
cid: file.cid,
|
|
1619
|
-
driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
|
|
1620
|
-
publishedAt: file.publishedAt,
|
|
1621
|
-
starred: file.starred || false,
|
|
1622
|
-
link: buildMostLink(file.cid, file.fileName),
|
|
1623
|
-
}))
|
|
1624
|
-
const trashFiles = this.#getTrashBucket(ownerAddress).map(file => ({
|
|
1625
|
-
fileName: file.fileName,
|
|
1626
|
-
cid: file.cid,
|
|
1627
|
-
driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
|
|
1628
|
-
size: Number(file.size) || 0,
|
|
1629
|
-
source: file.source || 'published',
|
|
1630
|
-
publishedAt: file.publishedAt,
|
|
1631
|
-
starred: file.starred || false,
|
|
1632
|
-
deletedAt: file.deletedAt,
|
|
1633
|
-
link: buildMostLink(file.cid, file.fileName),
|
|
1634
|
-
}))
|
|
1635
|
-
const channels = this.#channels
|
|
1636
|
-
.filter(channel => this.#channelHasMember(channel, ownerAddress))
|
|
1637
|
-
.map(channel => ({
|
|
1638
|
-
name: channel.name,
|
|
1639
|
-
type: channel.type,
|
|
1640
|
-
coreKey: channel.coreKey,
|
|
1641
|
-
createdAt: channel.createdAt,
|
|
1642
|
-
lastMessageAt: channel.lastMessageAt || '',
|
|
1643
|
-
member: this.#getChannelMembers(channel).find(
|
|
1644
|
-
member => member.address === ownerAddress
|
|
1645
|
-
),
|
|
1646
|
-
remark: channel.remarks?.[ownerAddress] || '',
|
|
1647
|
-
pinned: Boolean(channel.pinnedBy?.[ownerAddress]),
|
|
1648
|
-
}))
|
|
1649
|
-
|
|
1650
|
-
return {
|
|
1651
|
-
schemaVersion: USER_DATA_SCHEMA_VERSION,
|
|
1652
|
-
exportedAt: new Date().toISOString(),
|
|
1653
|
-
ownerAddress,
|
|
1654
|
-
files,
|
|
1655
|
-
trashFiles,
|
|
1656
|
-
channels,
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
async checkUserImport(input = {}, options = {}) {
|
|
1661
|
-
this.#ensureInitialized()
|
|
1662
|
-
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
1663
|
-
if (!ownerAddress) {
|
|
1664
|
-
throw new ValidationError('valid owner address is required')
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
const normalized = this.#normalizeUserImportPackage(input)
|
|
1668
|
-
const failures = []
|
|
1669
|
-
const seenPaths = new Set()
|
|
1670
|
-
const checkedActiveCids = new Set()
|
|
1671
|
-
const importedCids = new Set([
|
|
1672
|
-
...normalized.files.map(file => file.cid),
|
|
1673
|
-
...normalized.trashFiles.map(file => file.cid),
|
|
1674
|
-
])
|
|
1675
|
-
const currentCids = this.#collectUserCids(ownerAddress)
|
|
1676
|
-
let requiredBytes = 0
|
|
1677
|
-
|
|
1678
|
-
if (normalized.files.length > IMPORT_CHECK_MAX_FILES) {
|
|
1679
|
-
failures.push({
|
|
1680
|
-
scope: 'package',
|
|
1681
|
-
error: `too many files: max ${IMPORT_CHECK_MAX_FILES}`,
|
|
1682
|
-
})
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
for (const file of normalized.files) {
|
|
1686
|
-
const pathKey = sanitizeFilename(file.fileName).toLowerCase()
|
|
1687
|
-
if (seenPaths.has(pathKey)) {
|
|
1688
|
-
failures.push({
|
|
1689
|
-
cid: file.cid,
|
|
1690
|
-
fileName: file.fileName,
|
|
1691
|
-
error: 'duplicate file path',
|
|
1692
|
-
})
|
|
1693
|
-
continue
|
|
1694
|
-
}
|
|
1695
|
-
seenPaths.add(pathKey)
|
|
1696
|
-
|
|
1697
|
-
const link = buildMostLink(file.cid, file.fileName)
|
|
1698
|
-
try {
|
|
1699
|
-
const result = await this.checkDownloadAvailability(link, {
|
|
1700
|
-
ownerAddress,
|
|
1701
|
-
timeout: options.timeout || DRIVE_ENTRY_TIMEOUT,
|
|
1702
|
-
})
|
|
1703
|
-
if (!checkedActiveCids.has(file.cid) && !result.alreadyExists) {
|
|
1704
|
-
requiredBytes += Number(result.size) || Number(file.size) || 0
|
|
1705
|
-
}
|
|
1706
|
-
checkedActiveCids.add(file.cid)
|
|
1707
|
-
} catch (err) {
|
|
1708
|
-
failures.push({
|
|
1709
|
-
cid: file.cid,
|
|
1710
|
-
fileName: file.fileName,
|
|
1711
|
-
error: err.message,
|
|
1712
|
-
code: err.code || 'UNAVAILABLE',
|
|
1713
|
-
})
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
let reclaimableBytes = 0
|
|
1718
|
-
for (const cid of currentCids) {
|
|
1719
|
-
if (importedCids.has(cid)) continue
|
|
1720
|
-
const referencedByOtherUser =
|
|
1721
|
-
this.#allPublishedRecords().some(
|
|
1722
|
-
file => file.cid === cid && file.ownerAddress !== ownerAddress
|
|
1723
|
-
) ||
|
|
1724
|
-
this.#allTrashRecords().some(
|
|
1725
|
-
file => file.cid === cid && file.ownerAddress !== ownerAddress
|
|
1726
|
-
)
|
|
1727
|
-
if (referencedByOtherUser) continue
|
|
1728
|
-
const holding = this.#holdings.find(item => item.cid === cid)
|
|
1729
|
-
reclaimableBytes += Number(holding?.size) || 0
|
|
1730
|
-
}
|
|
1731
|
-
const availableBytes = Math.max(
|
|
1732
|
-
0,
|
|
1733
|
-
this.#options.capacityBytes - this.#getUsedBytes() + reclaimableBytes
|
|
1734
|
-
)
|
|
1735
|
-
if (requiredBytes > availableBytes) {
|
|
1736
|
-
failures.push({
|
|
1737
|
-
scope: 'capacity',
|
|
1738
|
-
error: 'insufficient capacity',
|
|
1739
|
-
requiredBytes,
|
|
1740
|
-
availableBytes,
|
|
1741
|
-
})
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
const ready = failures.length === 0
|
|
1745
|
-
const checkId = `import_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`
|
|
1746
|
-
if (ready) {
|
|
1747
|
-
this.#importChecks.set(checkId, {
|
|
1748
|
-
ownerAddress,
|
|
1749
|
-
packageHash: this.#hashImportPackage(input),
|
|
1750
|
-
expiresAt: Date.now() + IMPORT_CHECK_TTL_MS,
|
|
1751
|
-
})
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
return {
|
|
1755
|
-
ready,
|
|
1756
|
-
checkId: ready ? checkId : '',
|
|
1757
|
-
failures,
|
|
1758
|
-
currentFileCount: this.#getPublishedBucket(ownerAddress).length,
|
|
1759
|
-
currentTrashCount: this.#getTrashBucket(ownerAddress).length,
|
|
1760
|
-
currentCidCount: currentCids.size,
|
|
1761
|
-
importFileCount: normalized.files.length,
|
|
1762
|
-
importTrashCount: normalized.trashFiles.length,
|
|
1763
|
-
requiredBytes,
|
|
1764
|
-
availableBytes,
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
async importUserMetadata(input = {}, options = {}) {
|
|
1769
|
-
this.#ensureInitialized()
|
|
1770
|
-
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
1771
|
-
if (!ownerAddress) {
|
|
1772
|
-
throw new ValidationError('valid owner address is required')
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
const checkId = String(options.checkId || '').trim()
|
|
1776
|
-
const check = this.#importChecks.get(checkId)
|
|
1777
|
-
if (
|
|
1778
|
-
!check ||
|
|
1779
|
-
check.ownerAddress !== ownerAddress ||
|
|
1780
|
-
check.expiresAt < Date.now() ||
|
|
1781
|
-
check.packageHash !== this.#hashImportPackage(input)
|
|
1782
|
-
) {
|
|
1783
|
-
throw new ValidationError('valid import check is required')
|
|
1784
|
-
}
|
|
1785
|
-
this.#importChecks.delete(checkId)
|
|
1786
|
-
|
|
1787
|
-
const normalized = this.#normalizeUserImportPackage(input)
|
|
1788
|
-
const previousCids = this.#collectUserCids(ownerAddress)
|
|
1789
|
-
const previous = {
|
|
1790
|
-
removedFiles: this.#getPublishedBucket(ownerAddress).length,
|
|
1791
|
-
removedTrashFiles: this.#getTrashBucket(ownerAddress).length,
|
|
1792
|
-
}
|
|
1793
|
-
const now = new Date().toISOString()
|
|
1794
|
-
|
|
1795
|
-
this.#removeUserFromChannels(ownerAddress)
|
|
1796
|
-
this.#setPublishedBucket(
|
|
1797
|
-
ownerAddress,
|
|
1798
|
-
normalized.files.map(file => ({
|
|
1799
|
-
fileName: file.fileName,
|
|
1800
|
-
cid: file.cid,
|
|
1801
|
-
driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
|
|
1802
|
-
publishedAt: file.publishedAt || now,
|
|
1803
|
-
starred: file.starred || false,
|
|
1804
|
-
}))
|
|
1805
|
-
)
|
|
1806
|
-
this.#setTrashBucket(
|
|
1807
|
-
ownerAddress,
|
|
1808
|
-
normalized.trashFiles.map(file => ({
|
|
1809
|
-
fileName: file.fileName,
|
|
1810
|
-
cid: file.cid,
|
|
1811
|
-
driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
|
|
1812
|
-
size: Number(file.size) || 0,
|
|
1813
|
-
source: file.source || 'imported',
|
|
1814
|
-
publishedAt: file.publishedAt || now,
|
|
1815
|
-
starred: file.starred || false,
|
|
1816
|
-
deletedAt: file.deletedAt || now,
|
|
1817
|
-
}))
|
|
1818
|
-
)
|
|
1819
|
-
this.#applyImportedChannels(ownerAddress, normalized.channels)
|
|
1820
|
-
this.#savePublishedMetadata()
|
|
1821
|
-
this.#saveTrashMetadata()
|
|
1822
|
-
this.#saveChannelsMetadata()
|
|
1823
|
-
previous.removedReplicas = await this.#cleanupUnreferencedCids(previousCids)
|
|
1824
|
-
|
|
1825
|
-
const importedFiles = []
|
|
1826
|
-
const failedFiles = []
|
|
1827
|
-
for (const file of normalized.files) {
|
|
1828
|
-
try {
|
|
1829
|
-
const result = await this.pullByCid({
|
|
1830
|
-
link: buildMostLink(file.cid, file.fileName),
|
|
1831
|
-
ownerAddress,
|
|
1832
|
-
timeout: options.timeout,
|
|
1833
|
-
})
|
|
1834
|
-
importedFiles.push({ cid: file.cid, fileName: file.fileName, result })
|
|
1835
|
-
} catch (err) {
|
|
1836
|
-
failedFiles.push({
|
|
1837
|
-
cid: file.cid,
|
|
1838
|
-
fileName: file.fileName,
|
|
1839
|
-
error: err.message,
|
|
1840
|
-
code: err.code || 'IMPORT_PULL_FAILED',
|
|
1841
|
-
})
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
return {
|
|
1846
|
-
success: failedFiles.length === 0,
|
|
1847
|
-
ownerAddress,
|
|
1848
|
-
replacedFiles: previous.removedFiles,
|
|
1849
|
-
replacedTrashFiles: previous.removedTrashFiles,
|
|
1850
|
-
importedFiles: importedFiles.length,
|
|
1851
|
-
importedTrashFiles: normalized.trashFiles.length,
|
|
1852
|
-
failedFiles,
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
1675
|
/**
|
|
1857
1676
|
* 列出当前节点持有的可做种文件副本
|
|
1858
1677
|
* @returns {Array}
|
|
@@ -1904,8 +1723,12 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1904
1723
|
|
|
1905
1724
|
if (input.link) {
|
|
1906
1725
|
const parsed = parseMostLink(input.link)
|
|
1907
|
-
if (parsed.
|
|
1908
|
-
throw new ValidationError(
|
|
1726
|
+
if (parsed.errorCode) {
|
|
1727
|
+
throw new ValidationError(
|
|
1728
|
+
parsed.errorCode,
|
|
1729
|
+
parsed.errorCode,
|
|
1730
|
+
parsed.details
|
|
1731
|
+
)
|
|
1909
1732
|
}
|
|
1910
1733
|
const result = await this.downloadFile(input.link, input.taskId || null, {
|
|
1911
1734
|
timeout: input.timeout,
|
|
@@ -1955,19 +1778,130 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
1955
1778
|
|
|
1956
1779
|
const left = this.#store.replicate(true, { live: true })
|
|
1957
1780
|
const right = peerEngine.#store.replicate(false, { live: true })
|
|
1781
|
+
const [leftChat, rightChat] = createMemoryDuplexPair()
|
|
1958
1782
|
|
|
1959
1783
|
left.on('error', () => {})
|
|
1960
1784
|
right.on('error', () => {})
|
|
1785
|
+
leftChat.on('error', () => {})
|
|
1786
|
+
rightChat.on('error', () => {})
|
|
1961
1787
|
left.pipe(right).pipe(left)
|
|
1788
|
+
this.#handleChannelConnection(leftChat).catch(() => {})
|
|
1789
|
+
peerEngine.#handleChannelConnection(rightChat).catch(() => {})
|
|
1790
|
+
this.#exchangeUserSyncSessions(peerEngine).catch(() => {})
|
|
1791
|
+
peerEngine.#exchangeUserSyncSessions(this).catch(() => {})
|
|
1962
1792
|
|
|
1963
1793
|
return {
|
|
1964
1794
|
close: () => {
|
|
1965
1795
|
left.destroy()
|
|
1966
1796
|
right.destroy()
|
|
1797
|
+
leftChat.destroy()
|
|
1798
|
+
rightChat.destroy()
|
|
1967
1799
|
},
|
|
1968
1800
|
}
|
|
1969
1801
|
}
|
|
1970
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* 启动当前账号的隐藏 user.sync 元数据同步。
|
|
1805
|
+
*/
|
|
1806
|
+
async startUserSync(ownerAddressInput, input = {}) {
|
|
1807
|
+
this.#ensureInitialized()
|
|
1808
|
+
const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
|
|
1809
|
+
if (!ownerAddress) {
|
|
1810
|
+
throw new ValidationError('valid owner address is required')
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const syncTopicKey = normalizeUserSyncKey(input.syncTopicKey)
|
|
1814
|
+
const syncCipherKey = normalizeUserSyncKey(input.syncCipherKey)
|
|
1815
|
+
const syncMacKey = normalizeUserSyncKey(input.syncMacKey)
|
|
1816
|
+
if (!syncTopicKey || !syncCipherKey || !syncMacKey) {
|
|
1817
|
+
throw new ValidationError('valid user sync keys are required')
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const syncId = deriveUserSyncId(syncTopicKey)
|
|
1821
|
+
const syncName = getUserSyncName(syncId)
|
|
1822
|
+
const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
|
|
1823
|
+
const session = {
|
|
1824
|
+
ownerAddress,
|
|
1825
|
+
syncId,
|
|
1826
|
+
syncName,
|
|
1827
|
+
syncTopicKey,
|
|
1828
|
+
syncCipherKey,
|
|
1829
|
+
syncMacKey,
|
|
1830
|
+
writerId:
|
|
1831
|
+
persisted?.syncId === syncId && persisted?.writerId
|
|
1832
|
+
? persisted.writerId
|
|
1833
|
+
: createChannelWriterId(),
|
|
1834
|
+
localWriterCoreKey:
|
|
1835
|
+
persisted?.syncId === syncId ? persisted.localWriterCoreKey || '' : '',
|
|
1836
|
+
writerCoreKeys:
|
|
1837
|
+
persisted?.syncId === syncId
|
|
1838
|
+
? uniqueStrings(persisted.writerCoreKeys)
|
|
1839
|
+
: [],
|
|
1840
|
+
startedAt: new Date().toISOString(),
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
this.#userSyncSessions.set(ownerAddress, session)
|
|
1844
|
+
await this.#openUserSyncRuntime(session)
|
|
1845
|
+
await this.#joinUserSyncDiscovery(session)
|
|
1846
|
+
this.#persistUserSyncSession(session)
|
|
1847
|
+
await this.#appendUserSyncSnapshot(ownerAddress, 'start')
|
|
1848
|
+
|
|
1849
|
+
return this.getUserSyncStatus(ownerAddress)
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
getUserSyncStatus(ownerAddressInput) {
|
|
1853
|
+
this.#ensureInitialized()
|
|
1854
|
+
const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
|
|
1855
|
+
if (!ownerAddress) {
|
|
1856
|
+
throw new ValidationError('valid owner address is required')
|
|
1857
|
+
}
|
|
1858
|
+
const session = this.#userSyncSessions.get(ownerAddress)
|
|
1859
|
+
if (!session) {
|
|
1860
|
+
const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
|
|
1861
|
+
return {
|
|
1862
|
+
enabled: false,
|
|
1863
|
+
ownerAddress,
|
|
1864
|
+
syncName: persisted?.syncName || '',
|
|
1865
|
+
syncId: persisted?.syncId || '',
|
|
1866
|
+
peerCount: 0,
|
|
1867
|
+
writerCoreKeys: uniqueStrings(persisted?.writerCoreKeys),
|
|
1868
|
+
localWriterCoreKey: persisted?.localWriterCoreKey || '',
|
|
1869
|
+
lastSyncedAt: persisted?.lastSyncedAt || '',
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
return {
|
|
1874
|
+
enabled: true,
|
|
1875
|
+
ownerAddress,
|
|
1876
|
+
syncName: session.syncName,
|
|
1877
|
+
syncId: session.syncId,
|
|
1878
|
+
peerCount: this.#chatSwarm?.connections?.size || 0,
|
|
1879
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
1880
|
+
localWriterCoreKey: session.localWriterCoreKey,
|
|
1881
|
+
lastSyncedAt:
|
|
1882
|
+
this.#userSyncMetadata.sessions?.[ownerAddress]?.lastSyncedAt || '',
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
async cacheFile(cid, options = {}) {
|
|
1887
|
+
this.#ensureInitialized()
|
|
1888
|
+
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
1889
|
+
const fileRecord = this.#getPublishedBucket(ownerAddress).find(
|
|
1890
|
+
item => item.cid === cid
|
|
1891
|
+
)
|
|
1892
|
+
if (!fileRecord) {
|
|
1893
|
+
throw new Error('File not found')
|
|
1894
|
+
}
|
|
1895
|
+
const result = await this.pullByCid({
|
|
1896
|
+
cid,
|
|
1897
|
+
fileName: fileRecord.fileName,
|
|
1898
|
+
ownerAddress,
|
|
1899
|
+
timeout: options.timeout,
|
|
1900
|
+
taskId: options.taskId,
|
|
1901
|
+
})
|
|
1902
|
+
return result
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1971
1905
|
/**
|
|
1972
1906
|
* 读取已发布文件的内容(用于预览)
|
|
1973
1907
|
* Hyperdrive 中用 CID 作为 key 存储
|
|
@@ -2162,277 +2096,265 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2162
2096
|
// --- 频道管理 ---
|
|
2163
2097
|
|
|
2164
2098
|
/**
|
|
2165
|
-
*
|
|
2166
|
-
* @param {string}
|
|
2099
|
+
* 创建或加入频道。channelId 是用户输入的短 ID,channelKey 与频道名一致。
|
|
2100
|
+
* @param {string} channelIdInput - 用户可见短频道 ID
|
|
2167
2101
|
* @param {string} [type='personal'] - 频道类型
|
|
2168
|
-
* @returns {Promise<
|
|
2102
|
+
* @returns {Promise<object>}
|
|
2169
2103
|
*/
|
|
2170
|
-
async createChannel(
|
|
2104
|
+
async createChannel(channelIdInput, type = 'personal', options = {}) {
|
|
2171
2105
|
this.#ensureInitialized()
|
|
2172
2106
|
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2107
|
+
const channelId = normalizeChannelId(channelIdInput)
|
|
2173
2108
|
const channelType = String(type || 'personal').trim() || 'personal'
|
|
2174
2109
|
|
|
2175
|
-
if (
|
|
2110
|
+
if (channelId.includes('.') && channelType !== 'game') {
|
|
2111
|
+
throw new Error('点号为系统保留,不能用于手动频道 ID')
|
|
2112
|
+
}
|
|
2113
|
+
if (channelType === 'game' && !/^game\.[a-z0-9]+\.[a-z0-9]+$/.test(channelId)) {
|
|
2114
|
+
throw new Error('游戏频道必须使用 game.<gameId>.<roomCode> 格式')
|
|
2115
|
+
}
|
|
2116
|
+
if (channelType !== 'game' && !CHANNEL_NAME_REGEX.test(channelId)) {
|
|
2176
2117
|
throw new Error('频道名只能包含字母、数字、下划线和连字符')
|
|
2177
2118
|
}
|
|
2178
|
-
if (
|
|
2119
|
+
if (channelId.length < CHANNEL_NAME_MIN_LENGTH) {
|
|
2179
2120
|
throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
|
|
2180
2121
|
}
|
|
2181
|
-
if (
|
|
2122
|
+
if (channelId.length > CHANNEL_NAME_MAX_LENGTH) {
|
|
2182
2123
|
throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
|
|
2183
2124
|
}
|
|
2184
2125
|
|
|
2185
|
-
const
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2126
|
+
const localCandidates = this.#getLocalChannelCandidates(channelId)
|
|
2127
|
+
const remoteCandidates = options.discover
|
|
2128
|
+
? await this.#discoverChannelCandidates(channelId, {
|
|
2129
|
+
timeout: options.discoveryTimeout,
|
|
2130
|
+
})
|
|
2131
|
+
: []
|
|
2132
|
+
const candidates = this.#mergeChannelCandidates([
|
|
2133
|
+
...localCandidates,
|
|
2134
|
+
...remoteCandidates,
|
|
2135
|
+
])
|
|
2136
|
+
|
|
2137
|
+
if (candidates.length > 0) {
|
|
2138
|
+
const candidate = candidates[0]
|
|
2139
|
+
if (candidate.local) {
|
|
2140
|
+
const existing = this.#channels.find(
|
|
2141
|
+
channel => channel.channelKey === candidate.channelKey
|
|
2142
|
+
)
|
|
2143
|
+
if (existing) {
|
|
2144
|
+
const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
|
|
2145
|
+
existing,
|
|
2146
|
+
candidate.writerCoreKeys
|
|
2147
|
+
)
|
|
2148
|
+
const memberChanged = this.#upsertChannelMember(existing, options)
|
|
2149
|
+
if (writerKeysChanged || memberChanged) {
|
|
2150
|
+
existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
|
|
2151
|
+
this.#saveChannelsMetadata()
|
|
2152
|
+
this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
|
|
2153
|
+
this.#broadcastChannelHello()
|
|
2154
|
+
}
|
|
2155
|
+
return this.#formatChannelForResponse(existing, ownerAddress)
|
|
2156
|
+
}
|
|
2157
|
+
const joined = await this.#joinChannelFromCandidate(
|
|
2158
|
+
candidate,
|
|
2159
|
+
channelType,
|
|
2160
|
+
options
|
|
2161
|
+
)
|
|
2162
|
+
const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
|
|
2163
|
+
this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
|
|
2164
|
+
return joined
|
|
2196
2165
|
}
|
|
2166
|
+
const joined = await this.#joinChannelFromCandidate(
|
|
2167
|
+
candidate,
|
|
2168
|
+
channelType,
|
|
2169
|
+
options
|
|
2170
|
+
)
|
|
2171
|
+
const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
|
|
2172
|
+
this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
|
|
2173
|
+
return joined
|
|
2197
2174
|
}
|
|
2198
2175
|
|
|
2199
|
-
const
|
|
2200
|
-
|
|
2201
|
-
await core.ready()
|
|
2202
|
-
|
|
2203
|
-
const discoveryKey = this.#generateChannelDiscoveryKey(name)
|
|
2204
|
-
const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
|
|
2205
|
-
const appDiscovery = this.#swarm.join(discoveryKey, {
|
|
2206
|
-
server: true,
|
|
2207
|
-
client: true,
|
|
2208
|
-
})
|
|
2209
|
-
const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
|
|
2210
|
-
server: true,
|
|
2211
|
-
client: true,
|
|
2212
|
-
})
|
|
2213
|
-
|
|
2214
|
-
this.#setupChannelAppendListener(core, name)
|
|
2215
|
-
|
|
2216
|
-
const channelInfo = {
|
|
2217
|
-
name,
|
|
2218
|
-
discoveryKey: b4a.toString(discoveryKey, 'hex'),
|
|
2219
|
-
coreKey: b4a.toString(core.key, 'hex'),
|
|
2220
|
-
createdAt: new Date().toISOString(),
|
|
2221
|
-
type: channelType,
|
|
2176
|
+
const channelInfo = await this.#createLocalChannel(channelId, channelType, {
|
|
2177
|
+
...options,
|
|
2222
2178
|
ownerAddress,
|
|
2223
|
-
|
|
2224
|
-
remoteCoreKeys: [],
|
|
2225
|
-
}
|
|
2226
|
-
this.#upsertChannelMember(channelInfo, options)
|
|
2227
|
-
|
|
2228
|
-
this.#channels.push(channelInfo)
|
|
2229
|
-
const coreKeyHex = b4a.toString(core.key, 'hex')
|
|
2230
|
-
if (!this.#channelCores.has(name)) {
|
|
2231
|
-
this.#channelCores.set(name, new Map())
|
|
2232
|
-
}
|
|
2233
|
-
this.#channelCores.get(name).set(coreKeyHex, core)
|
|
2234
|
-
this.#channelLocalCoreKey.set(name, coreKeyHex)
|
|
2235
|
-
this.#channelPeers.set(name, new Map())
|
|
2236
|
-
this.#channelDiscoveries.set(name, appDiscovery)
|
|
2237
|
-
this.#channelChatDiscoveries.set(name, chatDiscovery)
|
|
2238
|
-
this.#saveChannelsMetadata()
|
|
2179
|
+
})
|
|
2239
2180
|
|
|
2240
|
-
console.log(`[MostBox] Channel created: ${
|
|
2241
|
-
this.emit('channel:joined', {
|
|
2181
|
+
console.log(`[MostBox] Channel created: ${channelInfo.channelKey}`)
|
|
2182
|
+
this.emit('channel:joined', {
|
|
2183
|
+
channel: channelInfo.channelKey,
|
|
2184
|
+
channelKey: channelInfo.channelKey,
|
|
2185
|
+
channelId: channelInfo.channelId,
|
|
2186
|
+
key: channelInfo.channelKey,
|
|
2187
|
+
})
|
|
2188
|
+
this.#appendUserSyncChannelUpsertSoon(channelInfo, ownerAddress)
|
|
2242
2189
|
|
|
2243
|
-
return
|
|
2244
|
-
name,
|
|
2245
|
-
key: channelInfo.coreKey,
|
|
2246
|
-
coreKey: channelInfo.coreKey,
|
|
2247
|
-
createdAt: channelInfo.createdAt,
|
|
2248
|
-
type: channelInfo.type,
|
|
2249
|
-
}
|
|
2190
|
+
return this.#formatChannelForResponse(channelInfo, ownerAddress)
|
|
2250
2191
|
}
|
|
2251
2192
|
|
|
2252
2193
|
/**
|
|
2253
|
-
*
|
|
2254
|
-
* @param {string}
|
|
2255
|
-
* @param {string}
|
|
2256
|
-
* @returns {Promise<
|
|
2194
|
+
* 通过已发现候选加入频道。
|
|
2195
|
+
* @param {string} channelIdInput - 用户可见短频道 ID
|
|
2196
|
+
* @param {object|string|null} candidateInput - 候选对象或 channelKey
|
|
2197
|
+
* @returns {Promise<object>}
|
|
2257
2198
|
*/
|
|
2258
|
-
async joinChannel(
|
|
2199
|
+
async joinChannel(channelIdInput, candidateInput = null, options = {}) {
|
|
2259
2200
|
this.#ensureInitialized()
|
|
2260
|
-
const
|
|
2201
|
+
const channelId = normalizeChannelId(channelIdInput)
|
|
2202
|
+
const candidate =
|
|
2203
|
+
candidateInput && typeof candidateInput === 'object'
|
|
2204
|
+
? candidateInput
|
|
2205
|
+
: candidateInput
|
|
2206
|
+
? { channelKey: String(candidateInput), channelId }
|
|
2207
|
+
: null
|
|
2261
2208
|
|
|
2262
|
-
|
|
2209
|
+
if (!candidate?.channelKey) {
|
|
2210
|
+
return this.createChannel(channelId, options.type || 'group', options)
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
const channelKey = buildChannelKey(channelId)
|
|
2214
|
+
const existing = this.#channels.find(c => c.channelKey === channelKey)
|
|
2263
2215
|
if (existing) {
|
|
2264
|
-
|
|
2216
|
+
const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
|
|
2217
|
+
existing,
|
|
2218
|
+
candidate.writerCoreKeys
|
|
2219
|
+
)
|
|
2220
|
+
const memberChanged = this.#upsertChannelMember(existing, options)
|
|
2221
|
+
if (writerKeysChanged || memberChanged) {
|
|
2222
|
+
existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
|
|
2265
2223
|
this.#saveChannelsMetadata()
|
|
2224
|
+
this.#appendUserSyncChannelUpsertSoon(existing, options.ownerAddress)
|
|
2225
|
+
this.#broadcastChannelHello()
|
|
2266
2226
|
}
|
|
2267
|
-
|
|
2268
|
-
if (!Array.isArray(existing.remoteCoreKeys)) {
|
|
2269
|
-
existing.remoteCoreKeys = []
|
|
2270
|
-
}
|
|
2271
|
-
if (!existing.remoteCoreKeys.includes(coreKey)) {
|
|
2272
|
-
existing.remoteCoreKeys.push(coreKey)
|
|
2273
|
-
this.#saveChannelsMetadata()
|
|
2274
|
-
}
|
|
2275
|
-
await this.#openRemoteChannelCore(name, coreKey)
|
|
2276
|
-
}
|
|
2277
|
-
return {
|
|
2278
|
-
name: existing.name,
|
|
2279
|
-
key: existing.coreKey,
|
|
2280
|
-
coreKey: existing.coreKey,
|
|
2281
|
-
createdAt: existing.createdAt,
|
|
2282
|
-
type: existing.type,
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
if (!coreKey) {
|
|
2287
|
-
throw new Error('加入已有频道需要提供 coreKey')
|
|
2227
|
+
return this.#formatChannelForResponse(existing, options.ownerAddress)
|
|
2288
2228
|
}
|
|
2289
2229
|
|
|
2290
|
-
const
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
const discoveryKey = this.#generateChannelDiscoveryKey(name)
|
|
2300
|
-
const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
|
|
2301
|
-
const appDiscovery = this.#swarm.join(discoveryKey, {
|
|
2302
|
-
server: true,
|
|
2303
|
-
client: true,
|
|
2230
|
+
const cached =
|
|
2231
|
+
this.#getCachedChannelCandidate(
|
|
2232
|
+
channelId,
|
|
2233
|
+
normalizeChannelKey(candidate.channelKey)
|
|
2234
|
+
) || this.#getCachedChannelCandidate(channelId, channelKey)
|
|
2235
|
+
const joined = await this.#joinChannelFromCandidate(cached || candidate, 'group', {
|
|
2236
|
+
...options,
|
|
2237
|
+
channelKey,
|
|
2304
2238
|
})
|
|
2305
|
-
const
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
const channelInfo = {
|
|
2313
|
-
name,
|
|
2314
|
-
discoveryKey: b4a.toString(discoveryKey, 'hex'),
|
|
2315
|
-
coreKey: localCoreKeyHex,
|
|
2316
|
-
createdAt: new Date().toISOString(),
|
|
2317
|
-
type: 'group',
|
|
2318
|
-
ownerAddress,
|
|
2319
|
-
members: [],
|
|
2320
|
-
remoteCoreKeys:
|
|
2321
|
-
remoteCoreKeyHex === localCoreKeyHex ? [] : [remoteCoreKeyHex],
|
|
2322
|
-
}
|
|
2323
|
-
this.#upsertChannelMember(channelInfo, options)
|
|
2324
|
-
|
|
2325
|
-
this.#channels.push(channelInfo)
|
|
2326
|
-
if (!this.#channelCores.has(name)) {
|
|
2327
|
-
this.#channelCores.set(name, new Map())
|
|
2328
|
-
}
|
|
2329
|
-
this.#channelCores.get(name).set(localCoreKeyHex, localCore)
|
|
2330
|
-
this.#channelLocalCoreKey.set(name, localCoreKeyHex)
|
|
2331
|
-
this.#channelPeers.set(name, new Map())
|
|
2332
|
-
this.#channelDiscoveries.set(name, appDiscovery)
|
|
2333
|
-
this.#channelChatDiscoveries.set(name, chatDiscovery)
|
|
2334
|
-
this.#saveChannelsMetadata()
|
|
2335
|
-
if (remoteCoreKeyHex !== localCoreKeyHex) {
|
|
2336
|
-
await this.#openRemoteChannelCore(name, remoteCoreKeyHex)
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
console.log(`[MostBox] Joined channel: ${name}`)
|
|
2340
|
-
this.emit('channel:joined', { name, key: localCoreKeyHex })
|
|
2341
|
-
|
|
2342
|
-
return {
|
|
2343
|
-
name,
|
|
2344
|
-
key: localCoreKeyHex,
|
|
2345
|
-
coreKey: localCoreKeyHex,
|
|
2346
|
-
createdAt: channelInfo.createdAt,
|
|
2347
|
-
type: channelInfo.type,
|
|
2348
|
-
}
|
|
2239
|
+
const joinedChannel = this.#resolveChannel(
|
|
2240
|
+
joined.channelKey,
|
|
2241
|
+
options.ownerAddress
|
|
2242
|
+
)
|
|
2243
|
+
this.#appendUserSyncChannelUpsertSoon(joinedChannel, options.ownerAddress)
|
|
2244
|
+
return joined
|
|
2349
2245
|
}
|
|
2350
2246
|
|
|
2351
2247
|
/**
|
|
2352
2248
|
* 离开频道
|
|
2353
|
-
* @param {string}
|
|
2249
|
+
* @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
|
|
2354
2250
|
* @returns {Promise<string[]>} 剩余频道列表
|
|
2355
2251
|
*/
|
|
2356
|
-
async leaveChannel(
|
|
2252
|
+
async leaveChannel(channelKeyInput, options = {}) {
|
|
2357
2253
|
this.#ensureInitialized()
|
|
2358
2254
|
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2359
2255
|
|
|
2360
|
-
const
|
|
2256
|
+
const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
|
|
2257
|
+
const index = this.#channels.findIndex(c => c.channelKey === channel.channelKey)
|
|
2361
2258
|
if (index === -1) {
|
|
2362
2259
|
throw new Error('频道不存在')
|
|
2363
2260
|
}
|
|
2364
2261
|
|
|
2365
|
-
const channel = this.#channels[index]
|
|
2366
2262
|
if (ownerAddress && Array.isArray(channel.members)) {
|
|
2367
2263
|
channel.members = channel.members.filter(
|
|
2368
2264
|
member => normalizeOwnerAddress(member?.address) !== ownerAddress
|
|
2369
2265
|
)
|
|
2266
|
+
const syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
|
|
2267
|
+
channel.syncUpdatedAt = syncUpdatedAt
|
|
2268
|
+
this.#appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt)
|
|
2370
2269
|
if (channel.members.length > 0) {
|
|
2371
2270
|
this.#saveChannelsMetadata()
|
|
2372
2271
|
return this.listChannels({ ownerAddress })
|
|
2373
2272
|
}
|
|
2374
2273
|
}
|
|
2375
2274
|
|
|
2376
|
-
const appDiscovery = this.#channelDiscoveries.get(
|
|
2275
|
+
const appDiscovery = this.#channelDiscoveries.get(channel.channelKey)
|
|
2377
2276
|
if (appDiscovery && this.#swarm) {
|
|
2378
|
-
this.#channelDiscoveries.delete(
|
|
2379
|
-
this.#swarm.leave(
|
|
2277
|
+
this.#channelDiscoveries.delete(channel.channelKey)
|
|
2278
|
+
this.#swarm.leave(this.#generateChannelDiscoveryKey(channel.channelKey)).catch(err => {
|
|
2380
2279
|
console.warn(
|
|
2381
|
-
`[MostBox] Failed to leave app swarm for ${
|
|
2280
|
+
`[MostBox] Failed to leave app swarm for ${channel.channelKey}:`,
|
|
2382
2281
|
err.message
|
|
2383
2282
|
)
|
|
2384
2283
|
})
|
|
2385
2284
|
}
|
|
2386
2285
|
|
|
2387
|
-
const chatDiscovery = this.#channelChatDiscoveries.get(
|
|
2286
|
+
const chatDiscovery = this.#channelChatDiscoveries.get(channel.channelKey)
|
|
2388
2287
|
if (chatDiscovery && this.#chatSwarm) {
|
|
2389
|
-
this.#channelChatDiscoveries.delete(
|
|
2390
|
-
const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
|
|
2288
|
+
this.#channelChatDiscoveries.delete(channel.channelKey)
|
|
2289
|
+
const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
|
|
2290
|
+
channel.channelKey
|
|
2291
|
+
)
|
|
2391
2292
|
this.#chatSwarm.leave(chatDiscoveryKey).catch(err => {
|
|
2392
2293
|
console.warn(
|
|
2393
|
-
`[MostBox] Failed to leave chat swarm for ${
|
|
2294
|
+
`[MostBox] Failed to leave chat swarm for ${channel.channelKey}:`,
|
|
2394
2295
|
err.message
|
|
2395
2296
|
)
|
|
2396
2297
|
})
|
|
2397
2298
|
}
|
|
2398
2299
|
|
|
2399
|
-
const
|
|
2300
|
+
const hasSameIdChannel = this.#channels.some(
|
|
2301
|
+
(item, itemIndex) =>
|
|
2302
|
+
itemIndex !== index && item.channelId === channel.channelId
|
|
2303
|
+
)
|
|
2304
|
+
if (!hasSameIdChannel) {
|
|
2305
|
+
const idDiscovery = this.#channelIdDiscoveries.get(channel.channelId)
|
|
2306
|
+
if (idDiscovery && this.#chatSwarm) {
|
|
2307
|
+
this.#channelIdDiscoveries.delete(channel.channelId)
|
|
2308
|
+
this.#chatSwarm
|
|
2309
|
+
.leave(this.#generateChannelIdDiscoveryKey(channel.channelId))
|
|
2310
|
+
.catch(err => {
|
|
2311
|
+
console.warn(
|
|
2312
|
+
`[MostBox] Failed to leave channel ID discovery for ${channel.channelId}:`,
|
|
2313
|
+
err.message
|
|
2314
|
+
)
|
|
2315
|
+
})
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
const coresMap = this.#channelCores.get(channel.channelKey)
|
|
2400
2320
|
if (coresMap) {
|
|
2401
2321
|
for (const [, core] of coresMap) {
|
|
2402
2322
|
try {
|
|
2403
2323
|
await core.close()
|
|
2404
2324
|
} catch (err) {
|
|
2405
2325
|
console.warn(
|
|
2406
|
-
`[MostBox] Failed to close channel core for ${
|
|
2326
|
+
`[MostBox] Failed to close channel core for ${channel.channelKey}:`,
|
|
2407
2327
|
err.message
|
|
2408
2328
|
)
|
|
2409
2329
|
}
|
|
2410
2330
|
}
|
|
2411
|
-
this.#channelCores.delete(
|
|
2331
|
+
this.#channelCores.delete(channel.channelKey)
|
|
2412
2332
|
}
|
|
2413
|
-
this.#channelLocalCoreKey.delete(
|
|
2333
|
+
this.#channelLocalCoreKey.delete(channel.channelKey)
|
|
2414
2334
|
|
|
2415
|
-
this.#channelPeers.delete(
|
|
2335
|
+
this.#channelPeers.delete(channel.channelKey)
|
|
2416
2336
|
this.#channels.splice(index, 1)
|
|
2417
2337
|
this.#saveChannelsMetadata()
|
|
2418
2338
|
|
|
2419
|
-
console.log(`[MostBox] Left channel: ${
|
|
2420
|
-
this.emit('channel:left', {
|
|
2339
|
+
console.log(`[MostBox] Left channel: ${channel.channelKey}`)
|
|
2340
|
+
this.emit('channel:left', {
|
|
2341
|
+
channel: channel.channelKey,
|
|
2342
|
+
channelKey: channel.channelKey,
|
|
2343
|
+
channelId: channel.channelId,
|
|
2344
|
+
name: channel.channelId,
|
|
2345
|
+
})
|
|
2421
2346
|
|
|
2422
2347
|
return this.listChannels({ ownerAddress })
|
|
2423
2348
|
}
|
|
2424
2349
|
|
|
2425
|
-
setChannelRemark(
|
|
2350
|
+
setChannelRemark(channelKeyInput, remark, options = {}) {
|
|
2426
2351
|
this.#ensureInitialized()
|
|
2427
2352
|
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2428
2353
|
if (!ownerAddress) {
|
|
2429
2354
|
throw new Error('需要登录才能设置备注')
|
|
2430
2355
|
}
|
|
2431
2356
|
|
|
2432
|
-
const channel = this.#
|
|
2433
|
-
if (!channel) {
|
|
2434
|
-
throw new Error('频道不存在')
|
|
2435
|
-
}
|
|
2357
|
+
const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
|
|
2436
2358
|
|
|
2437
2359
|
const trimmed = (remark || '').trim()
|
|
2438
2360
|
if (trimmed.length > 50) {
|
|
@@ -2449,22 +2371,21 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2449
2371
|
delete channel.remarks[ownerAddress]
|
|
2450
2372
|
}
|
|
2451
2373
|
|
|
2374
|
+
channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
|
|
2452
2375
|
this.#saveChannelsMetadata()
|
|
2376
|
+
this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
|
|
2453
2377
|
return trimmed
|
|
2454
2378
|
}
|
|
2455
2379
|
|
|
2456
|
-
setChannelPinned(
|
|
2380
|
+
setChannelPinned(channelKeyInput, pinned, options = {}) {
|
|
2457
2381
|
this.#ensureInitialized()
|
|
2458
2382
|
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2459
2383
|
if (!ownerAddress) {
|
|
2460
2384
|
throw new Error('需要登录才能设置置顶')
|
|
2461
2385
|
}
|
|
2462
2386
|
|
|
2463
|
-
const channel = this.#
|
|
2464
|
-
|
|
2465
|
-
throw new Error('频道不存在')
|
|
2466
|
-
}
|
|
2467
|
-
this.#assertChannelMember(name, ownerAddress)
|
|
2387
|
+
const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
|
|
2388
|
+
this.#assertChannelMember(channel.channelKey, ownerAddress)
|
|
2468
2389
|
|
|
2469
2390
|
if (!channel.pinnedBy) {
|
|
2470
2391
|
channel.pinnedBy = {}
|
|
@@ -2476,13 +2397,15 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2476
2397
|
delete channel.pinnedBy[ownerAddress]
|
|
2477
2398
|
}
|
|
2478
2399
|
|
|
2400
|
+
channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
|
|
2479
2401
|
this.#saveChannelsMetadata()
|
|
2402
|
+
this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
|
|
2480
2403
|
return Boolean(channel.pinnedBy[ownerAddress])
|
|
2481
2404
|
}
|
|
2482
2405
|
|
|
2483
2406
|
/**
|
|
2484
2407
|
* 列出所有频道
|
|
2485
|
-
* @returns {Array<{
|
|
2408
|
+
* @returns {Array<{ channelId: string, channelKey: string, name: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
|
|
2486
2409
|
*/
|
|
2487
2410
|
listChannels(options = {}) {
|
|
2488
2411
|
this.#ensureInitialized()
|
|
@@ -2500,45 +2423,33 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2500
2423
|
if (excludeType) return c.type !== excludeType
|
|
2501
2424
|
return true
|
|
2502
2425
|
})
|
|
2503
|
-
.map(c => (
|
|
2504
|
-
name: c.name,
|
|
2505
|
-
coreKey: c.coreKey,
|
|
2506
|
-
createdAt: c.createdAt,
|
|
2507
|
-
lastMessageAt: c.lastMessageAt || '',
|
|
2508
|
-
type: c.type,
|
|
2509
|
-
peerCount: (this.#channelPeers.get(c.name) || new Map()).size,
|
|
2510
|
-
remark: ownerAddress && c.remarks ? c.remarks[ownerAddress] || '' : '',
|
|
2511
|
-
pinned: Boolean(ownerAddress && c.pinnedBy?.[ownerAddress]),
|
|
2512
|
-
}))
|
|
2426
|
+
.map(c => this.#formatChannelForResponse(c, ownerAddress))
|
|
2513
2427
|
}
|
|
2514
2428
|
|
|
2515
|
-
getChannelMembers(
|
|
2429
|
+
getChannelMembers(channelKeyInput, options = {}) {
|
|
2516
2430
|
this.#ensureInitialized()
|
|
2517
|
-
this.#assertChannelMember(
|
|
2518
|
-
|
|
2519
|
-
const channel = this.#channels.find(c => c.name === name)
|
|
2520
|
-
if (!channel) {
|
|
2521
|
-
throw new Error('频道不存在')
|
|
2522
|
-
}
|
|
2431
|
+
this.#assertChannelMember(channelKeyInput, options.ownerAddress)
|
|
2432
|
+
const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
|
|
2523
2433
|
|
|
2524
2434
|
return this.#getChannelMembers(channel)
|
|
2525
2435
|
}
|
|
2526
2436
|
|
|
2527
2437
|
/**
|
|
2528
2438
|
* 获取频道消息
|
|
2529
|
-
* @param {string}
|
|
2439
|
+
* @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
|
|
2530
2440
|
* @param {object} [options] - 选项
|
|
2531
2441
|
* @param {number} [options.limit=100] - 消息数量
|
|
2532
2442
|
* @param {number} [options.offset=0] - 偏移量
|
|
2533
2443
|
* @returns {Promise<Array>}
|
|
2534
2444
|
*/
|
|
2535
|
-
async getChannelMessages(
|
|
2445
|
+
async getChannelMessages(channelKeyInput, options = {}) {
|
|
2536
2446
|
this.#ensureInitialized()
|
|
2537
|
-
this.#assertChannelMember(
|
|
2447
|
+
this.#assertChannelMember(channelKeyInput, options.ownerAddress)
|
|
2448
|
+
const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
|
|
2538
2449
|
|
|
2539
2450
|
const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
|
|
2540
2451
|
|
|
2541
|
-
const coresMap = this.#channelCores.get(
|
|
2452
|
+
const coresMap = this.#channelCores.get(channel.channelKey)
|
|
2542
2453
|
if (!coresMap || coresMap.size === 0) {
|
|
2543
2454
|
throw new Error('频道未初始化')
|
|
2544
2455
|
}
|
|
@@ -2578,26 +2489,26 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2578
2489
|
return unique
|
|
2579
2490
|
.slice(start, end)
|
|
2580
2491
|
.map(({ _coreKey, _index, ...msg }) =>
|
|
2581
|
-
this.#normalizeChannelMessageForResponse(
|
|
2492
|
+
this.#normalizeChannelMessageForResponse(channel.channelKey, msg)
|
|
2582
2493
|
)
|
|
2583
2494
|
}
|
|
2584
2495
|
|
|
2585
2496
|
/**
|
|
2586
2497
|
* 发送消息到频道
|
|
2587
|
-
* @param {string}
|
|
2498
|
+
* @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
|
|
2588
2499
|
* @param {string} content - 消息内容
|
|
2589
2500
|
* @param {string} author - 作者 address
|
|
2590
2501
|
* @param {string} authorName - 作者显示名
|
|
2591
2502
|
* @param {object} [options.attachment] - 附件元数据
|
|
2592
2503
|
* @returns {Promise<object>}
|
|
2593
2504
|
*/
|
|
2594
|
-
async sendMessage(
|
|
2505
|
+
async sendMessage(channelKeyInput, content, author, authorName, options = {}) {
|
|
2595
2506
|
this.#ensureInitialized()
|
|
2596
|
-
this.#assertChannelMember(
|
|
2597
|
-
const channel = this.#
|
|
2507
|
+
this.#assertChannelMember(channelKeyInput, options.ownerAddress)
|
|
2508
|
+
const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
|
|
2598
2509
|
|
|
2599
|
-
const localKeyHex = this.#channelLocalCoreKey.get(
|
|
2600
|
-
const coresMap = this.#channelCores.get(
|
|
2510
|
+
const localKeyHex = this.#channelLocalCoreKey.get(channel.channelKey)
|
|
2511
|
+
const coresMap = this.#channelCores.get(channel.channelKey)
|
|
2601
2512
|
const core = localKeyHex && coresMap ? coresMap.get(localKeyHex) : null
|
|
2602
2513
|
if (!core) {
|
|
2603
2514
|
throw new Error('频道未初始化或无可写 core')
|
|
@@ -2631,7 +2542,9 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2631
2542
|
author,
|
|
2632
2543
|
authorName,
|
|
2633
2544
|
content: trimmed,
|
|
2634
|
-
timestamp:
|
|
2545
|
+
timestamp: await this.#getNextChannelMessageTimestamp(
|
|
2546
|
+
channel.channelKey
|
|
2547
|
+
),
|
|
2635
2548
|
}
|
|
2636
2549
|
if (attachment) {
|
|
2637
2550
|
message.attachment = attachment
|
|
@@ -2643,19 +2556,20 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2643
2556
|
this.#saveChannelsMetadata()
|
|
2644
2557
|
}
|
|
2645
2558
|
|
|
2646
|
-
return message
|
|
2559
|
+
return this.#normalizeChannelMessageForResponse(channel.channelKey, message)
|
|
2647
2560
|
}
|
|
2648
2561
|
|
|
2649
2562
|
/**
|
|
2650
2563
|
* 获取频道内在线用户
|
|
2651
|
-
* @param {string}
|
|
2564
|
+
* @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
|
|
2652
2565
|
* @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
|
|
2653
2566
|
*/
|
|
2654
|
-
getChannelPeers(
|
|
2567
|
+
getChannelPeers(channelKeyInput, options = {}) {
|
|
2655
2568
|
this.#ensureInitialized()
|
|
2656
|
-
this.#assertChannelMember(
|
|
2569
|
+
this.#assertChannelMember(channelKeyInput, options.ownerAddress)
|
|
2570
|
+
const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
|
|
2657
2571
|
|
|
2658
|
-
const peers = this.#channelPeers.get(
|
|
2572
|
+
const peers = this.#channelPeers.get(channel.channelKey)
|
|
2659
2573
|
if (!peers) {
|
|
2660
2574
|
return []
|
|
2661
2575
|
}
|
|
@@ -2704,6 +2618,332 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2704
2618
|
|
|
2705
2619
|
// --- 私有方法 ---
|
|
2706
2620
|
|
|
2621
|
+
#resolveChannel(identifier, ownerAddress = '') {
|
|
2622
|
+
const value = normalizeChannelKey(identifier)
|
|
2623
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
2624
|
+
let channel = this.#channels.find(c => c.channelKey === value)
|
|
2625
|
+
if (channel) return channel
|
|
2626
|
+
|
|
2627
|
+
channel = this.#channels.find(c => c.channelId === value)
|
|
2628
|
+
if (channel && (!owner || this.#channelHasMember(channel, owner))) {
|
|
2629
|
+
return channel
|
|
2630
|
+
}
|
|
2631
|
+
throw new Error('频道不存在')
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
async #createLocalChannel(channelId, type = 'personal', options = {}) {
|
|
2635
|
+
const channelKey = buildChannelKey(channelId)
|
|
2636
|
+
const writerId = String(options.writerId || '').trim() || createChannelWriterId()
|
|
2637
|
+
const ns = this.#store.namespace(`channel-${channelKey}`)
|
|
2638
|
+
const localCore = ns.get({
|
|
2639
|
+
name: `messages-${writerId}`,
|
|
2640
|
+
valueEncoding: 'json',
|
|
2641
|
+
})
|
|
2642
|
+
await localCore.ready()
|
|
2643
|
+
const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
|
|
2644
|
+
const writerCoreKeys = uniqueStrings([
|
|
2645
|
+
...(Array.isArray(options.writerCoreKeys) ? options.writerCoreKeys : []),
|
|
2646
|
+
localWriterCoreKey,
|
|
2647
|
+
])
|
|
2648
|
+
const channelInfo = {
|
|
2649
|
+
channelId,
|
|
2650
|
+
channelKey,
|
|
2651
|
+
name: channelId,
|
|
2652
|
+
type: String(type || 'personal').trim() || 'personal',
|
|
2653
|
+
createdAt: options.createdAt || new Date().toISOString(),
|
|
2654
|
+
lastMessageAt: options.lastMessageAt || '',
|
|
2655
|
+
writerId,
|
|
2656
|
+
localWriterCoreKey,
|
|
2657
|
+
writerCoreKeys,
|
|
2658
|
+
members: [],
|
|
2659
|
+
syncUpdatedAt: Date.now(),
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
this.#upsertChannelMember(channelInfo, options)
|
|
2663
|
+
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2664
|
+
const remark = String(options.remark || '').trim()
|
|
2665
|
+
if (ownerAddress && remark) {
|
|
2666
|
+
channelInfo.remarks = { [ownerAddress]: remark.slice(0, 50) }
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
this.#channels.push(channelInfo)
|
|
2670
|
+
await this.#openChannelRuntime(channelInfo)
|
|
2671
|
+
await this.#joinChannelDiscoveryTopics(channelInfo)
|
|
2672
|
+
this.#cacheChannelCandidate(this.#channelToCandidate(channelInfo, true))
|
|
2673
|
+
this.#saveChannelsMetadata()
|
|
2674
|
+
this.#broadcastChannelHello()
|
|
2675
|
+
return channelInfo
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
async #joinChannelFromCandidate(candidateInput, type = 'group', options = {}) {
|
|
2679
|
+
const channelId = normalizeChannelId(
|
|
2680
|
+
candidateInput.channelId || options.channelId
|
|
2681
|
+
)
|
|
2682
|
+
const channelKey = buildChannelKey(channelId)
|
|
2683
|
+
if (!channelId || !channelKey) {
|
|
2684
|
+
throw new Error('频道候选缺少身份信息')
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const existing = this.#channels.find(
|
|
2688
|
+
channel => channel.channelKey === channelKey
|
|
2689
|
+
)
|
|
2690
|
+
if (existing) {
|
|
2691
|
+
if (this.#upsertChannelMember(existing, options)) {
|
|
2692
|
+
this.#saveChannelsMetadata()
|
|
2693
|
+
this.#broadcastChannelHello()
|
|
2694
|
+
}
|
|
2695
|
+
return this.#formatChannelForResponse(existing, options.ownerAddress)
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
const hasSameIdLocal = this.#channels.some(
|
|
2699
|
+
channel => channel.channelId === channelId
|
|
2700
|
+
)
|
|
2701
|
+
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2702
|
+
const remark =
|
|
2703
|
+
ownerAddress && hasSameIdLocal && !String(options.remark || '').trim()
|
|
2704
|
+
? `${channelId}-网络`
|
|
2705
|
+
: options.remark
|
|
2706
|
+
const channelInfo = await this.#createLocalChannel(channelId, candidateInput.type || type, {
|
|
2707
|
+
...options,
|
|
2708
|
+
ownerAddress,
|
|
2709
|
+
createdAt: candidateInput.createdAt,
|
|
2710
|
+
lastMessageAt: candidateInput.lastMessageAt,
|
|
2711
|
+
writerCoreKeys: candidateInput.writerCoreKeys,
|
|
2712
|
+
remark,
|
|
2713
|
+
})
|
|
2714
|
+
|
|
2715
|
+
console.log(`[MostBox] Joined channel: ${channelInfo.channelKey}`)
|
|
2716
|
+
this.emit('channel:joined', {
|
|
2717
|
+
channel: channelInfo.channelKey,
|
|
2718
|
+
channelKey: channelInfo.channelKey,
|
|
2719
|
+
channelId: channelInfo.channelId,
|
|
2720
|
+
key: channelInfo.channelKey,
|
|
2721
|
+
})
|
|
2722
|
+
|
|
2723
|
+
return this.#formatChannelForResponse(channelInfo, ownerAddress)
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
async #openChannelRuntime(channel) {
|
|
2727
|
+
const ns = this.#store.namespace(`channel-${channel.channelKey}`)
|
|
2728
|
+
const localCore = channel.localWriterCoreKey
|
|
2729
|
+
? ns.get({
|
|
2730
|
+
key: b4a.from(channel.localWriterCoreKey, 'hex'),
|
|
2731
|
+
valueEncoding: 'json',
|
|
2732
|
+
})
|
|
2733
|
+
: ns.get({
|
|
2734
|
+
name: `messages-${channel.writerId || createChannelWriterId()}`,
|
|
2735
|
+
valueEncoding: 'json',
|
|
2736
|
+
})
|
|
2737
|
+
await localCore.ready()
|
|
2738
|
+
const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
|
|
2739
|
+
channel.localWriterCoreKey = localWriterCoreKey
|
|
2740
|
+
channel.writerCoreKeys = uniqueStrings([
|
|
2741
|
+
...(Array.isArray(channel.writerCoreKeys) ? channel.writerCoreKeys : []),
|
|
2742
|
+
localWriterCoreKey,
|
|
2743
|
+
])
|
|
2744
|
+
|
|
2745
|
+
if (!this.#channelCores.has(channel.channelKey)) {
|
|
2746
|
+
this.#channelCores.set(channel.channelKey, new Map())
|
|
2747
|
+
}
|
|
2748
|
+
this.#channelCores.get(channel.channelKey).set(localWriterCoreKey, localCore)
|
|
2749
|
+
this.#channelLocalCoreKey.set(channel.channelKey, localWriterCoreKey)
|
|
2750
|
+
if (!this.#channelPeers.has(channel.channelKey)) {
|
|
2751
|
+
this.#channelPeers.set(channel.channelKey, new Map())
|
|
2752
|
+
}
|
|
2753
|
+
this.#setupChannelAppendListener(localCore, channel.channelKey)
|
|
2754
|
+
|
|
2755
|
+
for (const writerCoreKey of channel.writerCoreKeys) {
|
|
2756
|
+
if (writerCoreKey && writerCoreKey !== localWriterCoreKey) {
|
|
2757
|
+
await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async #mergeChannelWriterCoreKeys(channel, writerCoreKeys = []) {
|
|
2763
|
+
const nextKeys = uniqueStrings(writerCoreKeys)
|
|
2764
|
+
if (nextKeys.length === 0) return false
|
|
2765
|
+
|
|
2766
|
+
const previous = new Set(channel.writerCoreKeys || [])
|
|
2767
|
+
let changed = false
|
|
2768
|
+
for (const writerCoreKey of nextKeys) {
|
|
2769
|
+
if (!previous.has(writerCoreKey)) {
|
|
2770
|
+
previous.add(writerCoreKey)
|
|
2771
|
+
changed = true
|
|
2772
|
+
}
|
|
2773
|
+
if (writerCoreKey !== this.#channelLocalCoreKey.get(channel.channelKey)) {
|
|
2774
|
+
await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
if (changed) {
|
|
2778
|
+
channel.writerCoreKeys = [...previous]
|
|
2779
|
+
this.#saveChannelsMetadata()
|
|
2780
|
+
}
|
|
2781
|
+
return changed
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async #joinChannelDiscoveryTopics(channel) {
|
|
2785
|
+
if (!this.#channelDiscoveries.has(channel.channelKey)) {
|
|
2786
|
+
const appDiscovery = this.#swarm.join(
|
|
2787
|
+
this.#generateChannelDiscoveryKey(channel.channelKey),
|
|
2788
|
+
{ server: true, client: true }
|
|
2789
|
+
)
|
|
2790
|
+
this.#channelDiscoveries.set(channel.channelKey, appDiscovery)
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
if (!this.#channelChatDiscoveries.has(channel.channelKey)) {
|
|
2794
|
+
const chatDiscovery = this.#chatSwarm.join(
|
|
2795
|
+
this.#generateChannelChatDiscoveryKey(channel.channelKey),
|
|
2796
|
+
{ server: true, client: true }
|
|
2797
|
+
)
|
|
2798
|
+
this.#channelChatDiscoveries.set(channel.channelKey, chatDiscovery)
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
if (!this.#channelIdDiscoveries.has(channel.channelId)) {
|
|
2802
|
+
const idDiscovery = this.#chatSwarm.join(
|
|
2803
|
+
this.#generateChannelIdDiscoveryKey(channel.channelId),
|
|
2804
|
+
{ server: true, client: true }
|
|
2805
|
+
)
|
|
2806
|
+
this.#channelIdDiscoveries.set(channel.channelId, idDiscovery)
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
#getLocalChannelCandidates(channelId, options = {}) {
|
|
2811
|
+
const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
|
|
2812
|
+
return this.#channels
|
|
2813
|
+
.filter(channel => channel.channelId === channelId)
|
|
2814
|
+
.filter(channel => {
|
|
2815
|
+
if (!ownerAddress) return true
|
|
2816
|
+
return this.#channelHasMember(channel, ownerAddress)
|
|
2817
|
+
})
|
|
2818
|
+
.map(channel => this.#channelToCandidate(channel, true))
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
async #discoverChannelCandidates(channelId, options = {}) {
|
|
2822
|
+
const getCachedCandidates = () => {
|
|
2823
|
+
const now = Date.now()
|
|
2824
|
+
return [
|
|
2825
|
+
...(this.#channelCandidateCache.get(channelId)?.values() || []),
|
|
2826
|
+
].filter(
|
|
2827
|
+
candidate =>
|
|
2828
|
+
candidate.local ||
|
|
2829
|
+
!candidate.lastSeen ||
|
|
2830
|
+
now - candidate.lastSeen <= CHANNEL_CANDIDATE_TTL
|
|
2831
|
+
)
|
|
2832
|
+
}
|
|
2833
|
+
if (this.#options.disableNetwork) return getCachedCandidates()
|
|
2834
|
+
const timeout =
|
|
2835
|
+
Number(options.timeout) >= 0
|
|
2836
|
+
? Number(options.timeout)
|
|
2837
|
+
: CHANNEL_DISCOVERY_TIMEOUT
|
|
2838
|
+
const hadDiscovery = this.#channelIdDiscoveries.has(channelId)
|
|
2839
|
+
if (!hadDiscovery) {
|
|
2840
|
+
const discovery = this.#chatSwarm.join(
|
|
2841
|
+
this.#generateChannelIdDiscoveryKey(channelId),
|
|
2842
|
+
{ server: true, client: true }
|
|
2843
|
+
)
|
|
2844
|
+
this.#channelIdDiscoveries.set(channelId, discovery)
|
|
2845
|
+
}
|
|
2846
|
+
await sleep(timeout)
|
|
2847
|
+
const candidates = getCachedCandidates()
|
|
2848
|
+
if (!hadDiscovery && !this.#channels.some(c => c.channelId === channelId)) {
|
|
2849
|
+
this.#channelIdDiscoveries.delete(channelId)
|
|
2850
|
+
this.#chatSwarm
|
|
2851
|
+
.leave(this.#generateChannelIdDiscoveryKey(channelId))
|
|
2852
|
+
.catch(() => {})
|
|
2853
|
+
}
|
|
2854
|
+
return candidates
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
#mergeChannelCandidates(candidates) {
|
|
2858
|
+
const byKey = new Map()
|
|
2859
|
+
for (const candidate of candidates) {
|
|
2860
|
+
if (!candidate?.channelKey) continue
|
|
2861
|
+
const existing = byKey.get(candidate.channelKey)
|
|
2862
|
+
if (!existing) {
|
|
2863
|
+
byKey.set(candidate.channelKey, {
|
|
2864
|
+
...candidate,
|
|
2865
|
+
writerCoreKeys: uniqueStrings(candidate.writerCoreKeys),
|
|
2866
|
+
})
|
|
2867
|
+
continue
|
|
2868
|
+
}
|
|
2869
|
+
byKey.set(candidate.channelKey, {
|
|
2870
|
+
...existing,
|
|
2871
|
+
...candidate,
|
|
2872
|
+
local: existing.local || candidate.local,
|
|
2873
|
+
writerCoreKeys: uniqueStrings([
|
|
2874
|
+
...existing.writerCoreKeys,
|
|
2875
|
+
...(candidate.writerCoreKeys || []),
|
|
2876
|
+
]),
|
|
2877
|
+
})
|
|
2878
|
+
}
|
|
2879
|
+
return [...byKey.values()]
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
#channelToCandidate(channel, local = false) {
|
|
2883
|
+
return {
|
|
2884
|
+
channelId: channel.channelId,
|
|
2885
|
+
channelKey: channel.channelKey,
|
|
2886
|
+
type: channel.type,
|
|
2887
|
+
createdAt: channel.createdAt,
|
|
2888
|
+
lastMessageAt: channel.lastMessageAt || '',
|
|
2889
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
2890
|
+
local,
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
#cacheChannelCandidate(candidate) {
|
|
2895
|
+
const channelId = normalizeChannelId(candidate?.channelId)
|
|
2896
|
+
const channelKey = buildChannelKey(channelId)
|
|
2897
|
+
if (!channelId || !channelKey) return
|
|
2898
|
+
if (!this.#channelCandidateCache.has(channelId)) {
|
|
2899
|
+
this.#channelCandidateCache.set(channelId, new Map())
|
|
2900
|
+
}
|
|
2901
|
+
const cache = this.#channelCandidateCache.get(channelId)
|
|
2902
|
+
const existing = cache.get(channelKey)
|
|
2903
|
+
cache.set(channelKey, {
|
|
2904
|
+
...existing,
|
|
2905
|
+
...candidate,
|
|
2906
|
+
channelId,
|
|
2907
|
+
channelKey,
|
|
2908
|
+
writerCoreKeys: uniqueStrings([
|
|
2909
|
+
...(existing?.writerCoreKeys || []),
|
|
2910
|
+
...(candidate.writerCoreKeys || []),
|
|
2911
|
+
]),
|
|
2912
|
+
lastSeen: Date.now(),
|
|
2913
|
+
})
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
#getCachedChannelCandidate(channelId, channelKey) {
|
|
2917
|
+
const normalizedChannelId = normalizeChannelId(channelId)
|
|
2918
|
+
const normalizedChannelKey = buildChannelKey(normalizedChannelId)
|
|
2919
|
+
const cache = this.#channelCandidateCache.get(normalizedChannelId)
|
|
2920
|
+
const candidate = cache?.get(channelKey) || cache?.get(normalizedChannelKey)
|
|
2921
|
+
if (candidate) return candidate
|
|
2922
|
+
const local = this.#channels.find(
|
|
2923
|
+
channel => channel.channelKey === normalizedChannelKey
|
|
2924
|
+
)
|
|
2925
|
+
return local ? this.#channelToCandidate(local, true) : null
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
#formatChannelForResponse(channel, ownerAddress = '') {
|
|
2929
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
2930
|
+
return {
|
|
2931
|
+
name: channel.channelId,
|
|
2932
|
+
channelId: channel.channelId,
|
|
2933
|
+
channelKey: channel.channelKey,
|
|
2934
|
+
key: channel.channelKey,
|
|
2935
|
+
coreKey: channel.localWriterCoreKey,
|
|
2936
|
+
localWriterCoreKey: channel.localWriterCoreKey,
|
|
2937
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
2938
|
+
createdAt: channel.createdAt,
|
|
2939
|
+
lastMessageAt: channel.lastMessageAt || '',
|
|
2940
|
+
type: channel.type,
|
|
2941
|
+
peerCount: (this.#channelPeers.get(channel.channelKey) || new Map()).size,
|
|
2942
|
+
remark: owner && channel.remarks ? channel.remarks[owner] || '' : '',
|
|
2943
|
+
pinned: Boolean(owner && channel.pinnedBy?.[owner]),
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2707
2947
|
#ensureInitialized() {
|
|
2708
2948
|
if (!this.#initialized) {
|
|
2709
2949
|
throw new EngineNotInitializedError()
|
|
@@ -2714,10 +2954,7 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2714
2954
|
const normalizedOwner = normalizeOwnerAddress(ownerAddress)
|
|
2715
2955
|
if (!normalizedOwner) return
|
|
2716
2956
|
|
|
2717
|
-
const channel = this.#
|
|
2718
|
-
if (!channel) {
|
|
2719
|
-
throw new Error('频道不存在')
|
|
2720
|
-
}
|
|
2957
|
+
const channel = this.#resolveChannel(name)
|
|
2721
2958
|
if (!this.#channelHasMember(channel, normalizedOwner)) {
|
|
2722
2959
|
throw new PermissionError('未加入该频道')
|
|
2723
2960
|
}
|
|
@@ -2802,175 +3039,893 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
2802
3039
|
)
|
|
2803
3040
|
}
|
|
2804
3041
|
|
|
2805
|
-
#
|
|
2806
|
-
const
|
|
3042
|
+
async #getNextChannelMessageTimestamp(channelKey) {
|
|
3043
|
+
const coresMap = this.#channelCores.get(channelKey)
|
|
3044
|
+
let maxTimestamp = 0
|
|
3045
|
+
|
|
3046
|
+
if (coresMap) {
|
|
3047
|
+
for (const [, core] of coresMap) {
|
|
3048
|
+
for (let i = 0; i < core.length; i++) {
|
|
3049
|
+
try {
|
|
3050
|
+
const entry = await core.get(i)
|
|
3051
|
+
if (entry?.type === 'message') {
|
|
3052
|
+
maxTimestamp = Math.max(
|
|
3053
|
+
maxTimestamp,
|
|
3054
|
+
Number(entry.timestamp) || 0
|
|
3055
|
+
)
|
|
3056
|
+
}
|
|
3057
|
+
} catch {
|
|
3058
|
+
break
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
return Math.max(Date.now(), maxTimestamp + 1)
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
#normalizeChannelMessageForResponse(channelKey, message) {
|
|
3068
|
+
const channel = this.#channels.find(item => item.channelKey === channelKey)
|
|
3069
|
+
const authorAddress = normalizeOwnerAddress(message?.author)
|
|
3070
|
+
const member = Array.isArray(channel?.members)
|
|
3071
|
+
? channel.members.find(
|
|
3072
|
+
item => normalizeOwnerAddress(item?.address) === authorAddress
|
|
3073
|
+
)
|
|
3074
|
+
: null
|
|
3075
|
+
const avatar = normalizeChannelAvatar(member?.avatar)
|
|
3076
|
+
const baseMessage = avatar && message?.avatar !== avatar
|
|
3077
|
+
? { ...message, avatar }
|
|
3078
|
+
: message
|
|
3079
|
+
const attachment = baseMessage?.attachment
|
|
2807
3080
|
if (!attachment?.cid || !attachment.fileName) {
|
|
2808
|
-
return
|
|
3081
|
+
return baseMessage
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
const oldFileName = sanitizeFilename(String(attachment.fileName))
|
|
3085
|
+
const channelPathName = channel?.channelId || channelKey
|
|
3086
|
+
const channelPrefix = `${CHAT_FILE_ROOT}/${channelPathName}/`
|
|
3087
|
+
const fileName = oldFileName.startsWith(channelPrefix)
|
|
3088
|
+
? oldFileName
|
|
3089
|
+
: `${channelPrefix}${getPathBaseName(oldFileName)}`
|
|
3090
|
+
const link = buildMostLink(attachment.cid, fileName)
|
|
3091
|
+
const content =
|
|
3092
|
+
typeof baseMessage.content === 'string' &&
|
|
3093
|
+
(baseMessage.content === attachment.link ||
|
|
3094
|
+
parseMostLink(baseMessage.content).cid === attachment.cid)
|
|
3095
|
+
? link
|
|
3096
|
+
: baseMessage.content
|
|
3097
|
+
|
|
3098
|
+
if (
|
|
3099
|
+
fileName === attachment.fileName &&
|
|
3100
|
+
link === attachment.link &&
|
|
3101
|
+
content === baseMessage.content
|
|
3102
|
+
) {
|
|
3103
|
+
return baseMessage
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
return {
|
|
3107
|
+
...baseMessage,
|
|
3108
|
+
content,
|
|
3109
|
+
attachment: {
|
|
3110
|
+
...attachment,
|
|
3111
|
+
fileName,
|
|
3112
|
+
link,
|
|
3113
|
+
},
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
#getCidInfo(cid) {
|
|
3118
|
+
return getCidInfo(cid)
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
#setSeedState(cid, patch = {}) {
|
|
3122
|
+
const previous = this.#seedStates.get(cid) || {}
|
|
3123
|
+
const next = {
|
|
3124
|
+
...previous,
|
|
3125
|
+
cid,
|
|
3126
|
+
...patch,
|
|
3127
|
+
updatedAt: new Date().toISOString(),
|
|
3128
|
+
}
|
|
3129
|
+
this.#seedStates.set(cid, next)
|
|
3130
|
+
this.emit('seed:state', next)
|
|
3131
|
+
return next
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
#clearSeedState(cid) {
|
|
3135
|
+
if (this.#seedStates.delete(cid)) {
|
|
3136
|
+
this.emit('seed:state:removed', { cid })
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
#getUserSyncNamespace(session) {
|
|
3141
|
+
return this.#store.namespace(session.syncName)
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
async #openUserSyncRuntime(session) {
|
|
3145
|
+
const ns = this.#getUserSyncNamespace(session)
|
|
3146
|
+
const localCore = session.localWriterCoreKey
|
|
3147
|
+
? ns.get({
|
|
3148
|
+
key: b4a.from(session.localWriterCoreKey, 'hex'),
|
|
3149
|
+
valueEncoding: 'json',
|
|
3150
|
+
})
|
|
3151
|
+
: ns.get({
|
|
3152
|
+
name: `writer-${session.writerId}`,
|
|
3153
|
+
valueEncoding: 'json',
|
|
3154
|
+
})
|
|
3155
|
+
await localCore.ready()
|
|
3156
|
+
session.localWriterCoreKey = b4a.toString(localCore.key, 'hex')
|
|
3157
|
+
session.writerCoreKeys = uniqueStrings([
|
|
3158
|
+
...session.writerCoreKeys,
|
|
3159
|
+
session.localWriterCoreKey,
|
|
3160
|
+
])
|
|
3161
|
+
|
|
3162
|
+
if (!this.#userSyncCores.has(session.ownerAddress)) {
|
|
3163
|
+
this.#userSyncCores.set(session.ownerAddress, new Map())
|
|
3164
|
+
}
|
|
3165
|
+
this.#userSyncCores
|
|
3166
|
+
.get(session.ownerAddress)
|
|
3167
|
+
.set(session.localWriterCoreKey, localCore)
|
|
3168
|
+
this.#setupUserSyncAppendListener(
|
|
3169
|
+
localCore,
|
|
3170
|
+
session,
|
|
3171
|
+
session.localWriterCoreKey
|
|
3172
|
+
)
|
|
3173
|
+
|
|
3174
|
+
for (const writerCoreKey of session.writerCoreKeys) {
|
|
3175
|
+
if (writerCoreKey && writerCoreKey !== session.localWriterCoreKey) {
|
|
3176
|
+
await this.#openRemoteUserSyncCore(session, writerCoreKey)
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
async #joinUserSyncDiscovery(session) {
|
|
3182
|
+
if (this.#userSyncDiscoveries.has(session.ownerAddress)) return
|
|
3183
|
+
const discoveryKey = this.#generateUserSyncDiscoveryKey(session.syncId)
|
|
3184
|
+
const appDiscovery = this.#swarm.join(discoveryKey, {
|
|
3185
|
+
server: true,
|
|
3186
|
+
client: true,
|
|
3187
|
+
})
|
|
3188
|
+
const chatDiscovery = this.#chatSwarm.join(
|
|
3189
|
+
discoveryKey,
|
|
3190
|
+
{ server: true, client: true }
|
|
3191
|
+
)
|
|
3192
|
+
this.#userSyncDiscoveries.set(session.ownerAddress, {
|
|
3193
|
+
appDiscovery,
|
|
3194
|
+
chatDiscovery,
|
|
3195
|
+
})
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
async #openRemoteUserSyncCore(session, writerCoreKey) {
|
|
3199
|
+
const normalizedCoreKey = String(writerCoreKey || '').trim()
|
|
3200
|
+
if (
|
|
3201
|
+
!normalizedCoreKey ||
|
|
3202
|
+
normalizedCoreKey === session.localWriterCoreKey
|
|
3203
|
+
) {
|
|
3204
|
+
return null
|
|
3205
|
+
}
|
|
3206
|
+
if (!this.#userSyncCores.has(session.ownerAddress)) {
|
|
3207
|
+
this.#userSyncCores.set(session.ownerAddress, new Map())
|
|
3208
|
+
}
|
|
3209
|
+
const coresMap = this.#userSyncCores.get(session.ownerAddress)
|
|
3210
|
+
if (coresMap.has(normalizedCoreKey)) return coresMap.get(normalizedCoreKey)
|
|
3211
|
+
|
|
3212
|
+
const ns = this.#getUserSyncNamespace(session)
|
|
3213
|
+
const core = ns.get({
|
|
3214
|
+
key: b4a.from(normalizedCoreKey, 'hex'),
|
|
3215
|
+
valueEncoding: 'json',
|
|
3216
|
+
})
|
|
3217
|
+
await core.ready()
|
|
3218
|
+
coresMap.set(normalizedCoreKey, core)
|
|
3219
|
+
session.writerCoreKeys = uniqueStrings([
|
|
3220
|
+
...session.writerCoreKeys,
|
|
3221
|
+
normalizedCoreKey,
|
|
3222
|
+
])
|
|
3223
|
+
this.#persistUserSyncSession(session)
|
|
3224
|
+
this.#setupUserSyncAppendListener(core, session, normalizedCoreKey)
|
|
3225
|
+
return core
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
#setupUserSyncAppendListener(core, session, coreKey) {
|
|
3229
|
+
const offsetKey = `${session.ownerAddress}:${coreKey}`
|
|
3230
|
+
let processing = false
|
|
3231
|
+
const processEntries = async () => {
|
|
3232
|
+
if (processing) return
|
|
3233
|
+
processing = true
|
|
3234
|
+
try {
|
|
3235
|
+
let index = this.#userSyncCoreOffsets.get(offsetKey) || 0
|
|
3236
|
+
while (index < core.length) {
|
|
3237
|
+
const entry = await core.get(index)
|
|
3238
|
+
await this.#applyUserSyncEntry(session, entry)
|
|
3239
|
+
index += 1
|
|
3240
|
+
this.#userSyncCoreOffsets.set(offsetKey, index)
|
|
3241
|
+
}
|
|
3242
|
+
} finally {
|
|
3243
|
+
processing = false
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
core.on('append', () => {
|
|
3248
|
+
processEntries().catch(err => {
|
|
3249
|
+
console.warn('[MostBox] Failed to process user sync entry:', err.message)
|
|
3250
|
+
})
|
|
3251
|
+
})
|
|
3252
|
+
processEntries().catch(err => {
|
|
3253
|
+
console.warn('[MostBox] Failed to process user sync entries:', err.message)
|
|
3254
|
+
})
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
#encodeUserSyncEntry(session, op) {
|
|
3258
|
+
const nonce = crypto.randomBytes(12)
|
|
3259
|
+
const cipher = crypto.createCipheriv(
|
|
3260
|
+
'aes-256-gcm',
|
|
3261
|
+
Buffer.from(session.syncCipherKey, 'hex'),
|
|
3262
|
+
nonce
|
|
3263
|
+
)
|
|
3264
|
+
const encrypted = Buffer.concat([
|
|
3265
|
+
cipher.update(JSON.stringify(op), 'utf8'),
|
|
3266
|
+
cipher.final(),
|
|
3267
|
+
])
|
|
3268
|
+
const tag = cipher.getAuthTag()
|
|
3269
|
+
const body = `${nonce.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`
|
|
3270
|
+
const mac = crypto
|
|
3271
|
+
.createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
|
|
3272
|
+
.update(body)
|
|
3273
|
+
.digest('hex')
|
|
3274
|
+
|
|
3275
|
+
return {
|
|
3276
|
+
type: 'user-sync-op',
|
|
3277
|
+
schemaVersion: USER_SYNC_SCHEMA_VERSION,
|
|
3278
|
+
ownerAddress: session.ownerAddress,
|
|
3279
|
+
syncId: session.syncId,
|
|
3280
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
3281
|
+
body,
|
|
3282
|
+
mac,
|
|
3283
|
+
createdAt: new Date(op.timestamp).toISOString(),
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
#decodeUserSyncEntry(session, entry) {
|
|
3288
|
+
if (
|
|
3289
|
+
!entry ||
|
|
3290
|
+
entry.type !== 'user-sync-op' ||
|
|
3291
|
+
entry.syncId !== session.syncId ||
|
|
3292
|
+
entry.ownerAddress !== session.ownerAddress ||
|
|
3293
|
+
Number(entry.schemaVersion) !== USER_SYNC_SCHEMA_VERSION
|
|
3294
|
+
) {
|
|
3295
|
+
return null
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
const expectedMac = crypto
|
|
3299
|
+
.createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
|
|
3300
|
+
.update(String(entry.body || ''))
|
|
3301
|
+
.digest('hex')
|
|
3302
|
+
if (expectedMac !== entry.mac) return null
|
|
3303
|
+
|
|
3304
|
+
const [nonceHex, encryptedHex, tagHex] = String(entry.body || '').split('.')
|
|
3305
|
+
if (!nonceHex || !encryptedHex || !tagHex) return null
|
|
3306
|
+
|
|
3307
|
+
try {
|
|
3308
|
+
const decipher = crypto.createDecipheriv(
|
|
3309
|
+
'aes-256-gcm',
|
|
3310
|
+
Buffer.from(session.syncCipherKey, 'hex'),
|
|
3311
|
+
Buffer.from(nonceHex, 'hex')
|
|
3312
|
+
)
|
|
3313
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
|
|
3314
|
+
const decrypted = Buffer.concat([
|
|
3315
|
+
decipher.update(Buffer.from(encryptedHex, 'hex')),
|
|
3316
|
+
decipher.final(),
|
|
3317
|
+
])
|
|
3318
|
+
return JSON.parse(decrypted.toString('utf8'))
|
|
3319
|
+
} catch {
|
|
3320
|
+
return null
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
async #appendUserSyncOp(ownerAddressInput, kind, payload = {}) {
|
|
3325
|
+
const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
|
|
3326
|
+
const session = this.#userSyncSessions.get(ownerAddress)
|
|
3327
|
+
if (!session?.localWriterCoreKey) return null
|
|
3328
|
+
const coresMap = this.#userSyncCores.get(ownerAddress)
|
|
3329
|
+
const core = coresMap?.get(session.localWriterCoreKey)
|
|
3330
|
+
if (!core) return null
|
|
3331
|
+
|
|
3332
|
+
const op = {
|
|
3333
|
+
opId: `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
|
|
3334
|
+
schemaVersion: USER_SYNC_SCHEMA_VERSION,
|
|
3335
|
+
kind,
|
|
3336
|
+
ownerAddress,
|
|
3337
|
+
timestamp: Date.now(),
|
|
3338
|
+
payload,
|
|
3339
|
+
}
|
|
3340
|
+
this.#markUserSyncClockForOp(op)
|
|
3341
|
+
await core.append(this.#encodeUserSyncEntry(session, op))
|
|
3342
|
+
this.#touchUserSyncSession(session)
|
|
3343
|
+
return op
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
#appendUserSyncOpSoon(ownerAddress, kind, payload = {}) {
|
|
3347
|
+
this.#appendUserSyncOp(ownerAddress, kind, payload).catch(err => {
|
|
3348
|
+
console.warn('[MostBox] Failed to append user sync op:', err.message)
|
|
3349
|
+
})
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
async #appendUserSyncSnapshot(ownerAddressInput, reason = 'snapshot') {
|
|
3353
|
+
const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
|
|
3354
|
+
if (!this.#userSyncSessions.has(ownerAddress)) return null
|
|
3355
|
+
const files = this.#getPublishedBucket(ownerAddress).map(file =>
|
|
3356
|
+
this.#formatFileForSync(file, 'active')
|
|
3357
|
+
)
|
|
3358
|
+
const trashFiles = this.#getTrashBucket(ownerAddress).map(file =>
|
|
3359
|
+
this.#formatFileForSync(file, 'trash')
|
|
3360
|
+
)
|
|
3361
|
+
const channels = this.#channels
|
|
3362
|
+
.filter(channel => this.#channelHasMember(channel, ownerAddress))
|
|
3363
|
+
.map(channel => this.#formatChannelForSync(channel, ownerAddress))
|
|
3364
|
+
.filter(Boolean)
|
|
3365
|
+
return this.#appendUserSyncOp(ownerAddress, 'snapshot', {
|
|
3366
|
+
reason,
|
|
3367
|
+
files,
|
|
3368
|
+
trashFiles,
|
|
3369
|
+
channels,
|
|
3370
|
+
})
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
async #applyUserSyncEntry(session, entry) {
|
|
3374
|
+
const op = this.#decodeUserSyncEntry(session, entry)
|
|
3375
|
+
if (!op || op.ownerAddress !== session.ownerAddress) return false
|
|
3376
|
+
if (Array.isArray(entry.writerCoreKeys)) {
|
|
3377
|
+
await this.#mergeUserSyncWriterCoreKeys(session, entry.writerCoreKeys)
|
|
3378
|
+
}
|
|
3379
|
+
return this.#applyUserSyncOp(session, op)
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
async #applyUserSyncOp(session, op) {
|
|
3383
|
+
let changedFiles = false
|
|
3384
|
+
let changedChannels = false
|
|
3385
|
+
if (op.kind === 'snapshot') {
|
|
3386
|
+
const payload = op.payload || {}
|
|
3387
|
+
for (const file of Array.isArray(payload.files) ? payload.files : []) {
|
|
3388
|
+
changedFiles =
|
|
3389
|
+
this.#applyUserSyncFileRecord(
|
|
3390
|
+
session.ownerAddress,
|
|
3391
|
+
file,
|
|
3392
|
+
'active',
|
|
3393
|
+
getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
|
|
3394
|
+
) || changedFiles
|
|
3395
|
+
}
|
|
3396
|
+
for (const file of Array.isArray(payload.trashFiles) ? payload.trashFiles : []) {
|
|
3397
|
+
changedFiles =
|
|
3398
|
+
this.#applyUserSyncFileRecord(
|
|
3399
|
+
session.ownerAddress,
|
|
3400
|
+
file,
|
|
3401
|
+
'trash',
|
|
3402
|
+
getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
|
|
3403
|
+
) || changedFiles
|
|
3404
|
+
}
|
|
3405
|
+
for (const channel of Array.isArray(payload.channels) ? payload.channels : []) {
|
|
3406
|
+
changedChannels =
|
|
3407
|
+
(await this.#applyUserSyncChannelRecord(
|
|
3408
|
+
session.ownerAddress,
|
|
3409
|
+
channel,
|
|
3410
|
+
getSyncTimestamp(channel.syncUpdatedAt, op.timestamp)
|
|
3411
|
+
)) || changedChannels
|
|
3412
|
+
}
|
|
3413
|
+
} else if (op.kind === 'file:upsert') {
|
|
3414
|
+
changedFiles = this.#applyUserSyncFileRecord(
|
|
3415
|
+
session.ownerAddress,
|
|
3416
|
+
op.payload?.file,
|
|
3417
|
+
'active',
|
|
3418
|
+
getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
|
|
3419
|
+
)
|
|
3420
|
+
} else if (op.kind === 'file:trash') {
|
|
3421
|
+
changedFiles = this.#applyUserSyncFileRecord(
|
|
3422
|
+
session.ownerAddress,
|
|
3423
|
+
op.payload?.file,
|
|
3424
|
+
'trash',
|
|
3425
|
+
getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
|
|
3426
|
+
)
|
|
3427
|
+
} else if (op.kind === 'file:delete') {
|
|
3428
|
+
changedFiles = await this.#applyUserSyncFileDelete(
|
|
3429
|
+
session.ownerAddress,
|
|
3430
|
+
op.payload?.cid,
|
|
3431
|
+
getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
|
|
3432
|
+
)
|
|
3433
|
+
} else if (op.kind === 'channel:upsert') {
|
|
3434
|
+
changedChannels = await this.#applyUserSyncChannelRecord(
|
|
3435
|
+
session.ownerAddress,
|
|
3436
|
+
op.payload?.channel,
|
|
3437
|
+
getSyncTimestamp(op.payload?.channel?.syncUpdatedAt, op.timestamp)
|
|
3438
|
+
)
|
|
3439
|
+
} else if (op.kind === 'channel:leave') {
|
|
3440
|
+
changedChannels = this.#applyUserSyncChannelLeave(
|
|
3441
|
+
session.ownerAddress,
|
|
3442
|
+
op.payload?.channelKey,
|
|
3443
|
+
getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
|
|
3444
|
+
)
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
if (changedFiles) {
|
|
3448
|
+
this.#savePublishedMetadata()
|
|
3449
|
+
this.#saveTrashMetadata()
|
|
3450
|
+
this.emit('user:metadata:updated', {
|
|
3451
|
+
ownerAddress: session.ownerAddress,
|
|
3452
|
+
scope: 'files',
|
|
3453
|
+
})
|
|
3454
|
+
}
|
|
3455
|
+
if (changedChannels) {
|
|
3456
|
+
this.#saveChannelsMetadata()
|
|
3457
|
+
this.emit('user:metadata:updated', {
|
|
3458
|
+
ownerAddress: session.ownerAddress,
|
|
3459
|
+
scope: 'channels',
|
|
3460
|
+
})
|
|
3461
|
+
}
|
|
3462
|
+
if (changedFiles || changedChannels) {
|
|
3463
|
+
this.#touchUserSyncSession(session)
|
|
3464
|
+
}
|
|
3465
|
+
return changedFiles || changedChannels
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
async #mergeUserSyncWriterCoreKeys(session, writerCoreKeys = []) {
|
|
3469
|
+
const nextKeys = uniqueStrings(writerCoreKeys)
|
|
3470
|
+
if (nextKeys.length === 0) return false
|
|
3471
|
+
const previous = new Set(session.writerCoreKeys || [])
|
|
3472
|
+
let changed = false
|
|
3473
|
+
for (const writerCoreKey of nextKeys) {
|
|
3474
|
+
if (!previous.has(writerCoreKey)) {
|
|
3475
|
+
previous.add(writerCoreKey)
|
|
3476
|
+
changed = true
|
|
3477
|
+
}
|
|
3478
|
+
if (writerCoreKey !== session.localWriterCoreKey) {
|
|
3479
|
+
await this.#openRemoteUserSyncCore(session, writerCoreKey)
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
if (changed) {
|
|
3483
|
+
session.writerCoreKeys = [...previous]
|
|
3484
|
+
this.#persistUserSyncSession(session)
|
|
3485
|
+
}
|
|
3486
|
+
return changed
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
async #exchangeUserSyncSessions(peerEngine) {
|
|
3490
|
+
for (const session of this.#userSyncSessions.values()) {
|
|
3491
|
+
const peerSession = peerEngine.#userSyncSessions.get(session.ownerAddress)
|
|
3492
|
+
if (!peerSession || peerSession.syncId !== session.syncId) continue
|
|
3493
|
+
await this.#mergeUserSyncWriterCoreKeys(
|
|
3494
|
+
session,
|
|
3495
|
+
peerSession.writerCoreKeys
|
|
3496
|
+
)
|
|
3497
|
+
await peerEngine.#mergeUserSyncWriterCoreKeys(
|
|
3498
|
+
peerSession,
|
|
3499
|
+
session.writerCoreKeys
|
|
3500
|
+
)
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
#formatFileForSync(file, state = 'active') {
|
|
3505
|
+
const cid = String(file?.cid || '').trim()
|
|
3506
|
+
if (!cid) return null
|
|
3507
|
+
const { driveName } = this.#getCidInfo(cid)
|
|
3508
|
+
const syncUpdatedAt = getSyncTimestamp(
|
|
3509
|
+
file.syncUpdatedAt || file.deletedAt || file.publishedAt
|
|
3510
|
+
)
|
|
3511
|
+
return {
|
|
3512
|
+
cid,
|
|
3513
|
+
fileName: sanitizeFilename(file.fileName || cid),
|
|
3514
|
+
driveName: file.driveName || driveName,
|
|
3515
|
+
size: Number(file.size) || 0,
|
|
3516
|
+
source: String(file.source || (state === 'active' ? 'published' : 'trash')),
|
|
3517
|
+
publishedAt:
|
|
3518
|
+
typeof file.publishedAt === 'string'
|
|
3519
|
+
? file.publishedAt
|
|
3520
|
+
: new Date(syncUpdatedAt).toISOString(),
|
|
3521
|
+
deletedAt:
|
|
3522
|
+
typeof file.deletedAt === 'string'
|
|
3523
|
+
? file.deletedAt
|
|
3524
|
+
: state === 'trash'
|
|
3525
|
+
? new Date(syncUpdatedAt).toISOString()
|
|
3526
|
+
: '',
|
|
3527
|
+
starred: Boolean(file.starred),
|
|
3528
|
+
syncUpdatedAt,
|
|
2809
3529
|
}
|
|
3530
|
+
}
|
|
2810
3531
|
|
|
2811
|
-
|
|
2812
|
-
const
|
|
2813
|
-
const fileName = oldFileName.startsWith(channelPrefix)
|
|
2814
|
-
? oldFileName
|
|
2815
|
-
: `${channelPrefix}${getPathBaseName(oldFileName)}`
|
|
2816
|
-
const link = buildMostLink(attachment.cid, fileName)
|
|
2817
|
-
const content =
|
|
2818
|
-
typeof message.content === 'string' &&
|
|
2819
|
-
(message.content === attachment.link ||
|
|
2820
|
-
parseMostLink(message.content).cid === attachment.cid)
|
|
2821
|
-
? link
|
|
2822
|
-
: message.content
|
|
2823
|
-
|
|
3532
|
+
#formatChannelForSync(channel, ownerAddress) {
|
|
3533
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
2824
3534
|
if (
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
3535
|
+
!channel ||
|
|
3536
|
+
!owner ||
|
|
3537
|
+
TRANSIENT_CHANNEL_TYPES.has(channel.type) ||
|
|
3538
|
+
!this.#channelHasMember(channel, owner)
|
|
2828
3539
|
) {
|
|
2829
|
-
return
|
|
3540
|
+
return null
|
|
2830
3541
|
}
|
|
2831
|
-
|
|
2832
3542
|
return {
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3543
|
+
channelId: channel.channelId,
|
|
3544
|
+
channelKey: channel.channelKey,
|
|
3545
|
+
type: channel.type,
|
|
3546
|
+
createdAt: channel.createdAt,
|
|
3547
|
+
lastMessageAt: channel.lastMessageAt || '',
|
|
3548
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
3549
|
+
member: this.#getChannelMembers(channel).find(
|
|
3550
|
+
member => member.address === owner
|
|
3551
|
+
),
|
|
3552
|
+
remark: channel.remarks?.[owner] || '',
|
|
3553
|
+
pinned: Boolean(channel.pinnedBy?.[owner]),
|
|
3554
|
+
syncUpdatedAt: getSyncTimestamp(
|
|
3555
|
+
channel.syncUpdatedAt || channel.lastMessageAt || channel.createdAt
|
|
3556
|
+
),
|
|
2840
3557
|
}
|
|
2841
3558
|
}
|
|
2842
3559
|
|
|
2843
|
-
#
|
|
2844
|
-
|
|
3560
|
+
#appendUserSyncChannelUpsertSoon(channel, ownerAddress) {
|
|
3561
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
3562
|
+
const record = this.#formatChannelForSync(channel, owner)
|
|
3563
|
+
if (!record) return
|
|
3564
|
+
this.#appendUserSyncOpSoon(owner, 'channel:upsert', { channel: record })
|
|
2845
3565
|
}
|
|
2846
3566
|
|
|
2847
|
-
#
|
|
2848
|
-
const
|
|
2849
|
-
|
|
2850
|
-
|
|
3567
|
+
#appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt = Date.now()) {
|
|
3568
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
3569
|
+
if (!owner || !channel || TRANSIENT_CHANNEL_TYPES.has(channel.type)) return
|
|
3570
|
+
this.#appendUserSyncOpSoon(owner, 'channel:leave', {
|
|
3571
|
+
channelKey: channel.channelKey,
|
|
3572
|
+
syncUpdatedAt,
|
|
3573
|
+
})
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
#normalizeSyncFileRecord(record, state, timestamp) {
|
|
3577
|
+
if (!record || typeof record !== 'object') return null
|
|
3578
|
+
const cid = String(record.cid || '').trim()
|
|
3579
|
+
if (!cid) return null
|
|
3580
|
+
let driveName = ''
|
|
3581
|
+
try {
|
|
3582
|
+
driveName = this.#getCidInfo(cid).driveName
|
|
3583
|
+
} catch {
|
|
3584
|
+
return null
|
|
3585
|
+
}
|
|
3586
|
+
const fileName = sanitizeFilename(record.fileName || cid)
|
|
3587
|
+
if (!fileName || fileName === 'unnamed') return null
|
|
3588
|
+
const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
|
|
3589
|
+
return {
|
|
2851
3590
|
cid,
|
|
2852
|
-
|
|
2853
|
-
|
|
3591
|
+
fileName,
|
|
3592
|
+
driveName: record.driveName || driveName,
|
|
3593
|
+
size: Number(record.size) || 0,
|
|
3594
|
+
source: String(record.source || (state === 'active' ? 'synced' : 'trash')),
|
|
3595
|
+
publishedAt:
|
|
3596
|
+
typeof record.publishedAt === 'string'
|
|
3597
|
+
? record.publishedAt
|
|
3598
|
+
: new Date(syncUpdatedAt).toISOString(),
|
|
3599
|
+
deletedAt:
|
|
3600
|
+
typeof record.deletedAt === 'string'
|
|
3601
|
+
? record.deletedAt
|
|
3602
|
+
: state === 'trash'
|
|
3603
|
+
? new Date(syncUpdatedAt).toISOString()
|
|
3604
|
+
: '',
|
|
3605
|
+
starred: Boolean(record.starred),
|
|
3606
|
+
syncUpdatedAt,
|
|
2854
3607
|
}
|
|
2855
|
-
this.#seedStates.set(cid, next)
|
|
2856
|
-
this.emit('seed:state', next)
|
|
2857
|
-
return next
|
|
2858
3608
|
}
|
|
2859
3609
|
|
|
2860
|
-
#
|
|
2861
|
-
|
|
2862
|
-
|
|
3610
|
+
#applyUserSyncFileRecord(ownerAddress, record, state, timestamp) {
|
|
3611
|
+
const normalized = this.#normalizeSyncFileRecord(record, state, timestamp)
|
|
3612
|
+
if (!normalized) return false
|
|
3613
|
+
const entityKey = `file:${normalized.cid}`
|
|
3614
|
+
if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, normalized.syncUpdatedAt)) {
|
|
3615
|
+
return false
|
|
2863
3616
|
}
|
|
2864
|
-
}
|
|
2865
3617
|
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
3618
|
+
const publishedFiles = [...this.#getPublishedBucket(ownerAddress)]
|
|
3619
|
+
const trashFiles = [...this.#getTrashBucket(ownerAddress)]
|
|
3620
|
+
let changed = false
|
|
3621
|
+
|
|
3622
|
+
const publishedIndex = publishedFiles.findIndex(
|
|
3623
|
+
file => file.cid === normalized.cid
|
|
3624
|
+
)
|
|
3625
|
+
const trashIndex = trashFiles.findIndex(file => file.cid === normalized.cid)
|
|
3626
|
+
const localHolding = this.#holdings.find(
|
|
3627
|
+
holding => holding.cid === normalized.cid
|
|
3628
|
+
)
|
|
3629
|
+
const localSource = localHolding?.source || 'synced'
|
|
3630
|
+
|
|
3631
|
+
if (state === 'active') {
|
|
3632
|
+
const nextRecord = {
|
|
3633
|
+
fileName: normalized.fileName,
|
|
3634
|
+
cid: normalized.cid,
|
|
3635
|
+
driveName: normalized.driveName,
|
|
3636
|
+
size: normalized.size,
|
|
3637
|
+
source: localSource,
|
|
3638
|
+
publishedAt: normalized.publishedAt,
|
|
3639
|
+
starred: normalized.starred,
|
|
3640
|
+
syncUpdatedAt: normalized.syncUpdatedAt,
|
|
3641
|
+
}
|
|
3642
|
+
if (publishedIndex === -1) {
|
|
3643
|
+
publishedFiles.push(nextRecord)
|
|
3644
|
+
changed = true
|
|
3645
|
+
} else if (
|
|
3646
|
+
JSON.stringify(publishedFiles[publishedIndex]) !==
|
|
3647
|
+
JSON.stringify(nextRecord)
|
|
3648
|
+
) {
|
|
3649
|
+
publishedFiles[publishedIndex] = nextRecord
|
|
3650
|
+
changed = true
|
|
3651
|
+
}
|
|
3652
|
+
if (trashIndex !== -1) {
|
|
3653
|
+
trashFiles.splice(trashIndex, 1)
|
|
3654
|
+
changed = true
|
|
3655
|
+
}
|
|
3656
|
+
} else {
|
|
3657
|
+
const nextRecord = {
|
|
3658
|
+
fileName: normalized.fileName,
|
|
3659
|
+
cid: normalized.cid,
|
|
3660
|
+
driveName: normalized.driveName,
|
|
3661
|
+
size: normalized.size,
|
|
3662
|
+
source: localSource,
|
|
3663
|
+
publishedAt: normalized.publishedAt,
|
|
3664
|
+
starred: normalized.starred,
|
|
3665
|
+
deletedAt: normalized.deletedAt || new Date(normalized.syncUpdatedAt).toISOString(),
|
|
3666
|
+
syncUpdatedAt: normalized.syncUpdatedAt,
|
|
3667
|
+
}
|
|
3668
|
+
if (trashIndex === -1) {
|
|
3669
|
+
trashFiles.push(nextRecord)
|
|
3670
|
+
changed = true
|
|
3671
|
+
} else if (
|
|
3672
|
+
JSON.stringify(trashFiles[trashIndex]) !== JSON.stringify(nextRecord)
|
|
3673
|
+
) {
|
|
3674
|
+
trashFiles[trashIndex] = nextRecord
|
|
3675
|
+
changed = true
|
|
3676
|
+
}
|
|
3677
|
+
if (publishedIndex !== -1) {
|
|
3678
|
+
publishedFiles.splice(publishedIndex, 1)
|
|
3679
|
+
changed = true
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
if (changed) {
|
|
3684
|
+
this.#setPublishedBucket(ownerAddress, publishedFiles)
|
|
3685
|
+
this.#setTrashBucket(ownerAddress, trashFiles)
|
|
3686
|
+
this.#setUserSyncClock(ownerAddress, entityKey, normalized.syncUpdatedAt)
|
|
3687
|
+
}
|
|
3688
|
+
return changed
|
|
2871
3689
|
}
|
|
2872
3690
|
|
|
2873
|
-
#
|
|
2874
|
-
|
|
2875
|
-
|
|
3691
|
+
async #applyUserSyncFileDelete(ownerAddress, cidInput, timestamp) {
|
|
3692
|
+
const cid = String(cidInput || '').trim()
|
|
3693
|
+
if (!cid) return false
|
|
3694
|
+
const syncUpdatedAt = getSyncTimestamp(timestamp)
|
|
3695
|
+
const entityKey = `file:${cid}`
|
|
3696
|
+
if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
|
|
3697
|
+
return false
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
const publishedFiles = this.#getPublishedBucket(ownerAddress).filter(
|
|
3701
|
+
file => file.cid !== cid
|
|
3702
|
+
)
|
|
3703
|
+
const trashFiles = this.#getTrashBucket(ownerAddress).filter(
|
|
3704
|
+
file => file.cid !== cid
|
|
3705
|
+
)
|
|
3706
|
+
const changed =
|
|
3707
|
+
publishedFiles.length !== this.#getPublishedBucket(ownerAddress).length ||
|
|
3708
|
+
trashFiles.length !== this.#getTrashBucket(ownerAddress).length
|
|
3709
|
+
this.#setPublishedBucket(ownerAddress, publishedFiles)
|
|
3710
|
+
this.#setTrashBucket(ownerAddress, trashFiles)
|
|
3711
|
+
this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
|
|
3712
|
+
if (changed && !this.#hasAnyUserReference(cid)) {
|
|
3713
|
+
await this.#cleanupUnreferencedCids([cid])
|
|
2876
3714
|
}
|
|
2877
|
-
|
|
2878
|
-
|
|
3715
|
+
return changed
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
async #applyUserSyncChannelRecord(ownerAddress, record, timestamp) {
|
|
3719
|
+
if (!record || typeof record !== 'object') return false
|
|
3720
|
+
const channelId = normalizeChannelId(record.channelId)
|
|
3721
|
+
const expectedChannelKey = buildChannelKey(channelId)
|
|
3722
|
+
const channelKey = expectedChannelKey
|
|
3723
|
+
if (!channelId || !channelKey) return false
|
|
3724
|
+
const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
|
|
3725
|
+
const entityKey = `channel:${channelKey}`
|
|
3726
|
+
if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
|
|
3727
|
+
return false
|
|
2879
3728
|
}
|
|
2880
3729
|
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
3730
|
+
let channel = this.#channels.find(item => item.channelKey === channelKey)
|
|
3731
|
+
let changed = false
|
|
3732
|
+
if (!channel) {
|
|
3733
|
+
channel = {
|
|
3734
|
+
channelId,
|
|
3735
|
+
channelKey,
|
|
3736
|
+
name: channelId,
|
|
3737
|
+
createdAt:
|
|
3738
|
+
typeof record.createdAt === 'string'
|
|
3739
|
+
? record.createdAt
|
|
3740
|
+
: new Date(syncUpdatedAt).toISOString(),
|
|
3741
|
+
lastMessageAt:
|
|
3742
|
+
typeof record.lastMessageAt === 'string' ? record.lastMessageAt : '',
|
|
3743
|
+
type: String(record.type || 'personal').trim() || 'personal',
|
|
3744
|
+
writerId: createChannelWriterId(),
|
|
3745
|
+
localWriterCoreKey: '',
|
|
3746
|
+
writerCoreKeys: uniqueStrings(record.writerCoreKeys),
|
|
3747
|
+
members: [],
|
|
3748
|
+
syncUpdatedAt,
|
|
2884
3749
|
}
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
3750
|
+
this.#channels.push(channel)
|
|
3751
|
+
changed = true
|
|
3752
|
+
} else {
|
|
3753
|
+
const nextKeys = uniqueStrings([
|
|
3754
|
+
...(channel.writerCoreKeys || []),
|
|
3755
|
+
...(record.writerCoreKeys || []),
|
|
3756
|
+
])
|
|
3757
|
+
if (nextKeys.length !== (channel.writerCoreKeys || []).length) {
|
|
3758
|
+
channel.writerCoreKeys = nextKeys
|
|
3759
|
+
changed = true
|
|
2890
3760
|
}
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
driveName,
|
|
2895
|
-
size: Number(record.size) || 0,
|
|
2896
|
-
source: String(record.source || 'imported'),
|
|
2897
|
-
publishedAt:
|
|
2898
|
-
typeof record.publishedAt === 'string' ? record.publishedAt : '',
|
|
2899
|
-
deletedAt: typeof record.deletedAt === 'string' ? record.deletedAt : '',
|
|
2900
|
-
starred: Boolean(record.starred),
|
|
3761
|
+
if (record.lastMessageAt && record.lastMessageAt !== channel.lastMessageAt) {
|
|
3762
|
+
channel.lastMessageAt = record.lastMessageAt
|
|
3763
|
+
changed = true
|
|
2901
3764
|
}
|
|
3765
|
+
channel.syncUpdatedAt = syncUpdatedAt
|
|
2902
3766
|
}
|
|
2903
3767
|
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
3768
|
+
if (
|
|
3769
|
+
this.#upsertChannelMember(channel, {
|
|
3770
|
+
ownerAddress,
|
|
3771
|
+
displayName:
|
|
3772
|
+
record.member?.displayName || record.remark || '',
|
|
3773
|
+
avatar: record.member?.avatar || '',
|
|
3774
|
+
})
|
|
3775
|
+
) {
|
|
3776
|
+
changed = true
|
|
3777
|
+
}
|
|
3778
|
+
if (record.remark !== undefined) {
|
|
3779
|
+
channel.remarks = channel.remarks || {}
|
|
3780
|
+
const remark = String(record.remark || '').slice(0, 50)
|
|
3781
|
+
if (remark) channel.remarks[ownerAddress] = remark
|
|
3782
|
+
else delete channel.remarks[ownerAddress]
|
|
3783
|
+
changed = true
|
|
3784
|
+
}
|
|
3785
|
+
channel.pinnedBy = channel.pinnedBy || {}
|
|
3786
|
+
if (record.pinned) {
|
|
3787
|
+
channel.pinnedBy[ownerAddress] = true
|
|
3788
|
+
} else {
|
|
3789
|
+
delete channel.pinnedBy[ownerAddress]
|
|
3790
|
+
}
|
|
3791
|
+
this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
|
|
3792
|
+
|
|
3793
|
+
if (!this.#channelLocalCoreKey.get(channel.channelKey)) {
|
|
3794
|
+
await this.#openChannelRuntime(channel)
|
|
3795
|
+
await this.#joinChannelDiscoveryTopics(channel)
|
|
3796
|
+
changed = true
|
|
3797
|
+
this.emit('channel:joined', {
|
|
3798
|
+
channel: channel.channelKey,
|
|
3799
|
+
channelKey: channel.channelKey,
|
|
3800
|
+
channelId: channel.channelId,
|
|
3801
|
+
key: channel.channelKey,
|
|
3802
|
+
})
|
|
3803
|
+
}
|
|
3804
|
+
return changed
|
|
3805
|
+
}
|
|
2932
3806
|
|
|
2933
|
-
|
|
3807
|
+
#applyUserSyncChannelLeave(ownerAddress, channelKeyInput, timestamp) {
|
|
3808
|
+
const channelKey = normalizeChannelKey(channelKeyInput)
|
|
3809
|
+
if (!channelKey) return false
|
|
3810
|
+
const syncUpdatedAt = getSyncTimestamp(timestamp)
|
|
3811
|
+
const entityKey = `channel:${channelKey}`
|
|
3812
|
+
if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
|
|
3813
|
+
return false
|
|
3814
|
+
}
|
|
3815
|
+
const channel = this.#channels.find(item => item.channelKey === channelKey)
|
|
3816
|
+
if (!channel) {
|
|
3817
|
+
this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
|
|
3818
|
+
return false
|
|
3819
|
+
}
|
|
3820
|
+
const before = channel.members?.length || 0
|
|
3821
|
+
channel.members = (channel.members || []).filter(
|
|
3822
|
+
member => normalizeOwnerAddress(member?.address) !== ownerAddress
|
|
3823
|
+
)
|
|
3824
|
+
if (channel.remarks) delete channel.remarks[ownerAddress]
|
|
3825
|
+
if (channel.pinnedBy) delete channel.pinnedBy[ownerAddress]
|
|
3826
|
+
this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
|
|
3827
|
+
const changed = before !== channel.members.length
|
|
3828
|
+
if (channel.members.length === 0) {
|
|
3829
|
+
this.#channels = this.#channels.filter(item => item.channelKey !== channelKey)
|
|
3830
|
+
}
|
|
3831
|
+
if (changed) {
|
|
3832
|
+
this.emit('channel:left', {
|
|
3833
|
+
channel: channelKey,
|
|
3834
|
+
channelKey,
|
|
3835
|
+
channelId: channel.channelId,
|
|
3836
|
+
name: channel.channelId,
|
|
3837
|
+
})
|
|
3838
|
+
}
|
|
3839
|
+
return changed
|
|
2934
3840
|
}
|
|
2935
3841
|
|
|
2936
|
-
#
|
|
2937
|
-
const
|
|
2938
|
-
|
|
3842
|
+
#getUserSyncClock(ownerAddress, entityKey) {
|
|
3843
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
3844
|
+
return Number(this.#userSyncMetadata.clocks?.[owner]?.[entityKey]) || 0
|
|
3845
|
+
}
|
|
2939
3846
|
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
remoteCoreKeys: [],
|
|
2953
|
-
}
|
|
2954
|
-
this.#channels.push(channel)
|
|
2955
|
-
}
|
|
3847
|
+
#setUserSyncClock(ownerAddress, entityKey, timestamp) {
|
|
3848
|
+
const owner = normalizeOwnerAddress(ownerAddress)
|
|
3849
|
+
if (!owner || !entityKey) return
|
|
3850
|
+
this.#userSyncMetadata.clocks = this.#userSyncMetadata.clocks || {}
|
|
3851
|
+
this.#userSyncMetadata.clocks[owner] =
|
|
3852
|
+
this.#userSyncMetadata.clocks[owner] || {}
|
|
3853
|
+
this.#userSyncMetadata.clocks[owner][entityKey] = Math.max(
|
|
3854
|
+
this.#getUserSyncClock(owner, entityKey),
|
|
3855
|
+
getSyncTimestamp(timestamp)
|
|
3856
|
+
)
|
|
3857
|
+
this.#saveUserSyncMetadata()
|
|
3858
|
+
}
|
|
2956
3859
|
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
avatar: imported.member?.avatar || '',
|
|
2961
|
-
})
|
|
3860
|
+
#shouldApplyUserSyncEntity(ownerAddress, entityKey, timestamp) {
|
|
3861
|
+
return getSyncTimestamp(timestamp) > this.#getUserSyncClock(ownerAddress, entityKey)
|
|
3862
|
+
}
|
|
2962
3863
|
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
3864
|
+
#markUserSyncClockForOp(op) {
|
|
3865
|
+
const ownerAddress = normalizeOwnerAddress(op.ownerAddress)
|
|
3866
|
+
if (!ownerAddress) return
|
|
3867
|
+
if (op.kind === 'file:upsert' || op.kind === 'file:trash') {
|
|
3868
|
+
const cid = op.payload?.file?.cid
|
|
3869
|
+
const timestamp = getSyncTimestamp(
|
|
3870
|
+
op.payload?.file?.syncUpdatedAt,
|
|
3871
|
+
op.timestamp
|
|
3872
|
+
)
|
|
3873
|
+
if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
|
|
3874
|
+
} else if (op.kind === 'file:delete') {
|
|
3875
|
+
const cid = op.payload?.cid
|
|
3876
|
+
const timestamp = getSyncTimestamp(
|
|
3877
|
+
op.payload?.syncUpdatedAt,
|
|
3878
|
+
op.timestamp
|
|
3879
|
+
)
|
|
3880
|
+
if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
|
|
3881
|
+
} else if (op.kind === 'channel:upsert') {
|
|
3882
|
+
const channelKey = op.payload?.channel?.channelKey
|
|
3883
|
+
const timestamp = getSyncTimestamp(
|
|
3884
|
+
op.payload?.channel?.syncUpdatedAt,
|
|
3885
|
+
op.timestamp
|
|
3886
|
+
)
|
|
3887
|
+
if (channelKey) {
|
|
3888
|
+
this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
|
|
2966
3889
|
}
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
3890
|
+
} else if (op.kind === 'channel:leave') {
|
|
3891
|
+
const channelKey = op.payload?.channelKey
|
|
3892
|
+
const timestamp = getSyncTimestamp(
|
|
3893
|
+
op.payload?.syncUpdatedAt,
|
|
3894
|
+
op.timestamp
|
|
3895
|
+
)
|
|
3896
|
+
if (channelKey) {
|
|
3897
|
+
this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
|
|
2970
3898
|
}
|
|
2971
3899
|
}
|
|
2972
3900
|
}
|
|
2973
3901
|
|
|
3902
|
+
#persistUserSyncSession(session) {
|
|
3903
|
+
this.#userSyncMetadata.sessions = this.#userSyncMetadata.sessions || {}
|
|
3904
|
+
this.#userSyncMetadata.sessions[session.ownerAddress] = {
|
|
3905
|
+
ownerAddress: session.ownerAddress,
|
|
3906
|
+
syncId: session.syncId,
|
|
3907
|
+
syncName: session.syncName,
|
|
3908
|
+
writerId: session.writerId,
|
|
3909
|
+
localWriterCoreKey: session.localWriterCoreKey,
|
|
3910
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
3911
|
+
startedAt: session.startedAt,
|
|
3912
|
+
lastSyncedAt:
|
|
3913
|
+
this.#userSyncMetadata.sessions?.[session.ownerAddress]?.lastSyncedAt ||
|
|
3914
|
+
'',
|
|
3915
|
+
updatedAt: new Date().toISOString(),
|
|
3916
|
+
}
|
|
3917
|
+
this.#saveUserSyncMetadata()
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
#touchUserSyncSession(session) {
|
|
3921
|
+
this.#persistUserSyncSession(session)
|
|
3922
|
+
const persisted = this.#userSyncMetadata.sessions?.[session.ownerAddress]
|
|
3923
|
+
if (persisted) {
|
|
3924
|
+
persisted.lastSyncedAt = new Date().toISOString()
|
|
3925
|
+
this.#saveUserSyncMetadata()
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
|
|
2974
3929
|
#getFileRuntimeStats(cid) {
|
|
2975
3930
|
const state = this.#fileMonitors.get(cid)
|
|
2976
3931
|
if (!state) {
|
|
@@ -3549,6 +4504,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3549
4504
|
return path.join(this.#options.dataPath, 'trash-files.json')
|
|
3550
4505
|
}
|
|
3551
4506
|
|
|
4507
|
+
#getUserSyncMetadataPath() {
|
|
4508
|
+
return path.join(this.#options.dataPath, 'user-sync.json')
|
|
4509
|
+
}
|
|
4510
|
+
|
|
3552
4511
|
#atomicWrite(filePath, data) {
|
|
3553
4512
|
const tmpPath = filePath + '.tmp'
|
|
3554
4513
|
fs.writeFileSync(tmpPath, data, 'utf-8')
|
|
@@ -3641,6 +4600,65 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3641
4600
|
}
|
|
3642
4601
|
}
|
|
3643
4602
|
|
|
4603
|
+
#loadUserSyncMetadata() {
|
|
4604
|
+
try {
|
|
4605
|
+
const metadataPath = this.#getUserSyncMetadataPath()
|
|
4606
|
+
if (fs.existsSync(metadataPath)) {
|
|
4607
|
+
const parsed = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
|
4608
|
+
const sessions = {}
|
|
4609
|
+
for (const [owner, session] of Object.entries(parsed.sessions || {})) {
|
|
4610
|
+
const ownerAddress = normalizeOwnerAddress(owner)
|
|
4611
|
+
const syncId = String(session?.syncId || '').trim()
|
|
4612
|
+
if (!ownerAddress || !syncId) continue
|
|
4613
|
+
sessions[ownerAddress] = {
|
|
4614
|
+
ownerAddress,
|
|
4615
|
+
syncId,
|
|
4616
|
+
syncName: String(session.syncName || getUserSyncName(syncId)),
|
|
4617
|
+
writerId: String(session.writerId || ''),
|
|
4618
|
+
localWriterCoreKey: String(session.localWriterCoreKey || ''),
|
|
4619
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
4620
|
+
startedAt: String(session.startedAt || ''),
|
|
4621
|
+
lastSyncedAt: String(session.lastSyncedAt || ''),
|
|
4622
|
+
updatedAt: String(session.updatedAt || ''),
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
const clocks = {}
|
|
4627
|
+
for (const [owner, records] of Object.entries(parsed.clocks || {})) {
|
|
4628
|
+
const ownerAddress = normalizeOwnerAddress(owner)
|
|
4629
|
+
if (!ownerAddress || !records || typeof records !== 'object') continue
|
|
4630
|
+
clocks[ownerAddress] = {}
|
|
4631
|
+
for (const [entityKey, timestamp] of Object.entries(records)) {
|
|
4632
|
+
const value = Number(timestamp)
|
|
4633
|
+
if (entityKey && Number.isFinite(value) && value > 0) {
|
|
4634
|
+
clocks[ownerAddress][entityKey] = value
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
return { sessions, clocks }
|
|
4640
|
+
}
|
|
4641
|
+
} catch (err) {
|
|
4642
|
+
console.warn(
|
|
4643
|
+
'Failed to load user sync metadata, using empty state:',
|
|
4644
|
+
err.message
|
|
4645
|
+
)
|
|
4646
|
+
}
|
|
4647
|
+
return { sessions: {}, clocks: {} }
|
|
4648
|
+
}
|
|
4649
|
+
|
|
4650
|
+
#saveUserSyncMetadata() {
|
|
4651
|
+
try {
|
|
4652
|
+
const metadataPath = this.#getUserSyncMetadataPath()
|
|
4653
|
+
this.#atomicWrite(
|
|
4654
|
+
metadataPath,
|
|
4655
|
+
JSON.stringify(this.#userSyncMetadata, null, 2)
|
|
4656
|
+
)
|
|
4657
|
+
} catch (err) {
|
|
4658
|
+
console.error('Failed to save user sync metadata:', err.message)
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
|
|
3644
4662
|
#getChannelsMetadataPath() {
|
|
3645
4663
|
return path.join(this.#options.dataPath, 'channels.json')
|
|
3646
4664
|
}
|
|
@@ -3654,7 +4672,31 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3654
4672
|
const metadataPath = this.#getChannelsMetadataPath()
|
|
3655
4673
|
if (fs.existsSync(metadataPath)) {
|
|
3656
4674
|
const data = fs.readFileSync(metadataPath, 'utf-8')
|
|
3657
|
-
|
|
4675
|
+
const channels = JSON.parse(data)
|
|
4676
|
+
if (!Array.isArray(channels)) return []
|
|
4677
|
+
return channels
|
|
4678
|
+
.filter(channel => channel && typeof channel === 'object')
|
|
4679
|
+
.map(channel => {
|
|
4680
|
+
const channelId = normalizeChannelId(channel.channelId)
|
|
4681
|
+
const channelKey = normalizeChannelKey(channel.channelKey)
|
|
4682
|
+
const expectedChannelKey = buildChannelKey(channelId)
|
|
4683
|
+
return {
|
|
4684
|
+
...channel,
|
|
4685
|
+
channelId,
|
|
4686
|
+
channelKey,
|
|
4687
|
+
expectedChannelKey,
|
|
4688
|
+
name: channelId,
|
|
4689
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
4690
|
+
}
|
|
4691
|
+
})
|
|
4692
|
+
.filter(
|
|
4693
|
+
channel =>
|
|
4694
|
+
CHANNEL_NAME_REGEX.test(channel.channelId) &&
|
|
4695
|
+
channel.channelKey === channel.expectedChannelKey &&
|
|
4696
|
+
channel.writerId &&
|
|
4697
|
+
channel.localWriterCoreKey
|
|
4698
|
+
)
|
|
4699
|
+
.map(({ expectedChannelKey: _expectedChannelKey, ...channel }) => channel)
|
|
3658
4700
|
}
|
|
3659
4701
|
} catch (err) {
|
|
3660
4702
|
console.warn(
|
|
@@ -3668,9 +4710,22 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3668
4710
|
#saveChannelsMetadata() {
|
|
3669
4711
|
try {
|
|
3670
4712
|
const metadataPath = this.#getChannelsMetadataPath()
|
|
3671
|
-
const persistentChannels = this.#channels
|
|
3672
|
-
channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type)
|
|
3673
|
-
|
|
4713
|
+
const persistentChannels = this.#channels
|
|
4714
|
+
.filter(channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type))
|
|
4715
|
+
.map(channel => ({
|
|
4716
|
+
channelId: channel.channelId,
|
|
4717
|
+
channelKey: channel.channelKey,
|
|
4718
|
+
name: channel.channelId,
|
|
4719
|
+
type: channel.type,
|
|
4720
|
+
createdAt: channel.createdAt,
|
|
4721
|
+
lastMessageAt: channel.lastMessageAt || '',
|
|
4722
|
+
writerId: channel.writerId,
|
|
4723
|
+
localWriterCoreKey: channel.localWriterCoreKey,
|
|
4724
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
4725
|
+
members: Array.isArray(channel.members) ? channel.members : [],
|
|
4726
|
+
remarks: channel.remarks,
|
|
4727
|
+
pinnedBy: channel.pinnedBy,
|
|
4728
|
+
}))
|
|
3674
4729
|
this.#atomicWrite(
|
|
3675
4730
|
metadataPath,
|
|
3676
4731
|
JSON.stringify(persistentChannels, null, 2)
|
|
@@ -3680,23 +4735,39 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3680
4735
|
}
|
|
3681
4736
|
}
|
|
3682
4737
|
|
|
3683
|
-
#generateChannelDiscoveryKey(
|
|
4738
|
+
#generateChannelDiscoveryKey(channelKey) {
|
|
4739
|
+
const hash = crypto
|
|
4740
|
+
.createHash('sha256')
|
|
4741
|
+
.update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}`)
|
|
4742
|
+
.digest()
|
|
4743
|
+
return hash
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4746
|
+
#generateChannelChatDiscoveryKey(channelKey) {
|
|
4747
|
+
const hash = crypto
|
|
4748
|
+
.createHash('sha256')
|
|
4749
|
+
.update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}:chat`)
|
|
4750
|
+
.digest()
|
|
4751
|
+
return hash
|
|
4752
|
+
}
|
|
4753
|
+
|
|
4754
|
+
#generateChannelIdDiscoveryKey(channelId) {
|
|
3684
4755
|
const hash = crypto
|
|
3685
4756
|
.createHash('sha256')
|
|
3686
|
-
.update(`${CHANNEL_NAME_PREFIX}
|
|
4757
|
+
.update(`${CHANNEL_NAME_PREFIX}id:${channelId}:candidates`)
|
|
3687
4758
|
.digest()
|
|
3688
4759
|
return hash
|
|
3689
4760
|
}
|
|
3690
4761
|
|
|
3691
|
-
#
|
|
4762
|
+
#generateUserSyncDiscoveryKey(syncId) {
|
|
3692
4763
|
const hash = crypto
|
|
3693
4764
|
.createHash('sha256')
|
|
3694
|
-
.update(`${CHANNEL_NAME_PREFIX}${
|
|
4765
|
+
.update(`${CHANNEL_NAME_PREFIX}${USER_SYNC_NAMESPACE_PREFIX}${syncId}`)
|
|
3695
4766
|
.digest()
|
|
3696
4767
|
return hash
|
|
3697
4768
|
}
|
|
3698
4769
|
|
|
3699
|
-
#setupChannelAppendListener(core,
|
|
4770
|
+
#setupChannelAppendListener(core, channelKey) {
|
|
3700
4771
|
let lastCoreLength = core.length
|
|
3701
4772
|
core.on('append', async () => {
|
|
3702
4773
|
if (core.length > lastCoreLength) {
|
|
@@ -3704,7 +4775,9 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3704
4775
|
try {
|
|
3705
4776
|
const entry = await core.get(i)
|
|
3706
4777
|
if (entry && entry.type === 'message') {
|
|
3707
|
-
const channel = this.#channels.find(
|
|
4778
|
+
const channel = this.#channels.find(
|
|
4779
|
+
c => c.channelKey === channelKey
|
|
4780
|
+
)
|
|
3708
4781
|
if (channel) {
|
|
3709
4782
|
const entryTime = Number(entry.timestamp) || Date.now()
|
|
3710
4783
|
const currentTime = Date.parse(channel.lastMessageAt || '') || 0
|
|
@@ -3714,16 +4787,18 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3714
4787
|
}
|
|
3715
4788
|
}
|
|
3716
4789
|
this.emit('channel:message', {
|
|
3717
|
-
channel:
|
|
4790
|
+
channel: channelKey,
|
|
4791
|
+
channelKey,
|
|
4792
|
+
channelId: channel?.channelId || '',
|
|
3718
4793
|
message: this.#normalizeChannelMessageForResponse(
|
|
3719
|
-
|
|
4794
|
+
channelKey,
|
|
3720
4795
|
entry
|
|
3721
4796
|
),
|
|
3722
4797
|
})
|
|
3723
4798
|
}
|
|
3724
4799
|
} catch (err) {
|
|
3725
4800
|
console.error(
|
|
3726
|
-
`[MostBox] Failed to read channel message from ${
|
|
4801
|
+
`[MostBox] Failed to read channel message from ${channelKey}:`,
|
|
3727
4802
|
err.message
|
|
3728
4803
|
)
|
|
3729
4804
|
continue
|
|
@@ -3734,13 +4809,13 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3734
4809
|
})
|
|
3735
4810
|
}
|
|
3736
4811
|
|
|
3737
|
-
async #openRemoteChannelCore(
|
|
3738
|
-
const coresMap = this.#channelCores.get(
|
|
4812
|
+
async #openRemoteChannelCore(channelKey, coreKeyHex) {
|
|
4813
|
+
const coresMap = this.#channelCores.get(channelKey)
|
|
3739
4814
|
if (!coresMap) return
|
|
3740
4815
|
if (coresMap.has(coreKeyHex)) return
|
|
3741
4816
|
|
|
3742
4817
|
try {
|
|
3743
|
-
const ns = this.#store.namespace(`channel-${
|
|
4818
|
+
const ns = this.#store.namespace(`channel-${channelKey}`)
|
|
3744
4819
|
const core = ns.get({
|
|
3745
4820
|
key: b4a.from(coreKeyHex, 'hex'),
|
|
3746
4821
|
valueEncoding: 'json',
|
|
@@ -3748,89 +4823,198 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3748
4823
|
await core.ready()
|
|
3749
4824
|
const normalizedCoreKey = b4a.toString(core.key, 'hex')
|
|
3750
4825
|
coresMap.set(normalizedCoreKey, core)
|
|
3751
|
-
this.#setupChannelAppendListener(core,
|
|
3752
|
-
const channel = this.#channels.find(c => c.
|
|
3753
|
-
if (channel &&
|
|
3754
|
-
|
|
3755
|
-
channel.
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
this.#saveChannelsMetadata()
|
|
3760
|
-
}
|
|
4826
|
+
this.#setupChannelAppendListener(core, channelKey)
|
|
4827
|
+
const channel = this.#channels.find(c => c.channelKey === channelKey)
|
|
4828
|
+
if (channel && !channel.writerCoreKeys?.includes(normalizedCoreKey)) {
|
|
4829
|
+
channel.writerCoreKeys = uniqueStrings([
|
|
4830
|
+
...(channel.writerCoreKeys || []),
|
|
4831
|
+
normalizedCoreKey,
|
|
4832
|
+
])
|
|
4833
|
+
this.#saveChannelsMetadata()
|
|
3761
4834
|
}
|
|
3762
4835
|
console.log(
|
|
3763
|
-
`[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${
|
|
4836
|
+
`[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelKey}`
|
|
3764
4837
|
)
|
|
3765
4838
|
} catch (err) {
|
|
3766
4839
|
console.warn(
|
|
3767
|
-
`[MostBox] Failed to open remote channel core for ${
|
|
4840
|
+
`[MostBox] Failed to open remote channel core for ${channelKey}:`,
|
|
3768
4841
|
err.message
|
|
3769
4842
|
)
|
|
3770
4843
|
}
|
|
3771
4844
|
}
|
|
3772
4845
|
|
|
3773
|
-
|
|
3774
|
-
const
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4846
|
+
#buildChannelHelloMessage() {
|
|
4847
|
+
const channels = this.#channels.map(channel => ({
|
|
4848
|
+
channelId: channel.channelId,
|
|
4849
|
+
channelKey: channel.channelKey,
|
|
4850
|
+
type: channel.type,
|
|
4851
|
+
createdAt: channel.createdAt,
|
|
4852
|
+
lastMessageAt: channel.lastMessageAt || '',
|
|
4853
|
+
writerCoreKeys: uniqueStrings([
|
|
4854
|
+
...(channel.writerCoreKeys || []),
|
|
4855
|
+
this.#channelLocalCoreKey.get(channel.channelKey),
|
|
4856
|
+
]),
|
|
4857
|
+
}))
|
|
4858
|
+
const userSyncSessions = [...this.#userSyncSessions.values()].map(
|
|
4859
|
+
session => ({
|
|
4860
|
+
ownerAddress: session.ownerAddress,
|
|
4861
|
+
syncId: session.syncId,
|
|
4862
|
+
syncName: session.syncName,
|
|
4863
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
4864
|
+
})
|
|
4865
|
+
)
|
|
3781
4866
|
|
|
3782
|
-
|
|
4867
|
+
return {
|
|
3783
4868
|
type: 'channel-hello',
|
|
3784
4869
|
peerId: this.getNodeId(),
|
|
3785
4870
|
authorName: this.getNodeId().slice(0, 4),
|
|
3786
|
-
channels
|
|
3787
|
-
|
|
3788
|
-
}
|
|
4871
|
+
channels,
|
|
4872
|
+
userSyncSessions,
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
3789
4875
|
|
|
4876
|
+
#sendChannelHello(stream) {
|
|
4877
|
+
if (!stream || stream.destroyed || stream.writableEnded) {
|
|
4878
|
+
this.#channelStreams.delete(stream)
|
|
4879
|
+
return false
|
|
4880
|
+
}
|
|
3790
4881
|
try {
|
|
3791
|
-
stream.write(
|
|
4882
|
+
stream.write(`${JSON.stringify(this.#buildChannelHelloMessage())}\n`)
|
|
4883
|
+
return true
|
|
3792
4884
|
} catch {
|
|
3793
|
-
|
|
4885
|
+
this.#channelStreams.delete(stream)
|
|
4886
|
+
return false
|
|
3794
4887
|
}
|
|
4888
|
+
}
|
|
3795
4889
|
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
const theirChannels = new Set(msg.channels || [])
|
|
3803
|
-
for (const [name, peers] of this.#channelPeers) {
|
|
3804
|
-
if (theirChannels.has(name)) {
|
|
3805
|
-
peers.set(msg.peerId, {
|
|
3806
|
-
peerId: msg.peerId,
|
|
3807
|
-
authorName: msg.authorName,
|
|
3808
|
-
lastSeen: Date.now(),
|
|
3809
|
-
})
|
|
3810
|
-
}
|
|
3811
|
-
}
|
|
4890
|
+
#broadcastChannelHello() {
|
|
4891
|
+
for (const stream of [...this.#channelStreams]) {
|
|
4892
|
+
this.#sendChannelHello(stream)
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
3812
4895
|
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
msg.coreKeys
|
|
3816
|
-
)) {
|
|
3817
|
-
if (this.#channelCores.has(channelName) && coreKeyHex) {
|
|
3818
|
-
await this.#openRemoteChannelCore(channelName, coreKeyHex)
|
|
3819
|
-
}
|
|
3820
|
-
}
|
|
3821
|
-
}
|
|
4896
|
+
async #processChannelHelloMessage(msg) {
|
|
4897
|
+
if (msg.type !== 'channel-hello') return null
|
|
3822
4898
|
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
4899
|
+
const remoteChannels = Array.isArray(msg.channels)
|
|
4900
|
+
? msg.channels
|
|
4901
|
+
.filter(channel => channel && typeof channel === 'object')
|
|
4902
|
+
.map(channel => {
|
|
4903
|
+
const channelId = normalizeChannelId(channel.channelId)
|
|
4904
|
+
return {
|
|
4905
|
+
channelId,
|
|
4906
|
+
channelKey: buildChannelKey(channelId),
|
|
4907
|
+
type: String(channel.type || 'public').trim() || 'public',
|
|
4908
|
+
createdAt:
|
|
4909
|
+
typeof channel.createdAt === 'string' ? channel.createdAt : '',
|
|
4910
|
+
lastMessageAt:
|
|
4911
|
+
typeof channel.lastMessageAt === 'string'
|
|
4912
|
+
? channel.lastMessageAt
|
|
4913
|
+
: '',
|
|
4914
|
+
writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
|
|
4915
|
+
}
|
|
3826
4916
|
})
|
|
4917
|
+
.filter(channel => channel.channelId && channel.channelKey)
|
|
4918
|
+
: []
|
|
4919
|
+
|
|
4920
|
+
for (const remoteChannel of remoteChannels) {
|
|
4921
|
+
this.#cacheChannelCandidate({
|
|
4922
|
+
...remoteChannel,
|
|
4923
|
+
local: false,
|
|
4924
|
+
peerId: msg.peerId,
|
|
4925
|
+
})
|
|
4926
|
+
|
|
4927
|
+
const localChannel = this.#channels.find(
|
|
4928
|
+
channel => channel.channelKey === remoteChannel.channelKey
|
|
4929
|
+
)
|
|
4930
|
+
if (!localChannel) continue
|
|
4931
|
+
|
|
4932
|
+
const peers = this.#channelPeers.get(localChannel.channelKey)
|
|
4933
|
+
if (peers) {
|
|
4934
|
+
peers.set(msg.peerId, {
|
|
4935
|
+
peerId: msg.peerId,
|
|
4936
|
+
authorName: msg.authorName,
|
|
4937
|
+
lastSeen: Date.now(),
|
|
4938
|
+
})
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
for (const writerCoreKey of remoteChannel.writerCoreKeys) {
|
|
4942
|
+
if (
|
|
4943
|
+
writerCoreKey &&
|
|
4944
|
+
writerCoreKey !== this.#channelLocalCoreKey.get(localChannel.channelKey)
|
|
4945
|
+
) {
|
|
4946
|
+
await this.#openRemoteChannelCore(
|
|
4947
|
+
localChannel.channelKey,
|
|
4948
|
+
writerCoreKey
|
|
4949
|
+
)
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
|
|
4955
|
+
? msg.userSyncSessions
|
|
4956
|
+
.filter(session => session && typeof session === 'object')
|
|
4957
|
+
.map(session => ({
|
|
4958
|
+
ownerAddress: normalizeOwnerAddress(session.ownerAddress),
|
|
4959
|
+
syncId: String(session.syncId || '').trim(),
|
|
4960
|
+
writerCoreKeys: uniqueStrings(session.writerCoreKeys),
|
|
4961
|
+
}))
|
|
4962
|
+
.filter(session => session.ownerAddress && session.syncId)
|
|
4963
|
+
: []
|
|
4964
|
+
|
|
4965
|
+
for (const remoteSession of remoteUserSyncSessions) {
|
|
4966
|
+
const localSession = this.#userSyncSessions.get(
|
|
4967
|
+
remoteSession.ownerAddress
|
|
4968
|
+
)
|
|
4969
|
+
if (!localSession || localSession.syncId !== remoteSession.syncId) {
|
|
4970
|
+
continue
|
|
4971
|
+
}
|
|
4972
|
+
await this.#mergeUserSyncWriterCoreKeys(
|
|
4973
|
+
localSession,
|
|
4974
|
+
remoteSession.writerCoreKeys
|
|
4975
|
+
)
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4978
|
+
this.emit('channel:peer:online', {
|
|
4979
|
+
peerId: msg.peerId,
|
|
4980
|
+
authorName: msg.authorName,
|
|
4981
|
+
})
|
|
4982
|
+
|
|
4983
|
+
return msg.peerId
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4986
|
+
async #handleChannelConnection(conn) {
|
|
4987
|
+
const stream = conn
|
|
4988
|
+
let connectedPeerId = null
|
|
4989
|
+
let readBuffer = ''
|
|
4990
|
+
let closed = false
|
|
4991
|
+
|
|
4992
|
+
this.#channelStreams.add(stream)
|
|
4993
|
+
if (!this.#sendChannelHello(stream)) return
|
|
4994
|
+
|
|
4995
|
+
stream.on('data', async data => {
|
|
4996
|
+
readBuffer += data.toString()
|
|
4997
|
+
let newlineIndex = readBuffer.indexOf('\n')
|
|
4998
|
+
while (newlineIndex !== -1) {
|
|
4999
|
+
const line = readBuffer.slice(0, newlineIndex).trim()
|
|
5000
|
+
readBuffer = readBuffer.slice(newlineIndex + 1)
|
|
5001
|
+
newlineIndex = readBuffer.indexOf('\n')
|
|
5002
|
+
if (!line) continue
|
|
5003
|
+
try {
|
|
5004
|
+
const peerId = await this.#processChannelHelloMessage(
|
|
5005
|
+
JSON.parse(line)
|
|
5006
|
+
)
|
|
5007
|
+
if (peerId) connectedPeerId = peerId
|
|
5008
|
+
} catch (err) {
|
|
5009
|
+
console.warn(`[MostBox] Failed to process channel data:`, err.message)
|
|
3827
5010
|
}
|
|
3828
|
-
} catch (err) {
|
|
3829
|
-
console.warn(`[MostBox] Failed to process channel data:`, err.message)
|
|
3830
5011
|
}
|
|
3831
5012
|
})
|
|
3832
5013
|
|
|
3833
|
-
|
|
5014
|
+
const cleanup = () => {
|
|
5015
|
+
if (closed) return
|
|
5016
|
+
closed = true
|
|
5017
|
+
this.#channelStreams.delete(stream)
|
|
3834
5018
|
if (connectedPeerId) {
|
|
3835
5019
|
for (const [, peers] of this.#channelPeers) {
|
|
3836
5020
|
if (peers.has(connectedPeerId)) {
|
|
@@ -3843,7 +5027,10 @@ export class MostBoxEngine extends EventEmitter {
|
|
|
3843
5027
|
}
|
|
3844
5028
|
}
|
|
3845
5029
|
}
|
|
3846
|
-
}
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
stream.on('close', cleanup)
|
|
5033
|
+
stream.on('error', cleanup)
|
|
3847
5034
|
}
|
|
3848
5035
|
|
|
3849
5036
|
/**
|