most-box 0.3.4 → 0.3.6

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.
Files changed (133) hide show
  1. package/out/about/index.html +20 -0
  2. package/out/admin/index.html +0 -0
  3. package/out/app/index.html +0 -0
  4. package/out/assets/AccountMenu-B26qus-Q.js +1 -0
  5. package/out/assets/AppShell-BiYQsNO8.js +1 -0
  6. package/out/assets/AppTop-D-qYC0ep.js +1 -0
  7. package/out/assets/ChatRestoringIndicator-CLV78gNW.js +1 -0
  8. package/out/assets/CopyButton-BrEkzqEq.js +1 -0
  9. package/out/assets/MarketingHeader-CJXpogPY.js +1 -0
  10. package/out/assets/MarketingLayout-Ch4sw5Qm.js +1 -0
  11. package/out/assets/{MilkdownEditor-BzbYt-xb.js → MilkdownEditor-DfhuT9eU.js} +1 -1
  12. package/out/assets/OpenSidebarButton-Bvwr7B6k.js +1 -0
  13. package/out/assets/arrow-right-BsHzC0ZL.js +1 -0
  14. package/out/assets/{arrow-up-down-B-677ip8.js → arrow-up-down-DvLSz6Ma.js} +1 -1
  15. package/out/assets/{userProfile-BZvkCuet.js → channelApi-C5eHetoJ.js} +1 -1
  16. package/out/assets/chatJoinInvite-7tuD-STm.js +1 -0
  17. package/out/assets/{chatNoteDraft-CYbvcEvC.js → chatNoteDraft-DTy6In5R.js} +1 -1
  18. package/out/assets/{circle-alert-dfN4T6Nq.js → circle-alert-DXVkx8p8.js} +1 -1
  19. package/out/assets/{copy-B7pPZ2MP.js → copy-OwXDsstV.js} +1 -1
  20. package/out/assets/{download-COQOcPqI.js → download-Cy2BlfKM.js} +1 -1
  21. package/out/assets/downloadValidation-Dd43i9_9.js +1 -0
  22. package/out/assets/{external-link-D6tHlYgS.js → external-link-D_AQSgxW.js} +1 -1
  23. package/out/assets/{file-text-DiyIGL1I.js → file-text-zvTER2mN.js} +1 -1
  24. package/out/assets/fingerprint-pattern-BT73ASEB.js +1 -0
  25. package/out/assets/{folder-BS_GbpPQ.js → folder-6pYYnS2-.js} +1 -1
  26. package/out/assets/folder-open-DOJ4bB58.js +1 -0
  27. package/out/assets/format-CAKfypjK.js +1 -0
  28. package/out/assets/gamepad-2-BYqNgCQr.js +1 -0
  29. package/out/assets/{hard-drive-Cqa7Rstj.js → hard-drive-DL1KJHmG.js} +1 -1
  30. package/out/assets/i18n-DKsg67mH.js +1 -0
  31. package/out/assets/index-FEwag7ej.css +1 -0
  32. package/out/assets/index-mjFWLwue.js +31 -0
  33. package/out/assets/index.lazy-8GSHLIGt.js +3 -0
  34. package/out/assets/index.lazy-B3jH9yBA.js +1 -0
  35. package/out/assets/index.lazy-B5JMiv3z.js +1 -0
  36. package/out/assets/index.lazy-BAr7-dhf.js +1 -0
  37. package/out/assets/index.lazy-BMFwyE_Z.js +3 -0
  38. package/out/assets/index.lazy-BbA_1LnY.js +1 -0
  39. package/out/assets/index.lazy-BmpOpQqg.js +1 -0
  40. package/out/assets/index.lazy-CB3C6kMj.js +1 -0
  41. package/out/assets/index.lazy-CVOAOO0_.js +1 -0
  42. package/out/assets/index.lazy-CyFSwJuo.js +1 -0
  43. package/out/assets/index.lazy-D3iEq2ZW.js +2 -0
  44. package/out/assets/index.lazy-F3iHN_Ao.js +1 -0
  45. package/out/assets/index.lazy-PnZR3z1V.js +5 -0
  46. package/out/assets/index.lazy-qDpRLqXu.js +1 -0
  47. package/out/assets/index.lazy-s6Ok77GI.js +1 -0
  48. package/out/assets/{loader-CwyHYZdX.js → loader-C4pED_QP.js} +1 -1
  49. package/out/assets/lock-yG7M7GkB.js +1 -0
  50. package/out/assets/{message-square-BgsNXPft.js → message-square-CfApq8Ou.js} +1 -1
  51. package/out/assets/mp-cEQteKUC.js +1 -0
  52. package/out/assets/{music-Dn9nm9Qb.js → music-Br2x8TKX.js} +1 -1
  53. package/out/assets/notebook-pen-Bcd3MrtD.js +1 -0
  54. package/out/assets/play-DSHtNcOe.js +1 -0
  55. package/out/assets/{refresh-cw-D4BsHvhi.js → refresh-cw-CfkUlTK9.js} +1 -1
  56. package/out/assets/{save-B02sbO9Y.js → save-CfYzXcxH.js} +1 -1
  57. package/out/assets/saveLocalFile-BLTn3Z18.js +1 -0
  58. package/out/assets/search-DLBOUfny.js +1 -0
  59. package/out/assets/{send-4dhnJGuS.js → send-Bie9hgHI.js} +1 -1
  60. package/out/assets/{shield-check-Boae7Nc6.js → shield-check-2-h_eEXp.js} +1 -1
  61. package/out/assets/{smartphone-JXH-zQXB.js → smartphone-CrsocYJo.js} +1 -1
  62. package/out/assets/trash-2-B9gno5q6.js +1 -0
  63. package/out/assets/{triangle-alert-B9sMiA9l.js → triangle-alert-BhNtjKm2.js} +1 -1
  64. package/out/assets/{upload-DVuXJt_o.js → upload-BmufnMFu.js} +1 -1
  65. package/out/assets/{useAppStore-DOnk608w.js → useAppStore-DNhyhD20.js} +1 -1
  66. package/out/assets/useChannelMessages-DKlO_2oW.js +4 -0
  67. package/out/assets/useGameRoom-DLN636mz.js +1 -0
  68. package/out/assets/{wallet-D9Iw4YWE.js → wallet-D6HZocla.js} +1 -1
  69. package/out/assets/{wifi-ljEI_ySA.js → wifi-CILQyKk0.js} +1 -1
  70. package/out/chat/index.html +0 -0
  71. package/out/chat/join/demo/index.html +0 -0
  72. package/out/chat/join/index.html +0 -0
  73. package/out/download/index.html +2 -2
  74. package/out/game/gandengyan/index.html +0 -0
  75. package/out/game/index.html +0 -0
  76. package/out/game/zhajinhua/index.html +0 -0
  77. package/out/index.html +2 -2
  78. package/out/note/index.html +0 -0
  79. package/out/ping/index.html +2 -2
  80. package/out/profile/index.html +0 -0
  81. package/out/web3/index.html +0 -0
  82. package/package.json +1 -1
  83. package/server/index.js +18 -1
  84. package/server/src/core/channelVoice.js +164 -0
  85. package/server/src/core/mostLink.js +30 -13
  86. package/server/src/http/nodeStatus.js +2 -2
  87. package/server/src/http/routes/fileRoutes.js +10 -8
  88. package/server/src/http/uploads.js +53 -15
  89. package/server/src/index.js +317 -133
  90. package/server/src/node/config.js +4 -4
  91. package/server/src/utils/api.js +9 -1
  92. package/server/src/utils/avatar.js +1 -10
  93. package/server/src/utils/downloadMessages.js +7 -7
  94. package/server/src/utils/errors.js +11 -1
  95. package/server/src/utils/userIdentity.js +0 -1
  96. package/out/assets/AccountMenu-ptQklqvD.js +0 -1
  97. package/out/assets/AppTop-Cy0ZOCxO.js +0 -1
  98. package/out/assets/CopyButton-C8PIDSJZ.js +0 -1
  99. package/out/assets/FilePreviewOverlay-faaTnmlN.js +0 -1
  100. package/out/assets/MarketingHeader-CxJ_OVOW.js +0 -1
  101. package/out/assets/MarketingLayout-CiN5KYcE.js +0 -1
  102. package/out/assets/OpenSidebarButton-CGhXxQ5Z.js +0 -1
  103. package/out/assets/arrow-right-CXzRt0Fp.js +0 -1
  104. package/out/assets/chatJoinInvite-B-ucvYdg.js +0 -1
  105. package/out/assets/check-iBCQl0yr.js +0 -1
  106. package/out/assets/chevron-right-D75NRNNs.js +0 -1
  107. package/out/assets/downloadValidation-UPgpJbKz.js +0 -1
  108. package/out/assets/format-CR8oUWq6.js +0 -1
  109. package/out/assets/index-D0Uk9-58.js +0 -31
  110. package/out/assets/index-FV6RZlVI.css +0 -1
  111. package/out/assets/index.lazy-B6XDJ8fg.js +0 -1
  112. package/out/assets/index.lazy-BFdWbm3R.js +0 -1
  113. package/out/assets/index.lazy-BzIt7fP4.js +0 -1
  114. package/out/assets/index.lazy-CLkb32nv.js +0 -1
  115. package/out/assets/index.lazy-CNmqW4-r.js +0 -1
  116. package/out/assets/index.lazy-Cnkj-gAd.js +0 -3
  117. package/out/assets/index.lazy-Cx1bp84f.js +0 -5
  118. package/out/assets/index.lazy-CxLzR674.js +0 -2
  119. package/out/assets/index.lazy-D4RjTGu-.js +0 -3
  120. package/out/assets/index.lazy-D9xLqvqW.js +0 -1
  121. package/out/assets/index.lazy-DNCF6MTJ.js +0 -1
  122. package/out/assets/index.lazy-MY-mUG5Z.js +0 -1
  123. package/out/assets/index.lazy-erUKA2rI.js +0 -1
  124. package/out/assets/index.lazy-qTjgLKhY.js +0 -1
  125. package/out/assets/key-round-JR8M3aHl.js +0 -1
  126. package/out/assets/lock-CuwwlXlx.js +0 -1
  127. package/out/assets/mp-Dv12gDTx.js +0 -1
  128. package/out/assets/notebook-pen-BzrCXQJE.js +0 -1
  129. package/out/assets/play-CvWKSMwY.js +0 -1
  130. package/out/assets/search-HW4oPOBP.js +0 -1
  131. package/out/assets/trash-2-BLwoM0ME.js +0 -1
  132. package/out/assets/useChannelMessages-CDvw-f_x.js +0 -4
  133. package/out/assets/useGameRoom-CS1w4IqP.js +0 -1
@@ -20,9 +20,9 @@ import { Duplex } from 'node:stream'
20
20
 
21
21
  import { calculateCid, parseMostLink, buildMostLink } from './core/cid.js'
22
22
  import { normalizeChannelAttachment } from './core/channelAttachment.js'
23
+ import { normalizeChannelVoiceEvent } from './core/channelVoice.js'
23
24
  import { getCidInfo } from './core/cidTopic.js'
24
25
  import {
25
- CHAT_FILE_ROOT,
26
26
  TRANSIENT_CHANNEL_TYPES,
27
27
  CHANNEL_DISCOVERY_TIMEOUT,
28
28
  CHANNEL_CANDIDATE_TTL,
@@ -95,7 +95,11 @@ import {
95
95
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
96
96
  const CHANNEL_PRESENCE_HEARTBEAT_MS = 15 * 1000
97
97
  const CHANNEL_PRESENCE_TIMEOUT_MS = 45 * 1000
98
- const CHANNEL_WELCOME_MESSAGE = '我加入了群聊'
98
+ const CHANNEL_MEMBER_JOINED_EVENT = 'channel.member.joined'
99
+
100
+ function isChannelHistoryEntry(entry) {
101
+ return entry?.type === 'message' || entry?.type === 'system'
102
+ }
99
103
 
100
104
  function createMemoryDuplexPair() {
101
105
  let left
@@ -203,6 +207,16 @@ export class MostBoxEngine extends EventEmitter {
203
207
  return this.#getChannelPresenceList(channel.channelKey)
204
208
  }
205
209
 
210
+ sendChannelVoiceEvent(channelKeyInput, input = {}, options = {}) {
211
+ this.#ensureInitialized()
212
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
213
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
214
+ const event = normalizeChannelVoiceEvent(channel.channelKey, input, options)
215
+ this.#broadcastChannelVoice(event)
216
+ this.emit('channel:voice', event)
217
+ return event
218
+ }
219
+
206
220
  joinChannelPresence(channelKeyInput, options = {}) {
207
221
  this.#ensureInitialized()
208
222
  this.#assertChannelMember(channelKeyInput, options.ownerAddress)
@@ -279,6 +293,42 @@ export class MostBoxEngine extends EventEmitter {
279
293
  return this.#pruneStaleChannelPresence()
280
294
  }
281
295
 
296
+ #isClosedSessionError(err) {
297
+ if (!err || typeof err !== 'object') return false
298
+ const code = String(err.code || '')
299
+ const message = String(err.message || '')
300
+ return (
301
+ code === 'SESSION_CLOSED' ||
302
+ message.includes('SESSION_CLOSED') ||
303
+ message.includes('closed session')
304
+ )
305
+ }
306
+
307
+ async #reopenChannelLocalWriter(channel) {
308
+ const channelKey = channel.channelKey
309
+ const localKeyHex =
310
+ this.#channelLocalCoreKey.get(channelKey) || channel.localWriterCoreKey
311
+ const coresMap = this.#channelCores.get(channelKey)
312
+ const staleCore = localKeyHex && coresMap ? coresMap.get(localKeyHex) : null
313
+
314
+ if (staleCore) {
315
+ coresMap.delete(localKeyHex)
316
+ await staleCore.close().catch(() => {})
317
+ }
318
+ this.#channelLocalCoreKey.delete(channelKey)
319
+
320
+ await this.#openChannelRuntime(channel)
321
+
322
+ const reopenedKeyHex = this.#channelLocalCoreKey.get(channelKey)
323
+ const reopenedCore = reopenedKeyHex
324
+ ? this.#channelCores.get(channelKey)?.get(reopenedKeyHex)
325
+ : null
326
+ if (!reopenedCore) {
327
+ throw new Error('频道未初始化或无可写 core')
328
+ }
329
+ return reopenedCore
330
+ }
331
+
282
332
  /**
283
333
  * 初始化引擎 — 必须在调用其他方法之前调用
284
334
  */
@@ -610,29 +660,38 @@ export class MostBoxEngine extends EventEmitter {
610
660
  const publishedBucket = this.#getPublishedBucket(ownerAddress, true)
611
661
  // 检查相同内容是否已存在
612
662
  const existingIndex = publishedBucket.findIndex(f => f.cid === cidString)
663
+ const repairingMissingContent = existingIndex !== -1
613
664
  if (existingIndex !== -1) {
614
665
  const existing = publishedBucket[existingIndex]
615
- await this.#joinCidTopicInternal(cidString, {
616
- server: true,
617
- client: false,
618
- })
619
- this.#upsertHolding({
620
- cid: cidString,
621
- fileName: existing.fileName,
622
- size: fileSize,
623
- driveName: name,
624
- source: 'published',
666
+ const existingContent = await this.#getLocalCidContent(cidString, {
667
+ ownerAddress,
668
+ public: true,
669
+ allowHoldingFallback: true,
625
670
  })
626
- return {
627
- cid: cidString,
628
- link: buildMostLink(cidString, existing.fileName),
629
- fileName: existing.fileName,
630
- alreadyExists: true,
671
+ if (existingContent) {
672
+ await this.#joinCidTopicInternal(cidString, {
673
+ server: true,
674
+ client: false,
675
+ })
676
+ this.#upsertHolding({
677
+ cid: cidString,
678
+ fileName: existing.fileName,
679
+ size: fileSize,
680
+ driveName: name,
681
+ source: 'published',
682
+ })
683
+ return {
684
+ cid: cidString,
685
+ link: buildMostLink(cidString, existing.fileName),
686
+ fileName: existing.fileName,
687
+ alreadyExists: true,
688
+ }
631
689
  }
632
690
  }
633
691
 
634
692
  this.#assertDisplayNameAvailable(safeFileName, {
635
693
  ownerAddress,
694
+ excludeCid: repairingMissingContent ? cidString : undefined,
636
695
  })
637
696
 
638
697
  // 获取或创建该 CID 对应的 drive
@@ -653,40 +712,17 @@ export class MostBoxEngine extends EventEmitter {
653
712
 
654
713
  // Hyperdrive 中用 CID 作为 key 存储(解耦目录结构)
655
714
  const driveKey = '/' + cidString
656
-
657
- const ws = drive.createWriteStream(driveKey)
658
-
659
- if (Buffer.isBuffer(content)) {
660
- let offset = 0
661
- const waitForDrain = () =>
662
- new Promise(resolve => ws.once('drain', resolve))
663
-
664
- try {
665
- while (offset < content.length) {
666
- const chunk = content.slice(offset, offset + FILE_WRITE_CHUNK_SIZE)
667
- const canContinue = ws.write(chunk)
668
- offset += chunk.length
669
- if (!canContinue && offset < content.length) {
670
- await waitForDrain()
671
- }
672
- }
673
- ws.end()
674
- await new Promise((resolve, reject) => {
675
- ws.on('finish', resolve)
676
- ws.on('error', reject)
677
- })
678
- } catch (err) {
679
- ws.destroy()
715
+ try {
716
+ await this.#writeDriveFile(drive, driveKey, content, cleanPath)
717
+ } catch (err) {
718
+ if (!this.#isClosedSessionError(err)) {
680
719
  throw err
681
720
  }
682
- } else {
683
- const rs = fs.createReadStream(cleanPath)
684
- await new Promise((resolve, reject) => {
685
- rs.pipe(ws)
686
- ws.on('finish', resolve)
687
- ws.on('error', reject)
688
- rs.on('error', reject)
721
+ drive = await this.#reopenDrive(name, {
722
+ server: true,
723
+ client: false,
689
724
  })
725
+ await this.#writeDriveFile(drive, driveKey, content, cleanPath)
690
726
  }
691
727
 
692
728
  // 存储 displayName(用户看到的文件夹路径),不存储 drivePath
@@ -701,7 +737,11 @@ export class MostBoxEngine extends EventEmitter {
701
737
  starred: false,
702
738
  syncUpdatedAt: now,
703
739
  }
704
- publishedBucket.push(fileRecord)
740
+ if (repairingMissingContent) {
741
+ publishedBucket[existingIndex] = fileRecord
742
+ } else {
743
+ publishedBucket.push(fileRecord)
744
+ }
705
745
  this.#savePublishedMetadata()
706
746
  this.#upsertHolding({
707
747
  cid: cidString,
@@ -728,7 +768,7 @@ export class MostBoxEngine extends EventEmitter {
728
768
  * @param {object} [options] - 下载选项
729
769
  * @param {number} [options.timeout] - 等待 P2P 内容的超时时间(毫秒)
730
770
  * @param {number} [options.streamReadTimeout] - 下载流无进度超时时间(毫秒)
731
- * @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
771
+ * @returns {Promise<{ taskId: string, fileName: string, savedPath?: string, localAvailable?: boolean, alreadyExists?: boolean }>}
732
772
  */
733
773
  async downloadFile(link, taskId = null, options = {}) {
734
774
  this.#ensureInitialized()
@@ -767,12 +807,38 @@ export class MostBoxEngine extends EventEmitter {
767
807
  })
768
808
  if (localContent) {
769
809
  const existingFile = localContent.fileRecord
810
+ const publishedBucket = this.#getPublishedBucket(ownerAddress, true)
811
+ const existingIndex = publishedBucket.findIndex(
812
+ f => f.cid === cidString
813
+ )
814
+ const alreadyInOwnerLibrary = existingIndex !== -1
770
815
  console.log(
771
816
  `[MostBox] CID content already exists locally: ${cidString}`
772
817
  )
773
818
  const existingHolding = this.#holdings.find(
774
819
  item => item.cid === cidString
775
820
  )
821
+ const localSize =
822
+ Number(existingHolding?.size) ||
823
+ (Number.isFinite(localContent.size) ? localContent.size : 0)
824
+ if (!alreadyInOwnerLibrary) {
825
+ this.#assertDisplayNameAvailable(linkFileName, {
826
+ ownerAddress,
827
+ excludeCid: cidString,
828
+ })
829
+ const syncUpdatedAt = Date.now()
830
+ publishedBucket.push({
831
+ fileName: linkFileName,
832
+ cid: cidString,
833
+ driveName: existingFile?.driveName || name,
834
+ size: localSize,
835
+ source: 'downloaded',
836
+ publishedAt: new Date(syncUpdatedAt).toISOString(),
837
+ starred: false,
838
+ syncUpdatedAt,
839
+ })
840
+ this.#savePublishedMetadata()
841
+ }
776
842
  await this.#joinCidTopicInternal(cidString, {
777
843
  server: true,
778
844
  client: false,
@@ -780,17 +846,21 @@ export class MostBoxEngine extends EventEmitter {
780
846
  this.#upsertHolding({
781
847
  cid: cidString,
782
848
  fileName:
783
- existingHolding?.fileName || existingFile?.fileName || linkFileName,
784
- size:
785
- existingHolding?.size ??
786
- (Number.isFinite(localContent.size) ? localContent.size : 0),
849
+ existingHolding?.fileName ||
850
+ (alreadyInOwnerLibrary ? existingFile?.fileName : linkFileName) ||
851
+ linkFileName,
852
+ size: localSize,
787
853
  driveName: existingFile?.driveName || name,
788
- source: existingHolding?.source || 'published',
854
+ source:
855
+ existingHolding?.source ||
856
+ (alreadyInOwnerLibrary ? existingFile?.source : 'downloaded') ||
857
+ 'downloaded',
789
858
  })
790
859
  return {
791
860
  taskId,
792
861
  fileName: linkFileName,
793
- alreadyExists: true,
862
+ localAvailable: true,
863
+ alreadyExists: alreadyInOwnerLibrary,
794
864
  }
795
865
  }
796
866
 
@@ -1120,7 +1190,10 @@ export class MostBoxEngine extends EventEmitter {
1120
1190
  cid: parsed.cid,
1121
1191
  fileName: sanitizeFilename(parsed.fileName),
1122
1192
  size: localContent.size,
1123
- alreadyExists: true,
1193
+ localAvailable: true,
1194
+ alreadyExists: this.#getPublishedBucket(ownerAddress).some(
1195
+ f => f.cid === parsed.cid
1196
+ ),
1124
1197
  }
1125
1198
  }
1126
1199
 
@@ -1129,7 +1202,7 @@ export class MostBoxEngine extends EventEmitter {
1129
1202
  * @param {string} link - most:// 链接
1130
1203
  * @param {object} [options] - 检测选项
1131
1204
  * @param {number} [options.timeout] - 等待 P2P 内容的超时时间(毫秒)
1132
- * @returns {Promise<{ available: boolean, cid: string, fileName: string, size: number|null, alreadyExists?: boolean }>}
1205
+ * @returns {Promise<{ available: boolean, cid: string, fileName: string, size: number|null, localAvailable?: boolean, alreadyExists?: boolean }>}
1133
1206
  */
1134
1207
  async checkDownloadAvailability(link, options = {}) {
1135
1208
  this.#ensureInitialized()
@@ -1158,7 +1231,10 @@ export class MostBoxEngine extends EventEmitter {
1158
1231
  cid: cidString,
1159
1232
  fileName: sanitizeFilename(parsed.fileName),
1160
1233
  size: localContent.size,
1161
- alreadyExists: true,
1234
+ localAvailable: true,
1235
+ alreadyExists: this.#getPublishedBucket(ownerAddress).some(
1236
+ f => f.cid === cidString
1237
+ ),
1162
1238
  }
1163
1239
  }
1164
1240
 
@@ -2164,7 +2240,10 @@ export class MostBoxEngine extends EventEmitter {
2164
2240
  async #hasLocalDriveContent(drive, key) {
2165
2241
  try {
2166
2242
  return await drive.has(key)
2167
- } catch {
2243
+ } catch (err) {
2244
+ if (this.#isClosedSessionError(err)) {
2245
+ throw err
2246
+ }
2168
2247
  return false
2169
2248
  }
2170
2249
  }
@@ -2184,42 +2263,53 @@ export class MostBoxEngine extends EventEmitter {
2184
2263
  }
2185
2264
  const holding = this.#holdings.find(item => item.cid === cid)
2186
2265
  const { driveName } = this.#getCidInfo(cid)
2187
- const drive = await this.#getOrCreateDrive(
2266
+ let drive = await this.#getOrCreateDrive(
2188
2267
  fileRecord?.driveName || holding?.driveName || driveName,
2189
2268
  { server: true, client: false }
2190
2269
  )
2191
2270
  const driveKey = '/' + cid
2192
2271
 
2193
- try {
2194
- const entry = await drive.entry(driveKey, { wait: false })
2195
- if (!entry?.value?.blob) {
2196
- return null
2197
- }
2198
- const hasContent = await this.#hasLocalDriveContent(drive, driveKey)
2199
- if (!hasContent) {
2200
- return null
2201
- }
2272
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2273
+ try {
2274
+ const entry = await drive.entry(driveKey, { wait: false })
2275
+ if (!entry?.value?.blob) {
2276
+ return null
2277
+ }
2278
+ const hasContent = await this.#hasLocalDriveContent(drive, driveKey)
2279
+ if (!hasContent) {
2280
+ return null
2281
+ }
2202
2282
 
2203
- const size =
2204
- Number(entry.value.blob.byteLength) ||
2205
- Number(fileRecord?.size) ||
2206
- Number(holding?.size) ||
2207
- 0
2208
- return {
2209
- drive,
2210
- entry,
2211
- size,
2212
- fileRecord: fileRecord || {
2213
- cid,
2214
- fileName: holding?.fileName || cid,
2215
- driveName: holding?.driveName || driveName,
2283
+ const size =
2284
+ Number(entry.value.blob.byteLength) ||
2285
+ Number(fileRecord?.size) ||
2286
+ Number(holding?.size) ||
2287
+ 0
2288
+ return {
2289
+ drive,
2290
+ entry,
2216
2291
  size,
2217
- ownerAddress,
2218
- },
2292
+ fileRecord: fileRecord || {
2293
+ cid,
2294
+ fileName: holding?.fileName || cid,
2295
+ driveName: holding?.driveName || driveName,
2296
+ size,
2297
+ ownerAddress,
2298
+ },
2299
+ }
2300
+ } catch (err) {
2301
+ if (attempt === 0 && this.#isClosedSessionError(err)) {
2302
+ drive = await this.#reopenDrive(
2303
+ fileRecord?.driveName || holding?.driveName || driveName,
2304
+ { server: true, client: false }
2305
+ )
2306
+ continue
2307
+ }
2308
+ return null
2219
2309
  }
2220
- } catch {
2221
- return null
2222
2310
  }
2311
+
2312
+ return null
2223
2313
  }
2224
2314
 
2225
2315
  // --- 频道管理 ---
@@ -2567,7 +2657,7 @@ export class MostBoxEngine extends EventEmitter {
2567
2657
  for (let i = 0; i < core.length; i++) {
2568
2658
  try {
2569
2659
  const entry = await core.get(i)
2570
- if (entry && entry.type === 'message') {
2660
+ if (isChannelHistoryEntry(entry)) {
2571
2661
  allMessages.push({
2572
2662
  ...entry,
2573
2663
  _coreKey: coreKeyHex,
@@ -2582,7 +2672,7 @@ export class MostBoxEngine extends EventEmitter {
2582
2672
 
2583
2673
  const seen = new Set()
2584
2674
  const unique = allMessages.filter(m => {
2585
- const key = `${m._coreKey}:${m.author}:${m.timestamp}:${m.content}`
2675
+ const key = `${m._coreKey}:${m.type}:${m.event || ''}:${m.author}:${m.timestamp}:${m.content}`
2586
2676
  if (seen.has(key)) return false
2587
2677
  seen.add(key)
2588
2678
  return true
@@ -2649,7 +2739,7 @@ export class MostBoxEngine extends EventEmitter {
2649
2739
 
2650
2740
  const normalizedAvatar = normalizeChannelAvatar(options.avatar)
2651
2741
  const message = {
2652
- type: 'message',
2742
+ type: options.type === 'system' ? 'system' : 'message',
2653
2743
  author,
2654
2744
  authorName: normalizeChannelDisplayName(
2655
2745
  authorName,
@@ -2666,8 +2756,20 @@ export class MostBoxEngine extends EventEmitter {
2666
2756
  if (attachment) {
2667
2757
  message.attachment = attachment
2668
2758
  }
2759
+ if (message.type === 'system') {
2760
+ const event = String(options.event || '').trim()
2761
+ if (event) message.event = event
2762
+ }
2669
2763
 
2670
- await core.append(message)
2764
+ try {
2765
+ await core.append(message)
2766
+ } catch (err) {
2767
+ if (!this.#isClosedSessionError(err)) {
2768
+ throw err
2769
+ }
2770
+ const reopenedCore = await this.#reopenChannelLocalWriter(channel)
2771
+ await reopenedCore.append(message)
2772
+ }
2671
2773
  if (channel) {
2672
2774
  channel.lastMessageAt = new Date(message.timestamp).toISOString()
2673
2775
  this.#saveChannelsMetadata()
@@ -3158,11 +3260,13 @@ export class MostBoxEngine extends EventEmitter {
3158
3260
 
3159
3261
  await this.sendMessage(
3160
3262
  channel.channelKey,
3161
- CHANNEL_WELCOME_MESSAGE,
3263
+ CHANNEL_MEMBER_JOINED_EVENT,
3162
3264
  ownerAddress,
3163
3265
  normalizeChannelDisplayName(options.displayName, ownerAddress),
3164
3266
  {
3165
3267
  ownerAddress,
3268
+ type: 'system',
3269
+ event: CHANNEL_MEMBER_JOINED_EVENT,
3166
3270
  ...(Object.prototype.hasOwnProperty.call(options, 'avatar')
3167
3271
  ? { avatar: options.avatar }
3168
3272
  : {}),
@@ -3517,7 +3621,7 @@ export class MostBoxEngine extends EventEmitter {
3517
3621
  for (let i = 0; i < core.length; i++) {
3518
3622
  try {
3519
3623
  const entry = await core.get(i)
3520
- if (entry?.type === 'message') {
3624
+ if (isChannelHistoryEntry(entry)) {
3521
3625
  maxTimestamp = Math.max(
3522
3626
  maxTimestamp,
3523
3627
  Number(entry.timestamp) || 0
@@ -3555,42 +3659,7 @@ export class MostBoxEngine extends EventEmitter {
3555
3659
  baseMessage = { ...baseMessage, avatar }
3556
3660
  }
3557
3661
  }
3558
- const attachment = baseMessage?.attachment
3559
- if (!attachment?.cid || !attachment.fileName) {
3560
- return baseMessage
3561
- }
3562
-
3563
- const oldFileName = sanitizeFilename(String(attachment.fileName))
3564
- const channelPathName = channel?.channelId || channelKey
3565
- const channelPrefix = `${CHAT_FILE_ROOT}/${channelPathName}/`
3566
- const fileName = oldFileName.startsWith(channelPrefix)
3567
- ? oldFileName
3568
- : `${channelPrefix}${getPathBaseName(oldFileName)}`
3569
- const link = buildMostLink(attachment.cid, fileName)
3570
- const content =
3571
- typeof baseMessage.content === 'string' &&
3572
- (baseMessage.content === attachment.link ||
3573
- parseMostLink(baseMessage.content).cid === attachment.cid)
3574
- ? link
3575
- : baseMessage.content
3576
-
3577
- if (
3578
- fileName === attachment.fileName &&
3579
- link === attachment.link &&
3580
- content === baseMessage.content
3581
- ) {
3582
- return baseMessage
3583
- }
3584
-
3585
- return {
3586
- ...baseMessage,
3587
- content,
3588
- attachment: {
3589
- ...attachment,
3590
- fileName,
3591
- link,
3592
- },
3593
- }
3662
+ return baseMessage
3594
3663
  }
3595
3664
 
3596
3665
  #getCidInfo(cid) {
@@ -4064,6 +4133,19 @@ export class MostBoxEngine extends EventEmitter {
4064
4133
  if (!this.#holdings.some(current => current.cid === holding.cid)) {
4065
4134
  return
4066
4135
  }
4136
+ const localContent = await this.#getLocalCidContent(holding.cid, {
4137
+ public: true,
4138
+ allowHoldingFallback: true,
4139
+ })
4140
+ if (!localContent) {
4141
+ this.#setSeedState(holding.cid, {
4142
+ status: 'error',
4143
+ topic: holding.topic,
4144
+ driveName: holding.driveName,
4145
+ error: 'Local CID content missing',
4146
+ })
4147
+ return
4148
+ }
4067
4149
  await this.#joinCidTopicInternal(holding.cid, {
4068
4150
  server: true,
4069
4151
  client: false,
@@ -4291,6 +4373,60 @@ export class MostBoxEngine extends EventEmitter {
4291
4373
  return drive
4292
4374
  }
4293
4375
 
4376
+ async #reopenDrive(name, options = { server: true, client: false }) {
4377
+ const staleDrive = this.#drives.get(name)
4378
+ if (staleDrive) {
4379
+ this.#drives.delete(name)
4380
+ await staleDrive.close().catch(() => {})
4381
+ }
4382
+ this.#drivePromises.delete(name)
4383
+ return this.#getOrCreateDrive(name, options)
4384
+ }
4385
+
4386
+ async #writeDriveFile(drive, driveKey, content, cleanPath) {
4387
+ const ws = drive.createWriteStream(driveKey)
4388
+
4389
+ if (Buffer.isBuffer(content)) {
4390
+ let offset = 0
4391
+ const waitForDrain = () =>
4392
+ new Promise(resolve => ws.once('drain', resolve))
4393
+
4394
+ try {
4395
+ while (offset < content.length) {
4396
+ const chunk = content.slice(offset, offset + FILE_WRITE_CHUNK_SIZE)
4397
+ const canContinue = ws.write(chunk)
4398
+ offset += chunk.length
4399
+ if (!canContinue && offset < content.length) {
4400
+ await waitForDrain()
4401
+ }
4402
+ }
4403
+ ws.end()
4404
+ await new Promise((resolve, reject) => {
4405
+ ws.on('finish', resolve)
4406
+ ws.on('error', reject)
4407
+ })
4408
+ } catch (err) {
4409
+ ws.destroy()
4410
+ throw err
4411
+ }
4412
+ return
4413
+ }
4414
+
4415
+ const rs = fs.createReadStream(cleanPath)
4416
+ try {
4417
+ await new Promise((resolve, reject) => {
4418
+ rs.pipe(ws)
4419
+ ws.on('finish', resolve)
4420
+ ws.on('error', reject)
4421
+ rs.on('error', reject)
4422
+ })
4423
+ } catch (err) {
4424
+ ws.destroy()
4425
+ rs.destroy()
4426
+ throw err
4427
+ }
4428
+ }
4429
+
4294
4430
  #getOwnerKey(ownerAddress) {
4295
4431
  return getOwnerBucketKey(ownerAddress)
4296
4432
  }
@@ -4549,7 +4685,7 @@ export class MostBoxEngine extends EventEmitter {
4549
4685
  err.message
4550
4686
  )
4551
4687
  }
4552
- return []
4688
+ return {}
4553
4689
  }
4554
4690
 
4555
4691
  #savePublishedMetadata() {
@@ -4603,7 +4739,7 @@ export class MostBoxEngine extends EventEmitter {
4603
4739
  err.message
4604
4740
  )
4605
4741
  }
4606
- return []
4742
+ return {}
4607
4743
  }
4608
4744
 
4609
4745
  #saveTrashMetadata() {
@@ -4760,7 +4896,7 @@ export class MostBoxEngine extends EventEmitter {
4760
4896
  for (let i = lastCoreLength; i < core.length; i++) {
4761
4897
  try {
4762
4898
  const entry = await core.get(i)
4763
- if (entry && entry.type === 'message') {
4899
+ if (isChannelHistoryEntry(entry)) {
4764
4900
  const channel = this.#channels.find(
4765
4901
  c => c.channelKey === channelKey
4766
4902
  )
@@ -4913,6 +5049,32 @@ export class MostBoxEngine extends EventEmitter {
4913
5049
  }
4914
5050
  }
4915
5051
 
5052
+ #sendChannelVoice(stream, event) {
5053
+ if (!stream || stream.destroyed || stream.writableEnded || !event) {
5054
+ this.#channelStreams.delete(stream)
5055
+ return false
5056
+ }
5057
+ try {
5058
+ stream.write(
5059
+ `${JSON.stringify({
5060
+ type: 'channel-voice',
5061
+ peerId: this.getNodeId(),
5062
+ ...event,
5063
+ })}\n`
5064
+ )
5065
+ return true
5066
+ } catch {
5067
+ this.#channelStreams.delete(stream)
5068
+ return false
5069
+ }
5070
+ }
5071
+
5072
+ #broadcastChannelVoice(event) {
5073
+ for (const stream of [...this.#channelStreams]) {
5074
+ this.#sendChannelVoice(stream, event)
5075
+ }
5076
+ }
5077
+
4916
5078
  async #processChannelHelloMessage(msg) {
4917
5079
  if (msg.type !== 'channel-hello') return null
4918
5080
 
@@ -5033,6 +5195,26 @@ export class MostBoxEngine extends EventEmitter {
5033
5195
  return peerId
5034
5196
  }
5035
5197
 
5198
+ #processChannelVoiceMessage(msg) {
5199
+ if (msg.type !== 'channel-voice') return null
5200
+ const peerId = String(msg.peerId || '').trim()
5201
+ if (!peerId || peerId === this.getNodeId()) return null
5202
+ const channelId = normalizeChannelId(msg.channelId || msg.channelKey)
5203
+ const channelKey = buildChannelKey(channelId)
5204
+ const localChannel = this.#channels.find(
5205
+ channel => channel.channelKey === channelKey
5206
+ )
5207
+ if (!localChannel) return peerId
5208
+
5209
+ try {
5210
+ const event = normalizeChannelVoiceEvent(channelKey, msg, {
5211
+ timestamp: msg.timestamp,
5212
+ })
5213
+ this.emit('channel:voice', event)
5214
+ } catch {}
5215
+ return peerId
5216
+ }
5217
+
5036
5218
  async #handleChannelConnection(conn) {
5037
5219
  const stream = conn
5038
5220
  let connectedPeerId = null
@@ -5055,7 +5237,9 @@ export class MostBoxEngine extends EventEmitter {
5055
5237
  const peerId =
5056
5238
  message.type === 'channel-presence'
5057
5239
  ? this.#processChannelPresenceMessage(message)
5058
- : await this.#processChannelHelloMessage(message)
5240
+ : message.type === 'channel-voice'
5241
+ ? this.#processChannelVoiceMessage(message)
5242
+ : await this.#processChannelHelloMessage(message)
5059
5243
  if (peerId) connectedPeerId = peerId
5060
5244
  } catch (err) {
5061
5245
  console.warn(`[MostBox] Failed to process channel data:`, err.message)