@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.
- package/README.md +88 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +31 -0
- package/package.json +48 -0
- package/src/channel.ts +789 -0
- package/src/huddle/AudioPipeline.ts +174 -0
- package/src/huddle/BotHuddleManager.ts +882 -0
- package/src/huddle/SpeechToText.ts +223 -0
- package/src/huddle/TextToSpeech.ts +260 -0
- package/src/huddle/index.ts +8 -0
- package/src/monitor.ts +1266 -0
- package/src/peer/DecentChatNodePeer.ts +4570 -0
- package/src/peer/FileStore.ts +59 -0
- package/src/peer/NodeMessageProtocol.ts +1057 -0
- package/src/peer/SyncProtocol.ts +701 -0
- package/src/peer/polyfill.ts +43 -0
- package/src/peer-registry.ts +32 -0
- package/src/runtime.ts +63 -0
- package/src/types.ts +136 -0
|
@@ -0,0 +1,4570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecentChatNodePeer — permanent DecentChat P2P peer runtime.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// MUST be first import — installs RTCPeerConnection globals before PeerJS loads
|
|
6
|
+
import './polyfill.js';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CryptoManager,
|
|
10
|
+
InviteURI,
|
|
11
|
+
MessageStore,
|
|
12
|
+
OfflineQueue,
|
|
13
|
+
CustodyStore,
|
|
14
|
+
ManifestStore,
|
|
15
|
+
SeedPhraseManager,
|
|
16
|
+
WorkspaceManager,
|
|
17
|
+
Negentropy,
|
|
18
|
+
} from 'decent-protocol';
|
|
19
|
+
import type {
|
|
20
|
+
Workspace,
|
|
21
|
+
WorkspaceMember,
|
|
22
|
+
PlaintextMessage,
|
|
23
|
+
MessageMetadata,
|
|
24
|
+
AssistantMessageMetadata,
|
|
25
|
+
KeyPair,
|
|
26
|
+
CustodyEnvelope,
|
|
27
|
+
DeliveryReceipt,
|
|
28
|
+
SyncDomain,
|
|
29
|
+
ManifestDelta,
|
|
30
|
+
ManifestDiffRequest,
|
|
31
|
+
SyncManifestSummary,
|
|
32
|
+
SyncManifestSnapshot,
|
|
33
|
+
ManifestStoreState,
|
|
34
|
+
} from 'decent-protocol';
|
|
35
|
+
import { PeerTransport } from 'decent-transport-webrtc';
|
|
36
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
37
|
+
import { FileStore } from './FileStore.js';
|
|
38
|
+
import { NodeMessageProtocol } from './NodeMessageProtocol.js';
|
|
39
|
+
import { SyncProtocol, type SyncEvent } from './SyncProtocol.js';
|
|
40
|
+
import type { ResolvedDecentChatAccount } from '../types.js';
|
|
41
|
+
import { BotHuddleManager, type BotHuddleConfig } from '../huddle/BotHuddleManager.js';
|
|
42
|
+
import {
|
|
43
|
+
loadCompanyContextForAccount,
|
|
44
|
+
getCompanySimTemplate,
|
|
45
|
+
installCompanyTemplate,
|
|
46
|
+
getCompanySimControlState,
|
|
47
|
+
readCompanySimControlDocument,
|
|
48
|
+
writeCompanySimControlDocument,
|
|
49
|
+
previewCompanySimRouting,
|
|
50
|
+
getCompanySimEmployeeContext,
|
|
51
|
+
} from '@decentchat/company-sim';
|
|
52
|
+
import type { CompanyTemplateQuestionValue } from '@decentchat/company-sim';
|
|
53
|
+
|
|
54
|
+
const COMPANY_TEMPLATE_CONTROL_CAPABILITY = 'company-template-control-v1';
|
|
55
|
+
|
|
56
|
+
let decentChatNodePeerStartupLock: Promise<void> = Promise.resolve();
|
|
57
|
+
|
|
58
|
+
export function resetDecentChatNodePeerStartupLockForTests(): void {
|
|
59
|
+
decentChatNodePeerStartupLock = Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runDecentChatNodePeerStartupLocked<T>(task: () => Promise<T>): Promise<T> {
|
|
63
|
+
const waitForPrevious = decentChatNodePeerStartupLock;
|
|
64
|
+
let releaseCurrent: (() => void) | null = null;
|
|
65
|
+
|
|
66
|
+
decentChatNodePeerStartupLock = new Promise<void>((resolve) => {
|
|
67
|
+
releaseCurrent = resolve;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await waitForPrevious;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return await task();
|
|
74
|
+
} finally {
|
|
75
|
+
releaseCurrent?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DecentChatNodePeerOptions {
|
|
80
|
+
account: ResolvedDecentChatAccount;
|
|
81
|
+
onIncomingMessage: (params: {
|
|
82
|
+
channelId: string;
|
|
83
|
+
workspaceId: string;
|
|
84
|
+
content: string;
|
|
85
|
+
senderId: string;
|
|
86
|
+
senderName: string;
|
|
87
|
+
messageId: string;
|
|
88
|
+
chatType: 'channel' | 'direct';
|
|
89
|
+
timestamp: number;
|
|
90
|
+
replyToId?: string;
|
|
91
|
+
threadId?: string;
|
|
92
|
+
attachments?: Array<{
|
|
93
|
+
id: string;
|
|
94
|
+
name: string;
|
|
95
|
+
type: string;
|
|
96
|
+
size?: number;
|
|
97
|
+
thumbnail?: string;
|
|
98
|
+
width?: number;
|
|
99
|
+
height?: number;
|
|
100
|
+
}>;
|
|
101
|
+
}) => Promise<void>;
|
|
102
|
+
onReply: (params: {
|
|
103
|
+
channelId: string;
|
|
104
|
+
content: string;
|
|
105
|
+
inReplyToId: string;
|
|
106
|
+
}) => void;
|
|
107
|
+
onHuddleTranscription?: (text: string, peerId: string, channelId: string, senderName: string) => Promise<string | undefined>;
|
|
108
|
+
companyTemplateControl?: {
|
|
109
|
+
installTemplate?: (params: {
|
|
110
|
+
workspaceId: string;
|
|
111
|
+
templateId: string;
|
|
112
|
+
answers?: unknown;
|
|
113
|
+
requestedByPeerId: string;
|
|
114
|
+
}) => Promise<CompanyTemplateControlInstallResult>;
|
|
115
|
+
loadConfig?: () => Record<string, unknown>;
|
|
116
|
+
writeConfigFile?: (config: Record<string, unknown>) => Promise<void>;
|
|
117
|
+
workspaceRootDir?: string;
|
|
118
|
+
companySimsRootDir?: string;
|
|
119
|
+
templatesRoot?: string;
|
|
120
|
+
};
|
|
121
|
+
log?: { info: (s: string) => void; warn?: (s: string) => void; error?: (s: string) => void };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type MediaChunk = {
|
|
125
|
+
type: 'media-chunk';
|
|
126
|
+
attachmentId: string;
|
|
127
|
+
index: number;
|
|
128
|
+
total: number;
|
|
129
|
+
data: string;
|
|
130
|
+
chunkHash: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type MediaRequest = {
|
|
134
|
+
type: 'media-request';
|
|
135
|
+
attachmentId: string;
|
|
136
|
+
fromChunk?: number;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
type MediaResponse = {
|
|
140
|
+
type: 'media-response';
|
|
141
|
+
attachmentId: string;
|
|
142
|
+
available: boolean;
|
|
143
|
+
totalChunks?: number;
|
|
144
|
+
suggestedPeers?: string[];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type AssistantModelMeta = AssistantMessageMetadata;
|
|
148
|
+
|
|
149
|
+
function buildMessageMetadata(model?: AssistantModelMeta): MessageMetadata | undefined {
|
|
150
|
+
if (!model) return undefined;
|
|
151
|
+
const hasAssistantModel = Boolean(model.modelId || model.modelName || model.modelAlias || model.modelLabel);
|
|
152
|
+
if (!hasAssistantModel) return undefined;
|
|
153
|
+
return {
|
|
154
|
+
assistant: {
|
|
155
|
+
...(model.modelId ? { modelId: model.modelId } : {}),
|
|
156
|
+
...(model.modelName ? { modelName: model.modelName } : {}),
|
|
157
|
+
...(model.modelAlias ? { modelAlias: model.modelAlias } : {}),
|
|
158
|
+
...(model.modelLabel ? { modelLabel: model.modelLabel } : {}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
interface CompanyTemplateControlInstallResult {
|
|
165
|
+
provisioningMode: 'config-provisioned';
|
|
166
|
+
createdAccountIds?: string[];
|
|
167
|
+
provisionedAccountIds?: string[];
|
|
168
|
+
onlineReadyAccountIds?: string[];
|
|
169
|
+
manualActionRequiredAccountIds?: string[];
|
|
170
|
+
manualActionItems?: string[];
|
|
171
|
+
companyId?: string;
|
|
172
|
+
manifestPath?: string;
|
|
173
|
+
companyDirPath?: string;
|
|
174
|
+
communicationPolicy?: string;
|
|
175
|
+
benchmarkSuite?: {
|
|
176
|
+
templateId: string;
|
|
177
|
+
scenarioIds: string[];
|
|
178
|
+
policyScores: Record<string, {
|
|
179
|
+
score: number;
|
|
180
|
+
unexpectedResponders?: number;
|
|
181
|
+
missingExpectedResponders?: number;
|
|
182
|
+
silentViolations?: number;
|
|
183
|
+
}>;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
188
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function uniqueSorted(values: string[]): string[] {
|
|
192
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
|
193
|
+
.sort((a, b) => a.localeCompare(b));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toQuestionValue(value: unknown): CompanyTemplateQuestionValue | undefined {
|
|
197
|
+
if (typeof value === 'string') {
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
return trimmed ? trimmed : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof value === 'boolean') {
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
const normalized = uniqueSorted(
|
|
212
|
+
value
|
|
213
|
+
.map((entry) => (typeof entry === 'string' ? entry : String(entry ?? '')))
|
|
214
|
+
.map((entry) => entry.trim())
|
|
215
|
+
.filter(Boolean),
|
|
216
|
+
);
|
|
217
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeTemplateInstallAnswers(value: unknown): Record<string, CompanyTemplateQuestionValue> {
|
|
224
|
+
if (!isRecord(value)) return {};
|
|
225
|
+
|
|
226
|
+
const answers: Record<string, CompanyTemplateQuestionValue> = {};
|
|
227
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
228
|
+
const key = rawKey.trim();
|
|
229
|
+
if (!key) continue;
|
|
230
|
+
const normalized = toQuestionValue(rawValue);
|
|
231
|
+
if (normalized === undefined) continue;
|
|
232
|
+
answers[key] = normalized;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return answers;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildManualActionItems(manualActionRequiredAccountIds: string[]): string[] {
|
|
239
|
+
const actions = [
|
|
240
|
+
'Restart/reload OpenClaw so runtime bootstrap applies the new company manifest.',
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
if (manualActionRequiredAccountIds.length > 0) {
|
|
244
|
+
actions.push(`Fix invalid seed phrases for: ${manualActionRequiredAccountIds.join(', ')}.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return uniqueSorted(actions);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function normalizeCompanyTemplateControlErrorMessage(error: unknown): string {
|
|
251
|
+
const message = String((error as Error)?.message ?? error ?? '').trim();
|
|
252
|
+
if (!message) return 'Failed to install company template';
|
|
253
|
+
if (message.length > 240) return `${message.slice(0, 239)}…`;
|
|
254
|
+
return message;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
type PendingMediaRequest = {
|
|
259
|
+
attachmentId: string;
|
|
260
|
+
peerId: string;
|
|
261
|
+
resolve: (buffer: Buffer | null) => void;
|
|
262
|
+
chunks: Map<number, Buffer>;
|
|
263
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
type PendingPreKeyBundleFetch = {
|
|
267
|
+
ownerPeerId: string;
|
|
268
|
+
workspaceId?: string;
|
|
269
|
+
pendingPeerIds: Set<string>;
|
|
270
|
+
resolve: (value: boolean) => void;
|
|
271
|
+
timer: ReturnType<typeof setTimeout>;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
type DirectoryEntry = {
|
|
275
|
+
kind: 'user' | 'group';
|
|
276
|
+
id: string;
|
|
277
|
+
name?: string;
|
|
278
|
+
handle?: string;
|
|
279
|
+
rank?: number;
|
|
280
|
+
raw?: unknown;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export class DecentChatNodePeer {
|
|
284
|
+
private static readonly CUSTODIAN_REPLICATION_TARGET = 2;
|
|
285
|
+
private static readonly PRE_KEY_FETCH_TIMEOUT_MS = 2_500;
|
|
286
|
+
private static readonly DECRYPT_RECOVERY_HANDSHAKE_COOLDOWN_MS = 5_000;
|
|
287
|
+
private static readonly CONNECT_HANDSHAKE_COOLDOWN_MS = 5_000;
|
|
288
|
+
private static readonly INBOUND_HANDSHAKE_COOLDOWN_MS = 5_000;
|
|
289
|
+
private static readonly PEER_MAINTENANCE_RETRY_BASE_MS = 30_000;
|
|
290
|
+
private static readonly PEER_MAINTENANCE_RETRY_MAX_MS = 10 * 60_000;
|
|
291
|
+
private static readonly TRANSPORT_ERROR_LOG_WINDOW_MS = 30_000;
|
|
292
|
+
private static readonly GOSSIP_TTL = 2;
|
|
293
|
+
|
|
294
|
+
private readonly store: FileStore;
|
|
295
|
+
private readonly workspaceManager: WorkspaceManager;
|
|
296
|
+
private readonly messageStore: MessageStore;
|
|
297
|
+
private readonly cryptoManager: CryptoManager;
|
|
298
|
+
private transport: PeerTransport | null = null;
|
|
299
|
+
private syncProtocol: SyncProtocol | null = null;
|
|
300
|
+
private messageProtocol: NodeMessageProtocol | null = null;
|
|
301
|
+
private signingKeyPair: KeyPair | null = null;
|
|
302
|
+
private myPeerId = '';
|
|
303
|
+
private myPublicKey = '';
|
|
304
|
+
private destroyed = false;
|
|
305
|
+
private _maintenanceInterval: ReturnType<typeof setInterval> | null = null;
|
|
306
|
+
private readonly offlineQueue: OfflineQueue;
|
|
307
|
+
private readonly custodyStore: CustodyStore;
|
|
308
|
+
private readonly manifestStore: ManifestStore;
|
|
309
|
+
private readonly custodianInbox = new Map<string, CustodyEnvelope>();
|
|
310
|
+
private readonly pendingCustodyOffers = new Map<string, string[]>();
|
|
311
|
+
private readonly opts: DecentChatNodePeerOptions;
|
|
312
|
+
private readonly pendingMediaRequests = new Map<string, PendingMediaRequest>();
|
|
313
|
+
private readonly pendingPreKeyBundleFetches = new Map<string, PendingPreKeyBundleFetch>();
|
|
314
|
+
private readonly publishedPreKeyVersionByWorkspace = new Map<string, string>();
|
|
315
|
+
private readonly decryptRecoveryAtByPeer = new Map<string, number>();
|
|
316
|
+
private readonly connectHandshakeAtByPeer = new Map<string, number>();
|
|
317
|
+
private readonly inboundHandshakeAtByPeer = new Map<string, number>();
|
|
318
|
+
private readonly peerMaintenanceRetryAtByPeer = new Map<string, number>();
|
|
319
|
+
private readonly peerMaintenanceAttemptsByPeer = new Map<string, number>();
|
|
320
|
+
private readonly throttledTransportErrors = new Map<string, { windowStart: number; suppressed: number }>();
|
|
321
|
+
private readonly _gossipSeen = new Map<string, number>();
|
|
322
|
+
private _gossipCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
323
|
+
private companySimProfileLoaded = false;
|
|
324
|
+
private companySimProfile: WorkspaceMember["companySim"] | undefined;
|
|
325
|
+
private companyTemplateInstallLock: Promise<void> = Promise.resolve();
|
|
326
|
+
private readonly mediaChunkTimeout = 30000;
|
|
327
|
+
private manifestPersistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
328
|
+
public botHuddle: BotHuddleManager | null = null;
|
|
329
|
+
|
|
330
|
+
constructor(opts: DecentChatNodePeerOptions) {
|
|
331
|
+
this.opts = opts;
|
|
332
|
+
this.store = new FileStore(opts.account.dataDir);
|
|
333
|
+
this.workspaceManager = new WorkspaceManager();
|
|
334
|
+
this.messageStore = new MessageStore();
|
|
335
|
+
this.cryptoManager = new CryptoManager();
|
|
336
|
+
this.offlineQueue = new OfflineQueue();
|
|
337
|
+
this.custodyStore = new CustodyStore(this.offlineQueue);
|
|
338
|
+
this.manifestStore = new ManifestStore();
|
|
339
|
+
this.manifestStore.setChangeListener(() => this.schedulePersistManifestState());
|
|
340
|
+
this.offlineQueue.setPersistence(
|
|
341
|
+
async (peerId, data, meta) => {
|
|
342
|
+
const key = this.offlineQueueKey(peerId);
|
|
343
|
+
const seqKey = 'offline-queue-seq';
|
|
344
|
+
const seq = this.store.get<number>(seqKey, 1);
|
|
345
|
+
const queue = this.store.get<any[]>(key, []);
|
|
346
|
+
queue.push({
|
|
347
|
+
id: seq,
|
|
348
|
+
targetPeerId: peerId,
|
|
349
|
+
data,
|
|
350
|
+
createdAt: meta?.createdAt ?? Date.now(),
|
|
351
|
+
attempts: meta?.attempts ?? 0,
|
|
352
|
+
lastAttempt: meta?.lastAttempt,
|
|
353
|
+
...meta,
|
|
354
|
+
});
|
|
355
|
+
this.store.set(key, queue);
|
|
356
|
+
this.store.set(seqKey, seq + 1);
|
|
357
|
+
},
|
|
358
|
+
async (peerId) => this.store.get<any[]>(this.offlineQueueKey(peerId), []),
|
|
359
|
+
async (id) => {
|
|
360
|
+
for (const key of this.store.keys('offline-queue-')) {
|
|
361
|
+
if (key === 'offline-queue-seq') continue;
|
|
362
|
+
const queue = this.store.get<any[]>(key, []);
|
|
363
|
+
const idx = queue.findIndex((msg) => msg?.id === id);
|
|
364
|
+
if (idx < 0) continue;
|
|
365
|
+
queue.splice(idx, 1);
|
|
366
|
+
if (queue.length === 0) {
|
|
367
|
+
this.store.delete(key);
|
|
368
|
+
} else {
|
|
369
|
+
this.store.set(key, queue);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
async (peerId) => {
|
|
375
|
+
const key = this.offlineQueueKey(peerId);
|
|
376
|
+
const queue = this.store.get<any[]>(key, []);
|
|
377
|
+
this.store.delete(key);
|
|
378
|
+
return queue;
|
|
379
|
+
},
|
|
380
|
+
async (id, patch) => {
|
|
381
|
+
for (const key of this.store.keys('offline-queue-')) {
|
|
382
|
+
if (key === 'offline-queue-seq') continue;
|
|
383
|
+
const queue = this.store.get<any[]>(key, []);
|
|
384
|
+
const idx = queue.findIndex((msg) => msg?.id === id);
|
|
385
|
+
if (idx < 0) continue;
|
|
386
|
+
queue[idx] = { ...queue[idx], ...patch };
|
|
387
|
+
this.store.set(key, queue);
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
this.custodyStore.setReceiptPersistence(
|
|
394
|
+
async (receipt) => {
|
|
395
|
+
const key = this.receiptLogKey(receipt.recipientPeerId);
|
|
396
|
+
const receipts = this.store.get<DeliveryReceipt[]>(key, []);
|
|
397
|
+
if (!receipts.some((entry) => entry.receiptId === receipt.receiptId)) {
|
|
398
|
+
receipts.push(receipt);
|
|
399
|
+
receipts.sort((a, b) => a.timestamp - b.timestamp || a.receiptId.localeCompare(b.receiptId));
|
|
400
|
+
this.store.set(key, receipts);
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
async (peerId) => this.store.get<DeliveryReceipt[]>(this.receiptLogKey(peerId), []),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get peerId(): string {
|
|
408
|
+
return this.myPeerId;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async start(): Promise<void> {
|
|
412
|
+
const seedPhrase = this.opts.account.seedPhrase;
|
|
413
|
+
if (!seedPhrase) {
|
|
414
|
+
throw new Error('DecentChat seed phrase not configured (channels.decentchat.seedPhrase)');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const seedMgr = new SeedPhraseManager();
|
|
418
|
+
const validation = seedMgr.validate(seedPhrase);
|
|
419
|
+
if (!validation.valid) {
|
|
420
|
+
throw new Error(`Invalid seed phrase in channels.decentchat.seedPhrase: ${validation.error}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await runDecentChatNodePeerStartupLocked(async () => {
|
|
424
|
+
const { ecdhKeyPair, ecdsaKeyPair } = await seedMgr.deriveKeys(seedPhrase);
|
|
425
|
+
this.myPeerId = await seedMgr.derivePeerId(seedPhrase);
|
|
426
|
+
|
|
427
|
+
this.cryptoManager.setKeyPair(ecdhKeyPair);
|
|
428
|
+
this.myPublicKey = await this.cryptoManager.exportPublicKey(ecdhKeyPair.publicKey);
|
|
429
|
+
|
|
430
|
+
this.messageProtocol = new NodeMessageProtocol(this.cryptoManager, this.myPeerId);
|
|
431
|
+
this.messageProtocol.setPersistence({
|
|
432
|
+
save: async (peerId, state) => this.store.set(`ratchet-${peerId}`, state),
|
|
433
|
+
load: async (peerId) => this.store.get(`ratchet-${peerId}`, null),
|
|
434
|
+
delete: async (peerId) => this.store.delete(`ratchet-${peerId}`),
|
|
435
|
+
savePreKeyBundle: async (peerId, bundle) => this.store.set(`prekey-bundle-${peerId}`, bundle),
|
|
436
|
+
loadPreKeyBundle: async (peerId) => this.store.get(`prekey-bundle-${peerId}`, null),
|
|
437
|
+
deletePreKeyBundle: async (peerId) => this.store.delete(`prekey-bundle-${peerId}`),
|
|
438
|
+
saveLocalPreKeyState: async (ownerPeerId, state) => this.store.set(`prekey-state-${ownerPeerId}`, state),
|
|
439
|
+
loadLocalPreKeyState: async (ownerPeerId) => this.store.get(`prekey-state-${ownerPeerId}`, null),
|
|
440
|
+
deleteLocalPreKeyState: async (ownerPeerId) => this.store.delete(`prekey-state-${ownerPeerId}`),
|
|
441
|
+
});
|
|
442
|
+
await this.messageProtocol.init(ecdsaKeyPair);
|
|
443
|
+
this.signingKeyPair = ecdsaKeyPair;
|
|
444
|
+
|
|
445
|
+
this.restoreWorkspaces();
|
|
446
|
+
this.restoreMessages();
|
|
447
|
+
this.restoreManifestState();
|
|
448
|
+
this.restoreCustodianInbox();
|
|
449
|
+
|
|
450
|
+
const configServer = this.opts.account.signalingServer ?? 'https://decentchat.app/peerjs';
|
|
451
|
+
const allServers: string[] = [configServer];
|
|
452
|
+
|
|
453
|
+
// Normalize a signaling URL for deduplication: strip default ports so
|
|
454
|
+
// https://0.peerjs.com/ and https://0.peerjs.com:443/ are treated as identical.
|
|
455
|
+
const normalizeUrl = (url: string): string => {
|
|
456
|
+
try {
|
|
457
|
+
const u = new URL(url);
|
|
458
|
+
const defaultPort = u.protocol === 'https:' || u.protocol === 'wss:' ? '443' : '80';
|
|
459
|
+
if (u.port === defaultPort) u.port = '';
|
|
460
|
+
return u.toString();
|
|
461
|
+
} catch { return url; }
|
|
462
|
+
};
|
|
463
|
+
const normalizedServers = new Set(allServers.map(normalizeUrl));
|
|
464
|
+
|
|
465
|
+
// Collect signaling servers from all invites so we can find peers
|
|
466
|
+
// regardless of which PeerJS server they registered on.
|
|
467
|
+
for (const inviteUri of this.opts.account.invites ?? []) {
|
|
468
|
+
try {
|
|
469
|
+
const invite = InviteURI.decode(inviteUri);
|
|
470
|
+
const scheme = invite.secure ? 'https' : 'http';
|
|
471
|
+
const inviteServer = `${scheme}://${invite.host}:${invite.port}${invite.path}`;
|
|
472
|
+
if (!normalizedServers.has(normalizeUrl(inviteServer))) {
|
|
473
|
+
normalizedServers.add(normalizeUrl(inviteServer));
|
|
474
|
+
allServers.push(inviteServer);
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
// malformed invite — skip
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this.transport = new PeerTransport({
|
|
482
|
+
signalingServers: allServers,
|
|
483
|
+
// useTurn defaults to true → uses STUN + open-relay TURN for NAT traversal
|
|
484
|
+
});
|
|
485
|
+
this.opts.log?.info(`[decentchat-peer] signaling servers: ${allServers.join(', ')}`);
|
|
486
|
+
|
|
487
|
+
this.syncProtocol = new SyncProtocol(
|
|
488
|
+
this.workspaceManager,
|
|
489
|
+
this.messageStore,
|
|
490
|
+
(peerId, data) => this.transport?.send(peerId, data) ?? false,
|
|
491
|
+
(event) => {
|
|
492
|
+
void this.handleSyncEvent(event);
|
|
493
|
+
},
|
|
494
|
+
this.myPeerId,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
this.transport.onConnect = (peerId) => {
|
|
498
|
+
void this.handlePeerConnect(peerId);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
this.transport.onDisconnect = (peerId) => {
|
|
502
|
+
this.opts.log?.info(`[decentchat-peer] peer disconnected: ${peerId}`);
|
|
503
|
+
this.messageProtocol?.clearSharedSecret(peerId);
|
|
504
|
+
// Clear per-peer cooldowns so the next connect always proceeds.
|
|
505
|
+
// Without this, a reconnect within CONNECT_HANDSHAKE_COOLDOWN_MS
|
|
506
|
+
// silently drops the connect event — no handshake, no session resume,
|
|
507
|
+
// the remote peer sees a dead connection and closes it.
|
|
508
|
+
this.connectHandshakeAtByPeer.delete(peerId);
|
|
509
|
+
this.inboundHandshakeAtByPeer.delete(peerId);
|
|
510
|
+
this.decryptRecoveryAtByPeer.delete(peerId);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
this.transport.onMessage = (fromPeerId, rawData) => {
|
|
514
|
+
void this.handlePeerMessage(fromPeerId, rawData);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
this.transport.onError = (err) => {
|
|
518
|
+
this.notePeerMaintenanceFailure(this.extractPeerIdFromTransportError(err), Date.now());
|
|
519
|
+
this.logTransportError(err);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
this.myPeerId = await this.transport.init(this.myPeerId);
|
|
523
|
+
this.opts.log?.info(`[decentchat-peer] online as ${this.myPeerId}, signaling: ${allServers.join(', ')}`);
|
|
524
|
+
this.startPeerMaintenance();
|
|
525
|
+
this.startGossipCleanup();
|
|
526
|
+
|
|
527
|
+
// Initialize huddle manager after transport is ready (if enabled)
|
|
528
|
+
const huddleConfig = this.opts.account.huddle;
|
|
529
|
+
if (huddleConfig?.enabled !== false) {
|
|
530
|
+
this.botHuddle = new BotHuddleManager(this.myPeerId, {
|
|
531
|
+
sendSignal: (peerId, data) => this.transport?.send(peerId, data) ?? false,
|
|
532
|
+
broadcastSignal: (data) => {
|
|
533
|
+
if (!this.transport) return;
|
|
534
|
+
for (const peerId of this.transport.getConnectedPeers()) {
|
|
535
|
+
if (peerId !== this.myPeerId) {
|
|
536
|
+
this.transport.send(peerId, data);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
getDisplayName: (peerId) => this.resolveSenderName('', peerId),
|
|
541
|
+
onTranscription: async (text, peerId, channelId) => {
|
|
542
|
+
const senderName = this.resolveSenderName('', peerId);
|
|
543
|
+
return this.opts.onHuddleTranscription?.(text, peerId, channelId, senderName);
|
|
544
|
+
},
|
|
545
|
+
log: this.opts.log,
|
|
546
|
+
}, {
|
|
547
|
+
autoJoin: huddleConfig?.autoJoin,
|
|
548
|
+
sttEngine: huddleConfig?.sttEngine,
|
|
549
|
+
whisperModel: huddleConfig?.whisperModel,
|
|
550
|
+
sttLanguage: huddleConfig?.sttLanguage,
|
|
551
|
+
sttApiKey: huddleConfig?.sttApiKey,
|
|
552
|
+
ttsVoice: huddleConfig?.ttsVoice,
|
|
553
|
+
vadSilenceMs: huddleConfig?.vadSilenceMs,
|
|
554
|
+
vadThreshold: huddleConfig?.vadThreshold,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const inviteUri of this.opts.account.invites ?? []) {
|
|
559
|
+
const invite = (() => {
|
|
560
|
+
try {
|
|
561
|
+
return InviteURI.decode(inviteUri);
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
if (!invite || !this.shouldAttemptInviteJoin(invite)) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// Try immediately; if the peer is offline, retry with backoff
|
|
570
|
+
void this.joinWorkspaceWithRetry(inviteUri, invite);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private shouldAttemptInviteJoin(invite: { peerId?: string; workspaceId?: string }): boolean {
|
|
576
|
+
if (!invite.peerId) {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (invite.workspaceId) {
|
|
581
|
+
const workspace = this.workspaceManager.getWorkspace(invite.workspaceId);
|
|
582
|
+
if (workspace?.members.some((member) => member.peerId === this.myPeerId)) {
|
|
583
|
+
return !workspace.members.some((member) => member.peerId === invite.peerId);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
588
|
+
const memberPeerIds = new Set(workspace.members.map((member) => member.peerId));
|
|
589
|
+
if (memberPeerIds.has(this.myPeerId) && memberPeerIds.has(invite.peerId)) {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async joinWorkspaceWithRetry(
|
|
598
|
+
inviteUri: string,
|
|
599
|
+
decodedInvite: ReturnType<typeof InviteURI.decode> | null = null,
|
|
600
|
+
maxAttempts = 5,
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
const delays = [5000, 15000, 30000, 60000, 120000];
|
|
603
|
+
const invite = decodedInvite ?? (() => { try { return InviteURI.decode(inviteUri); } catch { return null; } })();
|
|
604
|
+
if (!invite || !this.shouldAttemptInviteJoin(invite)) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
608
|
+
if (this.destroyed) return;
|
|
609
|
+
try {
|
|
610
|
+
await this.joinWorkspace(inviteUri);
|
|
611
|
+
return; // success — stop retrying
|
|
612
|
+
} catch {
|
|
613
|
+
// joinWorkspace() catches internally and logs; we just check if we're connected
|
|
614
|
+
}
|
|
615
|
+
// If already connected to this peer (inbound connection arrived first), stop retrying
|
|
616
|
+
if (invite?.peerId && this.transport?.getConnectedPeers().includes(invite.peerId)) return;
|
|
617
|
+
if (!this.shouldAttemptInviteJoin(invite)) return;
|
|
618
|
+
|
|
619
|
+
if (attempt < maxAttempts - 1) {
|
|
620
|
+
const delay = delays[Math.min(attempt, delays.length - 1)];
|
|
621
|
+
this.opts.log?.info?.(`[decentchat-peer] join retry in ${delay / 1000}s (attempt ${attempt + 1}/${maxAttempts})`);
|
|
622
|
+
await new Promise(r => setTimeout(r, delay));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Persist a message to the local store (FileStore) without sending over WebRTC.
|
|
628
|
+
* Used after streaming completes so the bot has the message for Negentropy sync. */
|
|
629
|
+
async persistMessageLocally(
|
|
630
|
+
channelId: string,
|
|
631
|
+
workspaceId: string,
|
|
632
|
+
content: string,
|
|
633
|
+
threadId?: string,
|
|
634
|
+
replyToId?: string,
|
|
635
|
+
messageId?: string,
|
|
636
|
+
model?: AssistantModelMeta,
|
|
637
|
+
): Promise<void> {
|
|
638
|
+
if (!content.trim()) return;
|
|
639
|
+
const msg = await this.messageStore.createMessage(channelId, this.myPeerId, content.trim(), 'text', threadId);
|
|
640
|
+
if (messageId) msg.id = messageId;
|
|
641
|
+
if (model) {
|
|
642
|
+
(msg as any).metadata = buildMessageMetadata(model);
|
|
643
|
+
}
|
|
644
|
+
const added = await this.messageStore.addMessage(msg);
|
|
645
|
+
if (added.success) {
|
|
646
|
+
this.persistMessagesForChannel(channelId);
|
|
647
|
+
this.opts.log?.info?.(`[decentchat-peer] persisted message locally: ${msg.id.slice(0, 8)} (${content.length} chars)`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async sendMessage(
|
|
652
|
+
channelId: string,
|
|
653
|
+
workspaceId: string,
|
|
654
|
+
content: string,
|
|
655
|
+
threadId?: string,
|
|
656
|
+
replyToId?: string,
|
|
657
|
+
messageId?: string,
|
|
658
|
+
model?: AssistantModelMeta,
|
|
659
|
+
): Promise<void> {
|
|
660
|
+
if (!this.transport || !this.messageProtocol || !content.trim()) return;
|
|
661
|
+
|
|
662
|
+
const modelMeta = buildMessageMetadata(model);
|
|
663
|
+
const msg = await this.messageStore.createMessage(channelId, this.myPeerId, content.trim(), 'text', threadId);
|
|
664
|
+
if (messageId) msg.id = messageId; // Use provided messageId for dedup with streamed messages
|
|
665
|
+
if (modelMeta) {
|
|
666
|
+
(msg as any).metadata = modelMeta;
|
|
667
|
+
}
|
|
668
|
+
const added = await this.messageStore.addMessage(msg);
|
|
669
|
+
if (added.success) {
|
|
670
|
+
this.persistMessagesForChannel(channelId);
|
|
671
|
+
this.recordManifestDomain('channel-message', workspaceId, {
|
|
672
|
+
channelId,
|
|
673
|
+
itemCount: this.messageStore.getMessages(channelId).length,
|
|
674
|
+
operation: 'create',
|
|
675
|
+
subject: msg.id,
|
|
676
|
+
data: { messageId: msg.id, senderId: this.myPeerId },
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const recipients = this.getChannelRecipientPeerIds(channelId, workspaceId);
|
|
681
|
+
const gossipOriginSignature = await this.signGossipOrigin({
|
|
682
|
+
messageId: msg.id,
|
|
683
|
+
channelId,
|
|
684
|
+
content: content.trim(),
|
|
685
|
+
threadId,
|
|
686
|
+
replyToId,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
for (const peerId of recipients) {
|
|
690
|
+
try {
|
|
691
|
+
const encrypted = await this.encryptMessageWithPreKeyBootstrap(peerId, content.trim(), modelMeta, workspaceId);
|
|
692
|
+
(encrypted as any).channelId = channelId;
|
|
693
|
+
(encrypted as any).workspaceId = workspaceId;
|
|
694
|
+
(encrypted as any).senderId = this.myPeerId;
|
|
695
|
+
(encrypted as any).senderName = this.opts.account.alias;
|
|
696
|
+
(encrypted as any).messageId = msg.id;
|
|
697
|
+
if (gossipOriginSignature) {
|
|
698
|
+
(encrypted as any)._gossipOriginSignature = gossipOriginSignature;
|
|
699
|
+
}
|
|
700
|
+
if (threadId) (encrypted as any).threadId = threadId;
|
|
701
|
+
if (replyToId) (encrypted as any).replyToId = replyToId;
|
|
702
|
+
|
|
703
|
+
const connected = this.transport.getConnectedPeers().includes(peerId);
|
|
704
|
+
if (connected) {
|
|
705
|
+
await this.queuePendingAck(peerId, {
|
|
706
|
+
content: content.trim(),
|
|
707
|
+
channelId,
|
|
708
|
+
workspaceId,
|
|
709
|
+
senderId: this.myPeerId,
|
|
710
|
+
senderName: this.opts.account.alias,
|
|
711
|
+
messageId: msg.id,
|
|
712
|
+
threadId,
|
|
713
|
+
replyToId,
|
|
714
|
+
isDirect: false,
|
|
715
|
+
...(modelMeta ? { metadata: modelMeta } : {}),
|
|
716
|
+
});
|
|
717
|
+
const accepted = this.transport.send(peerId, encrypted);
|
|
718
|
+
if (!accepted) {
|
|
719
|
+
await this.custodyStore.storeEnvelope({
|
|
720
|
+
envelopeId: typeof (encrypted as any).id === 'string' ? (encrypted as any).id : undefined,
|
|
721
|
+
opId: msg.id,
|
|
722
|
+
recipientPeerIds: [peerId],
|
|
723
|
+
workspaceId,
|
|
724
|
+
channelId,
|
|
725
|
+
...(threadId ? { threadId } : {}),
|
|
726
|
+
domain: 'channel-message',
|
|
727
|
+
ciphertext: encrypted,
|
|
728
|
+
metadata: {
|
|
729
|
+
messageId: msg.id,
|
|
730
|
+
...this.buildCustodyResendMetadata({
|
|
731
|
+
content: content.trim(),
|
|
732
|
+
channelId,
|
|
733
|
+
workspaceId,
|
|
734
|
+
senderId: this.myPeerId,
|
|
735
|
+
senderName: this.opts.account.alias,
|
|
736
|
+
threadId,
|
|
737
|
+
replyToId,
|
|
738
|
+
isDirect: false,
|
|
739
|
+
gossipOriginSignature,
|
|
740
|
+
metadata: modelMeta,
|
|
741
|
+
}),
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
await this.replicateToCustodians(peerId, { workspaceId, channelId, opId: msg.id, domain: 'channel-message' });
|
|
745
|
+
}
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
await this.custodyStore.storeEnvelope({
|
|
750
|
+
envelopeId: typeof (encrypted as any).id === 'string' ? (encrypted as any).id : undefined,
|
|
751
|
+
opId: msg.id,
|
|
752
|
+
recipientPeerIds: [peerId],
|
|
753
|
+
workspaceId,
|
|
754
|
+
channelId,
|
|
755
|
+
...(threadId ? { threadId } : {}),
|
|
756
|
+
domain: 'channel-message',
|
|
757
|
+
ciphertext: encrypted,
|
|
758
|
+
metadata: {
|
|
759
|
+
messageId: msg.id,
|
|
760
|
+
...this.buildCustodyResendMetadata({
|
|
761
|
+
content: content.trim(),
|
|
762
|
+
channelId,
|
|
763
|
+
workspaceId,
|
|
764
|
+
senderId: this.myPeerId,
|
|
765
|
+
senderName: this.opts.account.alias,
|
|
766
|
+
threadId,
|
|
767
|
+
replyToId,
|
|
768
|
+
isDirect: false,
|
|
769
|
+
gossipOriginSignature,
|
|
770
|
+
metadata: modelMeta,
|
|
771
|
+
}),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
await this.replicateToCustodians(peerId, { workspaceId, channelId, opId: msg.id, domain: 'channel-message' });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
this.opts.log?.error?.(`[decentchat-peer] failed to prepare outbound for ${peerId}: ${String(err)}`);
|
|
777
|
+
await this.enqueueOffline(peerId, {
|
|
778
|
+
content: content.trim(),
|
|
779
|
+
channelId,
|
|
780
|
+
workspaceId,
|
|
781
|
+
senderId: this.myPeerId,
|
|
782
|
+
senderName: this.opts.account.alias,
|
|
783
|
+
messageId: msg.id,
|
|
784
|
+
threadId,
|
|
785
|
+
replyToId,
|
|
786
|
+
isDirect: false,
|
|
787
|
+
...(modelMeta ? { metadata: modelMeta } : {}),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private startGossipCleanup(): void {
|
|
794
|
+
if (this._gossipCleanupInterval) return;
|
|
795
|
+
const fiveMin = 5 * 60 * 1000;
|
|
796
|
+
this._gossipCleanupInterval = setInterval(() => {
|
|
797
|
+
const cutoff = Date.now() - fiveMin;
|
|
798
|
+
for (const [id, ts] of this._gossipSeen) {
|
|
799
|
+
if (ts < cutoff) this._gossipSeen.delete(id);
|
|
800
|
+
}
|
|
801
|
+
}, fiveMin);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private buildGossipOriginPayload(params: {
|
|
805
|
+
messageId: string;
|
|
806
|
+
channelId: string;
|
|
807
|
+
content: string;
|
|
808
|
+
threadId?: string;
|
|
809
|
+
replyToId?: string;
|
|
810
|
+
}): string {
|
|
811
|
+
const contentHash = createHash('sha256').update(params.content).digest('hex');
|
|
812
|
+
return `v1|${params.messageId}|${params.channelId}|${params.threadId ?? ''}|${params.replyToId ?? ''}|${contentHash}`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async signGossipOrigin(params: {
|
|
816
|
+
messageId: string;
|
|
817
|
+
channelId: string;
|
|
818
|
+
content: string;
|
|
819
|
+
threadId?: string;
|
|
820
|
+
replyToId?: string;
|
|
821
|
+
}): Promise<string | undefined> {
|
|
822
|
+
if (!this.signingKeyPair || !this.messageProtocol || typeof (this.messageProtocol as any).signData !== 'function') {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
return (this.messageProtocol as any).signData(this.buildGossipOriginPayload(params));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private async resolveInboundSenderId(
|
|
829
|
+
fromPeerId: string,
|
|
830
|
+
trustedSenderId: string | undefined,
|
|
831
|
+
msg: any,
|
|
832
|
+
channelId: string,
|
|
833
|
+
messageId: string,
|
|
834
|
+
content: string,
|
|
835
|
+
): Promise<{ senderId: string; allowRelay: boolean; verifiedGossipOrigin: boolean }> {
|
|
836
|
+
const gossipSender = typeof msg._gossipOriginalSender === 'string' && msg._gossipOriginalSender.length > 0
|
|
837
|
+
? msg._gossipOriginalSender
|
|
838
|
+
: undefined;
|
|
839
|
+
if (!gossipSender || gossipSender === fromPeerId) {
|
|
840
|
+
return { senderId: trustedSenderId ?? fromPeerId, allowRelay: true, verifiedGossipOrigin: false };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const originSignature = typeof msg._gossipOriginSignature === 'string' && msg._gossipOriginSignature.length > 0
|
|
844
|
+
? msg._gossipOriginSignature
|
|
845
|
+
: undefined;
|
|
846
|
+
if (!originSignature || !this.messageProtocol || typeof (this.messageProtocol as any).verifyData !== 'function') {
|
|
847
|
+
this.opts.log?.warn?.(
|
|
848
|
+
`[decentchat-peer] unsigned gossip origin claim ${gossipSender.slice(0, 8)} via ${fromPeerId.slice(0, 8)} for ${messageId.slice(0, 8)}; attributing to relay`,
|
|
849
|
+
);
|
|
850
|
+
return { senderId: fromPeerId, allowRelay: false, verifiedGossipOrigin: false };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
let isValid = false;
|
|
854
|
+
try {
|
|
855
|
+
isValid = await (this.messageProtocol as any).verifyData(
|
|
856
|
+
this.buildGossipOriginPayload({
|
|
857
|
+
messageId,
|
|
858
|
+
channelId,
|
|
859
|
+
content,
|
|
860
|
+
threadId: typeof msg.threadId === 'string' ? msg.threadId : undefined,
|
|
861
|
+
replyToId: typeof msg.replyToId === 'string' ? msg.replyToId : undefined,
|
|
862
|
+
}),
|
|
863
|
+
originSignature,
|
|
864
|
+
gossipSender,
|
|
865
|
+
);
|
|
866
|
+
} catch {
|
|
867
|
+
isValid = false;
|
|
868
|
+
}
|
|
869
|
+
if (!isValid) {
|
|
870
|
+
this.opts.log?.warn?.(
|
|
871
|
+
`[decentchat-peer] invalid gossip origin signature ${gossipSender.slice(0, 8)} via ${fromPeerId.slice(0, 8)} for ${messageId.slice(0, 8)}; attributing to relay`,
|
|
872
|
+
);
|
|
873
|
+
return { senderId: fromPeerId, allowRelay: false, verifiedGossipOrigin: false };
|
|
874
|
+
}
|
|
875
|
+
return { senderId: gossipSender, allowRelay: true, verifiedGossipOrigin: true };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private finalizeGossipRelayEnvelope(
|
|
879
|
+
relayEnv: any,
|
|
880
|
+
originalMsgId: string,
|
|
881
|
+
originalSenderId: string,
|
|
882
|
+
channelId: string,
|
|
883
|
+
workspaceId: string,
|
|
884
|
+
hop: number,
|
|
885
|
+
envelope: any,
|
|
886
|
+
): any {
|
|
887
|
+
relayEnv.messageId = originalMsgId;
|
|
888
|
+
relayEnv.channelId = channelId;
|
|
889
|
+
relayEnv.workspaceId = workspaceId;
|
|
890
|
+
relayEnv.senderId = originalSenderId;
|
|
891
|
+
if (typeof envelope.senderName === 'string' && envelope.senderName.trim()) {
|
|
892
|
+
relayEnv.senderName = envelope.senderName;
|
|
893
|
+
}
|
|
894
|
+
if (envelope.threadId) relayEnv.threadId = envelope.threadId;
|
|
895
|
+
if (envelope.replyToId) relayEnv.replyToId = envelope.replyToId;
|
|
896
|
+
if (envelope.vectorClock) relayEnv.vectorClock = envelope.vectorClock;
|
|
897
|
+
if (envelope.metadata) relayEnv.metadata = envelope.metadata;
|
|
898
|
+
if (Array.isArray(envelope.attachments) && envelope.attachments.length > 0) {
|
|
899
|
+
relayEnv.attachments = envelope.attachments;
|
|
900
|
+
}
|
|
901
|
+
if (envelope.threadRootSnapshot) relayEnv.threadRootSnapshot = envelope.threadRootSnapshot;
|
|
902
|
+
relayEnv._originalMessageId = originalMsgId;
|
|
903
|
+
relayEnv._gossipOriginalSender = originalSenderId;
|
|
904
|
+
relayEnv._gossipHop = hop;
|
|
905
|
+
if (typeof envelope._gossipOriginSignature === 'string' && envelope._gossipOriginSignature.length > 0) {
|
|
906
|
+
relayEnv._gossipOriginSignature = envelope._gossipOriginSignature;
|
|
907
|
+
}
|
|
908
|
+
return relayEnv;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private async gossipRelay(
|
|
912
|
+
fromPeerId: string,
|
|
913
|
+
originalMsgId: string,
|
|
914
|
+
originalSenderId: string,
|
|
915
|
+
plaintext: string,
|
|
916
|
+
channelId: string,
|
|
917
|
+
envelope: any,
|
|
918
|
+
): Promise<void> {
|
|
919
|
+
if (!this.transport || !this.messageProtocol) return;
|
|
920
|
+
|
|
921
|
+
const hop = (envelope._gossipHop ?? 0) + 1;
|
|
922
|
+
if (hop > DecentChatNodePeer.GOSSIP_TTL) return;
|
|
923
|
+
|
|
924
|
+
const workspaceId = typeof envelope.workspaceId === 'string' && envelope.workspaceId
|
|
925
|
+
? envelope.workspaceId
|
|
926
|
+
: this.findWorkspaceIdForChannel(channelId);
|
|
927
|
+
if (!workspaceId || workspaceId === 'direct') return;
|
|
928
|
+
|
|
929
|
+
const ws = this.workspaceManager.getWorkspace(workspaceId);
|
|
930
|
+
if (!ws) return;
|
|
931
|
+
|
|
932
|
+
const connectedPeers = new Set(this.transport.getConnectedPeers());
|
|
933
|
+
for (const member of ws.members) {
|
|
934
|
+
const targetPeerId = member.peerId;
|
|
935
|
+
if (!targetPeerId || targetPeerId === this.myPeerId) continue;
|
|
936
|
+
if (targetPeerId === fromPeerId) continue;
|
|
937
|
+
if (targetPeerId === originalSenderId) continue;
|
|
938
|
+
if (!connectedPeers.has(targetPeerId)) continue;
|
|
939
|
+
|
|
940
|
+
try {
|
|
941
|
+
const encrypted = await this.encryptMessageWithPreKeyBootstrap(
|
|
942
|
+
targetPeerId,
|
|
943
|
+
plaintext,
|
|
944
|
+
envelope.metadata as MessageMetadata | undefined,
|
|
945
|
+
workspaceId,
|
|
946
|
+
);
|
|
947
|
+
|
|
948
|
+
const relayEnv = this.finalizeGossipRelayEnvelope(
|
|
949
|
+
encrypted,
|
|
950
|
+
originalMsgId,
|
|
951
|
+
originalSenderId,
|
|
952
|
+
channelId,
|
|
953
|
+
workspaceId,
|
|
954
|
+
hop,
|
|
955
|
+
envelope,
|
|
956
|
+
);
|
|
957
|
+
this.transport.send(targetPeerId, relayEnv);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
this.opts.log?.warn?.(
|
|
960
|
+
`[decentchat-peer] gossip relay to ${targetPeerId.slice(0, 8)} failed: ${String((error as Error)?.message ?? error)}`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async joinWorkspace(inviteUri: string): Promise<void> {
|
|
967
|
+
if (!this.syncProtocol || !this.transport) return;
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const invite = InviteURI.decode(inviteUri);
|
|
971
|
+
if (!invite.peerId) {
|
|
972
|
+
this.opts.log?.warn?.('[decentchat-peer] invite missing peer ID; cannot auto-join');
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Validate invite expiration
|
|
977
|
+
if (InviteURI.isExpired(invite)) {
|
|
978
|
+
this.opts.log?.warn?.('[decentchat-peer] invite has expired; skipping join');
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
await this.transport.connect(invite.peerId);
|
|
983
|
+
|
|
984
|
+
const member: WorkspaceMember = {
|
|
985
|
+
peerId: this.myPeerId,
|
|
986
|
+
alias: this.opts.account.alias,
|
|
987
|
+
publicKey: this.myPublicKey,
|
|
988
|
+
role: 'member',
|
|
989
|
+
isBot: true,
|
|
990
|
+
companySim: this.getMyCompanySimProfile(),
|
|
991
|
+
joinedAt: Date.now(),
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
this.syncProtocol.requestJoin(invite.peerId, invite.inviteCode, member, invite.inviteId);
|
|
995
|
+
this.opts.log?.info(`[decentchat-peer] join request sent to ${invite.peerId}`);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
this.opts.log?.error?.(`[decentchat-peer] join failed: ${String(err)}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
destroy(): void {
|
|
1002
|
+
this.destroyed = true;
|
|
1003
|
+
if (this._gossipCleanupInterval) {
|
|
1004
|
+
clearInterval(this._gossipCleanupInterval);
|
|
1005
|
+
this._gossipCleanupInterval = null;
|
|
1006
|
+
}
|
|
1007
|
+
if (this._maintenanceInterval) {
|
|
1008
|
+
clearInterval(this._maintenanceInterval);
|
|
1009
|
+
this._maintenanceInterval = null;
|
|
1010
|
+
}
|
|
1011
|
+
if (this.manifestPersistTimer) {
|
|
1012
|
+
clearTimeout(this.manifestPersistTimer);
|
|
1013
|
+
this.manifestPersistTimer = null;
|
|
1014
|
+
this.persistManifestState();
|
|
1015
|
+
}
|
|
1016
|
+
// Clear all pending media request timeouts
|
|
1017
|
+
for (const pending of this.pendingMediaRequests.values()) {
|
|
1018
|
+
clearTimeout(pending.timeout);
|
|
1019
|
+
}
|
|
1020
|
+
this.pendingMediaRequests.clear();
|
|
1021
|
+
|
|
1022
|
+
for (const pending of this.pendingPreKeyBundleFetches.values()) {
|
|
1023
|
+
clearTimeout(pending.timer);
|
|
1024
|
+
pending.resolve(false);
|
|
1025
|
+
}
|
|
1026
|
+
this.pendingPreKeyBundleFetches.clear();
|
|
1027
|
+
this.botHuddle?.destroy();
|
|
1028
|
+
this.botHuddle = null;
|
|
1029
|
+
this.signingKeyPair = null;
|
|
1030
|
+
this.transport?.destroy();
|
|
1031
|
+
this.opts.log?.info('[decentchat-peer] stopped');
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Request full-quality image from a peer.
|
|
1036
|
+
* Returns a Buffer with the decrypted image data, or null if unavailable.
|
|
1037
|
+
*/
|
|
1038
|
+
|
|
1039
|
+
async requestFullImage(peerId: string, attachmentId: string): Promise<Buffer | null> {
|
|
1040
|
+
if (!this.transport) return null;
|
|
1041
|
+
|
|
1042
|
+
// Check if we already have this image stored locally
|
|
1043
|
+
const storedKey = `media-full:${attachmentId}`;
|
|
1044
|
+
const stored = this.store.get<string>(storedKey, null);
|
|
1045
|
+
if (stored) {
|
|
1046
|
+
try {
|
|
1047
|
+
return Buffer.from(stored, 'base64');
|
|
1048
|
+
} catch {
|
|
1049
|
+
// corrupted storage, continue to fetch
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return new Promise((resolve) => {
|
|
1054
|
+
const timeout = setTimeout(() => {
|
|
1055
|
+
this.pendingMediaRequests.delete(attachmentId);
|
|
1056
|
+
resolve(null);
|
|
1057
|
+
}, this.mediaChunkTimeout);
|
|
1058
|
+
|
|
1059
|
+
this.pendingMediaRequests.set(attachmentId, {
|
|
1060
|
+
attachmentId,
|
|
1061
|
+
peerId,
|
|
1062
|
+
resolve,
|
|
1063
|
+
chunks: new Map(),
|
|
1064
|
+
timeout,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
const request: MediaRequest = { type: 'media-request', attachmentId };
|
|
1068
|
+
this.transport?.send(peerId, request);
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private startPeerMaintenance(): void {
|
|
1073
|
+
if (this._maintenanceInterval) return;
|
|
1074
|
+
this._maintenanceInterval = setInterval(() => {
|
|
1075
|
+
void this.runPeerMaintenancePass();
|
|
1076
|
+
}, 30_000);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private extractPeerIdFromTransportError(error: Error): string | null {
|
|
1080
|
+
const match = /Could not connect to peer ([a-z0-9]+)/i.exec(error.message);
|
|
1081
|
+
return match?.[1] ?? null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private logTransportError(error: Error): void {
|
|
1085
|
+
const message = error.message || String(error);
|
|
1086
|
+
const peerId = this.extractPeerIdFromTransportError(error);
|
|
1087
|
+
if (!peerId) {
|
|
1088
|
+
this.opts.log?.error?.(`[decentchat-peer] transport error: ${message}`);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const now = Date.now();
|
|
1093
|
+
const current = this.throttledTransportErrors.get(peerId);
|
|
1094
|
+
if (!current || now - current.windowStart >= DecentChatNodePeer.TRANSPORT_ERROR_LOG_WINDOW_MS) {
|
|
1095
|
+
if (current && current.suppressed > 0) {
|
|
1096
|
+
this.opts.log?.warn?.(`[decentchat-peer] transport error repeats for ${peerId.slice(0, 8)} suppressed=${current.suppressed}`);
|
|
1097
|
+
}
|
|
1098
|
+
this.throttledTransportErrors.set(peerId, { windowStart: now, suppressed: 0 });
|
|
1099
|
+
this.opts.log?.error?.(`[decentchat-peer] transport error: ${message}`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
current.suppressed += 1;
|
|
1104
|
+
if (current.suppressed % 20 === 0) {
|
|
1105
|
+
this.opts.log?.warn?.(`[decentchat-peer] transport error repeats for ${peerId.slice(0, 8)} suppressed=${current.suppressed}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private notePeerMaintenanceFailure(peerId: string | null, now = Date.now()): void {
|
|
1110
|
+
if (!peerId || peerId === this.myPeerId) return;
|
|
1111
|
+
const attempt = (this.peerMaintenanceAttemptsByPeer.get(peerId) ?? 0) + 1;
|
|
1112
|
+
this.peerMaintenanceAttemptsByPeer.set(peerId, attempt);
|
|
1113
|
+
const delay = Math.min(
|
|
1114
|
+
DecentChatNodePeer.PEER_MAINTENANCE_RETRY_BASE_MS * (2 ** Math.max(0, attempt - 1)),
|
|
1115
|
+
DecentChatNodePeer.PEER_MAINTENANCE_RETRY_MAX_MS,
|
|
1116
|
+
);
|
|
1117
|
+
this.peerMaintenanceRetryAtByPeer.set(peerId, now + delay);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private clearPeerMaintenanceFailure(peerId: string): void {
|
|
1121
|
+
this.peerMaintenanceAttemptsByPeer.delete(peerId);
|
|
1122
|
+
this.peerMaintenanceRetryAtByPeer.delete(peerId);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async runPeerMaintenancePass(now = Date.now()): Promise<void> {
|
|
1126
|
+
if (this.destroyed || !this.transport) return;
|
|
1127
|
+
const connectedPeers = new Set(this.transport.getConnectedPeers());
|
|
1128
|
+
const seen = new Set<string>();
|
|
1129
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
1130
|
+
for (const member of workspace.members) {
|
|
1131
|
+
const peerId = member.peerId;
|
|
1132
|
+
if (peerId === this.myPeerId) continue;
|
|
1133
|
+
if (seen.has(peerId)) continue;
|
|
1134
|
+
seen.add(peerId);
|
|
1135
|
+
if (connectedPeers.has(peerId)) {
|
|
1136
|
+
this.clearPeerMaintenanceFailure(peerId);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const retryAt = this.peerMaintenanceRetryAtByPeer.get(peerId) ?? 0;
|
|
1140
|
+
if (retryAt > now) continue;
|
|
1141
|
+
try {
|
|
1142
|
+
await this.transport.connect(peerId);
|
|
1143
|
+
} catch {
|
|
1144
|
+
this.notePeerMaintenanceFailure(peerId, now);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private async withCompanyTemplateInstallLock<T>(task: () => Promise<T>): Promise<T> {
|
|
1151
|
+
const waitForPrevious = this.companyTemplateInstallLock;
|
|
1152
|
+
let releaseCurrent: (() => void) | null = null;
|
|
1153
|
+
|
|
1154
|
+
this.companyTemplateInstallLock = new Promise<void>((resolve) => {
|
|
1155
|
+
releaseCurrent = resolve;
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
await waitForPrevious;
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
return await task();
|
|
1162
|
+
} finally {
|
|
1163
|
+
releaseCurrent?.();
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
private canRequesterInstallCompanyTemplate(workspace: Workspace, peerId: string): boolean {
|
|
1168
|
+
const requester = workspace.members.find((member) => member.peerId === peerId);
|
|
1169
|
+
if (!requester) return false;
|
|
1170
|
+
|
|
1171
|
+
const role = requester.role ?? 'member';
|
|
1172
|
+
if (role === 'owner' || role === 'admin') return true;
|
|
1173
|
+
|
|
1174
|
+
return workspace.createdBy === peerId;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private sendCompanyTemplateInstallResponse(params: {
|
|
1178
|
+
targetPeerId: string;
|
|
1179
|
+
workspaceId: string;
|
|
1180
|
+
requestId: string;
|
|
1181
|
+
ok: boolean;
|
|
1182
|
+
result?: CompanyTemplateControlInstallResult;
|
|
1183
|
+
error?: { code: string; message: string };
|
|
1184
|
+
}): void {
|
|
1185
|
+
if (!this.transport) return;
|
|
1186
|
+
|
|
1187
|
+
this.transport.send(params.targetPeerId, {
|
|
1188
|
+
type: 'workspace-sync',
|
|
1189
|
+
workspaceId: params.workspaceId,
|
|
1190
|
+
sync: {
|
|
1191
|
+
type: 'company-template-install-response',
|
|
1192
|
+
requestId: params.requestId,
|
|
1193
|
+
ok: params.ok,
|
|
1194
|
+
...(params.result ? { result: params.result } : {}),
|
|
1195
|
+
...(params.error ? { error: params.error } : {}),
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private async installCompanyTemplateViaControlPlane(params: {
|
|
1201
|
+
workspaceId: string;
|
|
1202
|
+
templateId: string;
|
|
1203
|
+
answers?: unknown;
|
|
1204
|
+
requestedByPeerId: string;
|
|
1205
|
+
}): Promise<CompanyTemplateControlInstallResult> {
|
|
1206
|
+
const control = this.opts.companyTemplateControl;
|
|
1207
|
+
if (!control) {
|
|
1208
|
+
throw new Error('Company template control bridge is unavailable on this host');
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (typeof control.installTemplate === 'function') {
|
|
1212
|
+
return await control.installTemplate(params);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (typeof control.loadConfig !== 'function' || typeof control.writeConfigFile !== 'function') {
|
|
1216
|
+
throw new Error('Host control bridge is misconfigured for template installs');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const template = getCompanySimTemplate(
|
|
1220
|
+
params.templateId,
|
|
1221
|
+
control.templatesRoot ? { templatesRoot: control.templatesRoot } : undefined,
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
const existingConfig = control.loadConfig();
|
|
1225
|
+
if (!isRecord(existingConfig)) {
|
|
1226
|
+
throw new Error('OpenClaw config is not an object');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const install = installCompanyTemplate({
|
|
1230
|
+
template,
|
|
1231
|
+
config: existingConfig,
|
|
1232
|
+
answers: normalizeTemplateInstallAnswers(params.answers),
|
|
1233
|
+
targetWorkspaceId: params.workspaceId,
|
|
1234
|
+
...(control.workspaceRootDir ? { workspaceRootDir: control.workspaceRootDir } : {}),
|
|
1235
|
+
...(control.companySimsRootDir ? { companySimsRootDir: control.companySimsRootDir } : {}),
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
await control.writeConfigFile(install.config);
|
|
1239
|
+
|
|
1240
|
+
const manualActionItems = buildManualActionItems(install.summary.manualActionRequiredAccountIds);
|
|
1241
|
+
|
|
1242
|
+
return {
|
|
1243
|
+
provisioningMode: 'config-provisioned',
|
|
1244
|
+
createdAccountIds: install.summary.createdAccountIds,
|
|
1245
|
+
provisionedAccountIds: install.summary.provisionedAccountIds,
|
|
1246
|
+
onlineReadyAccountIds: install.summary.onlineReadyAccountIds,
|
|
1247
|
+
manualActionRequiredAccountIds: install.summary.manualActionRequiredAccountIds,
|
|
1248
|
+
manualActionItems,
|
|
1249
|
+
companyId: install.summary.companyId,
|
|
1250
|
+
manifestPath: install.summary.manifestPath,
|
|
1251
|
+
companyDirPath: install.summary.companyDirPath,
|
|
1252
|
+
...(install.summary.communicationPolicy ? { communicationPolicy: install.summary.communicationPolicy } : {}),
|
|
1253
|
+
...(install.summary.benchmarkSuite ? { benchmarkSuite: install.summary.benchmarkSuite } : {}),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private async handleCompanySimControlRequest(fromPeerId: string, sync: any): Promise<void> {
|
|
1258
|
+
const requestId = typeof sync?.requestId === 'string' ? sync.requestId.trim() : '';
|
|
1259
|
+
const workspaceId = typeof sync?.workspaceId === 'string' ? sync.workspaceId.trim() : '';
|
|
1260
|
+
const messageType = typeof sync?.type === 'string' ? sync.type : '';
|
|
1261
|
+
|
|
1262
|
+
if (!requestId || !workspaceId) return;
|
|
1263
|
+
|
|
1264
|
+
const responseType = messageType.replace(/-request$/, '-response');
|
|
1265
|
+
|
|
1266
|
+
const sendResponse = (ok: boolean, result?: any, error?: { code: string; message: string }) => {
|
|
1267
|
+
if (!this.transport) return;
|
|
1268
|
+
this.transport.send(fromPeerId, {
|
|
1269
|
+
type: 'workspace-sync',
|
|
1270
|
+
workspaceId,
|
|
1271
|
+
sync: { type: responseType, requestId, ok, ...(result ? { result } : {}), ...(error ? { error } : {}) },
|
|
1272
|
+
});
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
1276
|
+
if (!workspace) {
|
|
1277
|
+
sendResponse(false, undefined, { code: 'workspace_not_found', message: `Workspace not found: ${workspaceId}` });
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (this.workspaceManager.isBanned(workspaceId, fromPeerId) || !this.canRequesterInstallCompanyTemplate(workspace, fromPeerId)) {
|
|
1282
|
+
sendResponse(false, undefined, { code: 'forbidden', message: 'Only workspace owners/admins can access company sim control plane' });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const control = this.opts.companyTemplateControl;
|
|
1287
|
+
if (!control?.loadConfig) {
|
|
1288
|
+
sendResponse(false, undefined, { code: 'bridge_unavailable', message: 'Host control bridge is unavailable' });
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
try {
|
|
1293
|
+
const baseParams = {
|
|
1294
|
+
workspaceId,
|
|
1295
|
+
workspaceName: workspace.name,
|
|
1296
|
+
loadConfig: control.loadConfig,
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
if (messageType === 'company-sim-state-request') {
|
|
1300
|
+
const state = getCompanySimControlState(baseParams);
|
|
1301
|
+
sendResponse(true, state);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (messageType === 'company-sim-doc-read-request') {
|
|
1306
|
+
const relativePath = typeof sync?.relativePath === 'string' ? sync.relativePath.trim() : '';
|
|
1307
|
+
if (!relativePath) { sendResponse(false, undefined, { code: 'bad_request', message: 'relativePath is required' }); return; }
|
|
1308
|
+
const docResult = readCompanySimControlDocument({ ...baseParams, relativePath });
|
|
1309
|
+
sendResponse(true, { content: docResult.content, doc: docResult.doc });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (messageType === 'company-sim-doc-write-request') {
|
|
1314
|
+
const relativePath = typeof sync?.relativePath === 'string' ? sync.relativePath.trim() : '';
|
|
1315
|
+
const content = typeof sync?.content === 'string' ? sync.content : '';
|
|
1316
|
+
if (!relativePath) { sendResponse(false, undefined, { code: 'bad_request', message: 'relativePath is required' }); return; }
|
|
1317
|
+
const docResult = writeCompanySimControlDocument({ ...baseParams, relativePath, content });
|
|
1318
|
+
sendResponse(true, { content: docResult.content, doc: docResult.doc });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (messageType === 'company-sim-routing-preview-request') {
|
|
1323
|
+
const chatType = sync?.chatType === 'direct' ? 'direct' : 'channel';
|
|
1324
|
+
const text = typeof sync?.text === 'string' ? sync.text : '';
|
|
1325
|
+
const channelNameOrId = typeof sync?.channelNameOrId === 'string' ? sync.channelNameOrId.trim() : undefined;
|
|
1326
|
+
const threadId = typeof sync?.threadId === 'string' ? sync.threadId.trim() : undefined;
|
|
1327
|
+
const preview = previewCompanySimRouting({ ...baseParams, chatType, channelNameOrId, text, threadId });
|
|
1328
|
+
sendResponse(true, preview);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (messageType === 'company-sim-employee-context-request') {
|
|
1333
|
+
const employeeId = typeof sync?.employeeId === 'string' ? sync.employeeId.trim() : '';
|
|
1334
|
+
if (!employeeId) { sendResponse(false, undefined, { code: 'bad_request', message: 'employeeId is required' }); return; }
|
|
1335
|
+
const result = getCompanySimEmployeeContext({ ...baseParams, employeeId });
|
|
1336
|
+
sendResponse(true, result);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
sendResponse(false, undefined, { code: 'unknown_request', message: `Unknown control request: ${messageType}` });
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
const message = String((error as Error)?.message ?? error ?? 'Unknown error').trim();
|
|
1343
|
+
sendResponse(false, undefined, { code: 'bad_request', message: message.slice(0, 240) });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
private async handleCompanyTemplateInstallRequest(fromPeerId: string, sync: any): Promise<void> {
|
|
1348
|
+
const requestId = typeof sync?.requestId === 'string' ? sync.requestId.trim() : '';
|
|
1349
|
+
const workspaceId = typeof sync?.workspaceId === 'string' ? sync.workspaceId.trim() : '';
|
|
1350
|
+
const templateId = typeof sync?.templateId === 'string' ? sync.templateId.trim() : '';
|
|
1351
|
+
|
|
1352
|
+
if (!requestId || !workspaceId || !templateId) {
|
|
1353
|
+
if (requestId && workspaceId) {
|
|
1354
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1355
|
+
targetPeerId: fromPeerId,
|
|
1356
|
+
workspaceId,
|
|
1357
|
+
requestId,
|
|
1358
|
+
ok: false,
|
|
1359
|
+
error: {
|
|
1360
|
+
code: 'bad_request',
|
|
1361
|
+
message: 'requestId, workspaceId, and templateId are required',
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (!this.opts.companyTemplateControl) {
|
|
1369
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1370
|
+
targetPeerId: fromPeerId,
|
|
1371
|
+
workspaceId,
|
|
1372
|
+
requestId,
|
|
1373
|
+
ok: false,
|
|
1374
|
+
error: {
|
|
1375
|
+
code: 'bridge_unavailable',
|
|
1376
|
+
message: 'Host control bridge is unavailable for template installs',
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
1383
|
+
if (!workspace) {
|
|
1384
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1385
|
+
targetPeerId: fromPeerId,
|
|
1386
|
+
workspaceId,
|
|
1387
|
+
requestId,
|
|
1388
|
+
ok: false,
|
|
1389
|
+
error: {
|
|
1390
|
+
code: 'workspace_not_found',
|
|
1391
|
+
message: `Workspace not found: ${workspaceId}`,
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (this.workspaceManager.isBanned(workspaceId, fromPeerId) || !this.canRequesterInstallCompanyTemplate(workspace, fromPeerId)) {
|
|
1398
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1399
|
+
targetPeerId: fromPeerId,
|
|
1400
|
+
workspaceId,
|
|
1401
|
+
requestId,
|
|
1402
|
+
ok: false,
|
|
1403
|
+
error: {
|
|
1404
|
+
code: 'forbidden',
|
|
1405
|
+
message: 'Only workspace owners/admins can install AI teams via host control plane',
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
try {
|
|
1412
|
+
const result = await this.withCompanyTemplateInstallLock(() => this.installCompanyTemplateViaControlPlane({
|
|
1413
|
+
workspaceId,
|
|
1414
|
+
templateId,
|
|
1415
|
+
answers: {
|
|
1416
|
+
...(isRecord(sync?.answers) ? sync.answers : {}),
|
|
1417
|
+
workspaceName: workspace.name,
|
|
1418
|
+
},
|
|
1419
|
+
requestedByPeerId: fromPeerId,
|
|
1420
|
+
}));
|
|
1421
|
+
|
|
1422
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1423
|
+
targetPeerId: fromPeerId,
|
|
1424
|
+
workspaceId,
|
|
1425
|
+
requestId,
|
|
1426
|
+
ok: true,
|
|
1427
|
+
result,
|
|
1428
|
+
});
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
const message = normalizeCompanyTemplateControlErrorMessage(error);
|
|
1431
|
+
this.opts.log?.warn?.(`[decentchat-peer] rejected company template install request: ${message}`);
|
|
1432
|
+
this.sendCompanyTemplateInstallResponse({
|
|
1433
|
+
targetPeerId: fromPeerId,
|
|
1434
|
+
workspaceId,
|
|
1435
|
+
requestId,
|
|
1436
|
+
ok: false,
|
|
1437
|
+
error: {
|
|
1438
|
+
code: 'install_failed',
|
|
1439
|
+
message,
|
|
1440
|
+
},
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
private async handlePeerMessage(fromPeerId: string, rawData: unknown, trustedSenderId?: string): Promise<void> {
|
|
1446
|
+
if (this.destroyed || !this.syncProtocol || !this.messageProtocol || !this.transport) return;
|
|
1447
|
+
|
|
1448
|
+
const msg = rawData as any;
|
|
1449
|
+
|
|
1450
|
+
if (msg?.type === 'ack') {
|
|
1451
|
+
await this.handleInboundReceipt(fromPeerId, msg, 'acknowledged');
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (msg?.type === 'read') {
|
|
1456
|
+
await this.handleInboundReceipt(fromPeerId, msg, 'read');
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (await this.handlePreKeyControl(fromPeerId, msg)) {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (msg?.type === 'handshake') {
|
|
1465
|
+
if (this.shouldIgnoreInboundHandshakeBurst(fromPeerId)) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
this.decryptRecoveryAtByPeer.delete(fromPeerId);
|
|
1469
|
+
await this.messageProtocol.processHandshake(fromPeerId, msg);
|
|
1470
|
+
if (msg.preKeySupport) {
|
|
1471
|
+
const preKeyWorkspaceId = this.resolveSharedWorkspaceIds(fromPeerId)[0];
|
|
1472
|
+
this.transport.send(fromPeerId, {
|
|
1473
|
+
type: 'pre-key-bundle.request',
|
|
1474
|
+
...(preKeyWorkspaceId ? { workspaceId: preKeyWorkspaceId } : {}),
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
await this.publishPreKeyBundle(fromPeerId);
|
|
1478
|
+
const knownKeys = this.store.get<Record<string, string>>('peer-public-keys', {});
|
|
1479
|
+
knownKeys[fromPeerId] = msg.publicKey;
|
|
1480
|
+
this.store.set('peer-public-keys', knownKeys);
|
|
1481
|
+
this.updateWorkspaceMemberKey(fromPeerId, msg.publicKey);
|
|
1482
|
+
// Save sender's display name if provided
|
|
1483
|
+
if (msg.alias) {
|
|
1484
|
+
this.applyNameAnnounce(fromPeerId, {
|
|
1485
|
+
alias: msg.alias as string,
|
|
1486
|
+
workspaceId: typeof msg.workspaceId === 'string' ? msg.workspaceId : undefined,
|
|
1487
|
+
companySim: msg.companySim as any,
|
|
1488
|
+
isBot: msg.isBot === true,
|
|
1489
|
+
publicKey: typeof msg.publicKey === 'string' ? msg.publicKey : undefined,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
// Always reply with our own handshake so the remote peer can complete
|
|
1493
|
+
// crypto setup. Without this, a peer that hard-refreshed (lost its
|
|
1494
|
+
// shared secret) sends us a handshake, we process it on our side, but
|
|
1495
|
+
// never reply — the remote peer never enters readyPeers and sees us
|
|
1496
|
+
// as permanently offline.
|
|
1497
|
+
await this.sendHandshake(fromPeerId);
|
|
1498
|
+
await this.resumePeerSession(fromPeerId);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Handle name-announce (unencrypted) — must be before the encrypted guard
|
|
1503
|
+
if (msg?.type === 'name-announce' && msg.alias) {
|
|
1504
|
+
const alias = msg.alias as string;
|
|
1505
|
+
const result = this.applyNameAnnounce(fromPeerId, {
|
|
1506
|
+
alias,
|
|
1507
|
+
workspaceId: typeof msg.workspaceId === 'string' ? msg.workspaceId : undefined,
|
|
1508
|
+
companySim: msg.companySim as any,
|
|
1509
|
+
isBot: msg.isBot === true,
|
|
1510
|
+
});
|
|
1511
|
+
if (result.memberAdded && result.workspaceId && this.syncProtocol) {
|
|
1512
|
+
this.syncProtocol.requestSync(fromPeerId, result.workspaceId);
|
|
1513
|
+
}
|
|
1514
|
+
// Also cache directly so resolveSenderName can find it even before workspace sync
|
|
1515
|
+
this.store.set(`peer-alias-${fromPeerId}`, alias);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (msg?.type === 'workspace-sync' && msg.sync) {
|
|
1520
|
+
const merged = msg.workspaceId ? { ...msg.sync, workspaceId: msg.workspaceId } : msg.sync;
|
|
1521
|
+
|
|
1522
|
+
if (merged.type === 'company-template-install-request') {
|
|
1523
|
+
await this.handleCompanyTemplateInstallRequest(fromPeerId, merged);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (merged.type === 'company-sim-state-request' || merged.type === 'company-sim-doc-read-request' || merged.type === 'company-sim-doc-write-request' || merged.type === 'company-sim-routing-preview-request' || merged.type === 'company-sim-employee-context-request') {
|
|
1528
|
+
await this.handleCompanySimControlRequest(fromPeerId, merged);
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Handle workspace-state directly (SyncProtocol doesn't have a case for it)
|
|
1533
|
+
if (merged.type === 'workspace-state' && merged.workspaceId) {
|
|
1534
|
+
this.handleWorkspaceState(fromPeerId, merged.workspaceId, merged);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
await this.syncProtocol.handleMessage(fromPeerId, merged);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Handle Negentropy sync queries from web client
|
|
1543
|
+
if (msg?.type === 'message-sync-negentropy-query') {
|
|
1544
|
+
await this.handleNegentropyQuery(fromPeerId, msg);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Handle fetch requests for specific message IDs (after Negentropy reconciliation)
|
|
1549
|
+
if (msg?.type === 'message-sync-fetch-request') {
|
|
1550
|
+
await this.handleFetchRequest(fromPeerId, msg);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
if (msg?.type === 'sync.summary') {
|
|
1555
|
+
await this.handleManifestSummary(fromPeerId, msg);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (msg?.type === 'sync.diff_request') {
|
|
1560
|
+
await this.handleManifestDiffRequest(fromPeerId, msg);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (msg?.type === 'sync.diff_response') {
|
|
1565
|
+
await this.handleManifestDiffResponse(fromPeerId, msg);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if (msg?.type === 'sync.fetch_snapshot') {
|
|
1570
|
+
await this.handleManifestFetchSnapshot(fromPeerId, msg);
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
if (msg?.type === 'sync.snapshot_response') {
|
|
1575
|
+
await this.handleManifestSnapshotResponse(fromPeerId, msg);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (typeof msg?.type === 'string' && msg.type.startsWith('custody.')) {
|
|
1580
|
+
await this.handleCustodyControl(fromPeerId, msg);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Handle media requests (unencrypted, similar to web client)
|
|
1585
|
+
if (msg?.type === 'media-request') {
|
|
1586
|
+
await this.handleMediaRequest(fromPeerId, msg as MediaRequest);
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (msg?.type === 'media-response') {
|
|
1590
|
+
await this.handleMediaResponse(fromPeerId, msg as MediaResponse);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
if (msg?.type === 'media-chunk') {
|
|
1594
|
+
await this.handleMediaChunk(fromPeerId, msg as MediaChunk);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const gossipOrigId = typeof msg?._originalMessageId === 'string' ? msg._originalMessageId : undefined;
|
|
1599
|
+
if (gossipOrigId && this._gossipSeen.has(gossipOrigId)) {
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Route huddle signals to BotHuddleManager (unencrypted, like browser client)
|
|
1604
|
+
if (typeof msg?.type === 'string' && msg.type.startsWith('huddle-')) {
|
|
1605
|
+
await this.botHuddle?.handleSignal(fromPeerId, msg);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (!msg?.encrypted && !msg?.ratchet) {
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const peerPubKeyB64 = this.getPeerPublicKey(fromPeerId);
|
|
1614
|
+
if (!peerPubKeyB64) {
|
|
1615
|
+
this.opts.log?.warn?.(`[decentchat-peer] missing public key for ${fromPeerId}, skipping message`);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const peerPublicKey = await this.cryptoManager.importPublicKey(peerPubKeyB64);
|
|
1620
|
+
let content: string | null;
|
|
1621
|
+
try {
|
|
1622
|
+
content = await this.messageProtocol.decryptMessage(fromPeerId, msg, peerPublicKey);
|
|
1623
|
+
} catch (err) {
|
|
1624
|
+
if (this.shouldIgnoreDecryptReplay(fromPeerId, msg, err)) {
|
|
1625
|
+
this.opts.log?.info?.(`[decentchat-peer] replayed pre-key from ${fromPeerId} ignored`);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
this.opts.log?.warn?.(`[decentchat-peer] decrypt threw for ${fromPeerId}, resetting ratchet: ${String(err)}`);
|
|
1629
|
+
await this.triggerDecryptRecoveryHandshake(fromPeerId);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (!content) {
|
|
1633
|
+
// decryptMessage returned null (internal error) — ratchet desynced, reset it
|
|
1634
|
+
this.opts.log?.warn?.(`[decentchat-peer] decrypt returned null for ${fromPeerId}, resetting ratchet`);
|
|
1635
|
+
await this.triggerDecryptRecoveryHandshake(fromPeerId);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
this.decryptRecoveryAtByPeer.delete(fromPeerId);
|
|
1639
|
+
|
|
1640
|
+
const isDirect = msg.isDirect === true;
|
|
1641
|
+
const channelId = (msg.channelId as string | undefined) ?? (isDirect ? fromPeerId : undefined);
|
|
1642
|
+
if (!channelId) return;
|
|
1643
|
+
const envelopeMessageId = typeof msg.messageId === 'string' && msg.messageId.length > 0
|
|
1644
|
+
? msg.messageId
|
|
1645
|
+
: (gossipOrigId ?? '');
|
|
1646
|
+
const senderResolution = await this.resolveInboundSenderId(
|
|
1647
|
+
fromPeerId,
|
|
1648
|
+
trustedSenderId,
|
|
1649
|
+
msg,
|
|
1650
|
+
channelId,
|
|
1651
|
+
envelopeMessageId,
|
|
1652
|
+
content,
|
|
1653
|
+
);
|
|
1654
|
+
const actualSenderId = senderResolution.senderId;
|
|
1655
|
+
|
|
1656
|
+
const created = await this.messageStore.createMessage(
|
|
1657
|
+
channelId,
|
|
1658
|
+
actualSenderId,
|
|
1659
|
+
content,
|
|
1660
|
+
'text',
|
|
1661
|
+
msg.threadId,
|
|
1662
|
+
);
|
|
1663
|
+
const lastTs = this.messageStore.getMessages(channelId).slice(-1)[0]?.timestamp ?? 0;
|
|
1664
|
+
created.timestamp = Math.max((msg.timestamp as number | undefined) ?? Date.now(), lastTs + 1);
|
|
1665
|
+
if (typeof msg.messageId === 'string') {
|
|
1666
|
+
created.id = msg.messageId;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const result = await this.messageStore.addMessage(created);
|
|
1670
|
+
if (!result.success) {
|
|
1671
|
+
this.opts.log?.warn?.(`[decentchat-peer] rejected message ${created.id}: ${result.error}`);
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
this._gossipSeen.set(created.id, Date.now());
|
|
1675
|
+
|
|
1676
|
+
this.persistMessagesForChannel(channelId);
|
|
1677
|
+
|
|
1678
|
+
const workspaceId = (msg.workspaceId as string | undefined) ?? (isDirect ? 'direct' : '');
|
|
1679
|
+
this.recordManifestDomain('channel-message', workspaceId || this.findWorkspaceIdForChannel(channelId), {
|
|
1680
|
+
channelId,
|
|
1681
|
+
itemCount: this.messageStore.getMessages(channelId).length,
|
|
1682
|
+
operation: 'create',
|
|
1683
|
+
subject: created.id,
|
|
1684
|
+
data: { messageId: created.id, senderId: actualSenderId },
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
this.transport.send(fromPeerId, {
|
|
1688
|
+
type: 'ack',
|
|
1689
|
+
messageId: created.id,
|
|
1690
|
+
channelId,
|
|
1691
|
+
...(typeof msg.envelopeId === 'string' ? { envelopeId: msg.envelopeId } : {}),
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
const senderName = this.resolveSenderName(workspaceId, actualSenderId, msg.senderName as string | undefined);
|
|
1695
|
+
const attachments = Array.isArray(msg.attachments)
|
|
1696
|
+
? (msg.attachments as Array<{
|
|
1697
|
+
id: string;
|
|
1698
|
+
name: string;
|
|
1699
|
+
type: string;
|
|
1700
|
+
size?: number;
|
|
1701
|
+
thumbnail?: string;
|
|
1702
|
+
width?: number;
|
|
1703
|
+
height?: number;
|
|
1704
|
+
}>)
|
|
1705
|
+
: undefined;
|
|
1706
|
+
|
|
1707
|
+
await this.opts.onIncomingMessage({
|
|
1708
|
+
channelId,
|
|
1709
|
+
workspaceId,
|
|
1710
|
+
content,
|
|
1711
|
+
senderId: actualSenderId,
|
|
1712
|
+
senderName,
|
|
1713
|
+
messageId: created.id,
|
|
1714
|
+
chatType: msg.isDirect ? 'direct' : 'channel',
|
|
1715
|
+
timestamp: created.timestamp,
|
|
1716
|
+
replyToId: msg.replyToId as string | undefined,
|
|
1717
|
+
threadId: msg.threadId as string | undefined,
|
|
1718
|
+
attachments,
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
if (!isDirect && senderResolution.allowRelay) {
|
|
1722
|
+
void this.gossipRelay(fromPeerId, created.id, actualSenderId, content, channelId, msg);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
private async handleNegentropyQuery(fromPeerId: string, msg: any): Promise<void> {
|
|
1727
|
+
const wsId = msg.workspaceId as string | undefined;
|
|
1728
|
+
const channelId = msg.channelId as string | undefined;
|
|
1729
|
+
const requestId = msg.requestId as string | undefined;
|
|
1730
|
+
const query = msg.query;
|
|
1731
|
+
const sendReject = (reason: string): void => {
|
|
1732
|
+
this.opts.log?.warn?.(
|
|
1733
|
+
`[decentchat-peer] Negentropy query rejected from ${fromPeerId.slice(0, 8)}: ${reason}`,
|
|
1734
|
+
);
|
|
1735
|
+
if (!this.transport || !requestId) return;
|
|
1736
|
+
this.transport.send(fromPeerId, {
|
|
1737
|
+
type: 'message-sync-negentropy-response',
|
|
1738
|
+
requestId,
|
|
1739
|
+
...(wsId ? { workspaceId: wsId } : {}),
|
|
1740
|
+
...(channelId ? { channelId } : {}),
|
|
1741
|
+
response: {
|
|
1742
|
+
have: [],
|
|
1743
|
+
need: [],
|
|
1744
|
+
},
|
|
1745
|
+
error: 'rejected',
|
|
1746
|
+
});
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
if (!wsId || !channelId || !requestId || !query) {
|
|
1750
|
+
sendReject('invalid-request');
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const ws = this.workspaceManager.getWorkspace(wsId);
|
|
1755
|
+
if (!ws) {
|
|
1756
|
+
sendReject('workspace-not-found');
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (!ws.members.some((m: any) => m.peerId === fromPeerId)) {
|
|
1760
|
+
sendReject('peer-not-member');
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (!ws.channels.some((ch: any) => ch.id === channelId)) {
|
|
1764
|
+
sendReject('channel-not-found');
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const localItems = this.messageStore.getMessages(channelId).map((m) => ({ id: m.id, timestamp: m.timestamp }));
|
|
1769
|
+
const negentropy = new Negentropy();
|
|
1770
|
+
await negentropy.build(localItems);
|
|
1771
|
+
const response = await negentropy.processQuery(query);
|
|
1772
|
+
|
|
1773
|
+
this.transport!.send(fromPeerId, {
|
|
1774
|
+
type: 'message-sync-negentropy-response',
|
|
1775
|
+
requestId,
|
|
1776
|
+
workspaceId: wsId,
|
|
1777
|
+
channelId,
|
|
1778
|
+
response,
|
|
1779
|
+
});
|
|
1780
|
+
this.opts.log?.info?.(`[decentchat-peer] Negentropy query from ${fromPeerId.slice(0, 8)} for channel ${channelId.slice(0, 8)}: ${localItems.length} local messages`);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private async handleFetchRequest(fromPeerId: string, msg: any): Promise<void> {
|
|
1784
|
+
const wsId = msg.workspaceId as string | undefined;
|
|
1785
|
+
if (!wsId) return;
|
|
1786
|
+
const ws = this.workspaceManager.getWorkspace(wsId);
|
|
1787
|
+
if (!ws) return;
|
|
1788
|
+
if (!ws.members.some((m: any) => m.peerId === fromPeerId)) return;
|
|
1789
|
+
|
|
1790
|
+
const requested: Record<string, string[]> = msg.messageIdsByChannel || {};
|
|
1791
|
+
const allMessages: any[] = [];
|
|
1792
|
+
|
|
1793
|
+
for (const ch of ws.channels) {
|
|
1794
|
+
const requestedIds = Array.isArray(requested[ch.id]) ? requested[ch.id] : [];
|
|
1795
|
+
if (requestedIds.length === 0) continue;
|
|
1796
|
+
const idSet = new Set(requestedIds.filter((id: unknown) => typeof id === 'string'));
|
|
1797
|
+
if (idSet.size === 0) continue;
|
|
1798
|
+
|
|
1799
|
+
const channelMessages = this.messageStore.getMessages(ch.id)
|
|
1800
|
+
.filter((m) => idSet.has(m.id))
|
|
1801
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
1802
|
+
for (const m of channelMessages) {
|
|
1803
|
+
allMessages.push({
|
|
1804
|
+
id: m.id,
|
|
1805
|
+
channelId: m.channelId,
|
|
1806
|
+
senderId: m.senderId,
|
|
1807
|
+
content: m.content,
|
|
1808
|
+
timestamp: m.timestamp,
|
|
1809
|
+
type: m.type,
|
|
1810
|
+
threadId: m.threadId,
|
|
1811
|
+
prevHash: m.prevHash,
|
|
1812
|
+
vectorClock: (m as any).vectorClock,
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (allMessages.length > 0) {
|
|
1818
|
+
this.transport!.send(fromPeerId, {
|
|
1819
|
+
type: 'message-sync-response',
|
|
1820
|
+
workspaceId: wsId,
|
|
1821
|
+
messages: allMessages,
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
this.opts.log?.info?.(`[decentchat-peer] Fetch request from ${fromPeerId.slice(0, 8)}: sent ${allMessages.length} messages`);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
private resolveSharedWorkspaceIds(peerId: string): string[] {
|
|
1828
|
+
if (!peerId) return [];
|
|
1829
|
+
const ids: string[] = [];
|
|
1830
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
1831
|
+
const memberPeerIds = new Set(workspace.members.map((member) => member.peerId));
|
|
1832
|
+
if (memberPeerIds.has(peerId) && memberPeerIds.has(this.myPeerId)) {
|
|
1833
|
+
ids.push(workspace.id);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return ids;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private isWorkspaceMember(peerId: string, workspaceId?: string): boolean {
|
|
1840
|
+
if (!workspaceId || !peerId) return false;
|
|
1841
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
1842
|
+
if (!workspace) return false;
|
|
1843
|
+
return workspace.members.some((member) => member.peerId === peerId);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
private resolveNameAnnounceWorkspaceId(peerId: string): string | undefined {
|
|
1847
|
+
const allWorkspaces = this.workspaceManager.getAllWorkspaces();
|
|
1848
|
+
const workspaceWithPeer = allWorkspaces.find((ws) => ws.members.some((m) => m.peerId === peerId));
|
|
1849
|
+
if (workspaceWithPeer) return workspaceWithPeer.id;
|
|
1850
|
+
|
|
1851
|
+
const configuredWorkspaceId = this.opts.account.companySimBootstrap?.targetWorkspaceId;
|
|
1852
|
+
if (configuredWorkspaceId && this.workspaceManager.getWorkspace(configuredWorkspaceId)) {
|
|
1853
|
+
return configuredWorkspaceId;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
if (allWorkspaces.length === 1) return allWorkspaces[0]?.id;
|
|
1857
|
+
return undefined;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
private applyNameAnnounce(peerId: string, params: {
|
|
1861
|
+
alias: string;
|
|
1862
|
+
workspaceId?: string;
|
|
1863
|
+
companySim?: WorkspaceMember['companySim'];
|
|
1864
|
+
isBot?: boolean;
|
|
1865
|
+
publicKey?: string;
|
|
1866
|
+
}): { changed: boolean; memberAdded: boolean; workspaceId?: string } {
|
|
1867
|
+
const alias = params.alias.trim();
|
|
1868
|
+
if (!alias) return { changed: false, memberAdded: false, workspaceId: params.workspaceId };
|
|
1869
|
+
|
|
1870
|
+
const allWorkspaces = this.workspaceManager.getAllWorkspaces();
|
|
1871
|
+
const hintedWorkspace = params.workspaceId
|
|
1872
|
+
? this.workspaceManager.getWorkspace(params.workspaceId)
|
|
1873
|
+
: undefined;
|
|
1874
|
+
const existingWorkspace = allWorkspaces.find((ws) => ws.members.some((member) => member.peerId === peerId));
|
|
1875
|
+
const configuredWorkspaceId = this.opts.account.companySimBootstrap?.targetWorkspaceId;
|
|
1876
|
+
const configuredWorkspace = configuredWorkspaceId
|
|
1877
|
+
? this.workspaceManager.getWorkspace(configuredWorkspaceId)
|
|
1878
|
+
: undefined;
|
|
1879
|
+
const fallbackWorkspace = allWorkspaces.length === 1 ? allWorkspaces[0] : undefined;
|
|
1880
|
+
|
|
1881
|
+
const targetWorkspace = hintedWorkspace ?? existingWorkspace ?? configuredWorkspace ?? fallbackWorkspace;
|
|
1882
|
+
|
|
1883
|
+
let changed = false;
|
|
1884
|
+
let memberAdded = false;
|
|
1885
|
+
|
|
1886
|
+
if (targetWorkspace) {
|
|
1887
|
+
let member = targetWorkspace.members.find((entry) => entry.peerId === peerId);
|
|
1888
|
+
if (!member) {
|
|
1889
|
+
member = {
|
|
1890
|
+
peerId,
|
|
1891
|
+
alias,
|
|
1892
|
+
publicKey: params.publicKey ?? '',
|
|
1893
|
+
role: 'member',
|
|
1894
|
+
joinedAt: Date.now(),
|
|
1895
|
+
...(params.isBot ? { isBot: true } : {}),
|
|
1896
|
+
...(params.companySim ? { companySim: params.companySim } : {}),
|
|
1897
|
+
} as WorkspaceMember;
|
|
1898
|
+
targetWorkspace.members.push(member);
|
|
1899
|
+
changed = true;
|
|
1900
|
+
memberAdded = true;
|
|
1901
|
+
} else {
|
|
1902
|
+
const incomingLooksLikeId = /^[a-f0-9]{8}$/i.test(alias);
|
|
1903
|
+
const currentAlias = String(member.alias || '').trim();
|
|
1904
|
+
const currentLooksLikeId = /^[a-f0-9]{8}$/i.test(currentAlias);
|
|
1905
|
+
if (!incomingLooksLikeId || currentLooksLikeId || !currentAlias) {
|
|
1906
|
+
if (member.alias !== alias) {
|
|
1907
|
+
member.alias = alias;
|
|
1908
|
+
changed = true;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (params.publicKey && member.publicKey !== params.publicKey) {
|
|
1914
|
+
member.publicKey = params.publicKey;
|
|
1915
|
+
changed = true;
|
|
1916
|
+
}
|
|
1917
|
+
if (params.isBot === true && !member.isBot) {
|
|
1918
|
+
member.isBot = true;
|
|
1919
|
+
changed = true;
|
|
1920
|
+
}
|
|
1921
|
+
if (params.companySim) {
|
|
1922
|
+
const before = JSON.stringify(member.companySim || null);
|
|
1923
|
+
const after = JSON.stringify(params.companySim);
|
|
1924
|
+
if (before !== after) {
|
|
1925
|
+
member.companySim = params.companySim;
|
|
1926
|
+
changed = true;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
if (changed) {
|
|
1931
|
+
this.persistWorkspaces();
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return { changed, memberAdded, workspaceId: targetWorkspace.id };
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// No deterministic workspace mapping: only update aliases where this peer already exists.
|
|
1938
|
+
this.updateWorkspaceMemberAlias(peerId, alias, params.companySim, params.isBot);
|
|
1939
|
+
return { changed: false, memberAdded: false, workspaceId: params.workspaceId };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
private preKeyBundleVersionToken(bundle: any): string {
|
|
1943
|
+
const signedPreKeyId = typeof bundle?.signedPreKey?.keyId === 'number' ? bundle.signedPreKey.keyId : 0;
|
|
1944
|
+
const oneTimeCount = Array.isArray(bundle?.oneTimePreKeys) ? bundle.oneTimePreKeys.length : 0;
|
|
1945
|
+
const firstOneTimeId = Array.isArray(bundle?.oneTimePreKeys) && typeof bundle.oneTimePreKeys[0]?.keyId === 'number'
|
|
1946
|
+
? bundle.oneTimePreKeys[0].keyId
|
|
1947
|
+
: 0;
|
|
1948
|
+
const lastOneTimeId = Array.isArray(bundle?.oneTimePreKeys) && oneTimeCount > 0
|
|
1949
|
+
&& typeof bundle.oneTimePreKeys[oneTimeCount - 1]?.keyId === 'number'
|
|
1950
|
+
? bundle.oneTimePreKeys[oneTimeCount - 1].keyId
|
|
1951
|
+
: 0;
|
|
1952
|
+
|
|
1953
|
+
// Ignore generatedAt so repeated publishes of identical key material are deduped.
|
|
1954
|
+
return `${signedPreKeyId}:${oneTimeCount}:${firstOneTimeId}:${lastOneTimeId}`;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
private async publishPreKeyBundleToDomain(workspaceId: string, bundle: any): Promise<void> {
|
|
1958
|
+
if (!workspaceId || !this.transport) return;
|
|
1959
|
+
|
|
1960
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
1961
|
+
if (!workspace) return;
|
|
1962
|
+
|
|
1963
|
+
const versionToken = this.preKeyBundleVersionToken(bundle);
|
|
1964
|
+
if (this.publishedPreKeyVersionByWorkspace.get(workspaceId) === versionToken) {
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const recipients = workspace.members
|
|
1969
|
+
.map((member) => member.peerId)
|
|
1970
|
+
.filter((peerId) => peerId && peerId !== this.myPeerId);
|
|
1971
|
+
if (recipients.length === 0) return;
|
|
1972
|
+
|
|
1973
|
+
const payload = {
|
|
1974
|
+
type: 'pre-key-bundle.publish' as const,
|
|
1975
|
+
workspaceId,
|
|
1976
|
+
ownerPeerId: this.myPeerId,
|
|
1977
|
+
bundle,
|
|
1978
|
+
};
|
|
1979
|
+
const opId = `pre-key-bundle:${this.myPeerId}:${versionToken}`;
|
|
1980
|
+
|
|
1981
|
+
for (const recipientPeerId of recipients) {
|
|
1982
|
+
await this.custodyStore.storeEnvelope({
|
|
1983
|
+
opId,
|
|
1984
|
+
recipientPeerIds: [recipientPeerId],
|
|
1985
|
+
workspaceId,
|
|
1986
|
+
domain: 'pre-key-bundle',
|
|
1987
|
+
ciphertext: payload,
|
|
1988
|
+
metadata: {
|
|
1989
|
+
ownerPeerId: this.myPeerId,
|
|
1990
|
+
preKeyVersion: versionToken,
|
|
1991
|
+
bundleGeneratedAt: bundle?.generatedAt,
|
|
1992
|
+
signedPreKeyId: bundle?.signedPreKey?.keyId,
|
|
1993
|
+
},
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
await this.replicateToCustodians(recipientPeerId, {
|
|
1997
|
+
workspaceId,
|
|
1998
|
+
opId,
|
|
1999
|
+
domain: 'pre-key-bundle',
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
if (this.transport.getConnectedPeers().includes(recipientPeerId)) {
|
|
2003
|
+
this.transport.send(recipientPeerId, payload);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
this.recordManifestDomain('pre-key-bundle', workspaceId, {
|
|
2008
|
+
operation: 'update',
|
|
2009
|
+
subject: this.myPeerId,
|
|
2010
|
+
itemCount: recipients.length,
|
|
2011
|
+
data: {
|
|
2012
|
+
ownerPeerId: this.myPeerId,
|
|
2013
|
+
preKeyVersion: versionToken,
|
|
2014
|
+
bundleGeneratedAt: bundle?.generatedAt,
|
|
2015
|
+
signedPreKeyId: bundle?.signedPreKey?.keyId,
|
|
2016
|
+
},
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
this.publishedPreKeyVersionByWorkspace.set(workspaceId, versionToken);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
private async publishPreKeyBundle(peerId: string): Promise<void> {
|
|
2023
|
+
if (!this.transport || !this.messageProtocol) return;
|
|
2024
|
+
try {
|
|
2025
|
+
const bundle = await this.messageProtocol.createPreKeyBundle();
|
|
2026
|
+
const sharedWorkspaceIds = this.resolveSharedWorkspaceIds(peerId);
|
|
2027
|
+
const workspaceId = sharedWorkspaceIds[0];
|
|
2028
|
+
this.transport.send(peerId, {
|
|
2029
|
+
type: 'pre-key-bundle.publish',
|
|
2030
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
2031
|
+
ownerPeerId: this.myPeerId,
|
|
2032
|
+
bundle,
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
for (const sharedWorkspaceId of sharedWorkspaceIds) {
|
|
2036
|
+
await this.publishPreKeyBundleToDomain(sharedWorkspaceId, bundle);
|
|
2037
|
+
}
|
|
2038
|
+
} catch (error) {
|
|
2039
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to publish pre-key bundle to ${peerId.slice(0, 8)}: ${String(error)}`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
private shouldAttemptPreKeyBootstrap(error: unknown): boolean {
|
|
2044
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
2045
|
+
return message.includes('No shared secret with peer');
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
private resolvePreKeyLookupCandidates(ownerPeerId: string, workspaceId?: string): string[] {
|
|
2049
|
+
if (!this.transport || !ownerPeerId) return [];
|
|
2050
|
+
|
|
2051
|
+
const connectedPeers = new Set(this.transport.getConnectedPeers());
|
|
2052
|
+
if (workspaceId) {
|
|
2053
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
2054
|
+
return (workspace?.members ?? [])
|
|
2055
|
+
.map((member) => member.peerId)
|
|
2056
|
+
.filter((peerId) => peerId && peerId !== this.myPeerId && peerId !== ownerPeerId && connectedPeers.has(peerId));
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
return Array.from(connectedPeers).filter((peerId) => peerId !== this.myPeerId && peerId !== ownerPeerId);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
private resolveLikelyPreKeyCustodians(ownerPeerId: string, workspaceId?: string): string[] {
|
|
2063
|
+
if (!workspaceId) return [];
|
|
2064
|
+
return this.selectCustodianPeers(workspaceId, ownerPeerId);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
private async requestPreKeyBundleFromPeers(
|
|
2068
|
+
ownerPeerId: string,
|
|
2069
|
+
workspaceId?: string,
|
|
2070
|
+
opts?: {
|
|
2071
|
+
candidatePeerIds?: string[];
|
|
2072
|
+
timeoutMs?: number;
|
|
2073
|
+
querySource?: 'custodian-targeted' | 'peer-broadcast';
|
|
2074
|
+
},
|
|
2075
|
+
): Promise<boolean> {
|
|
2076
|
+
if (!this.transport || !this.messageProtocol || !ownerPeerId) return false;
|
|
2077
|
+
|
|
2078
|
+
const resolvedWorkspaceId = workspaceId || this.resolveSharedWorkspaceIds(ownerPeerId)[0];
|
|
2079
|
+
const connectedPeers = new Set(this.transport.getConnectedPeers());
|
|
2080
|
+
const requestedCandidates = opts?.candidatePeerIds ?? this.resolvePreKeyLookupCandidates(ownerPeerId, resolvedWorkspaceId);
|
|
2081
|
+
const candidates = Array.from(new Set(requestedCandidates))
|
|
2082
|
+
.filter((peerId) => peerId && peerId !== this.myPeerId && peerId !== ownerPeerId && connectedPeers.has(peerId))
|
|
2083
|
+
.filter((peerId) => !resolvedWorkspaceId || this.isWorkspaceMember(peerId, resolvedWorkspaceId));
|
|
2084
|
+
|
|
2085
|
+
if (candidates.length === 0) return false;
|
|
2086
|
+
|
|
2087
|
+
const requestId = randomUUID();
|
|
2088
|
+
const timeoutMs = Math.max(250, opts?.timeoutMs ?? DecentChatNodePeer.PRE_KEY_FETCH_TIMEOUT_MS);
|
|
2089
|
+
const querySource = opts?.querySource ?? 'peer-broadcast';
|
|
2090
|
+
|
|
2091
|
+
const result = await new Promise<boolean>((resolve) => {
|
|
2092
|
+
const timer = setTimeout(() => {
|
|
2093
|
+
this.pendingPreKeyBundleFetches.delete(requestId);
|
|
2094
|
+
resolve(false);
|
|
2095
|
+
}, timeoutMs);
|
|
2096
|
+
|
|
2097
|
+
const pending: PendingPreKeyBundleFetch = {
|
|
2098
|
+
ownerPeerId,
|
|
2099
|
+
...(resolvedWorkspaceId ? { workspaceId: resolvedWorkspaceId } : {}),
|
|
2100
|
+
pendingPeerIds: new Set(candidates),
|
|
2101
|
+
resolve: (value) => {
|
|
2102
|
+
clearTimeout(timer);
|
|
2103
|
+
this.pendingPreKeyBundleFetches.delete(requestId);
|
|
2104
|
+
resolve(value);
|
|
2105
|
+
},
|
|
2106
|
+
timer,
|
|
2107
|
+
};
|
|
2108
|
+
this.pendingPreKeyBundleFetches.set(requestId, pending);
|
|
2109
|
+
|
|
2110
|
+
let sentCount = 0;
|
|
2111
|
+
for (const peerId of candidates) {
|
|
2112
|
+
const accepted = this.transport!.send(peerId, {
|
|
2113
|
+
type: 'pre-key-bundle.fetch',
|
|
2114
|
+
requestId,
|
|
2115
|
+
ownerPeerId,
|
|
2116
|
+
...(resolvedWorkspaceId ? { workspaceId: resolvedWorkspaceId } : {}),
|
|
2117
|
+
querySource,
|
|
2118
|
+
});
|
|
2119
|
+
if (accepted) {
|
|
2120
|
+
sentCount += 1;
|
|
2121
|
+
} else {
|
|
2122
|
+
pending.pendingPeerIds.delete(peerId);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
if (sentCount === 0 || pending.pendingPeerIds.size === 0) {
|
|
2127
|
+
clearTimeout(timer);
|
|
2128
|
+
this.pendingPreKeyBundleFetches.delete(requestId);
|
|
2129
|
+
resolve(false);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
return result;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
private async ensurePeerPreKeyBundle(peerId: string, workspaceId?: string): Promise<boolean> {
|
|
2137
|
+
if (!this.messageProtocol || !peerId) return false;
|
|
2138
|
+
|
|
2139
|
+
const existing = await this.messageProtocol.getPeerPreKeyBundle(peerId);
|
|
2140
|
+
if (existing) return true;
|
|
2141
|
+
|
|
2142
|
+
const resolvedWorkspaceId = workspaceId || this.resolveSharedWorkspaceIds(peerId)[0];
|
|
2143
|
+
const likelyCustodians = this.resolveLikelyPreKeyCustodians(peerId, resolvedWorkspaceId);
|
|
2144
|
+
|
|
2145
|
+
if (likelyCustodians.length > 0) {
|
|
2146
|
+
const hydratedViaCustodians = await this.requestPreKeyBundleFromPeers(peerId, resolvedWorkspaceId, {
|
|
2147
|
+
candidatePeerIds: likelyCustodians,
|
|
2148
|
+
timeoutMs: 1_200,
|
|
2149
|
+
querySource: 'custodian-targeted',
|
|
2150
|
+
});
|
|
2151
|
+
if (hydratedViaCustodians) return true;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
const fallbackCandidates = this.resolvePreKeyLookupCandidates(peerId, resolvedWorkspaceId)
|
|
2155
|
+
.filter((candidatePeerId) => !likelyCustodians.includes(candidatePeerId));
|
|
2156
|
+
|
|
2157
|
+
if (fallbackCandidates.length === 0) {
|
|
2158
|
+
return this.requestPreKeyBundleFromPeers(peerId, resolvedWorkspaceId, {
|
|
2159
|
+
candidatePeerIds: likelyCustodians,
|
|
2160
|
+
querySource: 'peer-broadcast',
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
return this.requestPreKeyBundleFromPeers(peerId, resolvedWorkspaceId, {
|
|
2165
|
+
candidatePeerIds: fallbackCandidates,
|
|
2166
|
+
querySource: 'peer-broadcast',
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
private async encryptMessageWithPreKeyBootstrap(
|
|
2171
|
+
peerId: string,
|
|
2172
|
+
content: string,
|
|
2173
|
+
metadata?: MessageMetadata,
|
|
2174
|
+
workspaceId?: string,
|
|
2175
|
+
): Promise<any> {
|
|
2176
|
+
if (!this.messageProtocol) {
|
|
2177
|
+
throw new Error('Message protocol unavailable');
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
try {
|
|
2181
|
+
return await this.messageProtocol.encryptMessage(peerId, content, 'text', metadata);
|
|
2182
|
+
} catch (error) {
|
|
2183
|
+
if (!this.shouldAttemptPreKeyBootstrap(error)) throw error;
|
|
2184
|
+
|
|
2185
|
+
const hydrated = await this.ensurePeerPreKeyBundle(peerId, workspaceId);
|
|
2186
|
+
if (!hydrated) throw error;
|
|
2187
|
+
|
|
2188
|
+
return this.messageProtocol.encryptMessage(peerId, content, 'text', metadata);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
private async handlePreKeyControl(fromPeerId: string, msg: any): Promise<boolean> {
|
|
2193
|
+
if (!this.transport || !this.messageProtocol) return false;
|
|
2194
|
+
|
|
2195
|
+
if (msg?.type === 'pre-key-bundle.publish') {
|
|
2196
|
+
if (!msg.bundle) return true;
|
|
2197
|
+
const ownerPeerId = typeof msg?.ownerPeerId === 'string' ? msg.ownerPeerId : fromPeerId;
|
|
2198
|
+
const stored = await this.messageProtocol.storePeerPreKeyBundle(ownerPeerId, msg.bundle);
|
|
2199
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : this.resolveSharedWorkspaceIds(ownerPeerId)[0];
|
|
2200
|
+
if (stored && workspaceId) {
|
|
2201
|
+
this.recordManifestDomain('pre-key-bundle', workspaceId, {
|
|
2202
|
+
operation: 'update',
|
|
2203
|
+
subject: ownerPeerId,
|
|
2204
|
+
itemCount: 1,
|
|
2205
|
+
data: {
|
|
2206
|
+
ownerPeerId,
|
|
2207
|
+
source: 'publish',
|
|
2208
|
+
bundleGeneratedAt: msg.bundle?.generatedAt,
|
|
2209
|
+
signedPreKeyId: msg.bundle?.signedPreKey?.keyId,
|
|
2210
|
+
},
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
return true;
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
if (msg?.type === 'pre-key-bundle.request') {
|
|
2217
|
+
try {
|
|
2218
|
+
const bundle = await this.messageProtocol.createPreKeyBundle();
|
|
2219
|
+
this.transport.send(fromPeerId, {
|
|
2220
|
+
type: 'pre-key-bundle.response',
|
|
2221
|
+
ownerPeerId: this.myPeerId,
|
|
2222
|
+
...(typeof msg?.workspaceId === 'string' ? { workspaceId: msg.workspaceId } : {}),
|
|
2223
|
+
bundle,
|
|
2224
|
+
});
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to respond with pre-key bundle to ${fromPeerId.slice(0, 8)}: ${String(error)}`);
|
|
2227
|
+
}
|
|
2228
|
+
return true;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
if (msg?.type === 'pre-key-bundle.response') {
|
|
2232
|
+
if (!msg.bundle) return true;
|
|
2233
|
+
const ownerPeerId = typeof msg?.ownerPeerId === 'string' ? msg.ownerPeerId : fromPeerId;
|
|
2234
|
+
const stored = await this.messageProtocol.storePeerPreKeyBundle(ownerPeerId, msg.bundle);
|
|
2235
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : this.resolveSharedWorkspaceIds(ownerPeerId)[0];
|
|
2236
|
+
if (stored && workspaceId) {
|
|
2237
|
+
this.recordManifestDomain('pre-key-bundle', workspaceId, {
|
|
2238
|
+
operation: 'update',
|
|
2239
|
+
subject: ownerPeerId,
|
|
2240
|
+
itemCount: 1,
|
|
2241
|
+
data: {
|
|
2242
|
+
ownerPeerId,
|
|
2243
|
+
source: 'response',
|
|
2244
|
+
bundleGeneratedAt: msg.bundle?.generatedAt,
|
|
2245
|
+
signedPreKeyId: msg.bundle?.signedPreKey?.keyId,
|
|
2246
|
+
},
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
return true;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
if (msg?.type === 'pre-key-bundle.fetch') {
|
|
2253
|
+
const requestId = typeof msg?.requestId === 'string' ? msg.requestId : '';
|
|
2254
|
+
const ownerPeerId = typeof msg?.ownerPeerId === 'string' ? msg.ownerPeerId : '';
|
|
2255
|
+
if (!requestId || !ownerPeerId) return true;
|
|
2256
|
+
|
|
2257
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : undefined;
|
|
2258
|
+
if (workspaceId) {
|
|
2259
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
2260
|
+
const memberPeerIds = new Set((workspace?.members ?? []).map((member) => member.peerId));
|
|
2261
|
+
if (!workspace || !memberPeerIds.has(fromPeerId) || !memberPeerIds.has(ownerPeerId) || !memberPeerIds.has(this.myPeerId)) {
|
|
2262
|
+
return true;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const querySource = (msg?.querySource === 'custodian-targeted' || msg?.querySource === 'peer-broadcast')
|
|
2267
|
+
? msg.querySource
|
|
2268
|
+
: undefined;
|
|
2269
|
+
const bundle = await this.messageProtocol.getPeerPreKeyBundle(ownerPeerId);
|
|
2270
|
+
|
|
2271
|
+
this.transport.send(fromPeerId, {
|
|
2272
|
+
type: 'pre-key-bundle.fetch-response',
|
|
2273
|
+
requestId,
|
|
2274
|
+
ownerPeerId,
|
|
2275
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
2276
|
+
...(querySource ? { querySource } : {}),
|
|
2277
|
+
...(bundle ? { bundle } : { notAvailable: true }),
|
|
2278
|
+
});
|
|
2279
|
+
return true;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if (msg?.type === 'pre-key-bundle.fetch-response') {
|
|
2283
|
+
const requestId = typeof msg?.requestId === 'string' ? msg.requestId : '';
|
|
2284
|
+
if (!requestId) return true;
|
|
2285
|
+
|
|
2286
|
+
const pending = this.pendingPreKeyBundleFetches.get(requestId);
|
|
2287
|
+
if (!pending) return true;
|
|
2288
|
+
|
|
2289
|
+
if (!pending.pendingPeerIds.has(fromPeerId)) return true;
|
|
2290
|
+
|
|
2291
|
+
const ownerPeerId = typeof msg?.ownerPeerId === 'string' ? msg.ownerPeerId : pending.ownerPeerId;
|
|
2292
|
+
if (ownerPeerId !== pending.ownerPeerId) return true;
|
|
2293
|
+
|
|
2294
|
+
pending.pendingPeerIds.delete(fromPeerId);
|
|
2295
|
+
|
|
2296
|
+
if (msg?.bundle) {
|
|
2297
|
+
const stored = await this.messageProtocol.storePeerPreKeyBundle(ownerPeerId, msg.bundle);
|
|
2298
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : pending.workspaceId;
|
|
2299
|
+
if (stored && workspaceId) {
|
|
2300
|
+
this.recordManifestDomain('pre-key-bundle', workspaceId, {
|
|
2301
|
+
operation: 'update',
|
|
2302
|
+
subject: ownerPeerId,
|
|
2303
|
+
itemCount: 1,
|
|
2304
|
+
data: {
|
|
2305
|
+
ownerPeerId,
|
|
2306
|
+
source: 'fetch-response',
|
|
2307
|
+
bundleGeneratedAt: msg.bundle?.generatedAt,
|
|
2308
|
+
signedPreKeyId: msg.bundle?.signedPreKey?.keyId,
|
|
2309
|
+
},
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
if (stored) {
|
|
2314
|
+
pending.resolve(true);
|
|
2315
|
+
return true;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
if (pending.pendingPeerIds.size === 0) {
|
|
2320
|
+
pending.resolve(false);
|
|
2321
|
+
}
|
|
2322
|
+
return true;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
return false;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
private buildCustodyResendMetadata(payload: {
|
|
2329
|
+
content: string;
|
|
2330
|
+
channelId?: string;
|
|
2331
|
+
workspaceId?: string;
|
|
2332
|
+
senderId?: string;
|
|
2333
|
+
senderName?: string;
|
|
2334
|
+
threadId?: string;
|
|
2335
|
+
replyToId?: string;
|
|
2336
|
+
isDirect?: boolean;
|
|
2337
|
+
gossipOriginSignature?: string;
|
|
2338
|
+
metadata?: MessageMetadata;
|
|
2339
|
+
}): Record<string, unknown> {
|
|
2340
|
+
return {
|
|
2341
|
+
...(payload.isDirect ? { isDirect: true } : {}),
|
|
2342
|
+
...(payload.replyToId ? { replyToId: payload.replyToId } : {}),
|
|
2343
|
+
senderId: payload.senderId ?? this.myPeerId,
|
|
2344
|
+
senderName: payload.senderName ?? this.opts.account.alias,
|
|
2345
|
+
resend: {
|
|
2346
|
+
content: payload.content,
|
|
2347
|
+
...(payload.channelId ? { channelId: payload.channelId } : {}),
|
|
2348
|
+
...(payload.workspaceId ? { workspaceId: payload.workspaceId } : {}),
|
|
2349
|
+
...(payload.threadId ? { threadId: payload.threadId } : {}),
|
|
2350
|
+
...(payload.replyToId ? { replyToId: payload.replyToId } : {}),
|
|
2351
|
+
...(payload.isDirect ? { isDirect: true } : {}),
|
|
2352
|
+
...(payload.gossipOriginSignature ? { gossipOriginSignature: payload.gossipOriginSignature } : {}),
|
|
2353
|
+
...(payload.metadata ? { metadata: payload.metadata } : {}),
|
|
2354
|
+
},
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
private getCustodyResendPayload(envelope: CustodyEnvelope): {
|
|
2359
|
+
content: string;
|
|
2360
|
+
channelId?: string;
|
|
2361
|
+
workspaceId?: string;
|
|
2362
|
+
senderId?: string;
|
|
2363
|
+
senderName?: string;
|
|
2364
|
+
threadId?: string;
|
|
2365
|
+
replyToId?: string;
|
|
2366
|
+
isDirect?: boolean;
|
|
2367
|
+
gossipOriginSignature?: string;
|
|
2368
|
+
metadata?: MessageMetadata;
|
|
2369
|
+
} | null {
|
|
2370
|
+
const metadata = isRecord(envelope.metadata) ? envelope.metadata : null;
|
|
2371
|
+
const resend = metadata && isRecord(metadata.resend) ? metadata.resend : null;
|
|
2372
|
+
const content = typeof resend?.content === 'string' ? resend.content.trim() : '';
|
|
2373
|
+
if (!content) return null;
|
|
2374
|
+
|
|
2375
|
+
return {
|
|
2376
|
+
content,
|
|
2377
|
+
channelId: typeof resend?.channelId === 'string' ? resend.channelId : undefined,
|
|
2378
|
+
workspaceId: typeof resend?.workspaceId === 'string' ? resend.workspaceId : undefined,
|
|
2379
|
+
senderId: typeof metadata?.senderId === 'string' ? metadata.senderId : undefined,
|
|
2380
|
+
senderName: typeof metadata?.senderName === 'string' ? metadata.senderName : undefined,
|
|
2381
|
+
threadId: typeof resend?.threadId === 'string' ? resend.threadId : undefined,
|
|
2382
|
+
replyToId: typeof resend?.replyToId === 'string' ? resend.replyToId : undefined,
|
|
2383
|
+
isDirect: resend?.isDirect === true,
|
|
2384
|
+
gossipOriginSignature: typeof resend?.gossipOriginSignature === 'string' ? resend.gossipOriginSignature : undefined,
|
|
2385
|
+
metadata: resend?.metadata as MessageMetadata | undefined,
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
private shouldReencryptCustodyEnvelope(envelope: CustodyEnvelope): boolean {
|
|
2390
|
+
if (!isRecord(envelope.ciphertext)) return false;
|
|
2391
|
+
return envelope.ciphertext.protocolVersion === 3 && isRecord(envelope.ciphertext.sessionInit);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
private hasProtocolSession(peerId: string): boolean {
|
|
2395
|
+
const hasSharedSecret =
|
|
2396
|
+
typeof this.messageProtocol?.hasSharedSecret === 'function'
|
|
2397
|
+
? this.messageProtocol.hasSharedSecret.bind(this.messageProtocol)
|
|
2398
|
+
: undefined;
|
|
2399
|
+
return hasSharedSecret?.(peerId) ?? false;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
private isIncomingPreKeySessionEnvelope(value: unknown): value is { protocolVersion: 3; sessionInit: Record<string, unknown> } {
|
|
2403
|
+
return isRecord(value) && value.protocolVersion === 3 && isRecord(value.sessionInit);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
private shouldIgnoreDecryptReplay(peerId: string, msg: unknown, error: unknown): boolean {
|
|
2407
|
+
if (!this.isIncomingPreKeySessionEnvelope(msg)) {
|
|
2408
|
+
return false;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
2412
|
+
if (message.includes('Ratchet already established')) {
|
|
2413
|
+
return true;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
if (message.includes('Pre-key ') && message.includes(' unavailable') && this.hasProtocolSession(peerId)) {
|
|
2417
|
+
return true;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
return false;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
private async triggerDecryptRecoveryHandshake(peerId: string): Promise<void> {
|
|
2424
|
+
const now = Date.now();
|
|
2425
|
+
const lastRecoveryAt = this.decryptRecoveryAtByPeer.get(peerId) ?? 0;
|
|
2426
|
+
if (now - lastRecoveryAt < DecentChatNodePeer.DECRYPT_RECOVERY_HANDSHAKE_COOLDOWN_MS) {
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
this.decryptRecoveryAtByPeer.set(peerId, now);
|
|
2431
|
+
await this.messageProtocol?.clearRatchetState?.(peerId);
|
|
2432
|
+
this.messageProtocol?.clearSharedSecret?.(peerId);
|
|
2433
|
+
this.store.delete(`ratchet-${peerId}`);
|
|
2434
|
+
await this.sendHandshake(peerId);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
private async resumePeerSession(peerId: string): Promise<void> {
|
|
2438
|
+
// Resend previously pending ACK-tracked messages first, then flush newly queued
|
|
2439
|
+
// offline payloads to avoid immediate duplicate sends in the same handshake cycle.
|
|
2440
|
+
await this.resendPendingAcks(peerId);
|
|
2441
|
+
await this.flushOfflineQueue(peerId);
|
|
2442
|
+
await this.flushPendingReadReceipts(peerId);
|
|
2443
|
+
this.requestSyncForPeer(peerId);
|
|
2444
|
+
this.sendManifestSummary(peerId);
|
|
2445
|
+
this.requestCustodyRecovery(peerId);
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
private async handlePeerConnect(peerId: string): Promise<void> {
|
|
2449
|
+
this.opts.log?.info(`[decentchat-peer] peer connected: ${peerId}`);
|
|
2450
|
+
this.clearPeerMaintenanceFailure(peerId);
|
|
2451
|
+
|
|
2452
|
+
const now = Date.now();
|
|
2453
|
+
const lastHandshakeAt = this.connectHandshakeAtByPeer.get(peerId) ?? 0;
|
|
2454
|
+
if (now - lastHandshakeAt < DecentChatNodePeer.CONNECT_HANDSHAKE_COOLDOWN_MS) {
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
this.connectHandshakeAtByPeer.set(peerId, now);
|
|
2459
|
+
if (this.hasProtocolSession(peerId)) {
|
|
2460
|
+
await this.resumePeerSession(peerId);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
await this.sendHandshake(peerId);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
private shouldIgnoreInboundHandshakeBurst(peerId: string): boolean {
|
|
2467
|
+
const now = Date.now();
|
|
2468
|
+
const lastHandshakeAt = this.inboundHandshakeAtByPeer.get(peerId) ?? 0;
|
|
2469
|
+
const hasSession = this.hasProtocolSession(peerId);
|
|
2470
|
+
if (hasSession && now - lastHandshakeAt < DecentChatNodePeer.INBOUND_HANDSHAKE_COOLDOWN_MS) {
|
|
2471
|
+
return true;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
this.inboundHandshakeAtByPeer.set(peerId, now);
|
|
2475
|
+
return false;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
private async sendHandshake(peerId: string): Promise<void> {
|
|
2479
|
+
if (!this.transport || !this.messageProtocol) return;
|
|
2480
|
+
try {
|
|
2481
|
+
const handshake = await this.messageProtocol.createHandshake();
|
|
2482
|
+
const capabilities = ['negentropy-sync-v1'];
|
|
2483
|
+
if (this.opts.companyTemplateControl) {
|
|
2484
|
+
capabilities.push(COMPANY_TEMPLATE_CONTROL_CAPABILITY);
|
|
2485
|
+
}
|
|
2486
|
+
this.transport.send(peerId, { type: 'handshake', ...handshake, capabilities });
|
|
2487
|
+
await this.publishPreKeyBundle(peerId);
|
|
2488
|
+
// Announce display name (separate unencrypted message — same pattern as the web client)
|
|
2489
|
+
// Include workspaceId so the peer can deterministically add us to the correct workspace
|
|
2490
|
+
// (critical when the peer has multiple workspaces — without this, we'd only update
|
|
2491
|
+
// existing members, never add new ones)
|
|
2492
|
+
const announceWorkspaceId = this.resolveNameAnnounceWorkspaceId(peerId);
|
|
2493
|
+
this.transport.send(peerId, {
|
|
2494
|
+
type: 'name-announce',
|
|
2495
|
+
alias: this.opts.account.alias,
|
|
2496
|
+
isBot: true,
|
|
2497
|
+
companySim: this.getMyCompanySimProfile(),
|
|
2498
|
+
...(announceWorkspaceId ? { workspaceId: announceWorkspaceId } : {}),
|
|
2499
|
+
});
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
this.opts.log?.error?.(`[decentchat-peer] handshake failed for ${peerId}: ${String(err)}`);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
private async handleSyncEvent(event: SyncEvent): Promise<void> {
|
|
2506
|
+
switch (event.type) {
|
|
2507
|
+
case 'workspace-joined': {
|
|
2508
|
+
this.opts.log?.info(`[decentchat-peer] joined workspace: ${event.workspace.id}`);
|
|
2509
|
+
this.persistWorkspaces();
|
|
2510
|
+
this.recordManifestDomain('workspace-manifest', event.workspace.id, {
|
|
2511
|
+
operation: 'update',
|
|
2512
|
+
subject: event.workspace.id,
|
|
2513
|
+
itemCount: 1,
|
|
2514
|
+
data: { name: event.workspace.name },
|
|
2515
|
+
});
|
|
2516
|
+
this.recordManifestDomain('membership', event.workspace.id, {
|
|
2517
|
+
operation: 'update',
|
|
2518
|
+
subject: event.workspace.id,
|
|
2519
|
+
itemCount: event.workspace.members.length,
|
|
2520
|
+
data: { memberCount: event.workspace.members.length },
|
|
2521
|
+
});
|
|
2522
|
+
this.recordManifestDomain('channel-manifest', event.workspace.id, {
|
|
2523
|
+
operation: 'update',
|
|
2524
|
+
subject: event.workspace.id,
|
|
2525
|
+
itemCount: event.workspace.channels.length,
|
|
2526
|
+
data: { channelCount: event.workspace.channels.length },
|
|
2527
|
+
});
|
|
2528
|
+
break;
|
|
2529
|
+
}
|
|
2530
|
+
case 'member-joined':
|
|
2531
|
+
case 'member-left':
|
|
2532
|
+
case 'channel-created': {
|
|
2533
|
+
this.persistWorkspaces();
|
|
2534
|
+
const workspaceId = (event as any).workspaceId as string | undefined;
|
|
2535
|
+
const ws = workspaceId ? this.workspaceManager.getWorkspace(workspaceId) : undefined;
|
|
2536
|
+
if (workspaceId && ws) {
|
|
2537
|
+
if (event.type === 'channel-created') {
|
|
2538
|
+
this.recordManifestDomain('channel-manifest', workspaceId, {
|
|
2539
|
+
operation: 'create',
|
|
2540
|
+
subject: (event as any).channel?.id ?? workspaceId,
|
|
2541
|
+
itemCount: ws.channels.length,
|
|
2542
|
+
data: { channelCount: ws.channels.length },
|
|
2543
|
+
});
|
|
2544
|
+
} else {
|
|
2545
|
+
this.recordManifestDomain('membership', workspaceId, {
|
|
2546
|
+
operation: event.type === 'member-joined' ? 'create' : 'delete',
|
|
2547
|
+
subject: (event as any).member?.peerId ?? workspaceId,
|
|
2548
|
+
itemCount: ws.members.length,
|
|
2549
|
+
data: { memberCount: ws.members.length },
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
break;
|
|
2554
|
+
}
|
|
2555
|
+
case 'message-received': {
|
|
2556
|
+
this.persistMessagesForChannel(event.channelId);
|
|
2557
|
+
this.recordManifestDomain('channel-message', this.findWorkspaceIdForChannel(event.channelId), {
|
|
2558
|
+
channelId: event.channelId,
|
|
2559
|
+
operation: 'create',
|
|
2560
|
+
subject: event.message.id,
|
|
2561
|
+
itemCount: this.messageStore.getMessages(event.channelId).length,
|
|
2562
|
+
data: { messageId: event.message.id, senderId: event.message.senderId },
|
|
2563
|
+
});
|
|
2564
|
+
const attachments = Array.isArray((event.message as any).attachments)
|
|
2565
|
+
? ((event.message as any).attachments as Array<{
|
|
2566
|
+
id: string;
|
|
2567
|
+
name: string;
|
|
2568
|
+
type: string;
|
|
2569
|
+
size?: number;
|
|
2570
|
+
thumbnail?: string;
|
|
2571
|
+
width?: number;
|
|
2572
|
+
height?: number;
|
|
2573
|
+
}>)
|
|
2574
|
+
: undefined;
|
|
2575
|
+
await this.opts.onIncomingMessage({
|
|
2576
|
+
channelId: event.channelId,
|
|
2577
|
+
workspaceId: this.findWorkspaceIdForChannel(event.channelId),
|
|
2578
|
+
content: event.message.content,
|
|
2579
|
+
senderId: event.message.senderId,
|
|
2580
|
+
senderName: this.resolveSenderName(this.findWorkspaceIdForChannel(event.channelId), event.message.senderId),
|
|
2581
|
+
messageId: event.message.id,
|
|
2582
|
+
chatType: 'channel',
|
|
2583
|
+
timestamp: event.message.timestamp,
|
|
2584
|
+
replyToId: (event.message as any).replyToId,
|
|
2585
|
+
threadId: (event.message as any).threadId,
|
|
2586
|
+
attachments,
|
|
2587
|
+
});
|
|
2588
|
+
break;
|
|
2589
|
+
}
|
|
2590
|
+
case 'join-rejected':
|
|
2591
|
+
this.opts.log?.warn?.(`[decentchat-peer] join REJECTED: ${(event as any).reason || 'unknown reason'}`);
|
|
2592
|
+
break;
|
|
2593
|
+
case 'sync-complete':
|
|
2594
|
+
default:
|
|
2595
|
+
break;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
private restoreWorkspaces(): void {
|
|
2600
|
+
const savedWorkspaces = this.store.get<Workspace[]>('workspaces', []);
|
|
2601
|
+
for (const ws of savedWorkspaces) {
|
|
2602
|
+
this.workspaceManager.importWorkspace(ws);
|
|
2603
|
+
this.ensureBotFlag();
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const savedPeers = this.store.get<Record<string, string>>('peer-public-keys', {});
|
|
2607
|
+
for (const [peerId, pubKey] of Object.entries(savedPeers)) {
|
|
2608
|
+
this.updateWorkspaceMemberKey(peerId, pubKey);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
private restoreMessages(): void {
|
|
2613
|
+
const restoredKeys = new Set<string>();
|
|
2614
|
+
for (const ws of this.workspaceManager.getAllWorkspaces()) {
|
|
2615
|
+
for (const ch of ws.channels) {
|
|
2616
|
+
const key = `messages-${ch.id}`;
|
|
2617
|
+
this.restoreMessagesForKey(key);
|
|
2618
|
+
restoredKeys.add(key);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// Also restore any persisted message buckets not currently linked from
|
|
2623
|
+
// workspace state (e.g. channel-id drift during sync/remap).
|
|
2624
|
+
for (const key of this.store.keys('messages-')) {
|
|
2625
|
+
if (restoredKeys.has(key)) continue;
|
|
2626
|
+
this.restoreMessagesForKey(key);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
private restoreMessagesForKey(key: string): void {
|
|
2631
|
+
const messages = this.store.get<any[]>(key, []);
|
|
2632
|
+
const fallbackChannelId = key.startsWith('messages-') ? key.slice('messages-'.length) : '';
|
|
2633
|
+
for (const message of messages) {
|
|
2634
|
+
if (!message || typeof message !== 'object') continue;
|
|
2635
|
+
if (typeof message.channelId !== 'string' || message.channelId.length === 0) {
|
|
2636
|
+
if (!fallbackChannelId) continue;
|
|
2637
|
+
message.channelId = fallbackChannelId;
|
|
2638
|
+
}
|
|
2639
|
+
this.messageStore.forceAdd(message as any);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
private restoreCustodianInbox(): void {
|
|
2644
|
+
const raw = this.store.get<CustodyEnvelope[]>(this.custodialInboxKey(), []);
|
|
2645
|
+
this.custodianInbox.clear();
|
|
2646
|
+
for (const envelope of raw) {
|
|
2647
|
+
if (this.isCustodyEnvelope(envelope)) {
|
|
2648
|
+
this.custodianInbox.set(envelope.envelopeId, envelope);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
private persistCustodianInbox(): void {
|
|
2654
|
+
this.store.set(this.custodialInboxKey(), [...this.custodianInbox.values()]);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
private manifestStateKey(): string {
|
|
2658
|
+
return 'manifest-state-v1';
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
private restoreManifestState(): void {
|
|
2662
|
+
try {
|
|
2663
|
+
const persisted = this.store.get<ManifestStoreState | null>(this.manifestStateKey(), null);
|
|
2664
|
+
if (!persisted) return;
|
|
2665
|
+
this.manifestStore.importState(persisted);
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to restore manifest state: ${String(error)}`);
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
private persistManifestState(): void {
|
|
2672
|
+
try {
|
|
2673
|
+
this.store.set(this.manifestStateKey(), this.manifestStore.exportState());
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to persist manifest state: ${String(error)}`);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
private schedulePersistManifestState(): void {
|
|
2680
|
+
if (this.manifestPersistTimer) clearTimeout(this.manifestPersistTimer);
|
|
2681
|
+
this.manifestPersistTimer = setTimeout(() => {
|
|
2682
|
+
this.manifestPersistTimer = null;
|
|
2683
|
+
this.persistManifestState();
|
|
2684
|
+
}, 150);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* Handle workspace-state sync from a peer.
|
|
2689
|
+
* The web client sends this on connect — it contains the full workspace
|
|
2690
|
+
* (name, channels, members). We import or update our local copy so we
|
|
2691
|
+
* can include the workspaceId in future name-announce messages.
|
|
2692
|
+
*/
|
|
2693
|
+
private handleWorkspaceState(fromPeerId: string, workspaceId: string, sync: any): void {
|
|
2694
|
+
let ws = this.workspaceManager.getWorkspace(workspaceId);
|
|
2695
|
+
const remoteMembers = Array.isArray(sync?.members) ? sync.members : [];
|
|
2696
|
+
const remoteChannels = Array.isArray(sync?.channels) ? sync.channels : [];
|
|
2697
|
+
const senderListedInSync = remoteMembers.some((member: any) => member?.peerId === fromPeerId);
|
|
2698
|
+
|
|
2699
|
+
if (!senderListedInSync) {
|
|
2700
|
+
this.opts.log?.warn?.(`[decentchat-peer] ignoring workspace-state for ${workspaceId.slice(0, 8)}: sender ${fromPeerId.slice(0, 8)} missing from member list`);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
if (ws && !ws.members.some((member: any) => member.peerId === fromPeerId)) {
|
|
2705
|
+
this.opts.log?.warn?.(`[decentchat-peer] ignoring workspace-state for ${workspaceId.slice(0, 8)} from non-member ${fromPeerId.slice(0, 8)}`);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
if (ws && this.workspaceManager.isBanned(workspaceId, fromPeerId)) {
|
|
2710
|
+
this.opts.log?.warn?.(`[decentchat-peer] ignoring workspace-state for ${workspaceId.slice(0, 8)} from banned peer ${fromPeerId.slice(0, 8)}`);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
const senderPayload = remoteMembers.find((member: any) => member?.peerId === fromPeerId);
|
|
2715
|
+
const senderIsOwner = ws?.members.some((member: any) => member.peerId === fromPeerId && member.role === 'owner')
|
|
2716
|
+
|| senderPayload?.role === 'owner';
|
|
2717
|
+
|
|
2718
|
+
if (!ws) {
|
|
2719
|
+
// First time receiving this workspace — create it
|
|
2720
|
+
const workspace = {
|
|
2721
|
+
id: workspaceId,
|
|
2722
|
+
name: sync.name || workspaceId.slice(0, 8),
|
|
2723
|
+
description: sync.description || '',
|
|
2724
|
+
channels: remoteChannels.map((ch: any) => ({
|
|
2725
|
+
id: ch.id,
|
|
2726
|
+
workspaceId,
|
|
2727
|
+
name: ch.name,
|
|
2728
|
+
type: ch.type || 'channel',
|
|
2729
|
+
members: Array.isArray(ch.members)
|
|
2730
|
+
? ch.members.filter((memberId: unknown): memberId is string => typeof memberId === 'string')
|
|
2731
|
+
: [],
|
|
2732
|
+
...(ch.accessPolicy ? { accessPolicy: JSON.parse(JSON.stringify(ch.accessPolicy)) } : {}),
|
|
2733
|
+
createdBy: ch.createdBy || fromPeerId,
|
|
2734
|
+
createdAt: Number.isFinite(ch.createdAt) ? ch.createdAt : Date.now(),
|
|
2735
|
+
})),
|
|
2736
|
+
members: remoteMembers.map((m: any) => ({
|
|
2737
|
+
peerId: m.peerId,
|
|
2738
|
+
alias: m.alias || m.peerId.slice(0, 8),
|
|
2739
|
+
publicKey: m.publicKey || '',
|
|
2740
|
+
signingPublicKey: m.signingPublicKey || undefined,
|
|
2741
|
+
role: senderIsOwner && ['owner', 'admin', 'member'].includes(m.role) ? m.role : (m.peerId === fromPeerId && senderPayload?.role === 'owner' ? 'owner' : 'member'),
|
|
2742
|
+
isBot: m.isBot === true,
|
|
2743
|
+
companySim: m.companySim || undefined,
|
|
2744
|
+
allowWorkspaceDMs: m.allowWorkspaceDMs !== false,
|
|
2745
|
+
joinedAt: Date.now(),
|
|
2746
|
+
})),
|
|
2747
|
+
inviteCode: sync.inviteCode || '',
|
|
2748
|
+
permissions: senderIsOwner ? (sync.permissions || {}) : {},
|
|
2749
|
+
createdAt: Date.now(),
|
|
2750
|
+
createdBy: fromPeerId,
|
|
2751
|
+
};
|
|
2752
|
+
|
|
2753
|
+
// Make sure we're in the member list
|
|
2754
|
+
if (!workspace.members.some((m: any) => m.peerId === this.myPeerId)) {
|
|
2755
|
+
workspace.members.push({
|
|
2756
|
+
peerId: this.myPeerId,
|
|
2757
|
+
alias: this.opts.account.alias,
|
|
2758
|
+
publicKey: this.myPublicKey,
|
|
2759
|
+
role: 'member',
|
|
2760
|
+
isBot: true,
|
|
2761
|
+
companySim: this.getMyCompanySimProfile(),
|
|
2762
|
+
joinedAt: Date.now(),
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
this.workspaceManager.importWorkspace(workspace);
|
|
2767
|
+
this.ensureBotFlag();
|
|
2768
|
+
this.opts.log?.info(`[decentchat-peer] imported workspace ${workspaceId.slice(0, 8)} "${sync.name}" with ${workspace.members.length} members, ${workspace.channels.length} channels`);
|
|
2769
|
+
} else {
|
|
2770
|
+
// Update existing workspace: sync members and channels
|
|
2771
|
+
if (sync.name && ws.name !== sync.name) ws.name = sync.name;
|
|
2772
|
+
if (sync.description !== undefined) ws.description = sync.description;
|
|
2773
|
+
if (senderIsOwner && sync.permissions) ws.permissions = sync.permissions;
|
|
2774
|
+
|
|
2775
|
+
// Merge members
|
|
2776
|
+
for (const remoteMember of remoteMembers) {
|
|
2777
|
+
if (this.workspaceManager.isBanned(workspaceId, remoteMember.peerId)) continue;
|
|
2778
|
+
const existing = ws.members.find((m: any) => m.peerId === remoteMember.peerId);
|
|
2779
|
+
if (!existing) {
|
|
2780
|
+
ws.members.push({
|
|
2781
|
+
peerId: remoteMember.peerId,
|
|
2782
|
+
alias: remoteMember.alias || remoteMember.peerId.slice(0, 8),
|
|
2783
|
+
publicKey: remoteMember.publicKey || '',
|
|
2784
|
+
signingPublicKey: remoteMember.signingPublicKey || undefined,
|
|
2785
|
+
role: senderIsOwner && ['owner', 'admin', 'member'].includes(remoteMember.role) ? remoteMember.role : 'member',
|
|
2786
|
+
isBot: remoteMember.isBot === true,
|
|
2787
|
+
companySim: remoteMember.companySim || undefined,
|
|
2788
|
+
allowWorkspaceDMs: remoteMember.allowWorkspaceDMs !== false,
|
|
2789
|
+
joinedAt: Date.now(),
|
|
2790
|
+
});
|
|
2791
|
+
} else {
|
|
2792
|
+
if (remoteMember.alias && !/^[a-f0-9]{8}$/i.test(remoteMember.alias)) {
|
|
2793
|
+
existing.alias = remoteMember.alias;
|
|
2794
|
+
}
|
|
2795
|
+
if (remoteMember.publicKey) existing.publicKey = remoteMember.publicKey;
|
|
2796
|
+
if (remoteMember.signingPublicKey && !existing.signingPublicKey) existing.signingPublicKey = remoteMember.signingPublicKey;
|
|
2797
|
+
if (senderIsOwner && ['owner', 'admin', 'member'].includes(remoteMember.role)) existing.role = remoteMember.role;
|
|
2798
|
+
if (remoteMember.isBot === true) existing.isBot = true;
|
|
2799
|
+
if (remoteMember.companySim) existing.companySim = remoteMember.companySim;
|
|
2800
|
+
if (typeof remoteMember.allowWorkspaceDMs === 'boolean') existing.allowWorkspaceDMs = remoteMember.allowWorkspaceDMs;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// Merge channels (prefer canonical IDs, avoid duplicate same-name channels)
|
|
2805
|
+
for (const remoteCh of remoteChannels) {
|
|
2806
|
+
const remoteId = typeof remoteCh.id === 'string' ? remoteCh.id : '';
|
|
2807
|
+
const remoteType = remoteCh.type || 'channel';
|
|
2808
|
+
const remoteName = typeof remoteCh.name === 'string' ? remoteCh.name : '';
|
|
2809
|
+
const remoteMembersForChannel = Array.isArray(remoteCh.members)
|
|
2810
|
+
? remoteCh.members.filter((memberId: unknown): memberId is string => typeof memberId === 'string')
|
|
2811
|
+
: [];
|
|
2812
|
+
const remoteAccessPolicy = remoteCh.accessPolicy
|
|
2813
|
+
? JSON.parse(JSON.stringify(remoteCh.accessPolicy))
|
|
2814
|
+
: (remoteType === 'channel' ? { mode: 'public-workspace', workspaceId } : undefined);
|
|
2815
|
+
if (!remoteId || !remoteName) continue;
|
|
2816
|
+
|
|
2817
|
+
const localById = ws.channels.find((ch: any) => ch.id === remoteId);
|
|
2818
|
+
if (localById) {
|
|
2819
|
+
if (localById.name !== remoteName) localById.name = remoteName;
|
|
2820
|
+
if ((localById.type || 'channel') !== remoteType) localById.type = remoteType;
|
|
2821
|
+
if (remoteMembersForChannel.length > 0) localById.members = [...new Set(remoteMembersForChannel)];
|
|
2822
|
+
if (remoteAccessPolicy) (localById as any).accessPolicy = remoteAccessPolicy;
|
|
2823
|
+
if (remoteCh.createdBy && !localById.createdBy) localById.createdBy = remoteCh.createdBy;
|
|
2824
|
+
if (Number.isFinite(remoteCh.createdAt) && !Number.isFinite(localById.createdAt)) localById.createdAt = remoteCh.createdAt;
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
const localByName = ws.channels.find((ch: any) => ch.name === remoteName && (ch.type || 'channel') === remoteType);
|
|
2829
|
+
if (localByName) {
|
|
2830
|
+
const hasLocalHistory = this.messageStore.getMessages(localByName.id).length > 0;
|
|
2831
|
+
if (!hasLocalHistory) {
|
|
2832
|
+
localByName.id = remoteId;
|
|
2833
|
+
localByName.workspaceId = workspaceId;
|
|
2834
|
+
}
|
|
2835
|
+
if (remoteMembersForChannel.length > 0) localByName.members = [...new Set(remoteMembersForChannel)];
|
|
2836
|
+
if (remoteAccessPolicy) (localByName as any).accessPolicy = remoteAccessPolicy;
|
|
2837
|
+
if (remoteCh.createdBy && !localByName.createdBy) localByName.createdBy = remoteCh.createdBy;
|
|
2838
|
+
if (Number.isFinite(remoteCh.createdAt) && !Number.isFinite(localByName.createdAt)) localByName.createdAt = remoteCh.createdAt;
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
ws.channels.push({
|
|
2843
|
+
id: remoteId,
|
|
2844
|
+
workspaceId,
|
|
2845
|
+
name: remoteName,
|
|
2846
|
+
type: remoteType,
|
|
2847
|
+
members: remoteMembersForChannel,
|
|
2848
|
+
...(remoteAccessPolicy ? { accessPolicy: remoteAccessPolicy } : {}),
|
|
2849
|
+
createdBy: remoteCh.createdBy || fromPeerId,
|
|
2850
|
+
createdAt: Number.isFinite(remoteCh.createdAt) ? remoteCh.createdAt : Date.now(),
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
this.opts.log?.info(`[decentchat-peer] updated workspace ${workspaceId.slice(0, 8)} "${ws.name}" — now ${ws.members.length} members, ${ws.channels.length} channels`);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
this.persistWorkspaces();
|
|
2858
|
+
this.ensureBotFlag();
|
|
2859
|
+
|
|
2860
|
+
const current = this.workspaceManager.getWorkspace(workspaceId);
|
|
2861
|
+
if (current) {
|
|
2862
|
+
this.recordManifestDomain('workspace-manifest', workspaceId, {
|
|
2863
|
+
operation: 'update',
|
|
2864
|
+
subject: workspaceId,
|
|
2865
|
+
itemCount: 1,
|
|
2866
|
+
data: { name: current.name, description: current.description },
|
|
2867
|
+
});
|
|
2868
|
+
this.recordManifestDomain('membership', workspaceId, {
|
|
2869
|
+
operation: 'update',
|
|
2870
|
+
subject: workspaceId,
|
|
2871
|
+
itemCount: current.members.length,
|
|
2872
|
+
data: { memberCount: current.members.length },
|
|
2873
|
+
});
|
|
2874
|
+
this.recordManifestDomain('channel-manifest', workspaceId, {
|
|
2875
|
+
operation: 'update',
|
|
2876
|
+
subject: workspaceId,
|
|
2877
|
+
itemCount: current.channels.length,
|
|
2878
|
+
data: { channelCount: current.channels.length },
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
private persistWorkspaces(): void {
|
|
2884
|
+
this.store.set('workspaces', this.workspaceManager.getAllWorkspaces());
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
private persistMessagesForChannel(channelId: string): void {
|
|
2888
|
+
this.store.set(`messages-${channelId}`, this.messageStore.getMessages(channelId));
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
getThreadHistory(args: {
|
|
2892
|
+
channelId: string;
|
|
2893
|
+
threadId: string;
|
|
2894
|
+
limit: number;
|
|
2895
|
+
excludeMessageId?: string;
|
|
2896
|
+
}): Array<Pick<PlaintextMessage, 'id' | 'senderId' | 'content' | 'timestamp'>> {
|
|
2897
|
+
const safeChannelId = args.channelId.trim();
|
|
2898
|
+
const safeThreadId = args.threadId.trim();
|
|
2899
|
+
const safeLimit = Math.max(0, Math.floor(args.limit));
|
|
2900
|
+
if (!safeChannelId || !safeThreadId || safeLimit === 0) return [];
|
|
2901
|
+
|
|
2902
|
+
const excludeMessageId = args.excludeMessageId?.trim();
|
|
2903
|
+
|
|
2904
|
+
// Include the parent (root) message that started the thread.
|
|
2905
|
+
// getThread() only returns replies (messages with threadId set),
|
|
2906
|
+
// so we need to find the root message separately by its id.
|
|
2907
|
+
const allChannelMessages = this.messageStore.getMessages(safeChannelId);
|
|
2908
|
+
const parentMessage = allChannelMessages.find((m) => m.id === safeThreadId);
|
|
2909
|
+
|
|
2910
|
+
const threadReplies = this.messageStore
|
|
2911
|
+
.getThread(safeChannelId, safeThreadId)
|
|
2912
|
+
.filter((message) => !excludeMessageId || message.id !== excludeMessageId);
|
|
2913
|
+
|
|
2914
|
+
// Prepend parent message (if found and not excluded), then append replies
|
|
2915
|
+
const combined: PlaintextMessage[] = [];
|
|
2916
|
+
if (parentMessage && (!excludeMessageId || parentMessage.id !== excludeMessageId)) {
|
|
2917
|
+
combined.push(parentMessage);
|
|
2918
|
+
}
|
|
2919
|
+
combined.push(...threadReplies);
|
|
2920
|
+
|
|
2921
|
+
return combined
|
|
2922
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
2923
|
+
.slice(-safeLimit)
|
|
2924
|
+
.map((message) => ({
|
|
2925
|
+
id: message.id,
|
|
2926
|
+
senderId: message.senderId,
|
|
2927
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
2928
|
+
timestamp: message.timestamp,
|
|
2929
|
+
}));
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
listDirectoryPeersLive(params?: {
|
|
2933
|
+
query?: string | null;
|
|
2934
|
+
limit?: number | null;
|
|
2935
|
+
}): DirectoryEntry[] {
|
|
2936
|
+
const q = params?.query?.trim().toLowerCase() ?? '';
|
|
2937
|
+
const limit = params?.limit && params.limit > 0 ? Math.floor(params.limit) : undefined;
|
|
2938
|
+
const peers = new Map<string, { alias?: string; count: number }>();
|
|
2939
|
+
|
|
2940
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
2941
|
+
for (const member of workspace.members) {
|
|
2942
|
+
if (!member?.peerId || member.peerId === this.myPeerId) continue;
|
|
2943
|
+
const prev = peers.get(member.peerId) ?? { alias: undefined, count: 0 };
|
|
2944
|
+
peers.set(member.peerId, {
|
|
2945
|
+
alias: member.alias?.trim() || prev.alias,
|
|
2946
|
+
count: prev.count + 1,
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
const entries = Array.from(peers.entries())
|
|
2952
|
+
.map(([peerId, meta]) => ({
|
|
2953
|
+
kind: 'user' as const,
|
|
2954
|
+
id: peerId,
|
|
2955
|
+
name: meta.alias,
|
|
2956
|
+
handle: `decentchat:${peerId}`,
|
|
2957
|
+
rank: meta.count,
|
|
2958
|
+
}))
|
|
2959
|
+
.filter((entry) => {
|
|
2960
|
+
if (!q) return true;
|
|
2961
|
+
return entry.id.toLowerCase().includes(q)
|
|
2962
|
+
|| entry.handle.toLowerCase().includes(q)
|
|
2963
|
+
|| (entry.name?.toLowerCase().includes(q) ?? false);
|
|
2964
|
+
})
|
|
2965
|
+
.sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id));
|
|
2966
|
+
|
|
2967
|
+
return limit ? entries.slice(0, limit) : entries;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
listDirectoryGroupsLive(params?: {
|
|
2971
|
+
query?: string | null;
|
|
2972
|
+
limit?: number | null;
|
|
2973
|
+
}): DirectoryEntry[] {
|
|
2974
|
+
const q = params?.query?.trim().toLowerCase() ?? '';
|
|
2975
|
+
const limit = params?.limit && params.limit > 0 ? Math.floor(params.limit) : undefined;
|
|
2976
|
+
const groups: DirectoryEntry[] = [];
|
|
2977
|
+
|
|
2978
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
2979
|
+
for (const channel of workspace.channels) {
|
|
2980
|
+
if (!channel?.id) continue;
|
|
2981
|
+
if (channel.type === 'dm') continue;
|
|
2982
|
+
const id = `decentchat:channel:${channel.id}`;
|
|
2983
|
+
const name = workspace.name?.trim()
|
|
2984
|
+
? `${workspace.name} / #${channel.name}`
|
|
2985
|
+
: `#${channel.name}`;
|
|
2986
|
+
groups.push({
|
|
2987
|
+
kind: 'group',
|
|
2988
|
+
id,
|
|
2989
|
+
name,
|
|
2990
|
+
raw: {
|
|
2991
|
+
workspaceId: workspace.id,
|
|
2992
|
+
channelId: channel.id,
|
|
2993
|
+
channelName: channel.name,
|
|
2994
|
+
},
|
|
2995
|
+
});
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
const deduped = new Map<string, DirectoryEntry>();
|
|
3000
|
+
for (const group of groups) {
|
|
3001
|
+
if (!deduped.has(group.id)) deduped.set(group.id, group);
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
const entries = Array.from(deduped.values())
|
|
3005
|
+
.filter((entry) => {
|
|
3006
|
+
if (!q) return true;
|
|
3007
|
+
return entry.id.toLowerCase().includes(q)
|
|
3008
|
+
|| (entry.name?.toLowerCase().includes(q) ?? false)
|
|
3009
|
+
|| String((entry.raw as any)?.workspaceId ?? '').toLowerCase().includes(q)
|
|
3010
|
+
|| String((entry.raw as any)?.channelId ?? '').toLowerCase().includes(q);
|
|
3011
|
+
})
|
|
3012
|
+
.sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id));
|
|
3013
|
+
|
|
3014
|
+
return limit ? entries.slice(0, limit) : entries;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
/** Public convenience: resolve workspace by channelId then call sendMessage. */
|
|
3018
|
+
async sendToChannel(
|
|
3019
|
+
channelId: string,
|
|
3020
|
+
content: string,
|
|
3021
|
+
threadId?: string,
|
|
3022
|
+
replyToId?: string,
|
|
3023
|
+
messageId?: string,
|
|
3024
|
+
model?: AssistantModelMeta,
|
|
3025
|
+
): Promise<void> {
|
|
3026
|
+
const workspaceId = this.findWorkspaceIdForChannel(channelId);
|
|
3027
|
+
return this.sendMessage(channelId, workspaceId, content, threadId, replyToId, messageId, model);
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
/** Send a direct (non-workspace) message to a specific peer with isDirect=true. */
|
|
3031
|
+
async sendDirectToPeer(
|
|
3032
|
+
peerId: string,
|
|
3033
|
+
content: string,
|
|
3034
|
+
threadId?: string,
|
|
3035
|
+
replyToId?: string,
|
|
3036
|
+
messageId?: string,
|
|
3037
|
+
model?: AssistantModelMeta,
|
|
3038
|
+
): Promise<void> {
|
|
3039
|
+
if (!this.transport || !this.messageProtocol || !content.trim()) return;
|
|
3040
|
+
const modelMeta = buildMessageMetadata(model);
|
|
3041
|
+
const outboundMessageId = messageId || randomUUID();
|
|
3042
|
+
|
|
3043
|
+
try {
|
|
3044
|
+
const encrypted = await this.encryptMessageWithPreKeyBootstrap(peerId, content.trim(), modelMeta, this.resolveSharedWorkspaceIds(peerId)[0]);
|
|
3045
|
+
(encrypted as any).isDirect = true;
|
|
3046
|
+
(encrypted as any).senderId = this.myPeerId;
|
|
3047
|
+
(encrypted as any).senderName = this.opts.account.alias;
|
|
3048
|
+
(encrypted as any).messageId = outboundMessageId;
|
|
3049
|
+
if (threadId) (encrypted as any).threadId = threadId;
|
|
3050
|
+
if (replyToId) (encrypted as any).replyToId = replyToId;
|
|
3051
|
+
|
|
3052
|
+
const connected = this.transport.getConnectedPeers().includes(peerId);
|
|
3053
|
+
if (connected) {
|
|
3054
|
+
await this.queuePendingAck(peerId, {
|
|
3055
|
+
content: content.trim(),
|
|
3056
|
+
senderId: this.myPeerId,
|
|
3057
|
+
senderName: this.opts.account.alias,
|
|
3058
|
+
messageId: outboundMessageId,
|
|
3059
|
+
threadId,
|
|
3060
|
+
replyToId,
|
|
3061
|
+
isDirect: true,
|
|
3062
|
+
...(modelMeta ? { metadata: modelMeta } : {}),
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
const accepted = this.transport.send(peerId, encrypted);
|
|
3066
|
+
if (!accepted) {
|
|
3067
|
+
await this.custodyStore.storeEnvelope({
|
|
3068
|
+
envelopeId: typeof (encrypted as any).id === 'string' ? (encrypted as any).id : undefined,
|
|
3069
|
+
opId: outboundMessageId,
|
|
3070
|
+
recipientPeerIds: [peerId],
|
|
3071
|
+
workspaceId: 'direct',
|
|
3072
|
+
...(threadId ? { threadId } : {}),
|
|
3073
|
+
domain: 'channel-message',
|
|
3074
|
+
ciphertext: encrypted,
|
|
3075
|
+
metadata: {
|
|
3076
|
+
messageId: outboundMessageId,
|
|
3077
|
+
...this.buildCustodyResendMetadata({
|
|
3078
|
+
content: content.trim(),
|
|
3079
|
+
senderId: this.myPeerId,
|
|
3080
|
+
senderName: this.opts.account.alias,
|
|
3081
|
+
threadId,
|
|
3082
|
+
replyToId,
|
|
3083
|
+
isDirect: true,
|
|
3084
|
+
metadata: modelMeta,
|
|
3085
|
+
}),
|
|
3086
|
+
},
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
await this.custodyStore.storeEnvelope({
|
|
3093
|
+
envelopeId: typeof (encrypted as any).id === 'string' ? (encrypted as any).id : undefined,
|
|
3094
|
+
opId: outboundMessageId,
|
|
3095
|
+
recipientPeerIds: [peerId],
|
|
3096
|
+
workspaceId: 'direct',
|
|
3097
|
+
...(threadId ? { threadId } : {}),
|
|
3098
|
+
domain: 'channel-message',
|
|
3099
|
+
ciphertext: encrypted,
|
|
3100
|
+
metadata: {
|
|
3101
|
+
messageId: outboundMessageId,
|
|
3102
|
+
...this.buildCustodyResendMetadata({
|
|
3103
|
+
content: content.trim(),
|
|
3104
|
+
senderId: this.myPeerId,
|
|
3105
|
+
senderName: this.opts.account.alias,
|
|
3106
|
+
threadId,
|
|
3107
|
+
replyToId,
|
|
3108
|
+
isDirect: true,
|
|
3109
|
+
metadata: modelMeta,
|
|
3110
|
+
}),
|
|
3111
|
+
},
|
|
3112
|
+
});
|
|
3113
|
+
} catch (err) {
|
|
3114
|
+
this.opts.log?.error?.(`[decentchat-peer] DM to ${peerId} failed: ${String(err)}`);
|
|
3115
|
+
await this.enqueueOffline(peerId, {
|
|
3116
|
+
content: content.trim(),
|
|
3117
|
+
senderId: this.myPeerId,
|
|
3118
|
+
senderName: this.opts.account.alias,
|
|
3119
|
+
messageId: outboundMessageId,
|
|
3120
|
+
threadId,
|
|
3121
|
+
replyToId,
|
|
3122
|
+
isDirect: true,
|
|
3123
|
+
...(modelMeta ? { metadata: modelMeta } : {}),
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
async sendReadReceipt(peerId: string, channelId: string, messageId: string): Promise<void> {
|
|
3129
|
+
if (!this.transport || !peerId || !channelId || !messageId) return;
|
|
3130
|
+
|
|
3131
|
+
const payload = {
|
|
3132
|
+
type: 'read',
|
|
3133
|
+
channelId,
|
|
3134
|
+
messageId,
|
|
3135
|
+
} as const;
|
|
3136
|
+
|
|
3137
|
+
if (!this.transport.getConnectedPeers().includes(peerId)) {
|
|
3138
|
+
await this.enqueueOffline(peerId, payload);
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
try {
|
|
3143
|
+
const accepted = this.transport.send(peerId, payload);
|
|
3144
|
+
if (!accepted) {
|
|
3145
|
+
await this.enqueueOffline(peerId, payload);
|
|
3146
|
+
}
|
|
3147
|
+
} catch (err) {
|
|
3148
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to send read receipt to ${peerId}: ${String(err)}`);
|
|
3149
|
+
await this.enqueueOffline(peerId, payload);
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
this.recordManifestDomain('receipt', this.findWorkspaceIdForChannel(channelId), {
|
|
3153
|
+
channelId,
|
|
3154
|
+
operation: 'create',
|
|
3155
|
+
subject: messageId,
|
|
3156
|
+
data: {
|
|
3157
|
+
kind: 'read',
|
|
3158
|
+
targetPeerId: peerId,
|
|
3159
|
+
},
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
async sendTyping(params: { channelId: string; workspaceId: string; typing: boolean }): Promise<void> {
|
|
3164
|
+
if (!this.transport || !params.channelId) return;
|
|
3165
|
+
const recipients = this.getChannelRecipientPeerIds(params.channelId, params.workspaceId);
|
|
3166
|
+
const envelope = {
|
|
3167
|
+
type: 'typing' as const,
|
|
3168
|
+
channelId: params.channelId,
|
|
3169
|
+
peerId: this.myPeerId,
|
|
3170
|
+
typing: params.typing,
|
|
3171
|
+
};
|
|
3172
|
+
for (const peerId of recipients) {
|
|
3173
|
+
if (this.transport.getConnectedPeers().includes(peerId)) {
|
|
3174
|
+
this.transport.send(peerId, envelope);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
async sendDirectTyping(params: { peerId: string; typing: boolean }): Promise<void> {
|
|
3180
|
+
if (!this.transport || !params.peerId) return;
|
|
3181
|
+
if (!this.transport.getConnectedPeers().includes(params.peerId)) return;
|
|
3182
|
+
this.transport.send(params.peerId, {
|
|
3183
|
+
type: 'typing',
|
|
3184
|
+
channelId: params.peerId,
|
|
3185
|
+
workspaceId: '',
|
|
3186
|
+
peerId: this.myPeerId,
|
|
3187
|
+
typing: params.typing,
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
/** Send stream-start to all workspace peers (or direct peer for DMs) */
|
|
3192
|
+
async startStream(params: {
|
|
3193
|
+
channelId: string;
|
|
3194
|
+
workspaceId: string;
|
|
3195
|
+
messageId: string;
|
|
3196
|
+
threadId?: string;
|
|
3197
|
+
replyToId?: string;
|
|
3198
|
+
isDirect?: false;
|
|
3199
|
+
model?: AssistantModelMeta;
|
|
3200
|
+
}): Promise<void> {
|
|
3201
|
+
if (!this.transport) return;
|
|
3202
|
+
const recipients = this.getChannelRecipientPeerIds(params.channelId, params.workspaceId);
|
|
3203
|
+
const envelope: any = {
|
|
3204
|
+
type: 'stream-start',
|
|
3205
|
+
messageId: params.messageId,
|
|
3206
|
+
channelId: params.channelId,
|
|
3207
|
+
workspaceId: params.workspaceId,
|
|
3208
|
+
senderId: this.myPeerId,
|
|
3209
|
+
senderName: this.opts.account.alias,
|
|
3210
|
+
isDirect: false as const,
|
|
3211
|
+
...(params.threadId ? { threadId: params.threadId } : {}),
|
|
3212
|
+
...(params.replyToId ? { replyToId: params.replyToId } : {}),
|
|
3213
|
+
};
|
|
3214
|
+
if (params.model) {
|
|
3215
|
+
envelope.modelMeta = params.model;
|
|
3216
|
+
}
|
|
3217
|
+
for (const peerId of recipients) {
|
|
3218
|
+
if (this.transport.getConnectedPeers().includes(peerId)) {
|
|
3219
|
+
this.transport.send(peerId, envelope);
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
async startDirectStream(params: {
|
|
3225
|
+
peerId: string;
|
|
3226
|
+
messageId: string;
|
|
3227
|
+
model?: AssistantModelMeta;
|
|
3228
|
+
}): Promise<void> {
|
|
3229
|
+
if (!this.transport || !this.transport.getConnectedPeers().includes(params.peerId)) return;
|
|
3230
|
+
const envelope: any = {
|
|
3231
|
+
type: 'stream-start',
|
|
3232
|
+
messageId: params.messageId,
|
|
3233
|
+
channelId: params.peerId,
|
|
3234
|
+
workspaceId: '',
|
|
3235
|
+
senderId: this.myPeerId,
|
|
3236
|
+
senderName: this.opts.account.alias,
|
|
3237
|
+
isDirect: true,
|
|
3238
|
+
};
|
|
3239
|
+
if (params.model) {
|
|
3240
|
+
envelope.modelMeta = params.model;
|
|
3241
|
+
}
|
|
3242
|
+
this.transport.send(params.peerId, envelope);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
async sendStreamDelta(params: {
|
|
3246
|
+
channelId: string;
|
|
3247
|
+
workspaceId: string;
|
|
3248
|
+
messageId: string;
|
|
3249
|
+
content: string;
|
|
3250
|
+
}): Promise<void> {
|
|
3251
|
+
if (!this.transport) return;
|
|
3252
|
+
const recipients = this.getChannelRecipientPeerIds(params.channelId, params.workspaceId);
|
|
3253
|
+
const envelope = { type: 'stream-delta', messageId: params.messageId, content: params.content };
|
|
3254
|
+
for (const peerId of recipients) {
|
|
3255
|
+
if (this.transport.getConnectedPeers().includes(peerId)) {
|
|
3256
|
+
this.transport.send(peerId, envelope);
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
async sendDirectStreamDelta(params: {
|
|
3262
|
+
peerId: string;
|
|
3263
|
+
messageId: string;
|
|
3264
|
+
content: string;
|
|
3265
|
+
}): Promise<void> {
|
|
3266
|
+
if (!this.transport || !this.transport.getConnectedPeers().includes(params.peerId)) return;
|
|
3267
|
+
this.transport.send(params.peerId, { type: 'stream-delta', messageId: params.messageId, content: params.content });
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
async sendStreamDone(params: {
|
|
3271
|
+
channelId: string;
|
|
3272
|
+
workspaceId: string;
|
|
3273
|
+
messageId: string;
|
|
3274
|
+
}): Promise<void> {
|
|
3275
|
+
if (!this.transport) return;
|
|
3276
|
+
const recipients = this.getChannelRecipientPeerIds(params.channelId, params.workspaceId);
|
|
3277
|
+
const envelope = { type: 'stream-done', messageId: params.messageId };
|
|
3278
|
+
for (const peerId of recipients) {
|
|
3279
|
+
if (this.transport.getConnectedPeers().includes(peerId)) {
|
|
3280
|
+
this.transport.send(peerId, envelope);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
async sendDirectStreamDone(params: {
|
|
3286
|
+
peerId: string;
|
|
3287
|
+
messageId: string;
|
|
3288
|
+
}): Promise<void> {
|
|
3289
|
+
if (!this.transport || !this.transport.getConnectedPeers().includes(params.peerId)) return;
|
|
3290
|
+
this.transport.send(params.peerId, { type: 'stream-done', messageId: params.messageId });
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// =========================================================================
|
|
3294
|
+
// Media handling (full-quality image requests)
|
|
3295
|
+
// =========================================================================
|
|
3296
|
+
|
|
3297
|
+
private async handleMediaRequest(fromPeerId: string, request: MediaRequest): Promise<void> {
|
|
3298
|
+
if (!this.transport) return;
|
|
3299
|
+
|
|
3300
|
+
// Check if we have this attachment stored locally
|
|
3301
|
+
const attachmentKey = `attachment-meta:${request.attachmentId}`;
|
|
3302
|
+
const attachment = this.store.get<{ id: string; name: string; mimeType: string; size: number; totalChunks: number } | null>(attachmentKey, null);
|
|
3303
|
+
|
|
3304
|
+
if (!attachment) {
|
|
3305
|
+
const response: MediaResponse = { type: 'media-response', attachmentId: request.attachmentId, available: false };
|
|
3306
|
+
this.transport.send(fromPeerId, response);
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// Send response indicating availability
|
|
3311
|
+
const response: MediaResponse = {
|
|
3312
|
+
type: 'media-response',
|
|
3313
|
+
attachmentId: request.attachmentId,
|
|
3314
|
+
available: true,
|
|
3315
|
+
totalChunks: attachment.totalChunks,
|
|
3316
|
+
};
|
|
3317
|
+
this.transport.send(fromPeerId, response);
|
|
3318
|
+
|
|
3319
|
+
// Send chunks
|
|
3320
|
+
const startChunk = request.fromChunk ?? 0;
|
|
3321
|
+
for (let i = startChunk; i < attachment.totalChunks; i++) {
|
|
3322
|
+
const chunkKey = `media-chunk:${request.attachmentId}:${i}`;
|
|
3323
|
+
const chunkData = this.store.get<string | null>(chunkKey, null);
|
|
3324
|
+
if (chunkData) {
|
|
3325
|
+
const chunk: MediaChunk = {
|
|
3326
|
+
type: 'media-chunk',
|
|
3327
|
+
attachmentId: request.attachmentId,
|
|
3328
|
+
index: i,
|
|
3329
|
+
total: attachment.totalChunks,
|
|
3330
|
+
data: chunkData,
|
|
3331
|
+
chunkHash: '', // TODO: compute hash
|
|
3332
|
+
};
|
|
3333
|
+
this.transport.send(fromPeerId, chunk);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
private async handleMediaResponse(fromPeerId: string, response: MediaResponse): Promise<void> {
|
|
3339
|
+
const pending = this.pendingMediaRequests.get(response.attachmentId);
|
|
3340
|
+
if (!pending) return;
|
|
3341
|
+
|
|
3342
|
+
if (!response.available) {
|
|
3343
|
+
clearTimeout(pending.timeout);
|
|
3344
|
+
this.pendingMediaRequests.delete(response.attachmentId);
|
|
3345
|
+
pending.resolve(null);
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
// Chunks will arrive via handleMediaChunk; just wait
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
private async handleMediaChunk(fromPeerId: string, chunk: MediaChunk): Promise<void> {
|
|
3353
|
+
const pending = this.pendingMediaRequests.get(chunk.attachmentId);
|
|
3354
|
+
if (!pending) return;
|
|
3355
|
+
|
|
3356
|
+
try {
|
|
3357
|
+
const buffer = Buffer.from(chunk.data, 'base64');
|
|
3358
|
+
pending.chunks.set(chunk.index, buffer);
|
|
3359
|
+
|
|
3360
|
+
// Check if we have all chunks
|
|
3361
|
+
if (pending.chunks.size === chunk.total) {
|
|
3362
|
+
clearTimeout(pending.timeout);
|
|
3363
|
+
this.pendingMediaRequests.delete(chunk.attachmentId);
|
|
3364
|
+
|
|
3365
|
+
// Reassemble
|
|
3366
|
+
const chunks: Buffer[] = [];
|
|
3367
|
+
for (let i = 0; i < chunk.total; i++) {
|
|
3368
|
+
const c = pending.chunks.get(i);
|
|
3369
|
+
if (!c) {
|
|
3370
|
+
pending.resolve(null);
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
chunks.push(c);
|
|
3374
|
+
}
|
|
3375
|
+
const fullBuffer = Buffer.concat(chunks);
|
|
3376
|
+
|
|
3377
|
+
// Store locally for future use
|
|
3378
|
+
const storedKey = `media-full:${chunk.attachmentId}`;
|
|
3379
|
+
this.store.set(storedKey, fullBuffer.toString('base64'));
|
|
3380
|
+
|
|
3381
|
+
pending.resolve(fullBuffer);
|
|
3382
|
+
}
|
|
3383
|
+
} catch {
|
|
3384
|
+
// Invalid chunk data
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
/** Public: resolve channel name by id. Returns undefined if none found. */
|
|
3389
|
+
findChannelNameById(channelId: string): string | undefined {
|
|
3390
|
+
const ws = this.workspaceManager
|
|
3391
|
+
.getAllWorkspaces()
|
|
3392
|
+
.find((workspace) => workspace.channels.some((ch) => ch.id === channelId));
|
|
3393
|
+
return ws?.channels.find((ch) => ch.id === channelId)?.name;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
private getChannelRecipientPeerIds(channelId: string, workspaceId?: string): string[] {
|
|
3397
|
+
const workspace = workspaceId ? this.workspaceManager.getWorkspace(workspaceId) : undefined;
|
|
3398
|
+
if (!workspace) return this.transport?.getConnectedPeers().filter((p) => p !== this.myPeerId) ?? [];
|
|
3399
|
+
|
|
3400
|
+
const workspacePeers = workspace.members
|
|
3401
|
+
.map((member) => member.peerId)
|
|
3402
|
+
.filter((peerId) => Boolean(peerId) && peerId !== this.myPeerId);
|
|
3403
|
+
|
|
3404
|
+
const channels = Array.isArray((workspace as any).channels) ? workspace.channels : [];
|
|
3405
|
+
const channel = channels.find((entry) => entry.id === channelId);
|
|
3406
|
+
const accessPolicy = (channel as any)?.accessPolicy;
|
|
3407
|
+
if (accessPolicy?.mode === 'explicit' && Array.isArray(accessPolicy.explicitMemberPeerIds)) {
|
|
3408
|
+
return Array.from(new Set(
|
|
3409
|
+
accessPolicy.explicitMemberPeerIds
|
|
3410
|
+
.filter((peerId: unknown): peerId is string => typeof peerId === 'string' && peerId.length > 0)
|
|
3411
|
+
.filter((peerId: string) => peerId !== this.myPeerId),
|
|
3412
|
+
));
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
return workspacePeers;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
/** Compatibility alias used by monitor/company routing. */
|
|
3419
|
+
resolveChannelNameById(channelId: string): string | undefined {
|
|
3420
|
+
return this.findChannelNameById(channelId);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
/** Public: find the workspace ID that owns a given channel. Returns '' if none found. */
|
|
3424
|
+
findWorkspaceIdForChannel(channelId: string): string {
|
|
3425
|
+
const ws = this.workspaceManager
|
|
3426
|
+
.getAllWorkspaces()
|
|
3427
|
+
.find((workspace) => workspace.channels.some((ch) => ch.id === channelId));
|
|
3428
|
+
return ws?.id ?? '';
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
private resolveSenderName(workspaceId: string, peerId: string, fallback?: string): string {
|
|
3432
|
+
const ws = workspaceId ? this.workspaceManager.getWorkspace(workspaceId) : undefined;
|
|
3433
|
+
const alias = ws?.members.find((m) => m.peerId === peerId)?.alias;
|
|
3434
|
+
const cachedAlias = this.store.get<string>(`peer-alias-${peerId}`, '');
|
|
3435
|
+
return alias || cachedAlias || fallback || peerId.slice(0, 8);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
private getPeerPublicKey(peerId: string): string | null {
|
|
3439
|
+
const savedPeers = this.store.get<Record<string, string>>('peer-public-keys', {});
|
|
3440
|
+
if (savedPeers[peerId]) return savedPeers[peerId];
|
|
3441
|
+
|
|
3442
|
+
for (const ws of this.workspaceManager.getAllWorkspaces()) {
|
|
3443
|
+
const member = ws.members.find((m) => m.peerId === peerId && m.publicKey);
|
|
3444
|
+
if (member?.publicKey) return member.publicKey;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
return null;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
private updateWorkspaceMemberKey(peerId: string, publicKey: string): void {
|
|
3451
|
+
let changed = false;
|
|
3452
|
+
for (const ws of this.workspaceManager.getAllWorkspaces()) {
|
|
3453
|
+
const member = ws.members.find((m) => m.peerId === peerId);
|
|
3454
|
+
if (member && member.publicKey !== publicKey) {
|
|
3455
|
+
member.publicKey = publicKey;
|
|
3456
|
+
changed = true;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
if (changed) {
|
|
3460
|
+
this.persistWorkspaces();
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
private updateWorkspaceMemberAlias(peerId: string, alias: string, companySim?: WorkspaceMember["companySim"], isBot?: boolean): void {
|
|
3465
|
+
let changed = false;
|
|
3466
|
+
for (const ws of this.workspaceManager.getAllWorkspaces()) {
|
|
3467
|
+
const member = ws.members.find((m) => m.peerId === peerId);
|
|
3468
|
+
if (!member) continue;
|
|
3469
|
+
if (member.alias !== alias) {
|
|
3470
|
+
member.alias = alias;
|
|
3471
|
+
changed = true;
|
|
3472
|
+
}
|
|
3473
|
+
if (isBot === true && !member.isBot) {
|
|
3474
|
+
member.isBot = true;
|
|
3475
|
+
changed = true;
|
|
3476
|
+
}
|
|
3477
|
+
if (companySim) {
|
|
3478
|
+
const prev = JSON.stringify(member.companySim || null);
|
|
3479
|
+
const next = JSON.stringify(companySim);
|
|
3480
|
+
if (prev !== next) {
|
|
3481
|
+
member.companySim = companySim;
|
|
3482
|
+
changed = true;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
if (changed) {
|
|
3487
|
+
this.persistWorkspaces();
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
/** Ensure our own member records always have isBot: true */
|
|
3492
|
+
private ensureBotFlag(): void {
|
|
3493
|
+
let changed = false;
|
|
3494
|
+
for (const ws of this.workspaceManager.getAllWorkspaces()) {
|
|
3495
|
+
const me = ws.members.find((m) => m.peerId === this.myPeerId);
|
|
3496
|
+
if (me && !me.isBot) {
|
|
3497
|
+
me.isBot = true;
|
|
3498
|
+
changed = true;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
if (changed) this.persistWorkspaces();
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
private offlineQueueKey(peerId: string): string {
|
|
3505
|
+
return `offline-queue-${peerId}`;
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
private receiptLogKey(peerId: string): string {
|
|
3509
|
+
return `receipt-log-${peerId}`;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
private custodialInboxKey(): string {
|
|
3513
|
+
return 'custodian-inbox';
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
private pendingAckKey(peerId: string): string {
|
|
3517
|
+
return `pending-ack-${peerId}`;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
private getMyCompanySimProfile(): WorkspaceMember["companySim"] | undefined {
|
|
3521
|
+
if (this.companySimProfileLoaded) {
|
|
3522
|
+
return this.companySimProfile;
|
|
3523
|
+
}
|
|
3524
|
+
if (!this.opts.account.companySim?.enabled) {
|
|
3525
|
+
this.companySimProfileLoaded = true;
|
|
3526
|
+
this.companySimProfile = undefined;
|
|
3527
|
+
return undefined;
|
|
3528
|
+
}
|
|
3529
|
+
try {
|
|
3530
|
+
const context = loadCompanyContextForAccount(this.opts.account);
|
|
3531
|
+
this.companySimProfile = context ? {
|
|
3532
|
+
automationKind: 'openclaw-agent',
|
|
3533
|
+
roleTitle: context.employee.title,
|
|
3534
|
+
teamId: context.employee.teamId,
|
|
3535
|
+
} : undefined;
|
|
3536
|
+
} catch (err) {
|
|
3537
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed to load company profile for ${this.opts.account.accountId}: ${String(err)}`);
|
|
3538
|
+
this.companySimProfile = { automationKind: 'openclaw-agent' };
|
|
3539
|
+
}
|
|
3540
|
+
this.companySimProfileLoaded = true;
|
|
3541
|
+
return this.companySimProfile;
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
private pendingReadReceiptKey(peerId: string): string {
|
|
3545
|
+
return `pending-read-${peerId}`;
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
private requestSyncForPeer(peerId: string): void {
|
|
3549
|
+
if (!this.syncProtocol) return;
|
|
3550
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
3551
|
+
if (!workspace.members.some((member) => member.peerId === peerId)) continue;
|
|
3552
|
+
this.syncProtocol.requestSync(peerId, workspace.id);
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
private async queuePendingReadReceipt(peerId: string, channelId: string, messageId: string): Promise<void> {
|
|
3557
|
+
const key = this.pendingReadReceiptKey(peerId);
|
|
3558
|
+
const current = this.store.get<Array<{ channelId: string; messageId: string; queuedAt: number }>>(key, []);
|
|
3559
|
+
const exists = current.some((entry) => entry?.channelId === channelId && entry?.messageId === messageId);
|
|
3560
|
+
if (exists) return;
|
|
3561
|
+
current.push({ channelId, messageId, queuedAt: Date.now() });
|
|
3562
|
+
this.store.set(key, current);
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
private async flushPendingReadReceipts(peerId: string): Promise<void> {
|
|
3566
|
+
if (!this.transport) return;
|
|
3567
|
+
if (!this.transport.getConnectedPeers().includes(peerId)) return;
|
|
3568
|
+
|
|
3569
|
+
const key = this.pendingReadReceiptKey(peerId);
|
|
3570
|
+
const queued = this.store.get<Array<{ channelId: string; messageId: string; queuedAt: number }>>(key, []);
|
|
3571
|
+
if (queued.length === 0) return;
|
|
3572
|
+
|
|
3573
|
+
const retry: Array<{ channelId: string; messageId: string; queuedAt: number }> = [];
|
|
3574
|
+
for (const item of queued) {
|
|
3575
|
+
if (!item?.channelId || !item?.messageId) continue;
|
|
3576
|
+
try {
|
|
3577
|
+
this.transport.send(peerId, {
|
|
3578
|
+
type: 'read',
|
|
3579
|
+
channelId: item.channelId,
|
|
3580
|
+
messageId: item.messageId,
|
|
3581
|
+
});
|
|
3582
|
+
} catch {
|
|
3583
|
+
retry.push(item);
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
if (retry.length === 0) this.store.delete(key);
|
|
3588
|
+
else this.store.set(key, retry);
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
private isCustodyEnvelope(value: unknown): value is CustodyEnvelope {
|
|
3592
|
+
if (!value || typeof value !== 'object') return false;
|
|
3593
|
+
const envelope = value as Partial<CustodyEnvelope>;
|
|
3594
|
+
return typeof envelope.envelopeId === 'string'
|
|
3595
|
+
&& typeof envelope.opId === 'string'
|
|
3596
|
+
&& Array.isArray(envelope.recipientPeerIds)
|
|
3597
|
+
&& typeof envelope.workspaceId === 'string'
|
|
3598
|
+
&& typeof envelope.domain === 'string'
|
|
3599
|
+
&& 'ciphertext' in envelope;
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
private recordManifestDomain(
|
|
3603
|
+
domain: SyncDomain,
|
|
3604
|
+
workspaceId: string | undefined,
|
|
3605
|
+
params?: {
|
|
3606
|
+
channelId?: string;
|
|
3607
|
+
operation?: ManifestDelta['operation'];
|
|
3608
|
+
subject?: string;
|
|
3609
|
+
itemCount?: number;
|
|
3610
|
+
data?: Record<string, unknown>;
|
|
3611
|
+
},
|
|
3612
|
+
): ManifestDelta | null {
|
|
3613
|
+
if (!workspaceId) return null;
|
|
3614
|
+
return this.manifestStore.updateDomain({
|
|
3615
|
+
domain,
|
|
3616
|
+
workspaceId,
|
|
3617
|
+
...(params?.channelId ? { channelId: params.channelId } : {}),
|
|
3618
|
+
author: this.myPeerId || 'unknown',
|
|
3619
|
+
operation: params?.operation ?? 'update',
|
|
3620
|
+
subject: params?.subject,
|
|
3621
|
+
itemCount: params?.itemCount,
|
|
3622
|
+
data: params?.data,
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
private async handleInboundReceipt(fromPeerId: string, msg: any, kind: DeliveryReceipt['kind']): Promise<void> {
|
|
3627
|
+
const messageId = typeof msg?.messageId === 'string' ? msg.messageId : '';
|
|
3628
|
+
if (!messageId) return;
|
|
3629
|
+
|
|
3630
|
+
const receipt: DeliveryReceipt = {
|
|
3631
|
+
receiptId: `${kind}:${fromPeerId}:${messageId}:${Date.now()}`,
|
|
3632
|
+
kind,
|
|
3633
|
+
opId: messageId,
|
|
3634
|
+
recipientPeerId: fromPeerId,
|
|
3635
|
+
timestamp: Date.now(),
|
|
3636
|
+
...(typeof msg?.envelopeId === 'string' ? { envelopeId: msg.envelopeId } : {}),
|
|
3637
|
+
metadata: {
|
|
3638
|
+
...(typeof msg?.channelId === 'string' ? { channelId: msg.channelId } : {}),
|
|
3639
|
+
},
|
|
3640
|
+
};
|
|
3641
|
+
|
|
3642
|
+
await this.removePendingAck(fromPeerId, messageId);
|
|
3643
|
+
await this.custodyStore.applyReceipt(fromPeerId, receipt);
|
|
3644
|
+
await this.offlineQueue.applyReceipt(fromPeerId, receipt);
|
|
3645
|
+
|
|
3646
|
+
this.recordManifestDomain('receipt', typeof msg?.channelId === 'string' ? this.findWorkspaceIdForChannel(msg.channelId) : undefined, {
|
|
3647
|
+
channelId: typeof msg?.channelId === 'string' ? msg.channelId : undefined,
|
|
3648
|
+
operation: 'create',
|
|
3649
|
+
subject: messageId,
|
|
3650
|
+
data: {
|
|
3651
|
+
kind,
|
|
3652
|
+
recipientPeerId: fromPeerId,
|
|
3653
|
+
},
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
private sendManifestSummary(peerId: string, onlyWorkspaceId?: string): void {
|
|
3658
|
+
if (!this.transport) return;
|
|
3659
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
3660
|
+
if (onlyWorkspaceId && workspace.id !== onlyWorkspaceId) continue;
|
|
3661
|
+
if (!workspace.members.some((member) => member.peerId === peerId)) continue;
|
|
3662
|
+
const summary = this.manifestStore.getSummary(workspace.id);
|
|
3663
|
+
this.transport.send(peerId, {
|
|
3664
|
+
type: 'sync.summary',
|
|
3665
|
+
workspaceId: workspace.id,
|
|
3666
|
+
summary,
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
private async handleManifestSummary(peerId: string, msg: any): Promise<void> {
|
|
3672
|
+
if (!this.transport) return;
|
|
3673
|
+
const summary = (msg?.summary ?? msg) as SyncManifestSummary;
|
|
3674
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : summary?.workspaceId;
|
|
3675
|
+
if (!workspaceId || !summary || !Array.isArray(summary.versions)) return;
|
|
3676
|
+
|
|
3677
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
3678
|
+
if (!workspace || !workspace.members.some((member) => member.peerId === peerId)) return;
|
|
3679
|
+
|
|
3680
|
+
const missing = this.manifestStore.buildDiffRequest(workspaceId, summary);
|
|
3681
|
+
if (missing.length > 0) {
|
|
3682
|
+
this.transport.send(peerId, {
|
|
3683
|
+
type: 'sync.diff_request',
|
|
3684
|
+
workspaceId,
|
|
3685
|
+
requestId: randomUUID(),
|
|
3686
|
+
requests: missing,
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
const remoteByKey = new Map(summary.versions.map((version) => [`${version.domain}:${version.channelId ?? ''}`, version] as const));
|
|
3691
|
+
const localSummary = this.manifestStore.getSummary(workspaceId);
|
|
3692
|
+
const pushDeltas: ManifestDelta[] = [];
|
|
3693
|
+
|
|
3694
|
+
for (const localVersion of localSummary.versions) {
|
|
3695
|
+
const key = `${localVersion.domain}:${localVersion.channelId ?? ''}`;
|
|
3696
|
+
const remoteVersion = remoteByKey.get(key)?.version ?? 0;
|
|
3697
|
+
if (localVersion.version <= remoteVersion) continue;
|
|
3698
|
+
pushDeltas.push(...this.manifestStore.getDeltasSince({
|
|
3699
|
+
workspaceId,
|
|
3700
|
+
domain: localVersion.domain,
|
|
3701
|
+
channelId: localVersion.channelId,
|
|
3702
|
+
fromVersion: remoteVersion,
|
|
3703
|
+
toVersion: localVersion.version,
|
|
3704
|
+
limit: 500,
|
|
3705
|
+
}));
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
if (pushDeltas.length > 0) {
|
|
3709
|
+
this.transport.send(peerId, {
|
|
3710
|
+
type: 'sync.diff_response',
|
|
3711
|
+
workspaceId,
|
|
3712
|
+
requestId: `push:${randomUUID()}`,
|
|
3713
|
+
deltas: pushDeltas,
|
|
3714
|
+
});
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
private async handleManifestDiffRequest(peerId: string, msg: any): Promise<void> {
|
|
3719
|
+
if (!this.transport) return;
|
|
3720
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : '';
|
|
3721
|
+
if (!workspaceId) return;
|
|
3722
|
+
|
|
3723
|
+
const requests = Array.isArray(msg?.requests)
|
|
3724
|
+
? (msg.requests as ManifestDiffRequest[])
|
|
3725
|
+
: (msg?.request ? [msg.request as ManifestDiffRequest] : []);
|
|
3726
|
+
if (requests.length === 0) return;
|
|
3727
|
+
|
|
3728
|
+
const deltas: ManifestDelta[] = [];
|
|
3729
|
+
const snapshots: Array<{ domain: SyncDomain; workspaceId: string; channelId?: string; snapshotId: string; version: number; basedOnVersion: number; createdAt: number; createdBy: string }> = [];
|
|
3730
|
+
|
|
3731
|
+
for (const request of requests) {
|
|
3732
|
+
const slice = this.manifestStore.getDeltasSince({
|
|
3733
|
+
workspaceId,
|
|
3734
|
+
domain: request.domain,
|
|
3735
|
+
channelId: request.channelId,
|
|
3736
|
+
fromVersion: request.fromVersion,
|
|
3737
|
+
toVersion: request.toVersion,
|
|
3738
|
+
limit: 500,
|
|
3739
|
+
});
|
|
3740
|
+
deltas.push(...slice);
|
|
3741
|
+
|
|
3742
|
+
if (slice.length === 0 && (request.toVersion ?? 0) > request.fromVersion) {
|
|
3743
|
+
const snapshot = this.buildManifestSnapshot(workspaceId, request.domain, request.channelId);
|
|
3744
|
+
if (snapshot) {
|
|
3745
|
+
this.manifestStore.saveSnapshot(snapshot);
|
|
3746
|
+
snapshots.push({
|
|
3747
|
+
domain: snapshot.domain,
|
|
3748
|
+
workspaceId: snapshot.workspaceId,
|
|
3749
|
+
...(snapshot.domain === 'channel-message' && snapshot.channelId ? { channelId: snapshot.channelId } : {}),
|
|
3750
|
+
snapshotId: snapshot.snapshotId,
|
|
3751
|
+
version: snapshot.version,
|
|
3752
|
+
basedOnVersion: snapshot.basedOnVersion,
|
|
3753
|
+
createdAt: snapshot.createdAt,
|
|
3754
|
+
createdBy: snapshot.createdBy,
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
this.transport.send(peerId, {
|
|
3761
|
+
type: 'sync.diff_response',
|
|
3762
|
+
workspaceId,
|
|
3763
|
+
requestId: typeof msg?.requestId === 'string' ? msg.requestId : randomUUID(),
|
|
3764
|
+
deltas,
|
|
3765
|
+
...(snapshots.length > 0 ? { snapshots } : {}),
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
private async handleManifestDiffResponse(peerId: string, msg: any): Promise<void> {
|
|
3770
|
+
if (!this.transport) return;
|
|
3771
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : '';
|
|
3772
|
+
if (!workspaceId) return;
|
|
3773
|
+
|
|
3774
|
+
const deltas = Array.isArray(msg?.deltas) ? (msg.deltas as ManifestDelta[]) : [];
|
|
3775
|
+
// Apply all deltas in a batch — persists only once at the end.
|
|
3776
|
+
this.manifestStore.applyDeltaBatch(deltas);
|
|
3777
|
+
let needsSync = false;
|
|
3778
|
+
for (const delta of deltas) {
|
|
3779
|
+
if (delta.domain === 'channel-message') {
|
|
3780
|
+
needsSync = true;
|
|
3781
|
+
break;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
if (needsSync) {
|
|
3785
|
+
this.requestSyncForPeer(peerId);
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
const snapshots = Array.isArray(msg?.snapshots) ? msg.snapshots : [];
|
|
3789
|
+
for (const pointer of snapshots) {
|
|
3790
|
+
const existing = this.manifestStore.getSnapshot(workspaceId, pointer.domain, pointer.channelId);
|
|
3791
|
+
if (!existing || existing.version < pointer.version) {
|
|
3792
|
+
this.transport.send(peerId, {
|
|
3793
|
+
type: 'sync.fetch_snapshot',
|
|
3794
|
+
workspaceId,
|
|
3795
|
+
domain: pointer.domain,
|
|
3796
|
+
...(pointer.channelId ? { channelId: pointer.channelId } : {}),
|
|
3797
|
+
snapshotId: pointer.snapshotId,
|
|
3798
|
+
});
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
private async handleManifestFetchSnapshot(peerId: string, msg: any): Promise<void> {
|
|
3804
|
+
if (!this.transport) return;
|
|
3805
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : '';
|
|
3806
|
+
const domain = msg?.domain as SyncDomain | undefined;
|
|
3807
|
+
const channelId = typeof msg?.channelId === 'string' ? msg.channelId : undefined;
|
|
3808
|
+
if (!workspaceId || !domain) return;
|
|
3809
|
+
|
|
3810
|
+
const existing = this.manifestStore.getSnapshot(workspaceId, domain, channelId);
|
|
3811
|
+
const snapshot = existing ?? this.buildManifestSnapshot(workspaceId, domain, channelId);
|
|
3812
|
+
if (!snapshot) return;
|
|
3813
|
+
|
|
3814
|
+
this.manifestStore.saveSnapshot(snapshot);
|
|
3815
|
+
this.transport.send(peerId, {
|
|
3816
|
+
type: 'sync.snapshot_response',
|
|
3817
|
+
workspaceId,
|
|
3818
|
+
snapshot,
|
|
3819
|
+
});
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
private async handleManifestSnapshotResponse(peerId: string, msg: any): Promise<void> {
|
|
3823
|
+
const snapshot = msg?.snapshot as SyncManifestSnapshot | undefined;
|
|
3824
|
+
if (!snapshot) return;
|
|
3825
|
+
|
|
3826
|
+
this.manifestStore.restoreSnapshot(snapshot, this.myPeerId || 'unknown');
|
|
3827
|
+
|
|
3828
|
+
if (snapshot.domain === 'workspace-manifest') {
|
|
3829
|
+
const ws = this.workspaceManager.getWorkspace(snapshot.workspaceId);
|
|
3830
|
+
if (ws) {
|
|
3831
|
+
ws.name = snapshot.name;
|
|
3832
|
+
ws.description = snapshot.description;
|
|
3833
|
+
this.persistWorkspaces();
|
|
3834
|
+
}
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
if (snapshot.domain === 'membership') {
|
|
3839
|
+
const ws = this.workspaceManager.getWorkspace(snapshot.workspaceId);
|
|
3840
|
+
if (ws) {
|
|
3841
|
+
ws.members = snapshot.members.map((member) => ({
|
|
3842
|
+
peerId: member.peerId,
|
|
3843
|
+
alias: member.alias || member.peerId.slice(0, 8),
|
|
3844
|
+
publicKey: ws.members.find((existing) => existing.peerId === member.peerId)?.publicKey || '',
|
|
3845
|
+
role: member.role as any,
|
|
3846
|
+
joinedAt: member.joinedAt,
|
|
3847
|
+
}));
|
|
3848
|
+
this.ensureBotFlag();
|
|
3849
|
+
this.persistWorkspaces();
|
|
3850
|
+
}
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
if (snapshot.domain === 'channel-manifest') {
|
|
3855
|
+
const ws = this.workspaceManager.getWorkspace(snapshot.workspaceId);
|
|
3856
|
+
if (ws) {
|
|
3857
|
+
for (const channel of snapshot.channels) {
|
|
3858
|
+
if (ws.channels.some((existing) => existing.id === channel.id)) continue;
|
|
3859
|
+
ws.channels.push({
|
|
3860
|
+
id: channel.id,
|
|
3861
|
+
workspaceId: snapshot.workspaceId,
|
|
3862
|
+
name: channel.name,
|
|
3863
|
+
type: channel.type as any,
|
|
3864
|
+
members: [],
|
|
3865
|
+
createdAt: channel.createdAt,
|
|
3866
|
+
createdBy: channel.createdBy,
|
|
3867
|
+
});
|
|
3868
|
+
}
|
|
3869
|
+
this.persistWorkspaces();
|
|
3870
|
+
}
|
|
3871
|
+
return;
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
if (snapshot.domain === 'channel-message' && this.transport) {
|
|
3875
|
+
const existingIds = new Set(this.messageStore.getMessages(snapshot.channelId).map((message) => message.id));
|
|
3876
|
+
const missing = snapshot.messageIds.filter((id) => !existingIds.has(id));
|
|
3877
|
+
if (missing.length > 0) {
|
|
3878
|
+
this.transport.send(peerId, {
|
|
3879
|
+
type: 'message-sync-fetch-request',
|
|
3880
|
+
workspaceId: snapshot.workspaceId,
|
|
3881
|
+
messageIdsByChannel: {
|
|
3882
|
+
[snapshot.channelId]: missing,
|
|
3883
|
+
},
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
private buildManifestSnapshot(workspaceId: string, domain: SyncDomain, channelId?: string): SyncManifestSnapshot | null {
|
|
3890
|
+
const summary = this.manifestStore.getSummary(workspaceId);
|
|
3891
|
+
const version = summary.versions.find((entry) => entry.domain === domain && (entry.channelId ?? '') === (channelId ?? ''))?.version ?? 0;
|
|
3892
|
+
|
|
3893
|
+
if (domain === 'workspace-manifest') {
|
|
3894
|
+
const ws = this.workspaceManager.getWorkspace(workspaceId);
|
|
3895
|
+
if (!ws) return null;
|
|
3896
|
+
return {
|
|
3897
|
+
domain,
|
|
3898
|
+
workspaceId,
|
|
3899
|
+
version,
|
|
3900
|
+
name: ws.name,
|
|
3901
|
+
description: ws.description,
|
|
3902
|
+
policy: ws.permissions,
|
|
3903
|
+
snapshotId: randomUUID(),
|
|
3904
|
+
snapshotVersion: version,
|
|
3905
|
+
basedOnVersion: version,
|
|
3906
|
+
deltasSince: 0,
|
|
3907
|
+
createdAt: Date.now(),
|
|
3908
|
+
createdBy: this.myPeerId,
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
if (domain === 'membership') {
|
|
3913
|
+
const ws = this.workspaceManager.getWorkspace(workspaceId);
|
|
3914
|
+
if (!ws) return null;
|
|
3915
|
+
return {
|
|
3916
|
+
domain,
|
|
3917
|
+
workspaceId,
|
|
3918
|
+
version,
|
|
3919
|
+
snapshotId: randomUUID(),
|
|
3920
|
+
basedOnVersion: version,
|
|
3921
|
+
memberCount: ws.members.length,
|
|
3922
|
+
members: ws.members.map((member) => ({
|
|
3923
|
+
peerId: member.peerId,
|
|
3924
|
+
alias: member.alias,
|
|
3925
|
+
role: member.role,
|
|
3926
|
+
joinedAt: member.joinedAt,
|
|
3927
|
+
})),
|
|
3928
|
+
createdAt: Date.now(),
|
|
3929
|
+
createdBy: this.myPeerId,
|
|
3930
|
+
};
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
if (domain === 'channel-manifest') {
|
|
3934
|
+
const ws = this.workspaceManager.getWorkspace(workspaceId);
|
|
3935
|
+
if (!ws) return null;
|
|
3936
|
+
return {
|
|
3937
|
+
domain,
|
|
3938
|
+
workspaceId,
|
|
3939
|
+
version,
|
|
3940
|
+
snapshotId: randomUUID(),
|
|
3941
|
+
basedOnVersion: version,
|
|
3942
|
+
channelCount: ws.channels.length,
|
|
3943
|
+
channels: ws.channels.map((channel) => ({
|
|
3944
|
+
id: channel.id,
|
|
3945
|
+
name: channel.name,
|
|
3946
|
+
type: channel.type,
|
|
3947
|
+
createdAt: channel.createdAt,
|
|
3948
|
+
createdBy: channel.createdBy,
|
|
3949
|
+
})),
|
|
3950
|
+
createdAt: Date.now(),
|
|
3951
|
+
createdBy: this.myPeerId,
|
|
3952
|
+
};
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
if (domain === 'channel-message' && channelId) {
|
|
3956
|
+
const messages = this.messageStore.getMessages(channelId).slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
3957
|
+
const minTimestamp = messages[0]?.timestamp ?? Date.now();
|
|
3958
|
+
const maxTimestamp = messages[messages.length - 1]?.timestamp ?? minTimestamp;
|
|
3959
|
+
return {
|
|
3960
|
+
domain,
|
|
3961
|
+
workspaceId,
|
|
3962
|
+
channelId,
|
|
3963
|
+
version,
|
|
3964
|
+
snapshotId: randomUUID(),
|
|
3965
|
+
basedOnVersion: version,
|
|
3966
|
+
messageCount: messages.length,
|
|
3967
|
+
messageIds: messages.map((message) => message.id),
|
|
3968
|
+
minTimestamp,
|
|
3969
|
+
maxTimestamp,
|
|
3970
|
+
createdAt: Date.now(),
|
|
3971
|
+
createdBy: this.myPeerId,
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
return null;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
private requestCustodyRecovery(peerId: string): void {
|
|
3979
|
+
if (!this.transport) return;
|
|
3980
|
+
for (const workspace of this.workspaceManager.getAllWorkspaces()) {
|
|
3981
|
+
if (!workspace.members.some((member) => member.peerId === peerId)) continue;
|
|
3982
|
+
this.transport.send(peerId, {
|
|
3983
|
+
type: 'custody.fetch_index',
|
|
3984
|
+
workspaceId: workspace.id,
|
|
3985
|
+
recipientPeerId: this.myPeerId,
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
private selectCustodianPeers(workspaceId: string, recipientPeerId: string, limit = DecentChatNodePeer.CUSTODIAN_REPLICATION_TARGET): string[] {
|
|
3991
|
+
if (!this.transport) return [];
|
|
3992
|
+
const workspace = this.workspaceManager.getWorkspace(workspaceId);
|
|
3993
|
+
if (!workspace) return [];
|
|
3994
|
+
|
|
3995
|
+
const connected = new Set(this.transport.getConnectedPeers());
|
|
3996
|
+
|
|
3997
|
+
const scored = workspace.members
|
|
3998
|
+
.map((member) => member.peerId)
|
|
3999
|
+
.filter((peerId) => peerId !== this.myPeerId && peerId !== recipientPeerId && connected.has(peerId))
|
|
4000
|
+
.map((peerId) => {
|
|
4001
|
+
let score = 100;
|
|
4002
|
+
const alias = this.resolveSenderName(workspaceId, peerId).toLowerCase();
|
|
4003
|
+
if (alias.includes('mobile') || alias.includes('iphone') || alias.includes('android')) score -= 20;
|
|
4004
|
+
if (alias.includes('server') || alias.includes('desktop') || alias.includes('bot')) score += 20;
|
|
4005
|
+
score += this.store.get<number>(`custody-score-${peerId}`, 0);
|
|
4006
|
+
return { peerId, score };
|
|
4007
|
+
})
|
|
4008
|
+
.sort((a, b) => b.score - a.score || a.peerId.localeCompare(b.peerId));
|
|
4009
|
+
|
|
4010
|
+
return scored.slice(0, Math.max(0, limit)).map((entry) => entry.peerId);
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
private async replicateToCustodians(
|
|
4014
|
+
recipientPeerId: string,
|
|
4015
|
+
params: {
|
|
4016
|
+
workspaceId?: string | null;
|
|
4017
|
+
channelId?: string | null;
|
|
4018
|
+
opId?: string | null;
|
|
4019
|
+
domain?: SyncDomain;
|
|
4020
|
+
},
|
|
4021
|
+
): Promise<void> {
|
|
4022
|
+
const workspaceId = params.workspaceId ?? undefined;
|
|
4023
|
+
const opId = params.opId ?? undefined;
|
|
4024
|
+
if (!this.transport || !workspaceId || !opId) return;
|
|
4025
|
+
|
|
4026
|
+
const custodians = this.selectCustodianPeers(workspaceId, recipientPeerId);
|
|
4027
|
+
if (custodians.length === 0) return;
|
|
4028
|
+
|
|
4029
|
+
const pending = await this.custodyStore.getPendingForRecipient(recipientPeerId);
|
|
4030
|
+
const envelopes = pending.filter((envelope) => {
|
|
4031
|
+
if (envelope.opId !== opId || envelope.workspaceId !== workspaceId) return false;
|
|
4032
|
+
if (params.domain && envelope.domain !== params.domain) return false;
|
|
4033
|
+
if (params.channelId && envelope.channelId !== params.channelId) return false;
|
|
4034
|
+
return true;
|
|
4035
|
+
});
|
|
4036
|
+
if (envelopes.length === 0) return;
|
|
4037
|
+
|
|
4038
|
+
for (const envelope of envelopes) {
|
|
4039
|
+
this.pendingCustodyOffers.set(envelope.envelopeId, custodians);
|
|
4040
|
+
for (const custodianPeerId of custodians) {
|
|
4041
|
+
this.transport.send(custodianPeerId, {
|
|
4042
|
+
type: 'custody.offer',
|
|
4043
|
+
workspaceId,
|
|
4044
|
+
recipientPeerId,
|
|
4045
|
+
...(envelope.channelId ? { channelId: envelope.channelId } : {}),
|
|
4046
|
+
envelope: {
|
|
4047
|
+
envelopeId: envelope.envelopeId,
|
|
4048
|
+
opId: envelope.opId,
|
|
4049
|
+
workspaceId: envelope.workspaceId,
|
|
4050
|
+
channelId: envelope.channelId,
|
|
4051
|
+
threadId: envelope.threadId,
|
|
4052
|
+
domain: envelope.domain,
|
|
4053
|
+
createdAt: envelope.createdAt,
|
|
4054
|
+
expiresAt: envelope.expiresAt,
|
|
4055
|
+
replicationClass: envelope.replicationClass,
|
|
4056
|
+
},
|
|
4057
|
+
});
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
private async handleCustodyControl(fromPeerId: string, msg: any): Promise<void> {
|
|
4063
|
+
if (!this.transport) return;
|
|
4064
|
+
|
|
4065
|
+
if (msg?.type === 'custody.offer') {
|
|
4066
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : '';
|
|
4067
|
+
const workspace = workspaceId ? this.workspaceManager.getWorkspace(workspaceId) : undefined;
|
|
4068
|
+
const canAccept = Boolean(workspace?.members.some((member) => member.peerId === this.myPeerId));
|
|
4069
|
+
|
|
4070
|
+
this.transport.send(fromPeerId, {
|
|
4071
|
+
type: canAccept ? 'custody.accept' : 'custody.reject',
|
|
4072
|
+
workspaceId,
|
|
4073
|
+
envelopeId: msg?.envelope?.envelopeId,
|
|
4074
|
+
recipientPeerId: msg?.recipientPeerId,
|
|
4075
|
+
reason: canAccept ? undefined : 'not-a-member',
|
|
4076
|
+
});
|
|
4077
|
+
return;
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
if (msg?.type === 'custody.accept') {
|
|
4081
|
+
const envelopeId = typeof msg?.envelopeId === 'string' ? msg.envelopeId : '';
|
|
4082
|
+
const recipientPeerId = typeof msg?.recipientPeerId === 'string' ? msg.recipientPeerId : '';
|
|
4083
|
+
const offeredPeers = this.pendingCustodyOffers.get(envelopeId) ?? [];
|
|
4084
|
+
if (!envelopeId || !recipientPeerId || !offeredPeers.includes(fromPeerId)) return;
|
|
4085
|
+
|
|
4086
|
+
const envelopes = await this.custodyStore.listAllForRecipient(recipientPeerId);
|
|
4087
|
+
const envelope = envelopes.find((entry) => entry.envelopeId === envelopeId);
|
|
4088
|
+
if (!envelope) return;
|
|
4089
|
+
|
|
4090
|
+
this.transport.send(fromPeerId, {
|
|
4091
|
+
type: 'custody.store',
|
|
4092
|
+
workspaceId: envelope.workspaceId,
|
|
4093
|
+
recipientPeerId,
|
|
4094
|
+
envelope,
|
|
4095
|
+
});
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
if (msg?.type === 'custody.reject') {
|
|
4100
|
+
const envelopeId = typeof msg?.envelopeId === 'string' ? msg.envelopeId : '';
|
|
4101
|
+
if (!envelopeId) return;
|
|
4102
|
+
const offeredPeers = this.pendingCustodyOffers.get(envelopeId) ?? [];
|
|
4103
|
+
this.pendingCustodyOffers.set(
|
|
4104
|
+
envelopeId,
|
|
4105
|
+
offeredPeers.filter((peerId) => peerId !== fromPeerId),
|
|
4106
|
+
);
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
if (msg?.type === 'custody.store') {
|
|
4111
|
+
const envelope = msg?.envelope;
|
|
4112
|
+
if (!this.isCustodyEnvelope(envelope)) return;
|
|
4113
|
+
this.custodianInbox.set(envelope.envelopeId, envelope);
|
|
4114
|
+
this.persistCustodianInbox();
|
|
4115
|
+
this.transport.send(fromPeerId, {
|
|
4116
|
+
type: 'custody.ack',
|
|
4117
|
+
envelopeIds: [envelope.envelopeId],
|
|
4118
|
+
stage: 'stored',
|
|
4119
|
+
});
|
|
4120
|
+
return;
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
if (msg?.type === 'custody.fetch_index') {
|
|
4124
|
+
if (Array.isArray(msg?.index)) {
|
|
4125
|
+
const envelopeIds = msg.index
|
|
4126
|
+
.map((entry: any) => (typeof entry?.envelopeId === 'string' ? entry.envelopeId : null))
|
|
4127
|
+
.filter((value: string | null): value is string => Boolean(value));
|
|
4128
|
+
|
|
4129
|
+
if (envelopeIds.length > 0) {
|
|
4130
|
+
this.transport.send(fromPeerId, {
|
|
4131
|
+
type: 'custody.fetch_envelopes',
|
|
4132
|
+
workspaceId: msg.workspaceId,
|
|
4133
|
+
envelopeIds,
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
const recipientPeerId = typeof msg?.recipientPeerId === 'string' ? msg.recipientPeerId : fromPeerId;
|
|
4140
|
+
const workspaceId = typeof msg?.workspaceId === 'string' ? msg.workspaceId : undefined;
|
|
4141
|
+
const index = [...this.custodianInbox.values()]
|
|
4142
|
+
.filter((envelope) => envelope.recipientPeerIds.includes(recipientPeerId))
|
|
4143
|
+
.filter((envelope) => !workspaceId || envelope.workspaceId === workspaceId)
|
|
4144
|
+
.map((envelope) => ({
|
|
4145
|
+
envelopeId: envelope.envelopeId,
|
|
4146
|
+
opId: envelope.opId,
|
|
4147
|
+
workspaceId: envelope.workspaceId,
|
|
4148
|
+
channelId: envelope.channelId,
|
|
4149
|
+
domain: envelope.domain,
|
|
4150
|
+
createdAt: envelope.createdAt,
|
|
4151
|
+
expiresAt: envelope.expiresAt,
|
|
4152
|
+
}));
|
|
4153
|
+
|
|
4154
|
+
this.transport.send(fromPeerId, {
|
|
4155
|
+
type: 'custody.fetch_index',
|
|
4156
|
+
workspaceId: workspaceId ?? '',
|
|
4157
|
+
recipientPeerId,
|
|
4158
|
+
index,
|
|
4159
|
+
});
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
|
|
4163
|
+
if (msg?.type === 'custody.fetch_envelopes') {
|
|
4164
|
+
if (Array.isArray(msg?.envelopes)) {
|
|
4165
|
+
const recovered = msg.envelopes.filter((entry: any) => this.isCustodyEnvelope(entry)) as CustodyEnvelope[];
|
|
4166
|
+
if (recovered.length === 0) return;
|
|
4167
|
+
|
|
4168
|
+
const recoveredIds: string[] = [];
|
|
4169
|
+
for (const envelope of recovered) {
|
|
4170
|
+
if (!envelope.recipientPeerIds.includes(this.myPeerId)) continue;
|
|
4171
|
+
recoveredIds.push(envelope.envelopeId);
|
|
4172
|
+
if (envelope.workspaceId) {
|
|
4173
|
+
this.recordManifestDomain('channel-message', envelope.workspaceId, {
|
|
4174
|
+
channelId: envelope.channelId,
|
|
4175
|
+
operation: 'update',
|
|
4176
|
+
subject: envelope.opId,
|
|
4177
|
+
data: { recovered: true, envelopeId: envelope.envelopeId },
|
|
4178
|
+
});
|
|
4179
|
+
}
|
|
4180
|
+
const trustedSenderId = typeof envelope.metadata?.senderId === 'string' && envelope.metadata.senderId.length > 0
|
|
4181
|
+
? envelope.metadata.senderId
|
|
4182
|
+
: undefined;
|
|
4183
|
+
await this.handlePeerMessage(fromPeerId, envelope.ciphertext, trustedSenderId);
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
if (recoveredIds.length > 0) {
|
|
4187
|
+
this.transport.send(fromPeerId, {
|
|
4188
|
+
type: 'custody.ack',
|
|
4189
|
+
envelopeIds: recoveredIds,
|
|
4190
|
+
stage: 'delivered',
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
const envelopeIds = Array.isArray(msg?.envelopeIds)
|
|
4197
|
+
? msg.envelopeIds.filter((id: unknown): id is string => typeof id === 'string')
|
|
4198
|
+
: [];
|
|
4199
|
+
const envelopes = envelopeIds
|
|
4200
|
+
.map((id) => this.custodianInbox.get(id))
|
|
4201
|
+
.filter((entry): entry is CustodyEnvelope => Boolean(entry));
|
|
4202
|
+
|
|
4203
|
+
this.transport.send(fromPeerId, {
|
|
4204
|
+
type: 'custody.fetch_envelopes',
|
|
4205
|
+
workspaceId: typeof msg?.workspaceId === 'string' ? msg.workspaceId : '',
|
|
4206
|
+
envelopes,
|
|
4207
|
+
});
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
if (msg?.type === 'custody.ack') {
|
|
4212
|
+
const envelopeIds = Array.isArray(msg?.envelopeIds)
|
|
4213
|
+
? msg.envelopeIds.filter((id: unknown): id is string => typeof id === 'string')
|
|
4214
|
+
: [];
|
|
4215
|
+
if (envelopeIds.length === 0) return;
|
|
4216
|
+
|
|
4217
|
+
let changed = false;
|
|
4218
|
+
for (const envelopeId of envelopeIds) {
|
|
4219
|
+
if (this.custodianInbox.delete(envelopeId)) changed = true;
|
|
4220
|
+
}
|
|
4221
|
+
if (changed) {
|
|
4222
|
+
this.persistCustodianInbox();
|
|
4223
|
+
const key = `custody-score-${fromPeerId}`;
|
|
4224
|
+
const current = this.store.get<number>(key, 0);
|
|
4225
|
+
this.store.set(key, current + 1);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4230
|
+
private async queuePendingAck(peerId: string, payload: any): Promise<void> {
|
|
4231
|
+
if (!payload?.messageId) return;
|
|
4232
|
+
const key = this.pendingAckKey(peerId);
|
|
4233
|
+
const current = this.store.get<any[]>(key, []);
|
|
4234
|
+
const existingIndex = current.findIndex((entry) => entry?.messageId === payload.messageId);
|
|
4235
|
+
const entry = {
|
|
4236
|
+
...payload,
|
|
4237
|
+
queuedAt: Date.now(),
|
|
4238
|
+
};
|
|
4239
|
+
if (existingIndex >= 0) current[existingIndex] = entry;
|
|
4240
|
+
else current.push(entry);
|
|
4241
|
+
this.store.set(key, current);
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
private async removePendingAck(peerId: string, messageId: string): Promise<void> {
|
|
4245
|
+
const key = this.pendingAckKey(peerId);
|
|
4246
|
+
const current = this.store.get<any[]>(key, []);
|
|
4247
|
+
const next = current.filter((entry) => entry?.messageId !== messageId);
|
|
4248
|
+
if (next.length === 0) this.store.delete(key);
|
|
4249
|
+
else this.store.set(key, next);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
private async resendPendingAcks(peerId: string): Promise<void> {
|
|
4253
|
+
if (!this.transport || !this.messageProtocol) return;
|
|
4254
|
+
if (!this.transport.getConnectedPeers().includes(peerId)) return;
|
|
4255
|
+
|
|
4256
|
+
const key = this.pendingAckKey(peerId);
|
|
4257
|
+
const pending = this.store.get<any[]>(key, []);
|
|
4258
|
+
if (pending.length === 0) return;
|
|
4259
|
+
|
|
4260
|
+
for (const item of pending) {
|
|
4261
|
+
if (!item || typeof item !== 'object') continue;
|
|
4262
|
+
try {
|
|
4263
|
+
if (typeof item.content === 'string') {
|
|
4264
|
+
const envelope = await this.encryptMessageWithPreKeyBootstrap(peerId, item.content, item.metadata, item.workspaceId);
|
|
4265
|
+
(envelope as any).senderId = item.senderId ?? this.myPeerId;
|
|
4266
|
+
(envelope as any).senderName = item.senderName ?? this.opts.account.alias;
|
|
4267
|
+
(envelope as any).messageId = item.messageId;
|
|
4268
|
+
if (item.isDirect) {
|
|
4269
|
+
(envelope as any).isDirect = true;
|
|
4270
|
+
} else {
|
|
4271
|
+
(envelope as any).channelId = item.channelId;
|
|
4272
|
+
(envelope as any).workspaceId = item.workspaceId;
|
|
4273
|
+
}
|
|
4274
|
+
if (item.threadId) (envelope as any).threadId = item.threadId;
|
|
4275
|
+
if (item.replyToId) (envelope as any).replyToId = item.replyToId;
|
|
4276
|
+
this.transport.send(peerId, envelope);
|
|
4277
|
+
continue;
|
|
4278
|
+
}
|
|
4279
|
+
|
|
4280
|
+
if (item.ciphertext && typeof item.ciphertext === 'object') {
|
|
4281
|
+
const outbound = { ...item.ciphertext } as any;
|
|
4282
|
+
outbound._offlineReplay = 1;
|
|
4283
|
+
if (typeof item.envelopeId === 'string' && !outbound.envelopeId) {
|
|
4284
|
+
outbound.envelopeId = item.envelopeId;
|
|
4285
|
+
}
|
|
4286
|
+
this.transport.send(peerId, outbound);
|
|
4287
|
+
continue;
|
|
4288
|
+
}
|
|
4289
|
+
} catch (err) {
|
|
4290
|
+
this.opts.log?.warn?.(`[decentchat-peer] resend pending failed for ${peerId}: ${String(err)}`);
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
|
|
4295
|
+
private async enqueueOffline(peerId: string, payload: any): Promise<void> {
|
|
4296
|
+
try {
|
|
4297
|
+
const now = Date.now();
|
|
4298
|
+
const isReceipt = payload?.type === 'read' || payload?.type === 'ack';
|
|
4299
|
+
const workspaceId = typeof payload?.workspaceId === 'string'
|
|
4300
|
+
? payload.workspaceId
|
|
4301
|
+
: (typeof payload?.channelId === 'string' ? this.findWorkspaceIdForChannel(payload.channelId) : 'direct');
|
|
4302
|
+
|
|
4303
|
+
if (isReceipt) {
|
|
4304
|
+
await this.custodyStore.storeEnvelope({
|
|
4305
|
+
opId: typeof payload?.messageId === 'string' ? payload.messageId : randomUUID(),
|
|
4306
|
+
recipientPeerIds: [peerId],
|
|
4307
|
+
workspaceId: workspaceId || 'direct',
|
|
4308
|
+
...(typeof payload?.channelId === 'string' ? { channelId: payload.channelId } : {}),
|
|
4309
|
+
domain: 'receipt',
|
|
4310
|
+
ciphertext: payload,
|
|
4311
|
+
createdAt: now,
|
|
4312
|
+
metadata: {
|
|
4313
|
+
kind: payload?.type,
|
|
4314
|
+
},
|
|
4315
|
+
});
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
if (typeof payload?.content === 'string' && this.messageProtocol) {
|
|
4320
|
+
try {
|
|
4321
|
+
const encrypted = await this.encryptMessageWithPreKeyBootstrap(peerId, payload.content, payload.metadata, workspaceId);
|
|
4322
|
+
(encrypted as any).senderId = payload.senderId ?? this.myPeerId;
|
|
4323
|
+
(encrypted as any).senderName = payload.senderName ?? this.opts.account.alias;
|
|
4324
|
+
(encrypted as any).messageId = payload.messageId ?? randomUUID();
|
|
4325
|
+
if (payload.isDirect) {
|
|
4326
|
+
(encrypted as any).isDirect = true;
|
|
4327
|
+
} else {
|
|
4328
|
+
(encrypted as any).channelId = payload.channelId;
|
|
4329
|
+
(encrypted as any).workspaceId = payload.workspaceId;
|
|
4330
|
+
}
|
|
4331
|
+
if (payload.threadId) (encrypted as any).threadId = payload.threadId;
|
|
4332
|
+
if (payload.replyToId) (encrypted as any).replyToId = payload.replyToId;
|
|
4333
|
+
|
|
4334
|
+
await this.custodyStore.storeEnvelope({
|
|
4335
|
+
envelopeId: typeof (encrypted as any).id === 'string' ? (encrypted as any).id : undefined,
|
|
4336
|
+
opId: typeof payload?.messageId === 'string' ? payload.messageId : randomUUID(),
|
|
4337
|
+
recipientPeerIds: [peerId],
|
|
4338
|
+
workspaceId: workspaceId || 'direct',
|
|
4339
|
+
...(typeof payload?.channelId === 'string' ? { channelId: payload.channelId } : {}),
|
|
4340
|
+
...(typeof payload?.threadId === 'string' ? { threadId: payload.threadId } : {}),
|
|
4341
|
+
domain: 'channel-message',
|
|
4342
|
+
ciphertext: encrypted,
|
|
4343
|
+
createdAt: now,
|
|
4344
|
+
metadata: this.buildCustodyResendMetadata({
|
|
4345
|
+
content: payload.content,
|
|
4346
|
+
channelId: payload.channelId,
|
|
4347
|
+
workspaceId: payload.workspaceId,
|
|
4348
|
+
senderId: payload.senderId ?? this.myPeerId,
|
|
4349
|
+
senderName: payload.senderName ?? this.opts.account.alias,
|
|
4350
|
+
threadId: payload.threadId,
|
|
4351
|
+
replyToId: payload.replyToId,
|
|
4352
|
+
isDirect: payload.isDirect === true,
|
|
4353
|
+
metadata: payload.metadata,
|
|
4354
|
+
}),
|
|
4355
|
+
});
|
|
4356
|
+
return;
|
|
4357
|
+
} catch (err) {
|
|
4358
|
+
this.opts.log?.warn?.(`[decentchat-peer] encryption failed while queueing offline payload for ${peerId}: ${String(err)}`);
|
|
4359
|
+
// Fall back to deferred plaintext path below.
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
await this.offlineQueue.enqueue(peerId, payload, {
|
|
4364
|
+
createdAt: now,
|
|
4365
|
+
envelopeId: typeof payload?.id === 'string' ? payload.id : undefined,
|
|
4366
|
+
opId: typeof payload?.messageId === 'string'
|
|
4367
|
+
? payload.messageId
|
|
4368
|
+
: (typeof payload?.opId === 'string' ? payload.opId : undefined),
|
|
4369
|
+
workspaceId: typeof payload?.workspaceId === 'string' ? payload.workspaceId : undefined,
|
|
4370
|
+
channelId: typeof payload?.channelId === 'string' ? payload.channelId : undefined,
|
|
4371
|
+
threadId: typeof payload?.threadId === 'string' ? payload.threadId : undefined,
|
|
4372
|
+
domain: isReceipt ? 'receipt' : 'channel-message',
|
|
4373
|
+
recipientPeerIds: [peerId],
|
|
4374
|
+
replicationClass: 'standard',
|
|
4375
|
+
deliveryState: 'stored',
|
|
4376
|
+
});
|
|
4377
|
+
this.opts.log?.info?.(`[decentchat-peer] queued outbound message for offline peer ${peerId}`);
|
|
4378
|
+
} catch (err) {
|
|
4379
|
+
this.opts.log?.error?.(`[decentchat-peer] failed to queue outbound message for ${peerId}: ${String(err)}`);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
private async flushOfflineQueue(peerId: string): Promise<void> {
|
|
4384
|
+
if (!this.transport || !this.messageProtocol) return;
|
|
4385
|
+
if (!this.transport.getConnectedPeers().includes(peerId)) return;
|
|
4386
|
+
|
|
4387
|
+
const queued = await this.offlineQueue.getQueued(peerId);
|
|
4388
|
+
if (queued.length === 0) return;
|
|
4389
|
+
|
|
4390
|
+
let sentCount = 0;
|
|
4391
|
+
let failedCount = 0;
|
|
4392
|
+
const deliveredIds: number[] = []; // Batch-remove after loop
|
|
4393
|
+
|
|
4394
|
+
for (const queuedItem of queued) {
|
|
4395
|
+
const item = (queuedItem?.data ?? queuedItem) as any;
|
|
4396
|
+
if (!item || typeof item !== 'object') {
|
|
4397
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4398
|
+
deliveredIds.push(queuedItem.id);
|
|
4399
|
+
}
|
|
4400
|
+
continue;
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
try {
|
|
4404
|
+
if (this.isCustodyEnvelope(item)) {
|
|
4405
|
+
const resendPayload = item.domain === 'channel-message' && this.shouldReencryptCustodyEnvelope(item)
|
|
4406
|
+
? this.getCustodyResendPayload(item)
|
|
4407
|
+
: null;
|
|
4408
|
+
|
|
4409
|
+
if (resendPayload) {
|
|
4410
|
+
const envelope = await this.encryptMessageWithPreKeyBootstrap(
|
|
4411
|
+
peerId,
|
|
4412
|
+
resendPayload.content,
|
|
4413
|
+
resendPayload.metadata,
|
|
4414
|
+
resendPayload.workspaceId,
|
|
4415
|
+
);
|
|
4416
|
+
(envelope as any).senderId = resendPayload.senderId ?? this.myPeerId;
|
|
4417
|
+
(envelope as any).senderName = resendPayload.senderName ?? this.opts.account.alias;
|
|
4418
|
+
(envelope as any).messageId = item.opId;
|
|
4419
|
+
if (resendPayload.isDirect) {
|
|
4420
|
+
(envelope as any).isDirect = true;
|
|
4421
|
+
} else {
|
|
4422
|
+
(envelope as any).channelId = resendPayload.channelId ?? item.channelId;
|
|
4423
|
+
(envelope as any).workspaceId = resendPayload.workspaceId ?? item.workspaceId;
|
|
4424
|
+
}
|
|
4425
|
+
if (resendPayload.threadId) (envelope as any).threadId = resendPayload.threadId;
|
|
4426
|
+
if (resendPayload.replyToId) (envelope as any).replyToId = resendPayload.replyToId;
|
|
4427
|
+
if (resendPayload.gossipOriginSignature) {
|
|
4428
|
+
(envelope as any)._gossipOriginSignature = resendPayload.gossipOriginSignature;
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
const accepted = this.transport.send(peerId, envelope);
|
|
4432
|
+
if (!accepted) throw new Error('transport rejected queued send');
|
|
4433
|
+
|
|
4434
|
+
await this.queuePendingAck(peerId, {
|
|
4435
|
+
messageId: item.opId,
|
|
4436
|
+
channelId: resendPayload.channelId ?? item.channelId,
|
|
4437
|
+
workspaceId: resendPayload.workspaceId ?? item.workspaceId,
|
|
4438
|
+
threadId: resendPayload.threadId ?? item.threadId,
|
|
4439
|
+
content: resendPayload.content,
|
|
4440
|
+
isDirect: resendPayload.isDirect === true,
|
|
4441
|
+
replyToId: resendPayload.replyToId,
|
|
4442
|
+
senderId: resendPayload.senderId ?? this.myPeerId,
|
|
4443
|
+
senderName: resendPayload.senderName ?? this.opts.account.alias,
|
|
4444
|
+
...(resendPayload.metadata ? { metadata: resendPayload.metadata } : {}),
|
|
4445
|
+
});
|
|
4446
|
+
|
|
4447
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4448
|
+
deliveredIds.push(queuedItem.id);
|
|
4449
|
+
}
|
|
4450
|
+
sentCount += 1;
|
|
4451
|
+
continue;
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
const outbound = typeof item.ciphertext === 'object' && item.ciphertext
|
|
4455
|
+
? { ...(item.ciphertext as Record<string, unknown>) }
|
|
4456
|
+
: item.ciphertext;
|
|
4457
|
+
|
|
4458
|
+
if (!outbound || typeof outbound !== 'object') {
|
|
4459
|
+
throw new Error('custody envelope missing ciphertext payload');
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
(outbound as any)._offlineReplay = 1;
|
|
4463
|
+
if (!(outbound as any).envelopeId) {
|
|
4464
|
+
(outbound as any).envelopeId = item.envelopeId;
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
const accepted = this.transport.send(peerId, outbound);
|
|
4468
|
+
if (!accepted) throw new Error('transport rejected queued send');
|
|
4469
|
+
|
|
4470
|
+
if (item.domain === 'channel-message') {
|
|
4471
|
+
await this.queuePendingAck(peerId, {
|
|
4472
|
+
messageId: item.opId,
|
|
4473
|
+
envelopeId: item.envelopeId,
|
|
4474
|
+
channelId: item.channelId,
|
|
4475
|
+
workspaceId: item.workspaceId,
|
|
4476
|
+
threadId: item.threadId,
|
|
4477
|
+
ciphertext: outbound,
|
|
4478
|
+
isDirect: (item.metadata as any)?.isDirect === true,
|
|
4479
|
+
replyToId: (item.metadata as any)?.replyToId,
|
|
4480
|
+
senderId: (item.metadata as any)?.senderId ?? this.myPeerId,
|
|
4481
|
+
senderName: (item.metadata as any)?.senderName ?? this.opts.account.alias,
|
|
4482
|
+
});
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4485
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4486
|
+
deliveredIds.push(queuedItem.id);
|
|
4487
|
+
}
|
|
4488
|
+
sentCount += 1;
|
|
4489
|
+
continue;
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
if (item.type === 'read' || item.type === 'ack') {
|
|
4493
|
+
const accepted = this.transport.send(peerId, item);
|
|
4494
|
+
if (!accepted) throw new Error('transport rejected queued receipt send');
|
|
4495
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4496
|
+
deliveredIds.push(queuedItem.id);
|
|
4497
|
+
}
|
|
4498
|
+
sentCount += 1;
|
|
4499
|
+
continue;
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
if (typeof item.content !== 'string') {
|
|
4503
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4504
|
+
deliveredIds.push(queuedItem.id);
|
|
4505
|
+
}
|
|
4506
|
+
continue;
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
if (!item.messageId) item.messageId = randomUUID();
|
|
4510
|
+
await this.queuePendingAck(peerId, item);
|
|
4511
|
+
const envelope = await this.encryptMessageWithPreKeyBootstrap(peerId, item.content, item.metadata, item.workspaceId);
|
|
4512
|
+
(envelope as any).senderId = item.senderId ?? this.myPeerId;
|
|
4513
|
+
(envelope as any).senderName = item.senderName ?? this.opts.account.alias;
|
|
4514
|
+
(envelope as any).messageId = item.messageId;
|
|
4515
|
+
if (item.isDirect) {
|
|
4516
|
+
(envelope as any).isDirect = true;
|
|
4517
|
+
} else {
|
|
4518
|
+
(envelope as any).channelId = item.channelId;
|
|
4519
|
+
(envelope as any).workspaceId = item.workspaceId;
|
|
4520
|
+
const gossipOriginSignature = await this.signGossipOrigin({
|
|
4521
|
+
messageId: item.messageId,
|
|
4522
|
+
channelId: item.channelId,
|
|
4523
|
+
content: item.content,
|
|
4524
|
+
threadId: item.threadId,
|
|
4525
|
+
replyToId: item.replyToId,
|
|
4526
|
+
});
|
|
4527
|
+
if (gossipOriginSignature) {
|
|
4528
|
+
(envelope as any)._gossipOriginSignature = gossipOriginSignature;
|
|
4529
|
+
}
|
|
4530
|
+
}
|
|
4531
|
+
if (item.threadId) (envelope as any).threadId = item.threadId;
|
|
4532
|
+
if (item.replyToId) (envelope as any).replyToId = item.replyToId;
|
|
4533
|
+
|
|
4534
|
+
const accepted = this.transport.send(peerId, envelope);
|
|
4535
|
+
if (!accepted) throw new Error('transport rejected queued send');
|
|
4536
|
+
|
|
4537
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4538
|
+
deliveredIds.push(queuedItem.id);
|
|
4539
|
+
}
|
|
4540
|
+
sentCount += 1;
|
|
4541
|
+
} catch (err) {
|
|
4542
|
+
failedCount += 1;
|
|
4543
|
+
if (typeof queuedItem?.id === 'number') {
|
|
4544
|
+
await this.offlineQueue.markAttempt(peerId, queuedItem.id);
|
|
4545
|
+
}
|
|
4546
|
+
this.opts.log?.warn?.(`[decentchat-peer] failed queued send to ${peerId}: ${String(err)}`);
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
// Batch-remove all delivered/discarded items in a single IDB transaction
|
|
4551
|
+
if (deliveredIds.length > 0) {
|
|
4552
|
+
try {
|
|
4553
|
+
await this.offlineQueue.removeBatch(peerId, deliveredIds);
|
|
4554
|
+
} catch (batchErr) {
|
|
4555
|
+
this.opts.log?.error?.(`[decentchat-peer] batch remove failed, falling back to individual: ${String(batchErr)}`);
|
|
4556
|
+
for (const id of deliveredIds) {
|
|
4557
|
+
await this.offlineQueue.remove(peerId, id).catch(() => {});
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4562
|
+
if (sentCount > 0) {
|
|
4563
|
+
this.opts.log?.info?.(`[decentchat-peer] flushed ${sentCount} queued messages to ${peerId}`);
|
|
4564
|
+
}
|
|
4565
|
+
if (failedCount > 0) {
|
|
4566
|
+
this.opts.log?.warn?.(`[decentchat-peer] ${failedCount} queued message(s) remain pending for ${peerId}`);
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
|
|
4570
|
+
}
|