@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.
- 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 +638 -0
- package/dist/assets/generated/wasm/gossip_wasm.js +1557 -0
- package/dist/assets/generated/wasm/gossip_wasm_bg.wasm +0 -0
- package/dist/assets/generated/wasm/gossip_wasm_bg.wasm.d.ts +164 -0
- package/dist/assets/generated/wasm/package.json +15 -0
- package/dist/assets/generated/wasm-node/README.md +281 -0
- package/dist/assets/generated/wasm-node/gossip_wasm.d.ts +443 -0
- package/dist/assets/generated/wasm-node/gossip_wasm.js +1488 -0
- package/dist/assets/generated/wasm-node/gossip_wasm_bg.wasm +0 -0
- package/dist/assets/generated/wasm-node/gossip_wasm_bg.wasm.d.ts +164 -0
- package/dist/assets/generated/wasm-node/package.json +11 -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} +11 -95
- 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 +59 -0
- package/dist/index.js +61 -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 +22 -0
- package/dist/wasm/loader.js +78 -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 +15 -2
- 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
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|