@massalabs/gossip-sdk 0.0.1 → 0.0.2-dev.20260128111120
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/dist/api/messageProtocol/index.d.ts +19 -0
- package/dist/api/messageProtocol/index.js +26 -0
- package/dist/api/messageProtocol/mock.d.ts +12 -0
- package/{src/api/messageProtocol/mock.ts → dist/api/messageProtocol/mock.js} +2 -3
- package/dist/api/messageProtocol/rest.d.ts +22 -0
- package/dist/api/messageProtocol/rest.js +161 -0
- package/dist/api/messageProtocol/types.d.ts +61 -0
- package/dist/api/messageProtocol/types.js +6 -0
- package/dist/assets/generated/wasm/README.md +281 -0
- package/dist/assets/generated/wasm/gossip_wasm.d.ts +498 -0
- package/dist/assets/generated/wasm/gossip_wasm.js +1399 -0
- package/dist/assets/generated/wasm/gossip_wasm_bg.wasm +0 -0
- package/dist/assets/generated/wasm/gossip_wasm_bg.wasm.d.ts +68 -0
- package/dist/assets/generated/wasm/package.json +15 -0
- package/dist/config/protocol.d.ts +36 -0
- package/dist/config/protocol.js +77 -0
- package/dist/config/sdk.d.ts +82 -0
- package/dist/config/sdk.js +55 -0
- package/{src/contacts.ts → dist/contacts.d.ts} +10 -94
- package/dist/contacts.js +166 -0
- package/dist/core/SdkEventEmitter.d.ts +36 -0
- package/dist/core/SdkEventEmitter.js +59 -0
- package/dist/core/SdkPolling.d.ts +35 -0
- package/dist/core/SdkPolling.js +100 -0
- package/{src/core/index.ts → dist/core/index.d.ts} +0 -2
- package/dist/core/index.js +5 -0
- package/dist/crypto/bip39.d.ts +34 -0
- package/dist/crypto/bip39.js +62 -0
- package/dist/crypto/encryption.d.ts +37 -0
- package/dist/crypto/encryption.js +46 -0
- package/dist/db.d.ts +190 -0
- package/dist/db.js +311 -0
- package/dist/gossipSdk.d.ts +274 -0
- package/dist/gossipSdk.js +690 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +77 -0
- package/dist/services/announcement.d.ts +43 -0
- package/dist/services/announcement.js +491 -0
- package/dist/services/auth.d.ts +37 -0
- package/dist/services/auth.js +76 -0
- package/dist/services/discussion.d.ts +63 -0
- package/dist/services/discussion.js +297 -0
- package/dist/services/message.d.ts +74 -0
- package/dist/services/message.js +826 -0
- package/dist/services/refresh.d.ts +41 -0
- package/dist/services/refresh.js +205 -0
- package/{src/sw.ts → dist/sw.d.ts} +1 -8
- package/dist/sw.js +10 -0
- package/dist/types/events.d.ts +80 -0
- package/dist/types/events.js +7 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +7 -0
- package/dist/utils/base64.d.ts +10 -0
- package/dist/utils/base64.js +30 -0
- package/dist/utils/contacts.d.ts +42 -0
- package/dist/utils/contacts.js +113 -0
- package/dist/utils/discussions.d.ts +24 -0
- package/dist/utils/discussions.js +38 -0
- package/dist/utils/logs.d.ts +19 -0
- package/dist/utils/logs.js +89 -0
- package/dist/utils/messageSerialization.d.ts +64 -0
- package/dist/utils/messageSerialization.js +184 -0
- package/dist/utils/queue.d.ts +50 -0
- package/dist/utils/queue.js +110 -0
- package/dist/utils/type.d.ts +10 -0
- package/dist/utils/type.js +4 -0
- package/dist/utils/userId.d.ts +40 -0
- package/dist/utils/userId.js +90 -0
- package/dist/utils/validation.d.ts +50 -0
- package/dist/utils/validation.js +112 -0
- package/dist/utils.d.ts +30 -0
- package/{src/utils.ts → dist/utils.js} +9 -19
- package/dist/wasm/encryption.d.ts +56 -0
- package/{src/wasm/encryption.ts → dist/wasm/encryption.js} +22 -51
- package/dist/wasm/index.d.ts +10 -0
- package/{src/wasm/index.ts → dist/wasm/index.js} +1 -8
- package/dist/wasm/loader.d.ts +21 -0
- package/dist/wasm/loader.js +103 -0
- package/dist/wasm/session.d.ts +85 -0
- package/dist/wasm/session.js +226 -0
- package/dist/wasm/userKeys.d.ts +17 -0
- package/{src/wasm/userKeys.ts → dist/wasm/userKeys.js} +6 -13
- package/package.json +5 -1
- package/src/api/messageProtocol/index.ts +0 -53
- package/src/api/messageProtocol/rest.ts +0 -209
- package/src/api/messageProtocol/types.ts +0 -70
- package/src/config/protocol.ts +0 -97
- package/src/config/sdk.ts +0 -131
- package/src/core/SdkEventEmitter.ts +0 -91
- package/src/core/SdkPolling.ts +0 -134
- package/src/crypto/bip39.ts +0 -84
- package/src/crypto/encryption.ts +0 -77
- package/src/db.ts +0 -465
- package/src/gossipSdk.ts +0 -994
- package/src/index.ts +0 -211
- package/src/services/announcement.ts +0 -653
- package/src/services/auth.ts +0 -95
- package/src/services/discussion.ts +0 -380
- package/src/services/message.ts +0 -1055
- package/src/services/refresh.ts +0 -234
- package/src/types/events.ts +0 -108
- package/src/types.ts +0 -70
- package/src/utils/base64.ts +0 -39
- package/src/utils/contacts.ts +0 -161
- package/src/utils/discussions.ts +0 -55
- package/src/utils/logs.ts +0 -86
- package/src/utils/messageSerialization.ts +0 -257
- package/src/utils/queue.ts +0 -106
- package/src/utils/type.ts +0 -7
- package/src/utils/userId.ts +0 -114
- package/src/utils/validation.ts +0 -144
- package/src/wasm/loader.ts +0 -123
- package/src/wasm/session.ts +0 -276
- package/test/config/protocol.spec.ts +0 -31
- package/test/config/sdk.spec.ts +0 -163
- package/test/db/helpers.spec.ts +0 -142
- package/test/db/operations.spec.ts +0 -128
- package/test/db/states.spec.ts +0 -535
- package/test/integration/discussion-flow.spec.ts +0 -422
- package/test/integration/messaging-flow.spec.ts +0 -708
- package/test/integration/sdk-lifecycle.spec.ts +0 -325
- package/test/mocks/index.ts +0 -9
- package/test/mocks/mockMessageProtocol.ts +0 -100
- package/test/services/auth.spec.ts +0 -311
- package/test/services/discussion.spec.ts +0 -279
- package/test/services/message-deduplication.spec.ts +0 -299
- package/test/services/message-startup.spec.ts +0 -331
- package/test/services/message.spec.ts +0 -817
- package/test/services/refresh.spec.ts +0 -199
- package/test/services/session-status.spec.ts +0 -349
- package/test/session/wasm.spec.ts +0 -227
- package/test/setup.ts +0 -52
- package/test/utils/contacts.spec.ts +0 -156
- package/test/utils/discussions.spec.ts +0 -66
- package/test/utils/queue.spec.ts +0 -52
- package/test/utils/serialization.spec.ts +0 -120
- package/test/utils/userId.spec.ts +0 -120
- package/test/utils/validation.spec.ts +0 -223
- package/test/utils.ts +0 -212
- package/tsconfig.json +0 -26
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -28
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Reception Service
|
|
3
|
+
*
|
|
4
|
+
* Handles fetching encrypted messages from the protocol and decrypting them.
|
|
5
|
+
* This service works both in host app contexts and SDK/automation context.
|
|
6
|
+
*/
|
|
7
|
+
import { DiscussionStatus, MessageDirection, MessageStatus, MessageType, } from '../db';
|
|
8
|
+
import { decodeUserId, encodeUserId } from '../utils/userId';
|
|
9
|
+
import { SessionStatus, } from '../assets/generated/wasm/gossip_wasm';
|
|
10
|
+
import { serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, serializeKeepAliveMessage, deserializeMessage, } from '../utils/messageSerialization';
|
|
11
|
+
import { encodeToBase64 } from '../utils/base64';
|
|
12
|
+
import { sessionStatusToString } from '../wasm/session';
|
|
13
|
+
import { Logger } from '../utils/logs';
|
|
14
|
+
import { defaultSdkConfig } from '../config/sdk';
|
|
15
|
+
const sleep = (ms) => new Promise(res => setTimeout(res, ms));
|
|
16
|
+
const logger = new Logger('MessageService');
|
|
17
|
+
export class MessageService {
|
|
18
|
+
constructor(db, messageProtocol, session, discussionService, events = {}, config = defaultSdkConfig) {
|
|
19
|
+
Object.defineProperty(this, "db", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: void 0
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(this, "messageProtocol", {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: void 0
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(this, "session", {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true,
|
|
35
|
+
value: void 0
|
|
36
|
+
});
|
|
37
|
+
Object.defineProperty(this, "discussionService", {
|
|
38
|
+
enumerable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
writable: true,
|
|
41
|
+
value: void 0
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(this, "events", {
|
|
44
|
+
enumerable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
value: void 0
|
|
48
|
+
});
|
|
49
|
+
Object.defineProperty(this, "config", {
|
|
50
|
+
enumerable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
writable: true,
|
|
53
|
+
value: void 0
|
|
54
|
+
});
|
|
55
|
+
this.db = db;
|
|
56
|
+
this.messageProtocol = messageProtocol;
|
|
57
|
+
this.session = session;
|
|
58
|
+
this.discussionService = discussionService;
|
|
59
|
+
this.events = events;
|
|
60
|
+
this.config = config;
|
|
61
|
+
}
|
|
62
|
+
async fetchMessages() {
|
|
63
|
+
const log = logger.forMethod('fetchMessages');
|
|
64
|
+
try {
|
|
65
|
+
if (!this.session)
|
|
66
|
+
throw new Error('Session module not initialized');
|
|
67
|
+
let previousSeekers = new Set();
|
|
68
|
+
let iterations = 0;
|
|
69
|
+
let newMessagesCount = 0;
|
|
70
|
+
let seekers = [];
|
|
71
|
+
while (true) {
|
|
72
|
+
seekers = this.session.getMessageBoardReadKeys();
|
|
73
|
+
const currentSeekers = new Set(seekers.map(s => encodeToBase64(s)));
|
|
74
|
+
const allSame = seekers.length === previousSeekers.size &&
|
|
75
|
+
[...currentSeekers].every(s => previousSeekers.has(s));
|
|
76
|
+
const maxIterations = this.config.messages.maxFetchIterations;
|
|
77
|
+
if (allSame || iterations >= maxIterations) {
|
|
78
|
+
if (iterations >= maxIterations) {
|
|
79
|
+
log.warn('fetch loop stopped due to max iterations', {
|
|
80
|
+
iterations,
|
|
81
|
+
maxIterations,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const encryptedMessages = await this.messageProtocol.fetchMessages(seekers);
|
|
87
|
+
previousSeekers = currentSeekers;
|
|
88
|
+
if (encryptedMessages.length === 0) {
|
|
89
|
+
iterations++;
|
|
90
|
+
await sleep(this.config.messages.fetchDelayMs);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const { decrypted: decryptedMessages, acknowledgedSeekers } = await this.decryptMessages(encryptedMessages);
|
|
94
|
+
if (decryptedMessages.length > 0) {
|
|
95
|
+
const storedIds = await this.storeDecryptedMessages(decryptedMessages, this.session.userIdEncoded);
|
|
96
|
+
newMessagesCount += storedIds.length;
|
|
97
|
+
}
|
|
98
|
+
if (acknowledgedSeekers.size > 0) {
|
|
99
|
+
log.info('processing acknowledged seekers', {
|
|
100
|
+
count: acknowledgedSeekers.size,
|
|
101
|
+
});
|
|
102
|
+
await this.acknowledgeMessages(acknowledgedSeekers, this.session.userIdEncoded);
|
|
103
|
+
}
|
|
104
|
+
iterations++;
|
|
105
|
+
await sleep(this.config.messages.fetchDelayMs);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await this.db.setActiveSeekers(seekers);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
log.error('failed to update active seekers', error);
|
|
112
|
+
}
|
|
113
|
+
if (newMessagesCount > 0) {
|
|
114
|
+
log.info(`fetch completed — ${newMessagesCount} new messages received`);
|
|
115
|
+
}
|
|
116
|
+
return { success: true, newMessagesCount };
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
log.error('fetch failed', err);
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
newMessagesCount: 0,
|
|
123
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async decryptMessages(encrypted) {
|
|
128
|
+
const log = logger.forMethod('decryptMessages');
|
|
129
|
+
const decrypted = [];
|
|
130
|
+
const acknowledgedSeekers = new Set();
|
|
131
|
+
for (const msg of encrypted) {
|
|
132
|
+
try {
|
|
133
|
+
const out = await this.session.feedIncomingMessageBoardRead(msg.seeker, msg.ciphertext);
|
|
134
|
+
if (!out)
|
|
135
|
+
continue;
|
|
136
|
+
try {
|
|
137
|
+
const deserialized = deserializeMessage(out.message);
|
|
138
|
+
out.acknowledged_seekers.forEach((seeker) => acknowledgedSeekers.add(encodeToBase64(seeker)));
|
|
139
|
+
// keep-alive messages are just useful to keep the session alive, we don't need to store them
|
|
140
|
+
if (deserialized.type === MessageType.KEEP_ALIVE) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
decrypted.push({
|
|
144
|
+
content: deserialized.content,
|
|
145
|
+
sentAt: new Date(Number(out.timestamp)),
|
|
146
|
+
senderId: encodeUserId(out.user_id),
|
|
147
|
+
seeker: msg.seeker,
|
|
148
|
+
encryptedMessage: msg.ciphertext,
|
|
149
|
+
type: deserialized.type,
|
|
150
|
+
replyTo: deserialized.replyTo
|
|
151
|
+
? {
|
|
152
|
+
originalContent: deserialized.replyTo.originalContent,
|
|
153
|
+
originalSeeker: deserialized.replyTo.originalSeeker,
|
|
154
|
+
}
|
|
155
|
+
: undefined,
|
|
156
|
+
forwardOf: deserialized.forwardOf
|
|
157
|
+
? {
|
|
158
|
+
originalContent: deserialized.forwardOf.originalContent,
|
|
159
|
+
originalSeeker: deserialized.forwardOf.originalSeeker,
|
|
160
|
+
}
|
|
161
|
+
: undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (deserializationError) {
|
|
165
|
+
log.error('deserialization failed', {
|
|
166
|
+
error: deserializationError instanceof Error
|
|
167
|
+
? deserializationError.message
|
|
168
|
+
: 'Unknown error',
|
|
169
|
+
seeker: encodeToBase64(msg.seeker),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
log.error('decryption failed', {
|
|
175
|
+
error: e instanceof Error ? e.message : 'Unknown error',
|
|
176
|
+
seeker: encodeToBase64(msg.seeker),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { decrypted, acknowledgedSeekers };
|
|
181
|
+
}
|
|
182
|
+
async storeDecryptedMessages(decrypted, ownerUserId) {
|
|
183
|
+
const log = logger.forMethod('storeDecryptedMessages');
|
|
184
|
+
const storedIds = [];
|
|
185
|
+
for (const message of decrypted) {
|
|
186
|
+
const discussion = await this.db.getDiscussionByOwnerAndContact(ownerUserId, message.senderId);
|
|
187
|
+
if (!discussion) {
|
|
188
|
+
log.error('no discussion for incoming message', {
|
|
189
|
+
senderId: message.senderId,
|
|
190
|
+
preview: message.content.slice(0, 50),
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Check for duplicate message (same content + similar timestamp from same sender)
|
|
195
|
+
// This handles edge case: app crashes after network send but before DB update,
|
|
196
|
+
// message gets re-sent on restart, peer receives duplicate
|
|
197
|
+
const isDuplicate = await this.isDuplicateMessage(ownerUserId, message.senderId, message.content, message.sentAt);
|
|
198
|
+
if (isDuplicate) {
|
|
199
|
+
log.info('skipping duplicate message', {
|
|
200
|
+
senderId: message.senderId,
|
|
201
|
+
preview: message.content.slice(0, 30),
|
|
202
|
+
timestamp: message.sentAt.toISOString(),
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
let replyToMessageId;
|
|
207
|
+
if (message.replyTo?.originalSeeker) {
|
|
208
|
+
const original = await this.findMessageBySeeker(message.replyTo.originalSeeker, ownerUserId);
|
|
209
|
+
if (!original) {
|
|
210
|
+
log.warn('reply target not found', {
|
|
211
|
+
originalSeeker: encodeToBase64(message.replyTo.originalSeeker),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
replyToMessageId = original?.id;
|
|
215
|
+
}
|
|
216
|
+
const id = await this.db.transaction('rw', this.db.messages, this.db.discussions, async () => {
|
|
217
|
+
const id = await this.db.messages.add({
|
|
218
|
+
ownerUserId,
|
|
219
|
+
contactUserId: discussion.contactUserId,
|
|
220
|
+
content: message.content,
|
|
221
|
+
type: message.type,
|
|
222
|
+
direction: MessageDirection.INCOMING,
|
|
223
|
+
status: MessageStatus.DELIVERED,
|
|
224
|
+
timestamp: message.sentAt,
|
|
225
|
+
metadata: {},
|
|
226
|
+
seeker: message.seeker, // Store the seeker of the incoming message
|
|
227
|
+
encryptedMessage: message.encryptedMessage, // Store the ciphertext of the incoming message
|
|
228
|
+
replyTo: message.replyTo
|
|
229
|
+
? {
|
|
230
|
+
// Store the original content as a fallback only if we couldn't find
|
|
231
|
+
// the original message in the database (replyToMessageId is undefined).
|
|
232
|
+
// If the original message exists, we don't need to store the content
|
|
233
|
+
// since we can fetch it using the originalSeeker.
|
|
234
|
+
originalContent: replyToMessageId
|
|
235
|
+
? undefined
|
|
236
|
+
: message.replyTo.originalContent,
|
|
237
|
+
// Store the seeker (used to find the original message)
|
|
238
|
+
originalSeeker: message.replyTo.originalSeeker,
|
|
239
|
+
}
|
|
240
|
+
: undefined,
|
|
241
|
+
forwardOf: message.forwardOf
|
|
242
|
+
? {
|
|
243
|
+
originalContent: message.forwardOf.originalContent,
|
|
244
|
+
originalSeeker: message.forwardOf.originalSeeker,
|
|
245
|
+
}
|
|
246
|
+
: undefined,
|
|
247
|
+
});
|
|
248
|
+
const now = new Date();
|
|
249
|
+
await this.db.discussions.update(discussion.id, {
|
|
250
|
+
lastMessageId: id,
|
|
251
|
+
lastMessageContent: message.content,
|
|
252
|
+
lastMessageTimestamp: message.sentAt,
|
|
253
|
+
updatedAt: now,
|
|
254
|
+
lastSyncTimestamp: now,
|
|
255
|
+
unreadCount: discussion.unreadCount + 1,
|
|
256
|
+
});
|
|
257
|
+
return id;
|
|
258
|
+
});
|
|
259
|
+
storedIds.push(id);
|
|
260
|
+
// Emit event for new message
|
|
261
|
+
if (this.events.onMessageReceived) {
|
|
262
|
+
const storedMessage = await this.db.messages.get(id);
|
|
263
|
+
if (storedMessage) {
|
|
264
|
+
this.events.onMessageReceived(storedMessage);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return storedIds;
|
|
269
|
+
}
|
|
270
|
+
async findMessageBySeeker(seeker, ownerUserId) {
|
|
271
|
+
return await this.db.messages
|
|
272
|
+
.where('[ownerUserId+seeker]')
|
|
273
|
+
.equals([ownerUserId, seeker])
|
|
274
|
+
.first();
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Check if a message is a duplicate based on content and timestamp.
|
|
278
|
+
*
|
|
279
|
+
* A message is considered duplicate if:
|
|
280
|
+
* - Same sender (contactUserId)
|
|
281
|
+
* - Same content
|
|
282
|
+
* - Incoming direction
|
|
283
|
+
* - Timestamp within deduplication window (default 30 seconds)
|
|
284
|
+
*
|
|
285
|
+
* This handles the edge case where:
|
|
286
|
+
* 1. Sender sends message successfully to network
|
|
287
|
+
* 2. Sender app crashes before updating DB status to SENT
|
|
288
|
+
* 3. On restart, message is reset to WAITING_SESSION and re-sent
|
|
289
|
+
* 4. Receiver gets the same message twice with different seekers
|
|
290
|
+
*
|
|
291
|
+
* @param ownerUserId - The owner's user ID
|
|
292
|
+
* @param contactUserId - The sender's user ID
|
|
293
|
+
* @param content - The message content
|
|
294
|
+
* @param timestamp - The message timestamp
|
|
295
|
+
* @returns true if a duplicate exists
|
|
296
|
+
*/
|
|
297
|
+
async isDuplicateMessage(ownerUserId, contactUserId, content, timestamp) {
|
|
298
|
+
const windowMs = this.config.messages.deduplicationWindowMs;
|
|
299
|
+
const windowStart = new Date(timestamp.getTime() - windowMs);
|
|
300
|
+
const windowEnd = new Date(timestamp.getTime() + windowMs);
|
|
301
|
+
// Query for messages from same sender with same content within time window
|
|
302
|
+
const existing = await this.db.messages
|
|
303
|
+
.where('[ownerUserId+contactUserId]')
|
|
304
|
+
.equals([ownerUserId, contactUserId])
|
|
305
|
+
.and(msg => msg.direction === MessageDirection.INCOMING &&
|
|
306
|
+
msg.content === content &&
|
|
307
|
+
msg.timestamp >= windowStart &&
|
|
308
|
+
msg.timestamp <= windowEnd)
|
|
309
|
+
.first();
|
|
310
|
+
return existing !== undefined;
|
|
311
|
+
}
|
|
312
|
+
async acknowledgeMessages(seekers, userId) {
|
|
313
|
+
if (seekers.size === 0)
|
|
314
|
+
return;
|
|
315
|
+
const updatedCount = await this.db.messages
|
|
316
|
+
.where('[ownerUserId+direction+status]')
|
|
317
|
+
.equals([userId, MessageDirection.OUTGOING, MessageStatus.SENT])
|
|
318
|
+
.filter(message => message.seeker !== undefined &&
|
|
319
|
+
seekers.has(encodeToBase64(message.seeker)))
|
|
320
|
+
.modify({ status: MessageStatus.DELIVERED });
|
|
321
|
+
// After marking messages as DELIVERED, clean up DELIVERED keep-alive messages
|
|
322
|
+
await this.db.messages
|
|
323
|
+
.where({
|
|
324
|
+
ownerUserId: userId,
|
|
325
|
+
status: MessageStatus.DELIVERED,
|
|
326
|
+
type: MessageType.KEEP_ALIVE,
|
|
327
|
+
})
|
|
328
|
+
.delete();
|
|
329
|
+
if (updatedCount > 0) {
|
|
330
|
+
logger
|
|
331
|
+
.forMethod('acknowledgeMessages')
|
|
332
|
+
.info(`acknowledged ${updatedCount} messages`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async sendMessage(message) {
|
|
336
|
+
const log = logger.forMethod('sendMessage');
|
|
337
|
+
log.info('sending message', {
|
|
338
|
+
messageContent: message.content,
|
|
339
|
+
messageType: message.type,
|
|
340
|
+
messageReplyTo: message.replyTo,
|
|
341
|
+
messageForwardOf: message.forwardOf,
|
|
342
|
+
});
|
|
343
|
+
const peerId = decodeUserId(message.contactUserId);
|
|
344
|
+
if (peerId.length !== 32) {
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
error: 'Invalid contact userId (must be 32 bytes)',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const discussion = await this.db.getDiscussionByOwnerAndContact(message.ownerUserId, message.contactUserId);
|
|
351
|
+
if (!discussion) {
|
|
352
|
+
return { success: false, error: 'Discussion not found' };
|
|
353
|
+
}
|
|
354
|
+
const sessionStatus = this.session.peerSessionStatus(peerId);
|
|
355
|
+
// Check for session states that require renewal (session is truly lost)
|
|
356
|
+
// Per spec: when session is lost, queue message as WAITING_SESSION and trigger auto-renewal
|
|
357
|
+
const needsRenewalStatuses = [
|
|
358
|
+
SessionStatus.UnknownPeer,
|
|
359
|
+
SessionStatus.NoSession,
|
|
360
|
+
SessionStatus.Killed,
|
|
361
|
+
// Note: PeerRequested is NOT included - it means peer sent us an announcement
|
|
362
|
+
// and we should accept it, not trigger renewal (which would create a race condition)
|
|
363
|
+
];
|
|
364
|
+
if (needsRenewalStatuses.includes(sessionStatus)) {
|
|
365
|
+
// Add message as WAITING_SESSION - it will be sent when session becomes Active
|
|
366
|
+
const messageId = await this.db.addMessage({
|
|
367
|
+
...message,
|
|
368
|
+
status: MessageStatus.WAITING_SESSION,
|
|
369
|
+
});
|
|
370
|
+
log.info('session lost, queuing message as WAITING_SESSION', {
|
|
371
|
+
sessionStatus: sessionStatusToString(sessionStatus),
|
|
372
|
+
messageId,
|
|
373
|
+
});
|
|
374
|
+
// Trigger auto-renewal (per spec: call create_session when session is lost)
|
|
375
|
+
this.events.onSessionRenewalNeeded?.(message.contactUserId);
|
|
376
|
+
const queuedMessage = {
|
|
377
|
+
...message,
|
|
378
|
+
id: messageId,
|
|
379
|
+
status: MessageStatus.WAITING_SESSION,
|
|
380
|
+
};
|
|
381
|
+
// Return success=true because the message is queued and will be sent later
|
|
382
|
+
// This matches the spec where messages in WAITING_SESSION are valid queue items
|
|
383
|
+
return {
|
|
384
|
+
success: true,
|
|
385
|
+
message: queuedMessage,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
// PeerRequested: peer sent us an announcement, we need to accept/respond
|
|
389
|
+
// Queue the message but trigger accept flow, not renewal
|
|
390
|
+
if (sessionStatus === SessionStatus.PeerRequested) {
|
|
391
|
+
const messageId = await this.db.addMessage({
|
|
392
|
+
...message,
|
|
393
|
+
status: MessageStatus.WAITING_SESSION,
|
|
394
|
+
});
|
|
395
|
+
log.info('peer requested session, queuing message - need to accept', {
|
|
396
|
+
sessionStatus: sessionStatusToString(sessionStatus),
|
|
397
|
+
messageId,
|
|
398
|
+
});
|
|
399
|
+
// Trigger accept flow (different from renewal - we respond to their announcement)
|
|
400
|
+
this.events.onSessionAcceptNeeded?.(message.contactUserId);
|
|
401
|
+
return {
|
|
402
|
+
success: true,
|
|
403
|
+
message: {
|
|
404
|
+
...message,
|
|
405
|
+
id: messageId,
|
|
406
|
+
status: MessageStatus.WAITING_SESSION,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// Serialize message content (handle replies)
|
|
411
|
+
const serializeMessageResult = await this.serializeMessage(message);
|
|
412
|
+
if (!serializeMessageResult.success) {
|
|
413
|
+
return {
|
|
414
|
+
success: false,
|
|
415
|
+
error: serializeMessageResult.error,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
log.info('message serialized', {
|
|
419
|
+
serializedContent: serializeMessageResult.data,
|
|
420
|
+
});
|
|
421
|
+
message.serializedContent = serializeMessageResult.data;
|
|
422
|
+
// Check if we can send messages on this discussion
|
|
423
|
+
const isUnstable = !(await this.discussionService.isStableState(message.ownerUserId, message.contactUserId));
|
|
424
|
+
const isSelfRequested = sessionStatus === SessionStatus.SelfRequested;
|
|
425
|
+
// Per spec: if session is SelfRequested or discussion unstable, queue as WAITING_SESSION
|
|
426
|
+
if (isUnstable || isSelfRequested) {
|
|
427
|
+
const messageId = await this.db.addMessage({
|
|
428
|
+
...message,
|
|
429
|
+
status: MessageStatus.WAITING_SESSION,
|
|
430
|
+
});
|
|
431
|
+
// Clear console log for debugging
|
|
432
|
+
console.warn(`[SendMessage] WAITING_SESSION - isUnstable=${isUnstable}, isSelfRequested=${isSelfRequested}, sessionStatus=${sessionStatusToString(sessionStatus)}`);
|
|
433
|
+
log.info('discussion/session not ready, queuing as WAITING_SESSION', {
|
|
434
|
+
isUnstable,
|
|
435
|
+
isSelfRequested,
|
|
436
|
+
sessionStatus: sessionStatusToString(sessionStatus),
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
success: true,
|
|
440
|
+
message: {
|
|
441
|
+
...message,
|
|
442
|
+
id: messageId,
|
|
443
|
+
status: MessageStatus.WAITING_SESSION,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const messageId = await this.db.addMessage({
|
|
448
|
+
...message,
|
|
449
|
+
status: MessageStatus.SENDING,
|
|
450
|
+
});
|
|
451
|
+
let sendOutput;
|
|
452
|
+
try {
|
|
453
|
+
if (sessionStatus !== SessionStatus.Active) {
|
|
454
|
+
throw new Error(`Session not active: ${sessionStatusToString(sessionStatus)}`);
|
|
455
|
+
}
|
|
456
|
+
// CRITICAL: await session.sendMessage to ensure session state is persisted
|
|
457
|
+
// before the encrypted message is sent to the network
|
|
458
|
+
sendOutput = await this.session.sendMessage(peerId, message.serializedContent);
|
|
459
|
+
if (!sendOutput)
|
|
460
|
+
throw new Error('sendMessage returned null');
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
await this.db.transaction('rw', this.db.messages, this.db.discussions, async () => {
|
|
464
|
+
await this.db.messages.update(messageId, {
|
|
465
|
+
status: MessageStatus.FAILED,
|
|
466
|
+
});
|
|
467
|
+
await this.db.discussions.update(discussion.id, {
|
|
468
|
+
status: DiscussionStatus.BROKEN,
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
log.error('encryption failed → discussion marked broken', error);
|
|
472
|
+
const failedMessage = {
|
|
473
|
+
...message,
|
|
474
|
+
id: messageId,
|
|
475
|
+
status: MessageStatus.FAILED,
|
|
476
|
+
};
|
|
477
|
+
this.events.onMessageFailed?.(failedMessage, error instanceof Error ? error : new Error('Session error'));
|
|
478
|
+
return {
|
|
479
|
+
success: false,
|
|
480
|
+
error: 'Session error',
|
|
481
|
+
message: failedMessage,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
await this.messageProtocol.sendMessage({
|
|
486
|
+
seeker: sendOutput.seeker,
|
|
487
|
+
ciphertext: sendOutput.data,
|
|
488
|
+
});
|
|
489
|
+
await this.db.messages.update(messageId, {
|
|
490
|
+
status: MessageStatus.SENT,
|
|
491
|
+
seeker: sendOutput.seeker,
|
|
492
|
+
encryptedMessage: sendOutput.data,
|
|
493
|
+
});
|
|
494
|
+
const sentMessage = {
|
|
495
|
+
...message,
|
|
496
|
+
id: messageId,
|
|
497
|
+
status: MessageStatus.SENT,
|
|
498
|
+
};
|
|
499
|
+
this.events.onMessageSent?.(sentMessage);
|
|
500
|
+
return {
|
|
501
|
+
success: true,
|
|
502
|
+
message: sentMessage,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
await this.db.messages.update(messageId, {
|
|
507
|
+
status: MessageStatus.FAILED,
|
|
508
|
+
seeker: sendOutput.seeker,
|
|
509
|
+
encryptedMessage: sendOutput.data,
|
|
510
|
+
});
|
|
511
|
+
log.error('network send failed → will retry later', error);
|
|
512
|
+
const failedMessage = {
|
|
513
|
+
...message,
|
|
514
|
+
id: messageId,
|
|
515
|
+
status: MessageStatus.FAILED,
|
|
516
|
+
};
|
|
517
|
+
this.events.onMessageFailed?.(failedMessage, error instanceof Error ? error : new Error('Network send failed'));
|
|
518
|
+
return {
|
|
519
|
+
success: false,
|
|
520
|
+
error: 'Network send failed',
|
|
521
|
+
message: failedMessage,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async serializeMessage(message) {
|
|
526
|
+
const log = logger.forMethod('serializeMessage');
|
|
527
|
+
if (message.replyTo?.originalSeeker) {
|
|
528
|
+
const originalMessage = await this.findMessageBySeeker(message.replyTo.originalSeeker, message.ownerUserId);
|
|
529
|
+
if (!originalMessage) {
|
|
530
|
+
return {
|
|
531
|
+
success: false,
|
|
532
|
+
error: 'Original message not found for reply',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
success: true,
|
|
537
|
+
data: serializeReplyMessage(message.content, originalMessage.content, message.replyTo.originalSeeker),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
else if (message.type === MessageType.KEEP_ALIVE) {
|
|
541
|
+
return {
|
|
542
|
+
success: true,
|
|
543
|
+
data: serializeKeepAliveMessage(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
else if (message.forwardOf?.originalContent &&
|
|
547
|
+
message.forwardOf.originalSeeker) {
|
|
548
|
+
try {
|
|
549
|
+
return {
|
|
550
|
+
success: true,
|
|
551
|
+
data: serializeForwardMessage(message.forwardOf.originalContent, message.content, message.forwardOf.originalSeeker),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
log.error('failed to serialize forward message', error);
|
|
556
|
+
return {
|
|
557
|
+
success: false,
|
|
558
|
+
error: 'Failed to serialize forward message',
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
// Regular message with type tag
|
|
564
|
+
return {
|
|
565
|
+
success: true,
|
|
566
|
+
data: serializeRegularMessage(message.content),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async resendMessages(messages) {
|
|
571
|
+
const log = logger.forMethod('resendMessages');
|
|
572
|
+
const successfullySent = [];
|
|
573
|
+
let totalProcessed = 0;
|
|
574
|
+
for (const [contactId, retryMessages] of messages.entries()) {
|
|
575
|
+
const peerId = decodeUserId(contactId);
|
|
576
|
+
totalProcessed += retryMessages.length;
|
|
577
|
+
for (const msg of retryMessages) {
|
|
578
|
+
/* If the message has already been encrypted by sessionManager, resend it */
|
|
579
|
+
if (msg.encryptedMessage && msg.seeker) {
|
|
580
|
+
log.info('message has already been encrypted by sessionManager with seeker', {
|
|
581
|
+
messageContent: msg.content,
|
|
582
|
+
seeker: encodeToBase64(msg.seeker),
|
|
583
|
+
});
|
|
584
|
+
try {
|
|
585
|
+
await this.messageProtocol.sendMessage({
|
|
586
|
+
seeker: msg.seeker,
|
|
587
|
+
ciphertext: msg.encryptedMessage,
|
|
588
|
+
});
|
|
589
|
+
successfullySent.push(msg.id);
|
|
590
|
+
log.info('message has been resent successfully on the network', {
|
|
591
|
+
messageContent: msg.content,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
log.error('failed to resend message', {
|
|
596
|
+
error: error,
|
|
597
|
+
messageId: msg.id,
|
|
598
|
+
messageContent: msg.content,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/* If the message has not been encrypted by sessionManager, encrypt it and resend it */
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
log.info('message has not been encrypted by sessionManager', {
|
|
605
|
+
messageContent: msg.content,
|
|
606
|
+
});
|
|
607
|
+
const status = this.session.peerSessionStatus(peerId);
|
|
608
|
+
log.info('session status for peer', {
|
|
609
|
+
peerId: encodeUserId(peerId),
|
|
610
|
+
sessionStatus: sessionStatusToString(status),
|
|
611
|
+
});
|
|
612
|
+
/* If the session is waiting for peer acceptance, don't attempt to resend messages in this discussion
|
|
613
|
+
because we don't have the peer's next seeker yet*/
|
|
614
|
+
if (status === SessionStatus.SelfRequested) {
|
|
615
|
+
log.info('skipping resend — waiting for peer acceptance', {
|
|
616
|
+
contactId,
|
|
617
|
+
});
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
/*
|
|
621
|
+
If session manager encryption fails for a message N, we can't send next N+1, N+2, ... messages in the discussion.
|
|
622
|
+
If the message N+1 is passed with success in session.sendMessage() before passing the message N,
|
|
623
|
+
message N would be considered as posterior to message N+1, which is not correct.
|
|
624
|
+
So if a message can't be encrypted in session.sendMessage() because of error session status,
|
|
625
|
+
we should break the loop and trigger auto-renewal.
|
|
626
|
+
*/
|
|
627
|
+
const needsRenewalStatuses = [
|
|
628
|
+
SessionStatus.Killed,
|
|
629
|
+
SessionStatus.Saturated,
|
|
630
|
+
SessionStatus.NoSession,
|
|
631
|
+
SessionStatus.UnknownPeer,
|
|
632
|
+
// Note: PeerRequested is NOT included - it means peer sent us an announcement
|
|
633
|
+
// and we should accept it, not trigger renewal
|
|
634
|
+
];
|
|
635
|
+
if (needsRenewalStatuses.includes(status)) {
|
|
636
|
+
// Per spec: trigger auto-renewal instead of marking as BROKEN
|
|
637
|
+
// Messages stay in WAITING_SESSION/FAILED and will be processed when session is Active
|
|
638
|
+
log.info('session lost during resend, triggering renewal', {
|
|
639
|
+
sessionStatus: sessionStatusToString(status),
|
|
640
|
+
contactId,
|
|
641
|
+
});
|
|
642
|
+
this.events.onSessionRenewalNeeded?.(contactId);
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
// PeerRequested: peer sent us an announcement, need to accept
|
|
646
|
+
if (status === SessionStatus.PeerRequested) {
|
|
647
|
+
log.info('peer requested session during resend, triggering accept', {
|
|
648
|
+
sessionStatus: sessionStatusToString(status),
|
|
649
|
+
contactId,
|
|
650
|
+
});
|
|
651
|
+
this.events.onSessionAcceptNeeded?.(contactId);
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
if (status !== SessionStatus.Active) {
|
|
655
|
+
log.warn('session not active — stopping resend', {
|
|
656
|
+
sessionStatus: sessionStatusToString(status),
|
|
657
|
+
contactId,
|
|
658
|
+
});
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
// if the message has not been serialized, serialize it
|
|
662
|
+
let serializedContent = msg.serializedContent;
|
|
663
|
+
if (!serializedContent) {
|
|
664
|
+
log.info('message not serialized yet — serializing it', {
|
|
665
|
+
messageContent: msg.content,
|
|
666
|
+
});
|
|
667
|
+
const serializeResult = await this.serializeMessage(msg);
|
|
668
|
+
if (!serializeResult.success) {
|
|
669
|
+
log.error('serialization failed during resend', {
|
|
670
|
+
error: serializeResult.error,
|
|
671
|
+
});
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
serializedContent = serializeResult.data;
|
|
675
|
+
log.info('message serialized', {
|
|
676
|
+
messageContent: msg.content,
|
|
677
|
+
serializedContent: serializedContent,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const sendOutput = await this.session.sendMessage(peerId, serializedContent);
|
|
681
|
+
if (!sendOutput) {
|
|
682
|
+
log.error('session manager failed to send message', {
|
|
683
|
+
messageId: msg.id,
|
|
684
|
+
messageContent: msg.content,
|
|
685
|
+
});
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
await this.db.messages.update(msg.id, {
|
|
689
|
+
seeker: sendOutput.seeker,
|
|
690
|
+
encryptedMessage: sendOutput.data,
|
|
691
|
+
});
|
|
692
|
+
try {
|
|
693
|
+
await this.messageProtocol.sendMessage({
|
|
694
|
+
seeker: sendOutput.seeker,
|
|
695
|
+
ciphertext: sendOutput.data,
|
|
696
|
+
});
|
|
697
|
+
successfullySent.push(msg.id);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
log.error('network send failed during resend', error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (successfullySent.length > 0) {
|
|
706
|
+
await this.db.transaction('rw', this.db.messages, async () => {
|
|
707
|
+
await Promise.all(successfullySent.map(id => this.db.messages.update(id, { status: MessageStatus.SENT })));
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
log.info('resend completed', {
|
|
711
|
+
contacts: messages.size,
|
|
712
|
+
messagesProcessed: totalProcessed,
|
|
713
|
+
successfullySent: successfullySent.length,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Process messages that are waiting for an active session.
|
|
718
|
+
* Called when a session becomes Active to send queued messages.
|
|
719
|
+
* Per spec: when session becomes Active, encrypt and send WAITING_SESSION messages.
|
|
720
|
+
*
|
|
721
|
+
* @param contactUserId - The contact whose session became active
|
|
722
|
+
* @returns Number of messages successfully sent
|
|
723
|
+
*/
|
|
724
|
+
async processWaitingMessages(contactUserId) {
|
|
725
|
+
const log = logger.forMethod('processWaitingMessages');
|
|
726
|
+
const ownerUserId = this.session.userIdEncoded;
|
|
727
|
+
const peerId = decodeUserId(contactUserId);
|
|
728
|
+
// Check session is actually active
|
|
729
|
+
const sessionStatus = this.session.peerSessionStatus(peerId);
|
|
730
|
+
if (sessionStatus !== SessionStatus.Active) {
|
|
731
|
+
log.warn('cannot process waiting messages - session not active', {
|
|
732
|
+
sessionStatus: sessionStatusToString(sessionStatus),
|
|
733
|
+
contactUserId,
|
|
734
|
+
});
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
// Get all WAITING_SESSION messages for this contact, ordered by timestamp
|
|
738
|
+
const waitingMessages = await this.db.messages
|
|
739
|
+
.where('[ownerUserId+contactUserId+status]')
|
|
740
|
+
.equals([ownerUserId, contactUserId, MessageStatus.WAITING_SESSION])
|
|
741
|
+
.sortBy('timestamp');
|
|
742
|
+
if (waitingMessages.length === 0) {
|
|
743
|
+
return 0;
|
|
744
|
+
}
|
|
745
|
+
log.info('processing waiting messages', {
|
|
746
|
+
count: waitingMessages.length,
|
|
747
|
+
contactUserId,
|
|
748
|
+
});
|
|
749
|
+
let successCount = 0;
|
|
750
|
+
for (const msg of waitingMessages) {
|
|
751
|
+
// Serialize if not already done
|
|
752
|
+
let serializedContent = msg.serializedContent;
|
|
753
|
+
if (!serializedContent) {
|
|
754
|
+
const serializeResult = await this.serializeMessage(msg);
|
|
755
|
+
if (!serializeResult.success) {
|
|
756
|
+
log.error('failed to serialize waiting message', {
|
|
757
|
+
messageId: msg.id,
|
|
758
|
+
error: serializeResult.error,
|
|
759
|
+
});
|
|
760
|
+
// Mark as FAILED since we can't serialize
|
|
761
|
+
await this.db.messages.update(msg.id, {
|
|
762
|
+
status: MessageStatus.FAILED,
|
|
763
|
+
});
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
serializedContent = serializeResult.data;
|
|
767
|
+
}
|
|
768
|
+
// Encrypt with session manager (await to ensure persistence before network send)
|
|
769
|
+
const sendOutput = await this.session.sendMessage(peerId, serializedContent);
|
|
770
|
+
if (!sendOutput) {
|
|
771
|
+
log.error('session manager failed to encrypt waiting message', {
|
|
772
|
+
messageId: msg.id,
|
|
773
|
+
});
|
|
774
|
+
// Don't mark as FAILED - session might have changed, retry later
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
// Update message with encrypted data
|
|
778
|
+
await this.db.messages.update(msg.id, {
|
|
779
|
+
status: MessageStatus.SENDING,
|
|
780
|
+
seeker: sendOutput.seeker,
|
|
781
|
+
encryptedMessage: sendOutput.data,
|
|
782
|
+
serializedContent,
|
|
783
|
+
});
|
|
784
|
+
// Send over network
|
|
785
|
+
try {
|
|
786
|
+
await this.messageProtocol.sendMessage({
|
|
787
|
+
seeker: sendOutput.seeker,
|
|
788
|
+
ciphertext: sendOutput.data,
|
|
789
|
+
});
|
|
790
|
+
await this.db.messages.update(msg.id, {
|
|
791
|
+
status: MessageStatus.SENT,
|
|
792
|
+
});
|
|
793
|
+
successCount++;
|
|
794
|
+
this.events.onMessageSent?.({
|
|
795
|
+
...msg,
|
|
796
|
+
status: MessageStatus.SENT,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
log.error('network send failed for waiting message', {
|
|
801
|
+
messageId: msg.id,
|
|
802
|
+
error,
|
|
803
|
+
});
|
|
804
|
+
// Keep as SENDING - will be retried by resendMessages
|
|
805
|
+
await this.db.messages.update(msg.id, {
|
|
806
|
+
status: MessageStatus.FAILED,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
log.info('processed waiting messages', {
|
|
811
|
+
total: waitingMessages.length,
|
|
812
|
+
sent: successCount,
|
|
813
|
+
});
|
|
814
|
+
return successCount;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Get count of messages waiting for session with a specific contact.
|
|
818
|
+
*/
|
|
819
|
+
async getWaitingMessageCount(contactUserId) {
|
|
820
|
+
const ownerUserId = this.session.userIdEncoded;
|
|
821
|
+
return await this.db.messages
|
|
822
|
+
.where('[ownerUserId+contactUserId+status]')
|
|
823
|
+
.equals([ownerUserId, contactUserId, MessageStatus.WAITING_SESSION])
|
|
824
|
+
.count();
|
|
825
|
+
}
|
|
826
|
+
}
|