@massalabs/gossip-sdk 0.0.2-dev.20260128094509 → 0.0.2-dev.20260128160824

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 (148) hide show
  1. package/dist/api/messageProtocol/index.d.ts +19 -0
  2. package/dist/api/messageProtocol/index.js +26 -0
  3. package/dist/api/messageProtocol/mock.d.ts +12 -0
  4. package/{src/api/messageProtocol/mock.ts → dist/api/messageProtocol/mock.js} +2 -3
  5. package/dist/api/messageProtocol/rest.d.ts +22 -0
  6. package/dist/api/messageProtocol/rest.js +161 -0
  7. package/dist/api/messageProtocol/types.d.ts +61 -0
  8. package/dist/api/messageProtocol/types.js +6 -0
  9. package/dist/assets/generated/wasm/README.md +281 -0
  10. package/dist/assets/generated/wasm/gossip_wasm.d.ts +638 -0
  11. package/dist/assets/generated/wasm/gossip_wasm.js +1557 -0
  12. package/dist/assets/generated/wasm/gossip_wasm_bg.wasm +0 -0
  13. package/dist/assets/generated/wasm/gossip_wasm_bg.wasm.d.ts +164 -0
  14. package/dist/assets/generated/wasm/package.json +15 -0
  15. package/dist/assets/generated/wasm-node/README.md +281 -0
  16. package/dist/assets/generated/wasm-node/gossip_wasm.d.ts +443 -0
  17. package/dist/assets/generated/wasm-node/gossip_wasm.js +1488 -0
  18. package/dist/assets/generated/wasm-node/gossip_wasm_bg.wasm +0 -0
  19. package/dist/assets/generated/wasm-node/gossip_wasm_bg.wasm.d.ts +164 -0
  20. package/dist/assets/generated/wasm-node/package.json +11 -0
  21. package/dist/config/protocol.d.ts +36 -0
  22. package/dist/config/protocol.js +77 -0
  23. package/dist/config/sdk.d.ts +82 -0
  24. package/dist/config/sdk.js +55 -0
  25. package/{src/contacts.ts → dist/contacts.d.ts} +11 -95
  26. package/dist/contacts.js +166 -0
  27. package/dist/core/SdkEventEmitter.d.ts +36 -0
  28. package/dist/core/SdkEventEmitter.js +59 -0
  29. package/dist/core/SdkPolling.d.ts +35 -0
  30. package/dist/core/SdkPolling.js +100 -0
  31. package/{src/core/index.ts → dist/core/index.d.ts} +0 -2
  32. package/dist/core/index.js +5 -0
  33. package/dist/crypto/bip39.d.ts +34 -0
  34. package/dist/crypto/bip39.js +62 -0
  35. package/dist/crypto/encryption.d.ts +37 -0
  36. package/dist/crypto/encryption.js +46 -0
  37. package/dist/db.d.ts +190 -0
  38. package/dist/db.js +311 -0
  39. package/dist/gossipSdk.d.ts +274 -0
  40. package/dist/gossipSdk.js +690 -0
  41. package/dist/index.d.ts +59 -0
  42. package/dist/index.js +61 -0
  43. package/dist/services/announcement.d.ts +43 -0
  44. package/dist/services/announcement.js +491 -0
  45. package/dist/services/auth.d.ts +37 -0
  46. package/dist/services/auth.js +76 -0
  47. package/dist/services/discussion.d.ts +63 -0
  48. package/dist/services/discussion.js +297 -0
  49. package/dist/services/message.d.ts +74 -0
  50. package/dist/services/message.js +826 -0
  51. package/dist/services/refresh.d.ts +41 -0
  52. package/dist/services/refresh.js +205 -0
  53. package/{src/sw.ts → dist/sw.d.ts} +1 -8
  54. package/dist/sw.js +10 -0
  55. package/dist/types/events.d.ts +80 -0
  56. package/dist/types/events.js +7 -0
  57. package/dist/types.d.ts +32 -0
  58. package/dist/types.js +7 -0
  59. package/dist/utils/base64.d.ts +10 -0
  60. package/dist/utils/base64.js +30 -0
  61. package/dist/utils/contacts.d.ts +42 -0
  62. package/dist/utils/contacts.js +113 -0
  63. package/dist/utils/discussions.d.ts +24 -0
  64. package/dist/utils/discussions.js +38 -0
  65. package/dist/utils/logs.d.ts +19 -0
  66. package/dist/utils/logs.js +89 -0
  67. package/dist/utils/messageSerialization.d.ts +64 -0
  68. package/dist/utils/messageSerialization.js +184 -0
  69. package/dist/utils/queue.d.ts +50 -0
  70. package/dist/utils/queue.js +110 -0
  71. package/dist/utils/type.d.ts +10 -0
  72. package/dist/utils/type.js +4 -0
  73. package/dist/utils/userId.d.ts +40 -0
  74. package/dist/utils/userId.js +90 -0
  75. package/dist/utils/validation.d.ts +50 -0
  76. package/dist/utils/validation.js +112 -0
  77. package/dist/utils.d.ts +30 -0
  78. package/{src/utils.ts → dist/utils.js} +9 -19
  79. package/dist/wasm/encryption.d.ts +56 -0
  80. package/{src/wasm/encryption.ts → dist/wasm/encryption.js} +22 -51
  81. package/dist/wasm/index.d.ts +10 -0
  82. package/{src/wasm/index.ts → dist/wasm/index.js} +1 -8
  83. package/dist/wasm/loader.d.ts +22 -0
  84. package/dist/wasm/loader.js +78 -0
  85. package/dist/wasm/session.d.ts +85 -0
  86. package/dist/wasm/session.js +226 -0
  87. package/dist/wasm/userKeys.d.ts +17 -0
  88. package/{src/wasm/userKeys.ts → dist/wasm/userKeys.js} +6 -13
  89. package/package.json +15 -2
  90. package/src/api/messageProtocol/index.ts +0 -53
  91. package/src/api/messageProtocol/rest.ts +0 -209
  92. package/src/api/messageProtocol/types.ts +0 -70
  93. package/src/config/protocol.ts +0 -97
  94. package/src/config/sdk.ts +0 -131
  95. package/src/core/SdkEventEmitter.ts +0 -91
  96. package/src/core/SdkPolling.ts +0 -134
  97. package/src/crypto/bip39.ts +0 -84
  98. package/src/crypto/encryption.ts +0 -77
  99. package/src/db.ts +0 -465
  100. package/src/gossipSdk.ts +0 -994
  101. package/src/index.ts +0 -211
  102. package/src/services/announcement.ts +0 -653
  103. package/src/services/auth.ts +0 -95
  104. package/src/services/discussion.ts +0 -380
  105. package/src/services/message.ts +0 -1055
  106. package/src/services/refresh.ts +0 -234
  107. package/src/types/events.ts +0 -108
  108. package/src/types.ts +0 -70
  109. package/src/utils/base64.ts +0 -39
  110. package/src/utils/contacts.ts +0 -161
  111. package/src/utils/discussions.ts +0 -55
  112. package/src/utils/logs.ts +0 -86
  113. package/src/utils/messageSerialization.ts +0 -257
  114. package/src/utils/queue.ts +0 -106
  115. package/src/utils/type.ts +0 -7
  116. package/src/utils/userId.ts +0 -114
  117. package/src/utils/validation.ts +0 -144
  118. package/src/wasm/loader.ts +0 -123
  119. package/src/wasm/session.ts +0 -276
  120. package/test/config/protocol.spec.ts +0 -31
  121. package/test/config/sdk.spec.ts +0 -163
  122. package/test/db/helpers.spec.ts +0 -142
  123. package/test/db/operations.spec.ts +0 -128
  124. package/test/db/states.spec.ts +0 -535
  125. package/test/integration/discussion-flow.spec.ts +0 -422
  126. package/test/integration/messaging-flow.spec.ts +0 -708
  127. package/test/integration/sdk-lifecycle.spec.ts +0 -325
  128. package/test/mocks/index.ts +0 -9
  129. package/test/mocks/mockMessageProtocol.ts +0 -100
  130. package/test/services/auth.spec.ts +0 -311
  131. package/test/services/discussion.spec.ts +0 -279
  132. package/test/services/message-deduplication.spec.ts +0 -299
  133. package/test/services/message-startup.spec.ts +0 -331
  134. package/test/services/message.spec.ts +0 -817
  135. package/test/services/refresh.spec.ts +0 -199
  136. package/test/services/session-status.spec.ts +0 -349
  137. package/test/session/wasm.spec.ts +0 -227
  138. package/test/setup.ts +0 -52
  139. package/test/utils/contacts.spec.ts +0 -156
  140. package/test/utils/discussions.spec.ts +0 -66
  141. package/test/utils/queue.spec.ts +0 -52
  142. package/test/utils/serialization.spec.ts +0 -120
  143. package/test/utils/userId.spec.ts +0 -120
  144. package/test/utils/validation.spec.ts +0 -223
  145. package/test/utils.ts +0 -212
  146. package/tsconfig.json +0 -26
  147. package/tsconfig.tsbuildinfo +0 -1
  148. package/vitest.config.ts +0 -28
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Gossip SDK
3
+ *
4
+ * Main entry point for the Gossip SDK.
5
+ * Works in both browser and Node.js environments.
6
+ *
7
+ * WASM is loaded via the #wasm subpath import which resolves conditionally:
8
+ * - Browser: web target (uses import.meta.url)
9
+ * - Node: nodejs target (uses fs, no import.meta.url)
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { gossipSdk } from '@massalabs/gossip-sdk';
14
+ *
15
+ * await gossipSdk.init({ db, protocolBaseUrl: 'https://api.example.com' });
16
+ * await gossipSdk.openSession({ mnemonic: '...' });
17
+ * await gossipSdk.messages.send(contactId, 'Hello!');
18
+ * ```
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+ export { gossipSdk, GossipSdkImpl } from './gossipSdk';
23
+ export type { GossipSdkInitOptions, OpenSessionOptions, SdkEventType, SdkEventHandlers, } from './gossipSdk';
24
+ export type { GossipSdkEvents } from './types/events';
25
+ export { AuthService } from './services/auth';
26
+ export type { PublicKeyResult } from './services/auth';
27
+ export { getPublicKeyErrorMessage, PUBLIC_KEY_NOT_FOUND_ERROR, PUBLIC_KEY_NOT_FOUND_MESSAGE, FAILED_TO_FETCH_ERROR, FAILED_TO_FETCH_MESSAGE, FAILED_TO_RETRIEVE_CONTACT_PUBLIC_KEY_ERROR, } from './services/auth';
28
+ export { AnnouncementService, EstablishSessionError, } from './services/announcement';
29
+ export type { AnnouncementReceptionResult } from './services/announcement';
30
+ export { MessageService } from './services/message';
31
+ export type { MessageResult, SendMessageResult } from './services/message';
32
+ export { DiscussionService } from './services/discussion';
33
+ export { RefreshService } from './services/refresh';
34
+ export { getContacts, getContact, addContact, updateContactName, deleteContact, } from './contacts';
35
+ export type { UpdateContactNameResult, DeleteContactResult, } from './utils/contacts';
36
+ export { updateDiscussionName } from './utils/discussions';
37
+ export type { UpdateDiscussionNameResult } from './utils/discussions';
38
+ export * from './types';
39
+ export { createMessageProtocol, restMessageProtocol, RestMessageProtocol, MessageProtocol, } from './api/messageProtocol';
40
+ export type { IMessageProtocol, EncryptedMessage, MessageProtocolResponse, BulletinItem, } from './api/messageProtocol';
41
+ export { setProtocolBaseUrl, resetProtocolBaseUrl, MessageProtocolType, protocolConfig, } from './config/protocol';
42
+ export type { ProtocolConfig } from './config/protocol';
43
+ export { defaultSdkConfig, mergeConfig } from './config/sdk';
44
+ export type { SdkConfig, PollingConfig, MessagesConfig, AnnouncementsConfig, DeepPartial, } from './config/sdk';
45
+ export { setDb, getDb, db, GossipDatabase } from './db';
46
+ export { SessionModule, sessionStatusToString } from './wasm/session';
47
+ export { initializeWasm, ensureWasmInitialized, startWasmInitialization, } from './wasm/loader';
48
+ export { EncryptionKey, Nonce, generateEncryptionKey, generateEncryptionKeyFromSeed, encryptionKeyFromBytes, generateNonce, nonceFromBytes, encryptAead, decryptAead, } from './wasm/encryption';
49
+ export { generateUserKeys, UserKeys } from './wasm/userKeys';
50
+ export { encodeUserId, decodeUserId, isValidUserId, formatUserId, generate as generateUserId, } from './utils/userId';
51
+ export { validateUsernameFormat, validatePassword, validateUserIdFormat, validateUsernameAvailability, validateUsernameFormatAndAvailability, } from './utils/validation';
52
+ export type { ValidationResult } from './utils/validation';
53
+ export { encodeToBase64, decodeFromBase64, encodeToBase64Url, decodeFromBase64Url, } from './utils/base64';
54
+ export type { Result } from './utils/type';
55
+ export { MESSAGE_TYPE_KEEP_ALIVE, serializeKeepAliveMessage, serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, deserializeMessage, } from './utils/messageSerialization';
56
+ export type { DeserializedMessage } from './utils/messageSerialization';
57
+ export { generateMnemonic, validateMnemonic, mnemonicToSeed, accountFromMnemonic, PRIVATE_KEY_VERSION, } from './crypto/bip39';
58
+ export { encrypt, decrypt, deriveKey } from './crypto/encryption';
59
+ export { UserPublicKeys, UserSecretKeys, SessionStatus, SessionConfig, SessionManagerWrapper, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, generate_user_keys, } from '#wasm';
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Gossip SDK
3
+ *
4
+ * Main entry point for the Gossip SDK.
5
+ * Works in both browser and Node.js environments.
6
+ *
7
+ * WASM is loaded via the #wasm subpath import which resolves conditionally:
8
+ * - Browser: web target (uses import.meta.url)
9
+ * - Node: nodejs target (uses fs, no import.meta.url)
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { gossipSdk } from '@massalabs/gossip-sdk';
14
+ *
15
+ * await gossipSdk.init({ db, protocolBaseUrl: 'https://api.example.com' });
16
+ * await gossipSdk.openSession({ mnemonic: '...' });
17
+ * await gossipSdk.messages.send(contactId, 'Hello!');
18
+ * ```
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // SDK Singleton - Primary API
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ export { gossipSdk, GossipSdkImpl } from './gossipSdk';
26
+ // Services
27
+ export { AuthService } from './services/auth';
28
+ export { getPublicKeyErrorMessage, PUBLIC_KEY_NOT_FOUND_ERROR, PUBLIC_KEY_NOT_FOUND_MESSAGE, FAILED_TO_FETCH_ERROR, FAILED_TO_FETCH_MESSAGE, FAILED_TO_RETRIEVE_CONTACT_PUBLIC_KEY_ERROR, } from './services/auth';
29
+ export { AnnouncementService, EstablishSessionError, } from './services/announcement';
30
+ export { MessageService } from './services/message';
31
+ export { DiscussionService } from './services/discussion';
32
+ export { RefreshService } from './services/refresh';
33
+ // Contact Management
34
+ export { getContacts, getContact, addContact, updateContactName, deleteContact, } from './contacts';
35
+ // Discussion utilities
36
+ export { updateDiscussionName } from './utils/discussions';
37
+ // Types
38
+ export * from './types';
39
+ // Message Protocol
40
+ export { createMessageProtocol, restMessageProtocol, RestMessageProtocol, MessageProtocol, } from './api/messageProtocol';
41
+ // Config
42
+ export { setProtocolBaseUrl, resetProtocolBaseUrl, MessageProtocolType, protocolConfig, } from './config/protocol';
43
+ export { defaultSdkConfig, mergeConfig } from './config/sdk';
44
+ // Database
45
+ export { setDb, getDb, db, GossipDatabase } from './db';
46
+ // WASM utilities
47
+ export { SessionModule, sessionStatusToString } from './wasm/session';
48
+ export { initializeWasm, ensureWasmInitialized, startWasmInitialization, } from './wasm/loader';
49
+ export { EncryptionKey, Nonce, generateEncryptionKey, generateEncryptionKeyFromSeed, encryptionKeyFromBytes, generateNonce, nonceFromBytes, encryptAead, decryptAead, } from './wasm/encryption';
50
+ export { generateUserKeys, UserKeys } from './wasm/userKeys';
51
+ // Utility functions
52
+ export { encodeUserId, decodeUserId, isValidUserId, formatUserId, generate as generateUserId, } from './utils/userId';
53
+ export { validateUsernameFormat, validatePassword, validateUserIdFormat, validateUsernameAvailability, validateUsernameFormatAndAvailability, } from './utils/validation';
54
+ export { encodeToBase64, decodeFromBase64, encodeToBase64Url, decodeFromBase64Url, } from './utils/base64';
55
+ // Message serialization
56
+ export { MESSAGE_TYPE_KEEP_ALIVE, serializeKeepAliveMessage, serializeRegularMessage, serializeReplyMessage, serializeForwardMessage, deserializeMessage, } from './utils/messageSerialization';
57
+ // Crypto utilities
58
+ export { generateMnemonic, validateMnemonic, mnemonicToSeed, accountFromMnemonic, PRIVATE_KEY_VERSION, } from './crypto/bip39';
59
+ export { encrypt, decrypt, deriveKey } from './crypto/encryption';
60
+ // WASM types re-exported for convenience
61
+ export { UserPublicKeys, UserSecretKeys, SessionStatus, SessionConfig, SessionManagerWrapper, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, generate_user_keys, } from '#wasm';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Announcement Service
3
+ *
4
+ * Handles broadcasting and processing of session announcements.
5
+ */
6
+ import { type Discussion, type GossipDatabase } from '../db';
7
+ import { IMessageProtocol } from '../api/messageProtocol';
8
+ import { UserPublicKeys } from '#wasm';
9
+ import { SessionModule } from '../wasm/session';
10
+ import { GossipSdkEvents } from '../types/events';
11
+ import { SdkConfig } from '../config/sdk';
12
+ export interface AnnouncementReceptionResult {
13
+ success: boolean;
14
+ newAnnouncementsCount: number;
15
+ error?: string;
16
+ }
17
+ export declare const EstablishSessionError = "Session manager failed to establish outgoing session";
18
+ export declare class AnnouncementService {
19
+ private db;
20
+ private messageProtocol;
21
+ private session;
22
+ private isProcessingAnnouncements;
23
+ private events;
24
+ private config;
25
+ constructor(db: GossipDatabase, messageProtocol: IMessageProtocol, session: SessionModule, events?: GossipSdkEvents, config?: SdkConfig);
26
+ setMessageProtocol(messageProtocol: IMessageProtocol): void;
27
+ sendAnnouncement(announcement: Uint8Array): Promise<{
28
+ success: boolean;
29
+ counter?: string;
30
+ error?: string;
31
+ }>;
32
+ establishSession(contactPublicKeys: UserPublicKeys, userData?: Uint8Array): Promise<{
33
+ success: boolean;
34
+ error?: string;
35
+ announcement: Uint8Array;
36
+ }>;
37
+ fetchAndProcessAnnouncements(): Promise<AnnouncementReceptionResult>;
38
+ resendAnnouncements(failedDiscussions: Discussion[]): Promise<void>;
39
+ private _fetchAnnouncements;
40
+ private _generateTemporaryContactName;
41
+ private _processIncomingAnnouncement;
42
+ private _handleReceivedDiscussion;
43
+ }
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Announcement Service
3
+ *
4
+ * Handles broadcasting and processing of session announcements.
5
+ */
6
+ import { DiscussionStatus, DiscussionDirection, } from '../db';
7
+ import { decodeUserId, encodeUserId } from '../utils/userId';
8
+ import { SessionStatus } from '#wasm';
9
+ import { sessionStatusToString } from '../wasm/session';
10
+ import { Logger } from '../utils/logs';
11
+ import { defaultSdkConfig } from '../config/sdk';
12
+ const logger = new Logger('AnnouncementService');
13
+ export const EstablishSessionError = 'Session manager failed to establish outgoing session';
14
+ export class AnnouncementService {
15
+ constructor(db, messageProtocol, session, events = {}, config = defaultSdkConfig) {
16
+ Object.defineProperty(this, "db", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "messageProtocol", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "session", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
34
+ Object.defineProperty(this, "isProcessingAnnouncements", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: false
39
+ });
40
+ Object.defineProperty(this, "events", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ Object.defineProperty(this, "config", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: void 0
51
+ });
52
+ this.db = db;
53
+ this.messageProtocol = messageProtocol;
54
+ this.session = session;
55
+ this.events = events;
56
+ this.config = config;
57
+ }
58
+ setMessageProtocol(messageProtocol) {
59
+ this.messageProtocol = messageProtocol;
60
+ }
61
+ async sendAnnouncement(announcement) {
62
+ const log = logger.forMethod('sendAnnouncement');
63
+ try {
64
+ const counter = await this.messageProtocol.sendAnnouncement(announcement);
65
+ log.info('broadcast successful', { counter });
66
+ return { success: true, counter };
67
+ }
68
+ catch (error) {
69
+ log.error('broadcast failed', error);
70
+ return {
71
+ success: false,
72
+ error: error instanceof Error ? error.message : 'Unknown error',
73
+ };
74
+ }
75
+ }
76
+ async establishSession(contactPublicKeys, userData) {
77
+ const log = logger.forMethod('establishSession');
78
+ const contactUserId = encodeUserId(contactPublicKeys.derive_id());
79
+ // CRITICAL: await to ensure session state is persisted before sending
80
+ const announcement = await this.session.establishOutgoingSession(contactPublicKeys, userData);
81
+ if (announcement.length === 0) {
82
+ log.error('empty announcement returned', { contactUserId });
83
+ return {
84
+ success: false,
85
+ error: EstablishSessionError,
86
+ announcement,
87
+ };
88
+ }
89
+ const result = await this.sendAnnouncement(announcement);
90
+ if (!result.success) {
91
+ log.error('failed to broadcast announcement', {
92
+ contactUserId,
93
+ error: result.error,
94
+ });
95
+ return {
96
+ success: false,
97
+ error: result.error,
98
+ announcement,
99
+ };
100
+ }
101
+ log.info('announcement sent successfully', { contactUserId });
102
+ return { success: true, announcement };
103
+ }
104
+ async fetchAndProcessAnnouncements() {
105
+ const log = logger.forMethod('fetchAndProcessAnnouncements');
106
+ if (this.isProcessingAnnouncements) {
107
+ log.info('fetch already in progress, skipping');
108
+ return { success: true, newAnnouncementsCount: 0 };
109
+ }
110
+ const errors = [];
111
+ let announcements = [];
112
+ let fetchedCounters = [];
113
+ this.isProcessingAnnouncements = true;
114
+ try {
115
+ const pending = await this.db.pendingAnnouncements.toArray();
116
+ const successfullyProcessedPendingIds = [];
117
+ if (pending.length > 0) {
118
+ log.info(`processing ${pending.length} pending announcements from IndexedDB`);
119
+ // Process pending announcements one by one, tracking successes
120
+ let newAnnouncementsCount = 0;
121
+ for (const item of pending) {
122
+ try {
123
+ const result = await this._processIncomingAnnouncement(item.announcement);
124
+ // Mark as successfully processed (even if announcement was for unknown peer)
125
+ // Only keep if processing threw an error
126
+ if (item.id !== undefined) {
127
+ successfullyProcessedPendingIds.push(item.id);
128
+ }
129
+ if (result.success && result.contactUserId) {
130
+ newAnnouncementsCount++;
131
+ log.info(`processed pending announcement #${newAnnouncementsCount}`, {
132
+ contactUserId: result.contactUserId,
133
+ });
134
+ }
135
+ if (item.counter)
136
+ fetchedCounters.push(item.counter);
137
+ if (result.error)
138
+ errors.push(result.error);
139
+ }
140
+ catch (error) {
141
+ // Don't mark as processed - will be retried next time
142
+ log.error('failed to process pending announcement, will retry', {
143
+ id: item.id,
144
+ error,
145
+ });
146
+ errors.push(error instanceof Error ? error.message : 'Unknown error');
147
+ }
148
+ }
149
+ // Delete only successfully processed pending announcements
150
+ if (successfullyProcessedPendingIds.length > 0) {
151
+ await this.db.pendingAnnouncements.bulkDelete(successfullyProcessedPendingIds);
152
+ log.info(`deleted ${successfullyProcessedPendingIds.length} processed pending announcements`);
153
+ }
154
+ if (fetchedCounters.length > 0) {
155
+ const highestCounter = fetchedCounters.reduce((a, b) => Number(a) > Number(b) ? a : b);
156
+ await this.db.userProfile.update(this.session.userIdEncoded, {
157
+ lastBulletinCounter: highestCounter,
158
+ });
159
+ log.info('updated lastBulletinCounter', { highestCounter });
160
+ }
161
+ return {
162
+ success: errors.length === 0 || newAnnouncementsCount > 0,
163
+ newAnnouncementsCount,
164
+ error: errors.length > 0 ? errors.join(', ') : undefined,
165
+ };
166
+ }
167
+ // No pending - fetch from API
168
+ const cursor = (await this.db.userProfile.get(this.session.userIdEncoded))
169
+ ?.lastBulletinCounter;
170
+ const fetched = await this._fetchAnnouncements(cursor);
171
+ announcements = fetched.map(a => a.data);
172
+ fetchedCounters = fetched.map(a => a.counter);
173
+ let newAnnouncementsCount = 0;
174
+ for (const announcement of announcements) {
175
+ try {
176
+ const result = await this._processIncomingAnnouncement(announcement);
177
+ if (result.success && result.contactUserId) {
178
+ newAnnouncementsCount++;
179
+ log.info(`processed new announcement #${newAnnouncementsCount}`, {
180
+ contactUserId: result.contactUserId,
181
+ });
182
+ }
183
+ if (result.error)
184
+ errors.push(result.error);
185
+ }
186
+ catch (error) {
187
+ errors.push(error instanceof Error ? error.message : 'Unknown error');
188
+ }
189
+ }
190
+ if (fetchedCounters.length > 0) {
191
+ const highestCounter = fetchedCounters.reduce((a, b) => Number(a) > Number(b) ? a : b);
192
+ await this.db.userProfile.update(this.session.userIdEncoded, {
193
+ lastBulletinCounter: highestCounter,
194
+ });
195
+ log.info('updated lastBulletinCounter', { highestCounter });
196
+ }
197
+ return {
198
+ success: errors.length === 0 || newAnnouncementsCount > 0,
199
+ newAnnouncementsCount,
200
+ error: errors.length > 0 ? errors.join(', ') : undefined,
201
+ };
202
+ }
203
+ catch (error) {
204
+ log.error('unexpected error during fetch/process', error);
205
+ return {
206
+ success: false,
207
+ newAnnouncementsCount: 0,
208
+ error: error instanceof Error ? error.message : 'Unknown error',
209
+ };
210
+ }
211
+ finally {
212
+ this.isProcessingAnnouncements = false;
213
+ }
214
+ }
215
+ async resendAnnouncements(failedDiscussions) {
216
+ const log = logger.forMethod('resendAnnouncements');
217
+ if (!failedDiscussions.length) {
218
+ log.info('no failed discussions to resend');
219
+ return;
220
+ }
221
+ log.info(`starting resend for ${failedDiscussions.length} failed discussions`);
222
+ const sentDiscussions = [];
223
+ const brokenDiscussions = [];
224
+ for (const discussion of failedDiscussions) {
225
+ const { ownerUserId, contactUserId } = discussion;
226
+ try {
227
+ const result = await this.sendAnnouncement(discussion.initiationAnnouncement);
228
+ if (result.success) {
229
+ log.info('resent successfully', { ownerUserId, contactUserId });
230
+ sentDiscussions.push(discussion);
231
+ continue;
232
+ }
233
+ log.info('network send failed (retry)', { ownerUserId, contactUserId });
234
+ const ageMs = Date.now() - (discussion.updatedAt.getTime() ?? 0);
235
+ if (ageMs > this.config.announcements.brokenThresholdMs) {
236
+ log.info(`marking as broken (too old: ${Math.round(ageMs / 60000)}min)`, {
237
+ ownerUserId,
238
+ contactUserId,
239
+ });
240
+ brokenDiscussions.push(discussion.id);
241
+ }
242
+ }
243
+ catch (error) {
244
+ log.error('exception during resend', {
245
+ error: error instanceof Error ? error.message : 'Unknown error',
246
+ ownerUserId,
247
+ contactUserId,
248
+ });
249
+ }
250
+ }
251
+ if (sentDiscussions.length > 0 || brokenDiscussions.length > 0) {
252
+ await this.db.transaction('rw', this.db.discussions, async () => {
253
+ const now = new Date();
254
+ if (sentDiscussions.length > 0) {
255
+ await Promise.all(sentDiscussions.map(async (discussion) => {
256
+ const status = this.session.peerSessionStatus(decodeUserId(discussion.contactUserId));
257
+ const statusStr = sessionStatusToString(status);
258
+ if (status !== SessionStatus.Active &&
259
+ status !== SessionStatus.SelfRequested) {
260
+ log.info('skipping DB update - session not ready', {
261
+ contactUserId: discussion.contactUserId,
262
+ status: statusStr,
263
+ });
264
+ return;
265
+ }
266
+ const newStatus = status === SessionStatus.Active
267
+ ? DiscussionStatus.ACTIVE
268
+ : DiscussionStatus.PENDING;
269
+ await this.db.discussions.update(discussion.id, {
270
+ status: newStatus,
271
+ updatedAt: now,
272
+ });
273
+ log.info('updated discussion status in DB', {
274
+ contactUserId: discussion.contactUserId,
275
+ newStatus,
276
+ });
277
+ // Emit status change event
278
+ const updatedDiscussion = await this.db.discussions.get(discussion.id);
279
+ if (updatedDiscussion) {
280
+ this.events.onDiscussionStatusChanged?.(updatedDiscussion);
281
+ }
282
+ }));
283
+ }
284
+ if (brokenDiscussions.length > 0) {
285
+ // Per spec: announcement failures should trigger session renewal, not BROKEN status
286
+ // Clear the failed announcement and trigger renewal
287
+ log.info(`${brokenDiscussions.length} announcements timed out, triggering renewal`);
288
+ await Promise.all(brokenDiscussions.map(async (id) => {
289
+ await this.db.discussions.update(id, {
290
+ initiationAnnouncement: undefined,
291
+ updatedAt: now,
292
+ });
293
+ // Emit renewal needed event
294
+ const discussion = await this.db.discussions.get(id);
295
+ if (discussion) {
296
+ this.events.onSessionRenewalNeeded?.(discussion.contactUserId);
297
+ }
298
+ }));
299
+ }
300
+ });
301
+ }
302
+ log.info('resend completed', {
303
+ sent: sentDiscussions.length,
304
+ broken: brokenDiscussions.length,
305
+ });
306
+ }
307
+ async _fetchAnnouncements(cursor, limit) {
308
+ const fetchLimit = limit ?? this.config.announcements.fetchLimit;
309
+ const log = logger.forMethod('_fetchAnnouncements');
310
+ try {
311
+ const items = await this.messageProtocol.fetchAnnouncements(fetchLimit, cursor);
312
+ return items;
313
+ }
314
+ catch (error) {
315
+ log.error('network fetch failed', error);
316
+ return [];
317
+ }
318
+ }
319
+ async _generateTemporaryContactName(ownerUserId) {
320
+ const newRequestContacts = await this.db.contacts
321
+ .where('ownerUserId')
322
+ .equals(ownerUserId)
323
+ .filter(contact => contact.name.startsWith('New Request'))
324
+ .toArray();
325
+ const numbers = newRequestContacts
326
+ .map(contact => {
327
+ const match = contact.name.match(/^New Request (\d+)$/);
328
+ return match ? parseInt(match[1], 10) : 0;
329
+ })
330
+ .filter(number => number > 0);
331
+ const next = numbers.length ? Math.max(...numbers) + 1 : 1;
332
+ return `New Request ${next}`;
333
+ }
334
+ async _processIncomingAnnouncement(announcementData) {
335
+ const log = logger.forMethod('_processIncomingAnnouncement');
336
+ const result = await this.session.feedIncomingAnnouncement(announcementData);
337
+ if (!result) {
338
+ return { success: true };
339
+ }
340
+ log.info('announcement intended for us — decrypting');
341
+ let rawMessage;
342
+ if (result.user_data?.length > 0) {
343
+ try {
344
+ rawMessage = new TextDecoder().decode(result.user_data);
345
+ }
346
+ catch (error) {
347
+ log.error('failed to decode user data', error);
348
+ }
349
+ }
350
+ // Parse announcement message format:
351
+ // - JSON format: {"u":"username","m":"message"} (current)
352
+ // - Legacy colon format: "username:message" (backwards compat)
353
+ // - Plain text: "message" (oldest format)
354
+ // The username is used as the initial contact name if present.
355
+ // TODO: Remove legacy colon and plain text format support once all clients are updated
356
+ let extractedUsername;
357
+ let announcementMessage;
358
+ if (rawMessage) {
359
+ // Try JSON format first (starts with '{')
360
+ if (rawMessage.startsWith('{')) {
361
+ try {
362
+ const parsed = JSON.parse(rawMessage);
363
+ extractedUsername = parsed.u?.trim() || undefined;
364
+ announcementMessage = parsed.m?.trim() || undefined;
365
+ }
366
+ catch {
367
+ // Invalid JSON, treat as plain text
368
+ announcementMessage = rawMessage;
369
+ }
370
+ }
371
+ else {
372
+ // Legacy format: check for colon separator
373
+ const colonIndex = rawMessage.indexOf(':');
374
+ if (colonIndex !== -1) {
375
+ extractedUsername =
376
+ rawMessage.slice(0, colonIndex).trim() || undefined;
377
+ announcementMessage =
378
+ rawMessage.slice(colonIndex + 1).trim() || undefined;
379
+ }
380
+ else {
381
+ // Plain text (oldest format)
382
+ announcementMessage = rawMessage;
383
+ }
384
+ }
385
+ }
386
+ const announcerPkeys = result.announcer_public_keys;
387
+ const contactUserIdRaw = announcerPkeys.derive_id();
388
+ const contactUserId = encodeUserId(contactUserIdRaw);
389
+ const sessionStatus = this.session.peerSessionStatus(contactUserIdRaw);
390
+ // Log clearly for debugging
391
+ console.log(`[Announcement] Received from ${contactUserId.slice(0, 12)}... -> session status: ${sessionStatusToString(sessionStatus)}`);
392
+ log.info('session updated', {
393
+ contactUserId,
394
+ status: sessionStatusToString(sessionStatus),
395
+ });
396
+ let contact = await this.db.getContactByOwnerAndUserId(this.session.userIdEncoded, contactUserId);
397
+ const isNewContact = !contact;
398
+ if (isNewContact) {
399
+ // Use extracted username if present, otherwise generate temporary name
400
+ const name = extractedUsername ||
401
+ (await this._generateTemporaryContactName(this.session.userIdEncoded));
402
+ await this.db.contacts.add({
403
+ ownerUserId: this.session.userIdEncoded,
404
+ userId: contactUserId,
405
+ name,
406
+ publicKeys: announcerPkeys.to_bytes(),
407
+ avatar: undefined,
408
+ isOnline: false,
409
+ lastSeen: new Date(),
410
+ createdAt: new Date(),
411
+ });
412
+ contact = await this.db.getContactByOwnerAndUserId(this.session.userIdEncoded, contactUserId);
413
+ log.info('created new contact', { contactUserId, name });
414
+ }
415
+ if (!contact) {
416
+ log.error('contact lookup failed after creation');
417
+ throw new Error('Could not find or create contact');
418
+ }
419
+ const { discussionId } = await this._handleReceivedDiscussion(this.session.userIdEncoded, contactUserId, announcementMessage);
420
+ // Emit event for new discussion request
421
+ if (this.events.onDiscussionRequest) {
422
+ const discussion = await this.db.discussions.get(discussionId);
423
+ if (discussion && contact) {
424
+ this.events.onDiscussionRequest(discussion, contact);
425
+ }
426
+ }
427
+ // Auto-accept ONLY for existing contacts (session recovery scenario).
428
+ // For NEW contacts, the user must manually accept the discussion request.
429
+ // This completes the handshake by sending our announcement back.
430
+ if (sessionStatus === SessionStatus.PeerRequested && !isNewContact) {
431
+ log.info('session is PeerRequested for existing contact, triggering auto-accept', { contactUserId });
432
+ this.events.onSessionAcceptNeeded?.(contactUserId);
433
+ }
434
+ else if (sessionStatus === SessionStatus.PeerRequested && isNewContact) {
435
+ log.info('session is PeerRequested for NEW contact, waiting for manual accept', { contactUserId });
436
+ }
437
+ // When session becomes Active after peer accepts our announcement,
438
+ // trigger processing of WAITING_SESSION messages.
439
+ // This happens when we initiated (SelfRequested) and peer accepted.
440
+ if (sessionStatus === SessionStatus.Active) {
441
+ log.info('session is now Active, triggering WAITING_SESSION message processing', { contactUserId });
442
+ this.events.onSessionBecameActive?.(contactUserId);
443
+ }
444
+ return {
445
+ success: true,
446
+ discussionId,
447
+ contactUserId,
448
+ };
449
+ }
450
+ async _handleReceivedDiscussion(ownerUserId, contactUserId, announcementMessage) {
451
+ const log = logger.forMethod('handleReceivedDiscussion');
452
+ const discussionId = await this.db.transaction('rw', this.db.discussions, async () => {
453
+ const existing = await this.db.getDiscussionByOwnerAndContact(ownerUserId, contactUserId);
454
+ if (existing) {
455
+ const updateData = { updatedAt: new Date() };
456
+ if (announcementMessage)
457
+ updateData.announcementMessage = announcementMessage;
458
+ if (existing.status === DiscussionStatus.PENDING &&
459
+ existing.direction === DiscussionDirection.INITIATED) {
460
+ updateData.status = DiscussionStatus.ACTIVE;
461
+ log.info('transitioning to ACTIVE', {
462
+ discussionId: existing.id,
463
+ contactUserId,
464
+ });
465
+ }
466
+ else {
467
+ log.info('updating existing discussion', {
468
+ discussionId: existing.id,
469
+ status: existing.status,
470
+ direction: existing.direction,
471
+ });
472
+ }
473
+ await this.db.discussions.update(existing.id, updateData);
474
+ return existing.id;
475
+ }
476
+ log.info('creating new RECEIVED/PENDING discussion', { contactUserId });
477
+ return await this.db.discussions.add({
478
+ ownerUserId,
479
+ contactUserId,
480
+ direction: DiscussionDirection.RECEIVED,
481
+ status: DiscussionStatus.PENDING,
482
+ nextSeeker: undefined,
483
+ announcementMessage,
484
+ unreadCount: 0,
485
+ createdAt: new Date(),
486
+ updatedAt: new Date(),
487
+ });
488
+ });
489
+ return { discussionId };
490
+ }
491
+ }