@massalabs/gossip-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +484 -0
  2. package/package.json +41 -0
  3. package/src/api/messageProtocol/index.ts +53 -0
  4. package/src/api/messageProtocol/mock.ts +13 -0
  5. package/src/api/messageProtocol/rest.ts +209 -0
  6. package/src/api/messageProtocol/types.ts +70 -0
  7. package/src/config/protocol.ts +97 -0
  8. package/src/config/sdk.ts +131 -0
  9. package/src/contacts.ts +210 -0
  10. package/src/core/SdkEventEmitter.ts +91 -0
  11. package/src/core/SdkPolling.ts +134 -0
  12. package/src/core/index.ts +9 -0
  13. package/src/crypto/bip39.ts +84 -0
  14. package/src/crypto/encryption.ts +77 -0
  15. package/src/db.ts +465 -0
  16. package/src/gossipSdk.ts +994 -0
  17. package/src/index.ts +211 -0
  18. package/src/services/announcement.ts +653 -0
  19. package/src/services/auth.ts +95 -0
  20. package/src/services/discussion.ts +380 -0
  21. package/src/services/message.ts +1055 -0
  22. package/src/services/refresh.ts +234 -0
  23. package/src/sw.ts +17 -0
  24. package/src/types/events.ts +108 -0
  25. package/src/types.ts +70 -0
  26. package/src/utils/base64.ts +39 -0
  27. package/src/utils/contacts.ts +161 -0
  28. package/src/utils/discussions.ts +55 -0
  29. package/src/utils/logs.ts +86 -0
  30. package/src/utils/messageSerialization.ts +257 -0
  31. package/src/utils/queue.ts +106 -0
  32. package/src/utils/type.ts +7 -0
  33. package/src/utils/userId.ts +114 -0
  34. package/src/utils/validation.ts +144 -0
  35. package/src/utils.ts +47 -0
  36. package/src/wasm/encryption.ts +108 -0
  37. package/src/wasm/index.ts +20 -0
  38. package/src/wasm/loader.ts +123 -0
  39. package/src/wasm/session.ts +276 -0
  40. package/src/wasm/userKeys.ts +31 -0
  41. package/test/config/protocol.spec.ts +31 -0
  42. package/test/config/sdk.spec.ts +163 -0
  43. package/test/db/helpers.spec.ts +142 -0
  44. package/test/db/operations.spec.ts +128 -0
  45. package/test/db/states.spec.ts +535 -0
  46. package/test/integration/discussion-flow.spec.ts +422 -0
  47. package/test/integration/messaging-flow.spec.ts +708 -0
  48. package/test/integration/sdk-lifecycle.spec.ts +325 -0
  49. package/test/mocks/index.ts +9 -0
  50. package/test/mocks/mockMessageProtocol.ts +100 -0
  51. package/test/services/auth.spec.ts +311 -0
  52. package/test/services/discussion.spec.ts +279 -0
  53. package/test/services/message-deduplication.spec.ts +299 -0
  54. package/test/services/message-startup.spec.ts +331 -0
  55. package/test/services/message.spec.ts +817 -0
  56. package/test/services/refresh.spec.ts +199 -0
  57. package/test/services/session-status.spec.ts +349 -0
  58. package/test/session/wasm.spec.ts +227 -0
  59. package/test/setup.ts +52 -0
  60. package/test/utils/contacts.spec.ts +156 -0
  61. package/test/utils/discussions.spec.ts +66 -0
  62. package/test/utils/queue.spec.ts +52 -0
  63. package/test/utils/serialization.spec.ts +120 -0
  64. package/test/utils/userId.spec.ts +120 -0
  65. package/test/utils/validation.spec.ts +223 -0
  66. package/test/utils.ts +212 -0
  67. package/tsconfig.json +26 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/vitest.config.ts +28 -0
@@ -0,0 +1,653 @@
1
+ /**
2
+ * Announcement Service
3
+ *
4
+ * Handles broadcasting and processing of session announcements.
5
+ */
6
+
7
+ import {
8
+ type Discussion,
9
+ type GossipDatabase,
10
+ DiscussionStatus,
11
+ DiscussionDirection,
12
+ } from '../db';
13
+ import { decodeUserId, encodeUserId } from '../utils/userId';
14
+ import { IMessageProtocol } from '../api/messageProtocol';
15
+ import {
16
+ UserPublicKeys,
17
+ SessionStatus,
18
+ } from '../assets/generated/wasm/gossip_wasm';
19
+ import { SessionModule, sessionStatusToString } from '../wasm/session';
20
+ import { Logger } from '../utils/logs';
21
+ import { BulletinItem } from '../api/messageProtocol/types';
22
+ import { GossipSdkEvents } from '../types/events';
23
+ import { SdkConfig, defaultSdkConfig } from '../config/sdk';
24
+
25
+ const logger = new Logger('AnnouncementService');
26
+
27
+ export interface AnnouncementReceptionResult {
28
+ success: boolean;
29
+ newAnnouncementsCount: number;
30
+ error?: string;
31
+ }
32
+
33
+ export const EstablishSessionError =
34
+ 'Session manager failed to establish outgoing session';
35
+
36
+ export class AnnouncementService {
37
+ private db: GossipDatabase;
38
+ private messageProtocol: IMessageProtocol;
39
+ private session: SessionModule;
40
+ private isProcessingAnnouncements = false;
41
+ private events: GossipSdkEvents;
42
+ private config: SdkConfig;
43
+
44
+ constructor(
45
+ db: GossipDatabase,
46
+ messageProtocol: IMessageProtocol,
47
+ session: SessionModule,
48
+ events: GossipSdkEvents = {},
49
+ config: SdkConfig = defaultSdkConfig
50
+ ) {
51
+ this.db = db;
52
+ this.messageProtocol = messageProtocol;
53
+ this.session = session;
54
+ this.events = events;
55
+ this.config = config;
56
+ }
57
+
58
+ setMessageProtocol(messageProtocol: IMessageProtocol): void {
59
+ this.messageProtocol = messageProtocol;
60
+ }
61
+
62
+ async sendAnnouncement(announcement: Uint8Array): Promise<{
63
+ success: boolean;
64
+ counter?: string;
65
+ error?: string;
66
+ }> {
67
+ const log = logger.forMethod('sendAnnouncement');
68
+
69
+ try {
70
+ const counter = await this.messageProtocol.sendAnnouncement(announcement);
71
+ log.info('broadcast successful', { counter });
72
+ return { success: true, counter };
73
+ } catch (error) {
74
+ log.error('broadcast failed', error);
75
+ return {
76
+ success: false,
77
+ error: error instanceof Error ? error.message : 'Unknown error',
78
+ };
79
+ }
80
+ }
81
+
82
+ async establishSession(
83
+ contactPublicKeys: UserPublicKeys,
84
+ userData?: Uint8Array
85
+ ): Promise<{
86
+ success: boolean;
87
+ error?: string;
88
+ announcement: Uint8Array;
89
+ }> {
90
+ const log = logger.forMethod('establishSession');
91
+
92
+ const contactUserId = encodeUserId(contactPublicKeys.derive_id());
93
+
94
+ // CRITICAL: await to ensure session state is persisted before sending
95
+ const announcement = await this.session.establishOutgoingSession(
96
+ contactPublicKeys,
97
+ userData
98
+ );
99
+
100
+ if (announcement.length === 0) {
101
+ log.error('empty announcement returned', { contactUserId });
102
+ return {
103
+ success: false,
104
+ error: EstablishSessionError,
105
+ announcement,
106
+ };
107
+ }
108
+
109
+ const result = await this.sendAnnouncement(announcement);
110
+ if (!result.success) {
111
+ log.error('failed to broadcast announcement', {
112
+ contactUserId,
113
+ error: result.error,
114
+ });
115
+ return {
116
+ success: false,
117
+ error: result.error,
118
+ announcement,
119
+ };
120
+ }
121
+
122
+ log.info('announcement sent successfully', { contactUserId });
123
+ return { success: true, announcement };
124
+ }
125
+
126
+ async fetchAndProcessAnnouncements(): Promise<AnnouncementReceptionResult> {
127
+ const log = logger.forMethod('fetchAndProcessAnnouncements');
128
+
129
+ if (this.isProcessingAnnouncements) {
130
+ log.info('fetch already in progress, skipping');
131
+ return { success: true, newAnnouncementsCount: 0 };
132
+ }
133
+
134
+ const errors: string[] = [];
135
+ let announcements: Uint8Array[] = [];
136
+ let fetchedCounters: string[] = [];
137
+
138
+ this.isProcessingAnnouncements = true;
139
+ try {
140
+ const pending = await this.db.pendingAnnouncements.toArray();
141
+ const successfullyProcessedPendingIds: number[] = [];
142
+
143
+ if (pending.length > 0) {
144
+ log.info(
145
+ `processing ${pending.length} pending announcements from IndexedDB`
146
+ );
147
+
148
+ // Process pending announcements one by one, tracking successes
149
+ let newAnnouncementsCount = 0;
150
+ for (const item of pending) {
151
+ try {
152
+ const result = await this._processIncomingAnnouncement(
153
+ item.announcement
154
+ );
155
+
156
+ // Mark as successfully processed (even if announcement was for unknown peer)
157
+ // Only keep if processing threw an error
158
+ if (item.id !== undefined) {
159
+ successfullyProcessedPendingIds.push(item.id);
160
+ }
161
+
162
+ if (result.success && result.contactUserId) {
163
+ newAnnouncementsCount++;
164
+ log.info(
165
+ `processed pending announcement #${newAnnouncementsCount}`,
166
+ {
167
+ contactUserId: result.contactUserId,
168
+ }
169
+ );
170
+ }
171
+ if (item.counter) fetchedCounters.push(item.counter);
172
+ if (result.error) errors.push(result.error);
173
+ } catch (error) {
174
+ // Don't mark as processed - will be retried next time
175
+ log.error('failed to process pending announcement, will retry', {
176
+ id: item.id,
177
+ error,
178
+ });
179
+ errors.push(
180
+ error instanceof Error ? error.message : 'Unknown error'
181
+ );
182
+ }
183
+ }
184
+
185
+ // Delete only successfully processed pending announcements
186
+ if (successfullyProcessedPendingIds.length > 0) {
187
+ await this.db.pendingAnnouncements.bulkDelete(
188
+ successfullyProcessedPendingIds
189
+ );
190
+ log.info(
191
+ `deleted ${successfullyProcessedPendingIds.length} processed pending announcements`
192
+ );
193
+ }
194
+
195
+ if (fetchedCounters.length > 0) {
196
+ const highestCounter = fetchedCounters.reduce((a, b) =>
197
+ Number(a) > Number(b) ? a : b
198
+ );
199
+ await this.db.userProfile.update(this.session.userIdEncoded, {
200
+ lastBulletinCounter: highestCounter,
201
+ });
202
+ log.info('updated lastBulletinCounter', { highestCounter });
203
+ }
204
+
205
+ return {
206
+ success: errors.length === 0 || newAnnouncementsCount > 0,
207
+ newAnnouncementsCount,
208
+ error: errors.length > 0 ? errors.join(', ') : undefined,
209
+ };
210
+ }
211
+
212
+ // No pending - fetch from API
213
+ const cursor = (await this.db.userProfile.get(this.session.userIdEncoded))
214
+ ?.lastBulletinCounter;
215
+
216
+ const fetched = await this._fetchAnnouncements(cursor);
217
+ announcements = fetched.map(a => a.data);
218
+ fetchedCounters = fetched.map(a => a.counter);
219
+
220
+ let newAnnouncementsCount = 0;
221
+
222
+ for (const announcement of announcements) {
223
+ try {
224
+ const result = await this._processIncomingAnnouncement(announcement);
225
+
226
+ if (result.success && result.contactUserId) {
227
+ newAnnouncementsCount++;
228
+ log.info(`processed new announcement #${newAnnouncementsCount}`, {
229
+ contactUserId: result.contactUserId,
230
+ });
231
+ }
232
+
233
+ if (result.error) errors.push(result.error);
234
+ } catch (error) {
235
+ errors.push(error instanceof Error ? error.message : 'Unknown error');
236
+ }
237
+ }
238
+
239
+ if (fetchedCounters.length > 0) {
240
+ const highestCounter = fetchedCounters.reduce((a, b) =>
241
+ Number(a) > Number(b) ? a : b
242
+ );
243
+ await this.db.userProfile.update(this.session.userIdEncoded, {
244
+ lastBulletinCounter: highestCounter,
245
+ });
246
+ log.info('updated lastBulletinCounter', { highestCounter });
247
+ }
248
+
249
+ return {
250
+ success: errors.length === 0 || newAnnouncementsCount > 0,
251
+ newAnnouncementsCount,
252
+ error: errors.length > 0 ? errors.join(', ') : undefined,
253
+ };
254
+ } catch (error) {
255
+ log.error('unexpected error during fetch/process', error);
256
+ return {
257
+ success: false,
258
+ newAnnouncementsCount: 0,
259
+ error: error instanceof Error ? error.message : 'Unknown error',
260
+ };
261
+ } finally {
262
+ this.isProcessingAnnouncements = false;
263
+ }
264
+ }
265
+
266
+ async resendAnnouncements(failedDiscussions: Discussion[]): Promise<void> {
267
+ const log = logger.forMethod('resendAnnouncements');
268
+
269
+ if (!failedDiscussions.length) {
270
+ log.info('no failed discussions to resend');
271
+ return;
272
+ }
273
+
274
+ log.info(
275
+ `starting resend for ${failedDiscussions.length} failed discussions`
276
+ );
277
+
278
+ const sentDiscussions: Discussion[] = [];
279
+ const brokenDiscussions: number[] = [];
280
+
281
+ for (const discussion of failedDiscussions) {
282
+ const { ownerUserId, contactUserId } = discussion;
283
+
284
+ try {
285
+ const result = await this.sendAnnouncement(
286
+ discussion.initiationAnnouncement!
287
+ );
288
+
289
+ if (result.success) {
290
+ log.info('resent successfully', { ownerUserId, contactUserId });
291
+ sentDiscussions.push(discussion);
292
+ continue;
293
+ }
294
+
295
+ log.info('network send failed (retry)', { ownerUserId, contactUserId });
296
+
297
+ const ageMs = Date.now() - (discussion.updatedAt.getTime() ?? 0);
298
+ if (ageMs > this.config.announcements.brokenThresholdMs) {
299
+ log.info(
300
+ `marking as broken (too old: ${Math.round(ageMs / 60000)}min)`,
301
+ {
302
+ ownerUserId,
303
+ contactUserId,
304
+ }
305
+ );
306
+ brokenDiscussions.push(discussion.id!);
307
+ }
308
+ } catch (error) {
309
+ log.error('exception during resend', {
310
+ error: error instanceof Error ? error.message : 'Unknown error',
311
+ ownerUserId,
312
+ contactUserId,
313
+ });
314
+ }
315
+ }
316
+
317
+ if (sentDiscussions.length > 0 || brokenDiscussions.length > 0) {
318
+ await this.db.transaction('rw', this.db.discussions, async () => {
319
+ const now = new Date();
320
+
321
+ if (sentDiscussions.length > 0) {
322
+ await Promise.all(
323
+ sentDiscussions.map(async discussion => {
324
+ const status = this.session.peerSessionStatus(
325
+ decodeUserId(discussion.contactUserId)
326
+ );
327
+ const statusStr = sessionStatusToString(status);
328
+
329
+ if (
330
+ status !== SessionStatus.Active &&
331
+ status !== SessionStatus.SelfRequested
332
+ ) {
333
+ log.info('skipping DB update - session not ready', {
334
+ contactUserId: discussion.contactUserId,
335
+ status: statusStr,
336
+ });
337
+ return;
338
+ }
339
+
340
+ const newStatus =
341
+ status === SessionStatus.Active
342
+ ? DiscussionStatus.ACTIVE
343
+ : DiscussionStatus.PENDING;
344
+
345
+ await this.db.discussions.update(discussion.id!, {
346
+ status: newStatus,
347
+ updatedAt: now,
348
+ });
349
+
350
+ log.info('updated discussion status in DB', {
351
+ contactUserId: discussion.contactUserId,
352
+ newStatus,
353
+ });
354
+
355
+ // Emit status change event
356
+ const updatedDiscussion = await this.db.discussions.get(
357
+ discussion.id!
358
+ );
359
+ if (updatedDiscussion) {
360
+ this.events.onDiscussionStatusChanged?.(updatedDiscussion);
361
+ }
362
+ })
363
+ );
364
+ }
365
+
366
+ if (brokenDiscussions.length > 0) {
367
+ // Per spec: announcement failures should trigger session renewal, not BROKEN status
368
+ // Clear the failed announcement and trigger renewal
369
+ log.info(
370
+ `${brokenDiscussions.length} announcements timed out, triggering renewal`
371
+ );
372
+ await Promise.all(
373
+ brokenDiscussions.map(async id => {
374
+ await this.db.discussions.update(id, {
375
+ initiationAnnouncement: undefined,
376
+ updatedAt: now,
377
+ });
378
+
379
+ // Emit renewal needed event
380
+ const discussion = await this.db.discussions.get(id);
381
+ if (discussion) {
382
+ this.events.onSessionRenewalNeeded?.(discussion.contactUserId);
383
+ }
384
+ })
385
+ );
386
+ }
387
+ });
388
+ }
389
+
390
+ log.info('resend completed', {
391
+ sent: sentDiscussions.length,
392
+ broken: brokenDiscussions.length,
393
+ });
394
+ }
395
+
396
+ private async _fetchAnnouncements(
397
+ cursor?: string,
398
+ limit?: number
399
+ ): Promise<BulletinItem[]> {
400
+ const fetchLimit = limit ?? this.config.announcements.fetchLimit;
401
+ const log = logger.forMethod('_fetchAnnouncements');
402
+
403
+ try {
404
+ const items = await this.messageProtocol.fetchAnnouncements(
405
+ fetchLimit,
406
+ cursor
407
+ );
408
+ return items;
409
+ } catch (error) {
410
+ log.error('network fetch failed', error);
411
+ return [];
412
+ }
413
+ }
414
+
415
+ private async _generateTemporaryContactName(
416
+ ownerUserId: string
417
+ ): Promise<string> {
418
+ const newRequestContacts = await this.db.contacts
419
+ .where('ownerUserId')
420
+ .equals(ownerUserId)
421
+ .filter(contact => contact.name.startsWith('New Request'))
422
+ .toArray();
423
+
424
+ const numbers = newRequestContacts
425
+ .map(contact => {
426
+ const match = contact.name.match(/^New Request (\d+)$/);
427
+ return match ? parseInt(match[1], 10) : 0;
428
+ })
429
+ .filter(number => number > 0);
430
+
431
+ const next = numbers.length ? Math.max(...numbers) + 1 : 1;
432
+ return `New Request ${next}`;
433
+ }
434
+
435
+ private async _processIncomingAnnouncement(
436
+ announcementData: Uint8Array
437
+ ): Promise<{
438
+ success: boolean;
439
+ discussionId?: number;
440
+ contactUserId?: string;
441
+ error?: string;
442
+ }> {
443
+ const log = logger.forMethod('_processIncomingAnnouncement');
444
+
445
+ const result =
446
+ await this.session.feedIncomingAnnouncement(announcementData);
447
+
448
+ if (!result) {
449
+ return { success: true };
450
+ }
451
+
452
+ log.info('announcement intended for us — decrypting');
453
+
454
+ let rawMessage: string | undefined;
455
+ if (result.user_data?.length > 0) {
456
+ try {
457
+ rawMessage = new TextDecoder().decode(result.user_data);
458
+ } catch (error) {
459
+ log.error('failed to decode user data', error);
460
+ }
461
+ }
462
+
463
+ // Parse announcement message format:
464
+ // - JSON format: {"u":"username","m":"message"} (current)
465
+ // - Legacy colon format: "username:message" (backwards compat)
466
+ // - Plain text: "message" (oldest format)
467
+ // The username is used as the initial contact name if present.
468
+ // TODO: Remove legacy colon and plain text format support once all clients are updated
469
+ let extractedUsername: string | undefined;
470
+ let announcementMessage: string | undefined;
471
+
472
+ if (rawMessage) {
473
+ // Try JSON format first (starts with '{')
474
+ if (rawMessage.startsWith('{')) {
475
+ try {
476
+ const parsed = JSON.parse(rawMessage) as { u?: string; m?: string };
477
+ extractedUsername = parsed.u?.trim() || undefined;
478
+ announcementMessage = parsed.m?.trim() || undefined;
479
+ } catch {
480
+ // Invalid JSON, treat as plain text
481
+ announcementMessage = rawMessage;
482
+ }
483
+ } else {
484
+ // Legacy format: check for colon separator
485
+ const colonIndex = rawMessage.indexOf(':');
486
+ if (colonIndex !== -1) {
487
+ extractedUsername =
488
+ rawMessage.slice(0, colonIndex).trim() || undefined;
489
+ announcementMessage =
490
+ rawMessage.slice(colonIndex + 1).trim() || undefined;
491
+ } else {
492
+ // Plain text (oldest format)
493
+ announcementMessage = rawMessage;
494
+ }
495
+ }
496
+ }
497
+
498
+ const announcerPkeys = result.announcer_public_keys;
499
+ const contactUserIdRaw = announcerPkeys.derive_id();
500
+ const contactUserId = encodeUserId(contactUserIdRaw);
501
+
502
+ const sessionStatus = this.session.peerSessionStatus(contactUserIdRaw);
503
+ // Log clearly for debugging
504
+ console.log(
505
+ `[Announcement] Received from ${contactUserId.slice(0, 12)}... -> session status: ${sessionStatusToString(sessionStatus)}`
506
+ );
507
+ log.info('session updated', {
508
+ contactUserId,
509
+ status: sessionStatusToString(sessionStatus),
510
+ });
511
+
512
+ let contact = await this.db.getContactByOwnerAndUserId(
513
+ this.session.userIdEncoded,
514
+ contactUserId
515
+ );
516
+ const isNewContact = !contact;
517
+
518
+ if (isNewContact) {
519
+ // Use extracted username if present, otherwise generate temporary name
520
+ const name =
521
+ extractedUsername ||
522
+ (await this._generateTemporaryContactName(this.session.userIdEncoded));
523
+ await this.db.contacts.add({
524
+ ownerUserId: this.session.userIdEncoded,
525
+ userId: contactUserId,
526
+ name,
527
+ publicKeys: announcerPkeys.to_bytes(),
528
+ avatar: undefined,
529
+ isOnline: false,
530
+ lastSeen: new Date(),
531
+ createdAt: new Date(),
532
+ });
533
+
534
+ contact = await this.db.getContactByOwnerAndUserId(
535
+ this.session.userIdEncoded,
536
+ contactUserId
537
+ );
538
+ log.info('created new contact', { contactUserId, name });
539
+ }
540
+
541
+ if (!contact) {
542
+ log.error('contact lookup failed after creation');
543
+ throw new Error('Could not find or create contact');
544
+ }
545
+
546
+ const { discussionId } = await this._handleReceivedDiscussion(
547
+ this.session.userIdEncoded,
548
+ contactUserId,
549
+ announcementMessage
550
+ );
551
+
552
+ // Emit event for new discussion request
553
+ if (this.events.onDiscussionRequest) {
554
+ const discussion = await this.db.discussions.get(discussionId);
555
+ if (discussion && contact) {
556
+ this.events.onDiscussionRequest(discussion, contact);
557
+ }
558
+ }
559
+
560
+ // Auto-accept ONLY for existing contacts (session recovery scenario).
561
+ // For NEW contacts, the user must manually accept the discussion request.
562
+ // This completes the handshake by sending our announcement back.
563
+ if (sessionStatus === SessionStatus.PeerRequested && !isNewContact) {
564
+ log.info(
565
+ 'session is PeerRequested for existing contact, triggering auto-accept',
566
+ { contactUserId }
567
+ );
568
+ this.events.onSessionAcceptNeeded?.(contactUserId);
569
+ } else if (sessionStatus === SessionStatus.PeerRequested && isNewContact) {
570
+ log.info(
571
+ 'session is PeerRequested for NEW contact, waiting for manual accept',
572
+ { contactUserId }
573
+ );
574
+ }
575
+
576
+ // When session becomes Active after peer accepts our announcement,
577
+ // trigger processing of WAITING_SESSION messages.
578
+ // This happens when we initiated (SelfRequested) and peer accepted.
579
+ if (sessionStatus === SessionStatus.Active) {
580
+ log.info(
581
+ 'session is now Active, triggering WAITING_SESSION message processing',
582
+ { contactUserId }
583
+ );
584
+ this.events.onSessionBecameActive?.(contactUserId);
585
+ }
586
+
587
+ return {
588
+ success: true,
589
+ discussionId,
590
+ contactUserId,
591
+ };
592
+ }
593
+
594
+ private async _handleReceivedDiscussion(
595
+ ownerUserId: string,
596
+ contactUserId: string,
597
+ announcementMessage?: string
598
+ ): Promise<{ discussionId: number }> {
599
+ const log = logger.forMethod('handleReceivedDiscussion');
600
+
601
+ const discussionId = await this.db.transaction(
602
+ 'rw',
603
+ this.db.discussions,
604
+ async () => {
605
+ const existing = await this.db.getDiscussionByOwnerAndContact(
606
+ ownerUserId,
607
+ contactUserId
608
+ );
609
+
610
+ if (existing) {
611
+ const updateData: Partial<Discussion> = { updatedAt: new Date() };
612
+ if (announcementMessage)
613
+ updateData.announcementMessage = announcementMessage;
614
+
615
+ if (
616
+ existing.status === DiscussionStatus.PENDING &&
617
+ existing.direction === DiscussionDirection.INITIATED
618
+ ) {
619
+ updateData.status = DiscussionStatus.ACTIVE;
620
+ log.info('transitioning to ACTIVE', {
621
+ discussionId: existing.id,
622
+ contactUserId,
623
+ });
624
+ } else {
625
+ log.info('updating existing discussion', {
626
+ discussionId: existing.id,
627
+ status: existing.status,
628
+ direction: existing.direction,
629
+ });
630
+ }
631
+
632
+ await this.db.discussions.update(existing.id!, updateData);
633
+ return existing.id!;
634
+ }
635
+
636
+ log.info('creating new RECEIVED/PENDING discussion', { contactUserId });
637
+ return await this.db.discussions.add({
638
+ ownerUserId,
639
+ contactUserId,
640
+ direction: DiscussionDirection.RECEIVED,
641
+ status: DiscussionStatus.PENDING,
642
+ nextSeeker: undefined,
643
+ announcementMessage,
644
+ unreadCount: 0,
645
+ createdAt: new Date(),
646
+ updatedAt: new Date(),
647
+ });
648
+ }
649
+ );
650
+
651
+ return { discussionId };
652
+ }
653
+ }