@decentchat/decentclaw 0.1.0

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.
@@ -0,0 +1,701 @@
1
+ /**
2
+ * SyncProtocol - P2P workspace synchronization with Negentropy-based catch-up.
3
+ */
4
+
5
+ import {
6
+ Negentropy,
7
+ type Channel,
8
+ type PEXServer,
9
+ type PlaintextMessage,
10
+ type SyncMessage,
11
+ type Workspace,
12
+ type WorkspaceMember,
13
+ MessageStore,
14
+ WorkspaceManager,
15
+ } from 'decent-protocol';
16
+ import type { ServerDiscovery } from 'decent-protocol';
17
+ import type { NegentropyQuery, NegentropyResponse } from 'decent-protocol';
18
+
19
+ export type SendFn = (peerId: string, data: any) => boolean;
20
+ export type OnEvent = (event: SyncEvent) => void;
21
+
22
+ type SyncedHistoryMessage = Omit<PlaintextMessage, 'content'> & { content?: string };
23
+
24
+ type CapabilityMessage = {
25
+ type: 'sync-capabilities';
26
+ workspaceId: string;
27
+ response?: boolean;
28
+ features: {
29
+ negentropy: boolean;
30
+ };
31
+ };
32
+
33
+ type NegentropyQueryMessage = {
34
+ type: 'negentropy-query';
35
+ workspaceId: string;
36
+ channelId: string;
37
+ query: NegentropyQuery;
38
+ };
39
+
40
+ type NegentropyResponseMessage = {
41
+ type: 'negentropy-response';
42
+ workspaceId: string;
43
+ channelId: string;
44
+ response: NegentropyResponse;
45
+ };
46
+
47
+ type NegentropyRequestMessagesMessage = {
48
+ type: 'negentropy-request-messages';
49
+ workspaceId: string;
50
+ channelId: string;
51
+ ids: string[];
52
+ };
53
+
54
+ type NegentropyMessageBatchMessage = {
55
+ type: 'negentropy-message-batch';
56
+ workspaceId: string;
57
+ channelId: string;
58
+ messages: SyncedHistoryMessage[];
59
+ done: boolean;
60
+ };
61
+
62
+ type ExtendedSyncMessage =
63
+ | SyncMessage
64
+ | CapabilityMessage
65
+ | NegentropyQueryMessage
66
+ | NegentropyResponseMessage
67
+ | NegentropyRequestMessagesMessage
68
+ | NegentropyMessageBatchMessage;
69
+
70
+ export type SyncEvent =
71
+ | { type: 'member-joined'; workspaceId: string; member: WorkspaceMember }
72
+ | { type: 'member-left'; workspaceId: string; peerId: string }
73
+ | { type: 'channel-created'; workspaceId: string; channel: Channel }
74
+ | { type: 'channel-removed'; workspaceId: string; channelId: string }
75
+ | { type: 'workspace-deleted'; workspaceId: string; deletedBy: string }
76
+ | { type: 'workspace-joined'; workspace: Workspace; messageHistory: Record<string, SyncedHistoryMessage[]> }
77
+ | { type: 'join-rejected'; reason: string }
78
+ | { type: 'message-received'; channelId: string; message: PlaintextMessage }
79
+ | { type: 'sync-complete'; workspaceId: string };
80
+
81
+ interface SyncProtocolOptions {
82
+ enableNegentropy?: boolean;
83
+ capabilityWaitMs?: number;
84
+ negentropyBatchSize?: number;
85
+ }
86
+
87
+ const DEFAULT_CAPABILITY_WAIT_MS = 800;
88
+ const DEFAULT_NEGENTROPY_BATCH_SIZE = 50;
89
+
90
+ export class SyncProtocol {
91
+ private workspaceManager: WorkspaceManager;
92
+ private messageStore: MessageStore;
93
+ private sendFn: SendFn;
94
+ private onEvent: OnEvent;
95
+ private myPeerId: string;
96
+ private serverDiscovery?: ServerDiscovery;
97
+
98
+ private readonly enableNegentropy: boolean;
99
+ private readonly capabilityWaitMs: number;
100
+ private readonly negentropyBatchSize: number;
101
+
102
+ private peerCapabilities = new Map<string, { negentropy: boolean; updatedAt: number }>();
103
+ private pendingCapabilityFallback = new Map<string, ReturnType<typeof setTimeout>>();
104
+ private pendingNegentropyResponse = new Map<string, (response: NegentropyResponse) => void>();
105
+ private pendingNegentropyBatches = new Map<string, {
106
+ resolve: (messages: SyncedHistoryMessage[]) => void;
107
+ reject: (error: Error) => void;
108
+ timer: ReturnType<typeof setTimeout>;
109
+ messages: SyncedHistoryMessage[];
110
+ }>();
111
+
112
+ constructor(
113
+ workspaceManager: WorkspaceManager,
114
+ messageStore: MessageStore,
115
+ sendFn: SendFn,
116
+ onEvent: OnEvent,
117
+ myPeerId: string,
118
+ serverDiscovery?: ServerDiscovery,
119
+ options: SyncProtocolOptions = {},
120
+ ) {
121
+ this.workspaceManager = workspaceManager;
122
+ this.messageStore = messageStore;
123
+ this.sendFn = sendFn;
124
+ this.onEvent = onEvent;
125
+ this.myPeerId = myPeerId;
126
+ this.serverDiscovery = serverDiscovery;
127
+
128
+ this.enableNegentropy = options.enableNegentropy ?? true;
129
+ this.capabilityWaitMs = options.capabilityWaitMs ?? DEFAULT_CAPABILITY_WAIT_MS;
130
+ this.negentropyBatchSize = options.negentropyBatchSize ?? DEFAULT_NEGENTROPY_BATCH_SIZE;
131
+ }
132
+
133
+ async handleMessage(fromPeerId: string, msg: ExtendedSyncMessage): Promise<void> {
134
+ switch (msg.type) {
135
+ case 'sync-capabilities':
136
+ this.handleCapabilities(fromPeerId, msg);
137
+ break;
138
+ case 'negentropy-query':
139
+ await this.handleNegentropyQuery(fromPeerId, msg);
140
+ break;
141
+ case 'negentropy-response':
142
+ this.handleNegentropyResponse(fromPeerId, msg);
143
+ break;
144
+ case 'negentropy-request-messages':
145
+ this.handleNegentropyRequestMessages(fromPeerId, msg);
146
+ break;
147
+ case 'negentropy-message-batch':
148
+ this.handleNegentropyMessageBatch(fromPeerId, msg);
149
+ break;
150
+ case 'join-request':
151
+ this.handleJoinRequest(fromPeerId, msg);
152
+ break;
153
+ case 'join-accepted':
154
+ await this.handleJoinAccepted(msg);
155
+ break;
156
+ case 'join-rejected':
157
+ this.onEvent({ type: 'join-rejected', reason: msg.reason });
158
+ break;
159
+ case 'member-joined':
160
+ this.handleMemberJoined(msg);
161
+ break;
162
+ case 'member-left':
163
+ this.handleMemberLeft(msg);
164
+ break;
165
+ case 'channel-created':
166
+ this.handleChannelCreated(msg);
167
+ break;
168
+ case 'channel-removed':
169
+ this.handleChannelRemoved(msg);
170
+ break;
171
+ case 'workspace-deleted':
172
+ this.handleWorkspaceDeleted(msg);
173
+ break;
174
+ case 'channel-message':
175
+ await this.handleChannelMessage(fromPeerId, msg);
176
+ break;
177
+ case 'sync-request':
178
+ this.handleSyncRequest(fromPeerId, msg);
179
+ break;
180
+ case 'sync-response':
181
+ await this.handleSyncResponse(msg);
182
+ break;
183
+ case 'peer-exchange':
184
+ this.handlePeerExchange(msg);
185
+ break;
186
+ default:
187
+ break;
188
+ }
189
+ }
190
+
191
+ requestJoin(targetPeerId: string, inviteCode: string, myMember: WorkspaceMember, inviteId?: string): void {
192
+ const msg: SyncMessage = {
193
+ type: 'join-request',
194
+ inviteCode,
195
+ member: myMember,
196
+ inviteId,
197
+ pexServers: this.serverDiscovery?.getHandshakeServers(),
198
+ };
199
+ this.sendFn(targetPeerId, { type: 'workspace-sync', sync: msg });
200
+ }
201
+
202
+ broadcastMemberJoined(workspaceId: string, member: WorkspaceMember, connectedPeerIds: string[]): void {
203
+ const msg: SyncMessage = { type: 'member-joined', member };
204
+ for (const peerId of connectedPeerIds) {
205
+ if (peerId !== member.peerId && peerId !== this.myPeerId) {
206
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg, workspaceId });
207
+ }
208
+ }
209
+ }
210
+
211
+ broadcastChannelCreated(workspaceId: string, channel: Channel, connectedPeerIds: string[]): void {
212
+ const msg: SyncMessage = { type: 'channel-created', channel };
213
+ for (const peerId of connectedPeerIds) {
214
+ if (peerId !== this.myPeerId) {
215
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg, workspaceId });
216
+ }
217
+ }
218
+ }
219
+
220
+ broadcastWorkspaceDeleted(workspaceId: string, deletedBy: string, connectedPeerIds: string[]): void {
221
+ const msg: SyncMessage = { type: 'workspace-deleted', workspaceId, deletedBy } as any;
222
+ for (const peerId of connectedPeerIds) {
223
+ if (peerId !== this.myPeerId) {
224
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg, workspaceId });
225
+ }
226
+ }
227
+ }
228
+
229
+ broadcastMessage(channelId: string, message: PlaintextMessage, connectedPeerIds: string[]): void {
230
+ const msg: SyncMessage = { type: 'channel-message', channelId, message: message as any };
231
+ for (const peerId of connectedPeerIds) {
232
+ if (peerId !== this.myPeerId) {
233
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg });
234
+ }
235
+ }
236
+ }
237
+
238
+ requestSync(targetPeerId: string, workspaceId: string): void {
239
+ if (!this.enableNegentropy) {
240
+ this.sendLegacySyncRequest(targetPeerId, workspaceId);
241
+ return;
242
+ }
243
+
244
+ const known = this.peerCapabilities.get(targetPeerId);
245
+ if (known?.negentropy) {
246
+ this.startNegentropySyncSafely(targetPeerId, workspaceId);
247
+ return;
248
+ }
249
+
250
+ this.sendCapabilities(targetPeerId, workspaceId);
251
+
252
+ const key = `${targetPeerId}:${workspaceId}`;
253
+ const existing = this.pendingCapabilityFallback.get(key);
254
+ if (existing) clearTimeout(existing);
255
+
256
+ const timer = setTimeout(() => {
257
+ this.pendingCapabilityFallback.delete(key);
258
+ const current = this.peerCapabilities.get(targetPeerId);
259
+ if (current?.negentropy) {
260
+ this.startNegentropySyncSafely(targetPeerId, workspaceId);
261
+ } else {
262
+ this.sendLegacySyncRequest(targetPeerId, workspaceId);
263
+ }
264
+ }, this.capabilityWaitMs);
265
+
266
+ this.pendingCapabilityFallback.set(key, timer);
267
+ }
268
+
269
+ broadcastPeerExchange(connectedPeerIds: string[]): void {
270
+ if (!this.serverDiscovery) return;
271
+
272
+ const msg: SyncMessage = {
273
+ type: 'peer-exchange',
274
+ servers: this.serverDiscovery.getHandshakeServers(),
275
+ };
276
+
277
+ for (const peerId of connectedPeerIds) {
278
+ if (peerId !== this.myPeerId) {
279
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg });
280
+ }
281
+ }
282
+ }
283
+
284
+ getServerDiscovery(): ServerDiscovery | undefined {
285
+ return this.serverDiscovery;
286
+ }
287
+
288
+ private sendCapabilities(peerId: string, workspaceId: string, response = false): void {
289
+ const msg: CapabilityMessage = {
290
+ type: 'sync-capabilities',
291
+ workspaceId,
292
+ response,
293
+ features: { negentropy: this.enableNegentropy },
294
+ };
295
+ this.sendFn(peerId, { type: 'workspace-sync', sync: msg });
296
+ }
297
+
298
+ private handleCapabilities(fromPeerId: string, msg: CapabilityMessage): void {
299
+ this.peerCapabilities.set(fromPeerId, {
300
+ negentropy: Boolean(msg.features?.negentropy),
301
+ updatedAt: Date.now(),
302
+ });
303
+
304
+ if (!msg.response) {
305
+ this.sendCapabilities(fromPeerId, msg.workspaceId, true);
306
+ }
307
+
308
+ const key = `${fromPeerId}:${msg.workspaceId}`;
309
+ const pending = this.pendingCapabilityFallback.get(key);
310
+ if (!pending) return;
311
+
312
+ clearTimeout(pending);
313
+ this.pendingCapabilityFallback.delete(key);
314
+
315
+ if (msg.features?.negentropy && this.enableNegentropy) {
316
+ this.startNegentropySyncSafely(fromPeerId, msg.workspaceId);
317
+ } else {
318
+ this.sendLegacySyncRequest(fromPeerId, msg.workspaceId);
319
+ }
320
+ }
321
+
322
+ private startNegentropySyncSafely(targetPeerId: string, workspaceId: string): void {
323
+ void this.startNegentropySync(targetPeerId, workspaceId).catch(() => {
324
+ this.sendLegacySyncRequest(targetPeerId, workspaceId);
325
+ });
326
+ }
327
+
328
+ private async startNegentropySync(targetPeerId: string, workspaceId: string): Promise<void> {
329
+ const workspace = this.workspaceManager.getWorkspace(workspaceId);
330
+ if (!workspace) return;
331
+
332
+ for (const channel of workspace.channels) {
333
+ await this.syncChannelWithNegentropy(targetPeerId, workspaceId, channel.id);
334
+ }
335
+
336
+ this.onEvent({ type: 'sync-complete', workspaceId });
337
+ }
338
+
339
+ private async syncChannelWithNegentropy(targetPeerId: string, workspaceId: string, channelId: string): Promise<void> {
340
+ const localMessages = this.messageStore.getMessages(channelId);
341
+ const negentropy = new Negentropy();
342
+ await negentropy.build(localMessages.map((message) => ({ id: message.id, timestamp: message.timestamp })));
343
+
344
+ const needResult = await negentropy.reconcile(async (query) => {
345
+ return this.sendNegentropyQuery(targetPeerId, workspaceId, channelId, query);
346
+ });
347
+
348
+ if (needResult.need.length === 0) return;
349
+
350
+ const fetched = await this.requestMissingMessages(targetPeerId, workspaceId, channelId, needResult.need);
351
+ if (fetched.length === 0) return;
352
+
353
+ await this.mergeSyncedMessages(channelId, fetched);
354
+ }
355
+
356
+ private async sendNegentropyQuery(
357
+ targetPeerId: string,
358
+ workspaceId: string,
359
+ channelId: string,
360
+ query: NegentropyQuery,
361
+ ): Promise<NegentropyResponse> {
362
+ const key = `${targetPeerId}:${workspaceId}:${channelId}`;
363
+
364
+ return await new Promise<NegentropyResponse>((resolve, reject) => {
365
+ const timeout = setTimeout(() => {
366
+ this.pendingNegentropyResponse.delete(key);
367
+ reject(new Error(`Negentropy response timeout from ${targetPeerId}`));
368
+ }, 5000);
369
+
370
+ this.pendingNegentropyResponse.set(key, (response) => {
371
+ clearTimeout(timeout);
372
+ resolve(response);
373
+ });
374
+
375
+ const msg: NegentropyQueryMessage = {
376
+ type: 'negentropy-query',
377
+ workspaceId,
378
+ channelId,
379
+ query,
380
+ };
381
+
382
+ const sent = this.sendFn(targetPeerId, { type: 'workspace-sync', sync: msg });
383
+ if (!sent) {
384
+ clearTimeout(timeout);
385
+ this.pendingNegentropyResponse.delete(key);
386
+ reject(new Error(`Failed to send negentropy query to ${targetPeerId}`));
387
+ }
388
+ });
389
+ }
390
+
391
+ private async handleNegentropyQuery(fromPeerId: string, msg: NegentropyQueryMessage): Promise<void> {
392
+ const localMessages = this.messageStore.getMessages(msg.channelId);
393
+ const negentropy = new Negentropy();
394
+ await negentropy.build(localMessages.map((message) => ({ id: message.id, timestamp: message.timestamp })));
395
+ const response = await negentropy.processQuery(msg.query);
396
+
397
+ const payload: NegentropyResponseMessage = {
398
+ type: 'negentropy-response',
399
+ workspaceId: msg.workspaceId,
400
+ channelId: msg.channelId,
401
+ response,
402
+ };
403
+
404
+ this.sendFn(fromPeerId, { type: 'workspace-sync', sync: payload });
405
+ }
406
+
407
+ private handleNegentropyResponse(fromPeerId: string, msg: NegentropyResponseMessage): void {
408
+ const key = `${fromPeerId}:${msg.workspaceId}:${msg.channelId}`;
409
+ const resolver = this.pendingNegentropyResponse.get(key);
410
+ if (!resolver) return;
411
+ this.pendingNegentropyResponse.delete(key);
412
+ resolver(msg.response);
413
+ }
414
+
415
+ private async requestMissingMessages(
416
+ targetPeerId: string,
417
+ workspaceId: string,
418
+ channelId: string,
419
+ ids: string[],
420
+ ): Promise<SyncedHistoryMessage[]> {
421
+ const key = `${targetPeerId}:${workspaceId}:${channelId}`;
422
+
423
+ return await new Promise<SyncedHistoryMessage[]>((resolve, reject) => {
424
+ const timeout = setTimeout(() => {
425
+ this.pendingNegentropyBatches.delete(key);
426
+ reject(new Error(`Negentropy message batch timeout from ${targetPeerId}`));
427
+ }, 5000);
428
+
429
+ this.pendingNegentropyBatches.set(key, {
430
+ resolve,
431
+ reject,
432
+ timer: timeout,
433
+ messages: [],
434
+ });
435
+
436
+ const msg: NegentropyRequestMessagesMessage = {
437
+ type: 'negentropy-request-messages',
438
+ workspaceId,
439
+ channelId,
440
+ ids,
441
+ };
442
+
443
+ const sent = this.sendFn(targetPeerId, { type: 'workspace-sync', sync: msg });
444
+ if (!sent) {
445
+ clearTimeout(timeout);
446
+ this.pendingNegentropyBatches.delete(key);
447
+ reject(new Error(`Failed to request missing messages from ${targetPeerId}`));
448
+ }
449
+ });
450
+ }
451
+
452
+ private handleNegentropyRequestMessages(fromPeerId: string, msg: NegentropyRequestMessagesMessage): void {
453
+ const requested = new Set(msg.ids);
454
+ const messages = this.messageStore
455
+ .getMessages(msg.channelId)
456
+ .filter((message) => requested.has(message.id))
457
+ .map((message) => {
458
+ const { content, ...safe } = message;
459
+ return safe;
460
+ })
461
+ .sort((a, b) => a.timestamp - b.timestamp);
462
+
463
+ if (messages.length === 0) {
464
+ const emptyDone: NegentropyMessageBatchMessage = {
465
+ type: 'negentropy-message-batch',
466
+ workspaceId: msg.workspaceId,
467
+ channelId: msg.channelId,
468
+ messages: [],
469
+ done: true,
470
+ };
471
+ this.sendFn(fromPeerId, { type: 'workspace-sync', sync: emptyDone });
472
+ return;
473
+ }
474
+
475
+ for (let i = 0; i < messages.length; i += this.negentropyBatchSize) {
476
+ const batch = messages.slice(i, i + this.negentropyBatchSize);
477
+ const payload: NegentropyMessageBatchMessage = {
478
+ type: 'negentropy-message-batch',
479
+ workspaceId: msg.workspaceId,
480
+ channelId: msg.channelId,
481
+ messages: batch,
482
+ done: i + this.negentropyBatchSize >= messages.length,
483
+ };
484
+ this.sendFn(fromPeerId, { type: 'workspace-sync', sync: payload });
485
+ }
486
+ }
487
+
488
+ private handleNegentropyMessageBatch(fromPeerId: string, msg: NegentropyMessageBatchMessage): void {
489
+ const key = `${fromPeerId}:${msg.workspaceId}:${msg.channelId}`;
490
+ const pending = this.pendingNegentropyBatches.get(key);
491
+ if (!pending) return;
492
+
493
+ pending.messages.push(...msg.messages);
494
+
495
+ if (!msg.done) return;
496
+
497
+ clearTimeout(pending.timer);
498
+ this.pendingNegentropyBatches.delete(key);
499
+ pending.resolve(pending.messages);
500
+ }
501
+
502
+ private async mergeSyncedMessages(channelId: string, incoming: SyncedHistoryMessage[]): Promise<void> {
503
+ const existing = this.messageStore.getMessages(channelId);
504
+ const merged = new Map<string, SyncedHistoryMessage>();
505
+
506
+ for (const message of existing) {
507
+ const { content, ...safe } = message;
508
+ merged.set(message.id, safe);
509
+ }
510
+
511
+ for (const message of incoming) {
512
+ if (!merged.has(message.id)) {
513
+ merged.set(message.id, { ...message });
514
+ }
515
+ }
516
+
517
+ const sorted = Array.from(merged.values()).sort((a, b) => {
518
+ if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
519
+ return a.id.localeCompare(b.id);
520
+ });
521
+
522
+ await this.messageStore.importMessages(channelId, sorted);
523
+ }
524
+
525
+ private sendLegacySyncRequest(targetPeerId: string, workspaceId: string): void {
526
+ const msg: SyncMessage = { type: 'sync-request', workspaceId };
527
+ this.sendFn(targetPeerId, { type: 'workspace-sync', sync: msg });
528
+ }
529
+
530
+ private handleJoinRequest(fromPeerId: string, msg: Extract<SyncMessage, { type: 'join-request' }>): void {
531
+ if (msg.pexServers && this.serverDiscovery) {
532
+ this.serverDiscovery.mergeReceivedServers(msg.pexServers);
533
+ }
534
+
535
+ const workspace = this.workspaceManager.validateInviteCode(msg.inviteCode);
536
+
537
+ if (!workspace) {
538
+ this.sendFn(fromPeerId, {
539
+ type: 'workspace-sync',
540
+ sync: { type: 'join-rejected', reason: 'Invalid invite code' } as SyncMessage,
541
+ });
542
+ return;
543
+ }
544
+
545
+ if (msg.inviteId && this.workspaceManager.isInviteRevoked(workspace.id, msg.inviteId)) {
546
+ this.sendFn(fromPeerId, {
547
+ type: 'workspace-sync',
548
+ sync: { type: 'join-rejected', reason: 'This invite link has been revoked by an admin' } as SyncMessage,
549
+ });
550
+ return;
551
+ }
552
+
553
+ const result = this.workspaceManager.addMember(workspace.id, msg.member);
554
+
555
+ if (!result.success) {
556
+ this.sendFn(fromPeerId, {
557
+ type: 'workspace-sync',
558
+ sync: { type: 'join-rejected', reason: result.error || 'Failed to join' } as SyncMessage,
559
+ });
560
+ return;
561
+ }
562
+
563
+ const messageHistory: Record<string, SyncedHistoryMessage[]> = {};
564
+ for (const channel of workspace.channels) {
565
+ const msgs = this.messageStore.getMessages(channel.id);
566
+ if (msgs.length > 0) {
567
+ messageHistory[channel.id] = msgs.map((message) => {
568
+ const { content, ...safeMsg } = message;
569
+ return safeMsg;
570
+ });
571
+ }
572
+ }
573
+
574
+ const acceptMsg: SyncMessage = {
575
+ type: 'join-accepted',
576
+ workspace: this.workspaceManager.exportWorkspace(workspace.id)!,
577
+ messageHistory,
578
+ pexServers: this.serverDiscovery?.getHandshakeServers(),
579
+ };
580
+
581
+ this.sendFn(fromPeerId, { type: 'workspace-sync', sync: acceptMsg });
582
+ this.onEvent({ type: 'member-joined', workspaceId: workspace.id, member: msg.member });
583
+ }
584
+
585
+ private async handleJoinAccepted(msg: Extract<SyncMessage, { type: 'join-accepted' }>): Promise<void> {
586
+ if (msg.pexServers && this.serverDiscovery) {
587
+ this.serverDiscovery.mergeReceivedServers(msg.pexServers);
588
+ }
589
+
590
+ this.workspaceManager.importWorkspace(msg.workspace);
591
+
592
+ for (const [channelId, messages] of Object.entries(msg.messageHistory)) {
593
+ await this.messageStore.importMessages(channelId, messages as SyncedHistoryMessage[]);
594
+ }
595
+
596
+ this.onEvent({
597
+ type: 'workspace-joined',
598
+ workspace: msg.workspace,
599
+ messageHistory: msg.messageHistory,
600
+ });
601
+ }
602
+
603
+ private handleMemberJoined(msg: Extract<SyncMessage, { type: 'member-joined' }> & { workspaceId?: string }): void {
604
+ if (!msg.workspaceId) return;
605
+ const result = this.workspaceManager.addMember(msg.workspaceId, msg.member);
606
+ if (result.success) {
607
+ this.onEvent({ type: 'member-joined', workspaceId: msg.workspaceId, member: msg.member });
608
+ }
609
+ }
610
+
611
+ private handleMemberLeft(msg: Extract<SyncMessage, { type: 'member-left' }> & { workspaceId?: string }): void {
612
+ if (!msg.workspaceId) return;
613
+ this.onEvent({ type: 'member-left', workspaceId: msg.workspaceId, peerId: msg.peerId });
614
+ }
615
+
616
+ private handleChannelCreated(msg: Extract<SyncMessage, { type: 'channel-created' }> & { workspaceId?: string }): void {
617
+ const targetWsId = msg.workspaceId || msg.channel.workspaceId;
618
+ if (!targetWsId) return;
619
+
620
+ const ws = this.workspaceManager.getWorkspace(targetWsId);
621
+ if (!ws) return;
622
+
623
+ const existing = ws.channels.find((channel: Channel) => channel.id === msg.channel.id);
624
+ if (!existing) {
625
+ ws.channels.push(msg.channel);
626
+ this.onEvent({ type: 'channel-created', workspaceId: ws.id, channel: msg.channel });
627
+ }
628
+ }
629
+
630
+ private handleChannelRemoved(msg: Extract<SyncMessage, { type: 'channel-removed' }> & { workspaceId?: string }): void {
631
+ if (!msg.workspaceId) return;
632
+
633
+ const ws = this.workspaceManager.getWorkspace(msg.workspaceId);
634
+ if (!ws) return;
635
+
636
+ const index = ws.channels.findIndex((channel: Channel) => channel.id === msg.channelId && channel.type === 'channel');
637
+ if (index >= 0) {
638
+ ws.channels.splice(index, 1);
639
+ this.onEvent({ type: 'channel-removed', workspaceId: ws.id, channelId: msg.channelId });
640
+ }
641
+ }
642
+
643
+ private handleWorkspaceDeleted(msg: Extract<SyncMessage, { type: 'workspace-deleted' }> & { workspaceId?: string }): void {
644
+ const workspaceId = msg.workspaceId;
645
+ if (!workspaceId) return;
646
+
647
+ this.workspaceManager.removeWorkspace(workspaceId);
648
+ this.onEvent({ type: 'workspace-deleted', workspaceId, deletedBy: msg.deletedBy });
649
+ }
650
+
651
+ private async handleChannelMessage(fromPeerId: string, msg: Extract<SyncMessage, { type: 'channel-message' }>): Promise<void> {
652
+ const message = msg.message as unknown as PlaintextMessage;
653
+
654
+ const result = await this.messageStore.addMessage(message);
655
+ if (result.success) {
656
+ this.onEvent({ type: 'message-received', channelId: msg.channelId, message });
657
+ } else {
658
+ console.warn('Rejected message from', fromPeerId, ':', result.error);
659
+ }
660
+ }
661
+
662
+ private handleSyncRequest(fromPeerId: string, msg: Extract<SyncMessage, { type: 'sync-request' }>): void {
663
+ const workspace = this.workspaceManager.getWorkspace(msg.workspaceId);
664
+ if (!workspace) return;
665
+
666
+ const messageHistory: Record<string, SyncedHistoryMessage[]> = {};
667
+ for (const channel of workspace.channels) {
668
+ const messages = this.messageStore.getMessages(channel.id);
669
+ if (messages.length > 0) {
670
+ messageHistory[channel.id] = messages.map((message) => {
671
+ const { content, ...safeMsg } = message;
672
+ return safeMsg;
673
+ });
674
+ }
675
+ }
676
+
677
+ const response: SyncMessage = {
678
+ type: 'sync-response',
679
+ workspace,
680
+ messageHistory,
681
+ };
682
+
683
+ this.sendFn(fromPeerId, { type: 'workspace-sync', sync: response });
684
+ }
685
+
686
+ private async handleSyncResponse(msg: Extract<SyncMessage, { type: 'sync-response' }>): Promise<void> {
687
+ this.workspaceManager.importWorkspace(msg.workspace);
688
+
689
+ for (const [channelId, messages] of Object.entries(msg.messageHistory)) {
690
+ await this.messageStore.importMessages(channelId, messages as SyncedHistoryMessage[]);
691
+ }
692
+
693
+ this.onEvent({ type: 'sync-complete', workspaceId: msg.workspace.id });
694
+ }
695
+
696
+ private handlePeerExchange(msg: Extract<SyncMessage, { type: 'peer-exchange' }>): void {
697
+ if (this.serverDiscovery && msg.servers) {
698
+ this.serverDiscovery.mergeReceivedServers(msg.servers);
699
+ }
700
+ }
701
+ }