signal-sdk 0.1.0 → 0.1.2

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/SignalCli.js CHANGED
@@ -88,6 +88,25 @@ class SignalCli extends events_1.EventEmitter {
88
88
  this.account = phoneNumber;
89
89
  }
90
90
  async connect() {
91
+ const daemonMode = this.config.daemonMode || 'json-rpc';
92
+ switch (daemonMode) {
93
+ case 'json-rpc':
94
+ await this.connectJsonRpc();
95
+ break;
96
+ case 'unix-socket':
97
+ await this.connectUnixSocket();
98
+ break;
99
+ case 'tcp':
100
+ await this.connectTcp();
101
+ break;
102
+ case 'http':
103
+ await this.connectHttp();
104
+ break;
105
+ default:
106
+ throw new Error(`Invalid daemon mode: ${daemonMode}`);
107
+ }
108
+ }
109
+ async connectJsonRpc() {
91
110
  const args = this.account ? ['-a', this.account, 'jsonRpc'] : ['jsonRpc'];
92
111
  if (process.platform === 'win32') {
93
112
  // On Windows, use cmd.exe to run the batch file with proper quoting for paths with spaces
@@ -133,11 +152,120 @@ class SignalCli extends events_1.EventEmitter {
133
152
  });
134
153
  });
135
154
  }
155
+ async connectUnixSocket() {
156
+ const net = await Promise.resolve().then(() => __importStar(require('net')));
157
+ const socketPath = this.config.socketPath || '/tmp/signal-cli.sock';
158
+ return new Promise((resolve, reject) => {
159
+ const socket = net.createConnection(socketPath);
160
+ socket.on('connect', () => {
161
+ this.logger.debug('Connected to Unix socket:', socketPath);
162
+ socket.on('data', (data) => this.handleRpcResponse(data.toString('utf8')));
163
+ socket.on('error', (err) => this.emit('error', err));
164
+ socket.on('close', () => {
165
+ this.emit('close', 0);
166
+ });
167
+ this.socket = socket;
168
+ resolve();
169
+ });
170
+ socket.on('error', (err) => {
171
+ reject(new errors_1.ConnectionError(`Failed to connect to Unix socket: ${err.message}`));
172
+ });
173
+ setTimeout(() => reject(new errors_1.ConnectionError('Unix socket connection timeout')), this.config.connectionTimeout);
174
+ });
175
+ }
176
+ async connectTcp() {
177
+ const net = await Promise.resolve().then(() => __importStar(require('net')));
178
+ const host = this.config.tcpHost || 'localhost';
179
+ const port = this.config.tcpPort || 7583;
180
+ return new Promise((resolve, reject) => {
181
+ const socket = net.createConnection(port, host);
182
+ socket.on('connect', () => {
183
+ this.logger.debug(`Connected to TCP: ${host}:${port}`);
184
+ socket.on('data', (data) => this.handleRpcResponse(data.toString('utf8')));
185
+ socket.on('error', (err) => this.emit('error', err));
186
+ socket.on('close', () => {
187
+ this.emit('close', 0);
188
+ });
189
+ this.socket = socket;
190
+ resolve();
191
+ });
192
+ socket.on('error', (err) => {
193
+ reject(new errors_1.ConnectionError(`Failed to connect to TCP: ${err.message}`));
194
+ });
195
+ setTimeout(() => reject(new errors_1.ConnectionError('TCP connection timeout')), this.config.connectionTimeout);
196
+ });
197
+ }
198
+ async connectHttp() {
199
+ const baseUrl = this.config.httpBaseUrl || 'http://localhost:8080';
200
+ // For HTTP mode, we don't maintain a persistent connection
201
+ // Instead, we'll use the httpRequest method for each operation
202
+ this.logger.debug('HTTP mode configured:', baseUrl);
203
+ this.httpBaseUrl = baseUrl;
204
+ // Test connection by sending a simple request
205
+ try {
206
+ await this.httpRequest({ jsonrpc: '2.0', method: 'version', params: {}, id: (0, uuid_1.v4)() });
207
+ this.logger.debug('HTTP connection verified');
208
+ }
209
+ catch (error) {
210
+ throw new errors_1.ConnectionError(`Failed to connect to HTTP endpoint: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ }
213
+ async httpRequest(request) {
214
+ const https = await Promise.resolve(`${this.config.httpBaseUrl?.startsWith('https:') ? 'https' : 'http'}`).then(s => __importStar(require(s)));
215
+ const baseUrl = this.config.httpBaseUrl || 'http://localhost:8080';
216
+ const url = new URL('/api/v1/rpc', baseUrl);
217
+ return new Promise((resolve, reject) => {
218
+ const data = JSON.stringify(request);
219
+ const options = {
220
+ method: 'POST',
221
+ headers: {
222
+ 'Content-Type': 'application/json',
223
+ 'Content-Length': Buffer.byteLength(data)
224
+ }
225
+ };
226
+ const req = https.request(url, options, (res) => {
227
+ let body = '';
228
+ res.on('data', (chunk) => body += chunk);
229
+ res.on('end', () => {
230
+ try {
231
+ const response = JSON.parse(body);
232
+ if (response.error) {
233
+ reject(new Error(`[${response.error.code}] ${response.error.message}`));
234
+ }
235
+ else {
236
+ resolve(response.result);
237
+ }
238
+ }
239
+ catch (error) {
240
+ reject(new Error(`Failed to parse HTTP response: ${body}`));
241
+ }
242
+ });
243
+ });
244
+ req.on('error', (err) => reject(new errors_1.ConnectionError(`HTTP request failed: ${err.message}`)));
245
+ req.setTimeout(this.config.requestTimeout, () => {
246
+ req.destroy();
247
+ reject(new errors_1.ConnectionError('HTTP request timeout'));
248
+ });
249
+ req.write(data);
250
+ req.end();
251
+ });
252
+ }
136
253
  disconnect() {
137
- if (this.cliProcess) {
254
+ const daemonMode = this.config.daemonMode || 'json-rpc';
255
+ // Close socket connections
256
+ if (daemonMode === 'unix-socket' || daemonMode === 'tcp') {
257
+ const socket = this.socket;
258
+ if (socket && !socket.destroyed) {
259
+ socket.destroy();
260
+ this.socket = null;
261
+ }
262
+ }
263
+ // Close process for json-rpc mode
264
+ if (daemonMode === 'json-rpc' && this.cliProcess) {
138
265
  this.cliProcess.kill();
139
266
  this.cliProcess = null;
140
267
  }
268
+ // For HTTP mode, nothing to disconnect (stateless)
141
269
  }
142
270
  async gracefulShutdown() {
143
271
  return new Promise((resolve) => {
@@ -153,13 +281,17 @@ class SignalCli extends events_1.EventEmitter {
153
281
  // Send SIGTERM for graceful shutdown
154
282
  this.cliProcess.kill('SIGTERM');
155
283
  // Force kill after 5 seconds if it doesn't close gracefully
156
- setTimeout(() => {
284
+ const forceKillTimer = setTimeout(() => {
157
285
  if (this.cliProcess) {
158
286
  this.cliProcess.kill('SIGKILL');
159
287
  this.cliProcess = null;
160
288
  resolve();
161
289
  }
162
290
  }, 5000);
291
+ // Use unref() to prevent this timer from keeping the process alive
292
+ if (forceKillTimer.unref) {
293
+ forceKillTimer.unref();
294
+ }
163
295
  });
164
296
  }
165
297
  handleRpcResponse(data) {
@@ -234,8 +366,40 @@ class SignalCli extends events_1.EventEmitter {
234
366
  }
235
367
  }
236
368
  async sendJsonRpcRequest(method, params) {
369
+ const daemonMode = this.config.daemonMode || 'json-rpc';
370
+ // For HTTP mode, use HTTP requests
371
+ if (daemonMode === 'http') {
372
+ const id = (0, uuid_1.v4)();
373
+ const request = {
374
+ jsonrpc: '2.0',
375
+ method,
376
+ params,
377
+ id,
378
+ };
379
+ return await this.httpRequest(request);
380
+ }
381
+ // For socket modes (Unix socket, TCP), write to socket
382
+ if (daemonMode === 'unix-socket' || daemonMode === 'tcp') {
383
+ const socket = this.socket;
384
+ if (!socket || socket.destroyed) {
385
+ throw new errors_1.ConnectionError('Not connected. Call connect() first.');
386
+ }
387
+ const id = (0, uuid_1.v4)();
388
+ const request = {
389
+ jsonrpc: '2.0',
390
+ method,
391
+ params,
392
+ id,
393
+ };
394
+ const promise = new Promise((resolve, reject) => {
395
+ this.requestPromises.set(id, { resolve, reject });
396
+ });
397
+ socket.write(JSON.stringify(request) + '\n');
398
+ return promise;
399
+ }
400
+ // Default JSON-RPC mode with stdin/stdout
237
401
  if (!this.cliProcess || !this.cliProcess.stdin) {
238
- throw new Error('Not connected. Call connect() first.');
402
+ throw new errors_1.ConnectionError('Not connected. Call connect() first.');
239
403
  }
240
404
  const id = (0, uuid_1.v4)();
241
405
  const request = {
@@ -272,7 +436,7 @@ class SignalCli extends events_1.EventEmitter {
272
436
  else {
273
437
  params.recipients = [recipient];
274
438
  }
275
- // Only add safe, well-known options to avoid JSON parsing issues
439
+ // Add well-known options
276
440
  if (options.attachments && options.attachments.length > 0) {
277
441
  params.attachments = options.attachments;
278
442
  }
@@ -280,7 +444,64 @@ class SignalCli extends events_1.EventEmitter {
280
444
  params.expiresInSeconds = options.expiresInSeconds;
281
445
  }
282
446
  if (options.isViewOnce) {
283
- params.isViewOnce = options.isViewOnce;
447
+ params.viewOnce = options.isViewOnce;
448
+ }
449
+ // Add advanced text formatting options
450
+ if (options.mentions && options.mentions.length > 0) {
451
+ params.mentions = options.mentions.map(m => ({
452
+ start: m.start,
453
+ length: m.length,
454
+ number: m.recipient || m.number
455
+ }));
456
+ }
457
+ if (options.textStyles && options.textStyles.length > 0) {
458
+ params.textStyles = options.textStyles.map(ts => ({
459
+ start: ts.start,
460
+ length: ts.length,
461
+ style: ts.style
462
+ }));
463
+ }
464
+ // Add quote/reply information
465
+ if (options.quote) {
466
+ params.quoteTimestamp = options.quote.timestamp;
467
+ params.quoteAuthor = options.quote.author;
468
+ if (options.quote.text) {
469
+ params.quoteMessage = options.quote.text;
470
+ }
471
+ if (options.quote.mentions && options.quote.mentions.length > 0) {
472
+ params.quoteMentions = options.quote.mentions.map(m => ({
473
+ start: m.start,
474
+ length: m.length,
475
+ number: m.recipient || m.number
476
+ }));
477
+ }
478
+ if (options.quote.textStyles && options.quote.textStyles.length > 0) {
479
+ params.quoteTextStyles = options.quote.textStyles.map(ts => ({
480
+ start: ts.start,
481
+ length: ts.length,
482
+ style: ts.style
483
+ }));
484
+ }
485
+ }
486
+ // Add preview URL
487
+ if (options.previewUrl) {
488
+ params.previewUrl = options.previewUrl;
489
+ }
490
+ // Add edit timestamp for editing existing messages
491
+ if (options.editTimestamp) {
492
+ params.editTimestamp = options.editTimestamp;
493
+ }
494
+ // Add story reply information
495
+ if (options.storyTimestamp && options.storyAuthor) {
496
+ params.storyTimestamp = options.storyTimestamp;
497
+ params.storyAuthor = options.storyAuthor;
498
+ }
499
+ // Add special flags
500
+ if (options.noteToSelf) {
501
+ params.noteToSelf = options.noteToSelf;
502
+ }
503
+ if (options.endSession) {
504
+ params.endSession = options.endSession;
284
505
  }
285
506
  return this.sendJsonRpcRequest('send', params);
286
507
  }
@@ -371,6 +592,79 @@ class SignalCli extends events_1.EventEmitter {
371
592
  async trustIdentity(number, safetyNumber, verified = true) {
372
593
  await this.sendJsonRpcRequest('trust', { account: this.account, recipient: number, safetyNumber, verified });
373
594
  }
595
+ /**
596
+ * Get the safety number for a specific contact.
597
+ * This is a helper method that extracts just the safety number from identity information.
598
+ *
599
+ * @param number - The phone number of the contact
600
+ * @returns The safety number string, or null if not found
601
+ *
602
+ * @example
603
+ * ```typescript
604
+ * const safetyNumber = await signal.getSafetyNumber('+33123456789');
605
+ * console.log(`Safety number: ${safetyNumber}`);
606
+ * ```
607
+ */
608
+ async getSafetyNumber(number) {
609
+ const identities = await this.listIdentities(number);
610
+ if (identities.length > 0 && identities[0].safetyNumber) {
611
+ return identities[0].safetyNumber;
612
+ }
613
+ return null;
614
+ }
615
+ /**
616
+ * Verify a safety number for a contact.
617
+ * Checks if the provided safety number matches the stored one and marks it as trusted if it does.
618
+ *
619
+ * @param number - The phone number of the contact
620
+ * @param safetyNumber - The safety number to verify
621
+ * @returns True if the safety number matches and was trusted, false otherwise
622
+ *
623
+ * @example
624
+ * ```typescript
625
+ * const verified = await signal.verifySafetyNumber('+33123456789', '123456 78901...');
626
+ * if (verified) {
627
+ * console.log('Safety number verified and trusted');
628
+ * } else {
629
+ * console.log('Safety number does not match!');
630
+ * }
631
+ * ```
632
+ */
633
+ async verifySafetyNumber(number, safetyNumber) {
634
+ const storedSafetyNumber = await this.getSafetyNumber(number);
635
+ if (!storedSafetyNumber) {
636
+ return false;
637
+ }
638
+ // Compare safety numbers (remove spaces for comparison)
639
+ const normalizedStored = storedSafetyNumber.replace(/\s/g, '');
640
+ const normalizedProvided = safetyNumber.replace(/\s/g, '');
641
+ if (normalizedStored === normalizedProvided) {
642
+ await this.trustIdentity(number, safetyNumber, true);
643
+ return true;
644
+ }
645
+ return false;
646
+ }
647
+ /**
648
+ * List all untrusted identities.
649
+ * Returns identities that have not been explicitly trusted.
650
+ *
651
+ * @returns Array of untrusted identity keys
652
+ *
653
+ * @example
654
+ * ```typescript
655
+ * const untrusted = await signal.listUntrustedIdentities();
656
+ * console.log(`Found ${untrusted.length} untrusted identities`);
657
+ * untrusted.forEach(id => {
658
+ * console.log(`${id.number}: ${id.safetyNumber}`);
659
+ * });
660
+ * ```
661
+ */
662
+ async listUntrustedIdentities() {
663
+ const allIdentities = await this.listIdentities();
664
+ return allIdentities.filter(identity => identity.trustLevel === 'UNTRUSTED' ||
665
+ identity.trustLevel === 'TRUST_ON_FIRST_USE' ||
666
+ !identity.trustLevel);
667
+ }
374
668
  async link(deviceName) {
375
669
  const result = await this.sendJsonRpcRequest('link', { deviceName });
376
670
  return result.uri;
@@ -505,6 +799,10 @@ class SignalCli extends events_1.EventEmitter {
505
799
  params.promoteAdmins = options.promoteAdmins;
506
800
  if (options.demoteAdmins)
507
801
  params.demoteAdmins = options.demoteAdmins;
802
+ if (options.banMembers)
803
+ params.banMembers = options.banMembers;
804
+ if (options.unbanMembers)
805
+ params.unbanMembers = options.unbanMembers;
508
806
  if (options.resetInviteLink)
509
807
  params.resetLink = true;
510
808
  if (options.permissionAddMember)
@@ -520,12 +818,93 @@ class SignalCli extends events_1.EventEmitter {
520
818
  async listGroups() {
521
819
  return this.sendJsonRpcRequest('listGroups', { account: this.account });
522
820
  }
821
+ /**
822
+ * Send group invite link to a recipient.
823
+ * Retrieves and sends the invitation link for a group.
824
+ *
825
+ * @param groupId - The group ID
826
+ * @param recipient - The recipient to send the invite link to
827
+ * @returns Send response
828
+ *
829
+ * @example
830
+ * ```typescript
831
+ * await signal.sendGroupInviteLink('groupId123==', '+33123456789');
832
+ * ```
833
+ */
834
+ async sendGroupInviteLink(groupId, recipient) {
835
+ // Get group info to retrieve invite link
836
+ const groups = await this.listGroups();
837
+ const group = groups.find(g => g.groupId === groupId);
838
+ const inviteLink = group?.groupInviteLink || group?.inviteLink;
839
+ if (!group || !inviteLink) {
840
+ throw new Error('Group not found or does not have an invite link');
841
+ }
842
+ return this.sendMessage(recipient, `Join our group: ${inviteLink}`);
843
+ }
844
+ /**
845
+ * Set banned members for a group.
846
+ * Ban specific members from the group.
847
+ *
848
+ * @param groupId - The group ID
849
+ * @param members - Array of phone numbers to ban
850
+ *
851
+ * @example
852
+ * ```typescript
853
+ * await signal.setBannedMembers('groupId123==', ['+33111111111', '+33222222222']);
854
+ * ```
855
+ */
856
+ async setBannedMembers(groupId, members) {
857
+ await this.updateGroup(groupId, { banMembers: members });
858
+ }
859
+ /**
860
+ * Reset group invite link.
861
+ * Invalidates the current group invite link and generates a new one.
862
+ *
863
+ * @param groupId - The group ID
864
+ *
865
+ * @example
866
+ * ```typescript
867
+ * await signal.resetGroupLink('groupId123==');
868
+ * ```
869
+ */
870
+ async resetGroupLink(groupId) {
871
+ await this.updateGroup(groupId, { resetInviteLink: true });
872
+ }
523
873
  async listContacts() {
524
874
  return this.sendJsonRpcRequest('listContacts', { account: this.account });
525
875
  }
526
876
  async listDevices() {
527
877
  return this.sendJsonRpcRequest('listDevices', { account: this.account });
528
878
  }
879
+ /**
880
+ * Update a linked device name (signal-cli v0.13.23+).
881
+ * Allows changing the display name of a linked device.
882
+ *
883
+ * @param options - Device update options
884
+ * @returns Promise that resolves when device is updated
885
+ *
886
+ * @example
887
+ * ```typescript
888
+ * // List devices to get device IDs
889
+ * const devices = await signal.listDevices();
890
+ *
891
+ * // Update device name
892
+ * await signal.updateDevice({
893
+ * deviceId: 2,
894
+ * deviceName: 'My New Device Name'
895
+ * });
896
+ * ```
897
+ */
898
+ async updateDevice(options) {
899
+ this.logger.debug('Updating device', options);
900
+ (0, validators_1.validateDeviceId)(options.deviceId);
901
+ (0, validators_1.validateMessage)(options.deviceName, 200);
902
+ await this.sendJsonRpcRequest('updateDevice', {
903
+ deviceId: options.deviceId,
904
+ deviceName: options.deviceName,
905
+ account: this.account
906
+ });
907
+ }
529
908
  async listAccounts() {
530
909
  const result = await this.sendJsonRpcRequest('listAccounts');
531
910
  return result.accounts.map((acc) => acc.number);
@@ -559,6 +938,101 @@ class SignalCli extends events_1.EventEmitter {
559
938
  console.warn("stopDaemon is deprecated. Use gracefulShutdown() or disconnect() instead.");
560
939
  this.gracefulShutdown();
561
940
  }
941
+ // ############# MESSAGE RECEIVING #############
942
+ /**
943
+ * Receive messages from Signal with configurable options.
944
+ * This is the modern replacement for the deprecated receiveMessages().
945
+ *
946
+ * @param options - Options for receiving messages
947
+ * @returns Array of received messages
948
+ *
949
+ * @example
950
+ * ```typescript
951
+ * // Receive with default timeout
952
+ * const messages = await signal.receive();
953
+ *
954
+ * // Receive with custom options
955
+ * const messages = await signal.receive({
956
+ * timeout: 10,
957
+ * maxMessages: 5,
958
+ * ignoreAttachments: true,
959
+ * sendReadReceipts: true
960
+ * });
961
+ * ```
962
+ */
963
+ async receive(options = {}) {
964
+ const params = { account: this.account };
965
+ // Set timeout (default: 5 seconds)
966
+ if (options.timeout !== undefined) {
967
+ params.timeout = options.timeout;
968
+ }
969
+ // Set maximum number of messages
970
+ if (options.maxMessages !== undefined) {
971
+ params.maxMessages = options.maxMessages;
972
+ }
973
+ // Skip attachment downloads
974
+ if (options.ignoreAttachments) {
975
+ params.ignoreAttachments = true;
976
+ }
977
+ // Skip stories
978
+ if (options.ignoreStories) {
979
+ params.ignoreStories = true;
980
+ }
981
+ // Send read receipts automatically
982
+ if (options.sendReadReceipts) {
983
+ params.sendReadReceipts = true;
984
+ }
985
+ try {
986
+ const result = await this.sendJsonRpcRequest('receive', params);
987
+ // Parse and return messages
988
+ if (Array.isArray(result)) {
989
+ return result.map(envelope => this.parseEnvelope(envelope));
990
+ }
991
+ return [];
992
+ }
993
+ catch (error) {
994
+ this.logger.error('Failed to receive messages:', error);
995
+ throw error;
996
+ }
997
+ }
998
+ /**
999
+ * Parse a message envelope from signal-cli into a Message object.
1000
+ * @private
1001
+ */
1002
+ parseEnvelope(envelope) {
1003
+ const message = {
1004
+ timestamp: envelope.timestamp || Date.now(),
1005
+ source: envelope.source || envelope.sourceNumber,
1006
+ sourceUuid: envelope.sourceUuid,
1007
+ sourceDevice: envelope.sourceDevice,
1008
+ };
1009
+ // Parse data message
1010
+ if (envelope.dataMessage) {
1011
+ const data = envelope.dataMessage;
1012
+ message.text = data.message || data.body;
1013
+ message.groupId = data.groupInfo?.groupId;
1014
+ message.attachments = data.attachments;
1015
+ message.mentions = data.mentions;
1016
+ message.quote = data.quote;
1017
+ message.reaction = data.reaction;
1018
+ message.sticker = data.sticker;
1019
+ message.expiresInSeconds = data.expiresInSeconds;
1020
+ message.viewOnce = data.viewOnce;
1021
+ }
1022
+ // Parse sync message
1023
+ if (envelope.syncMessage) {
1024
+ message.syncMessage = envelope.syncMessage;
1025
+ }
1026
+ // Parse receipt message
1027
+ if (envelope.receiptMessage) {
1028
+ message.receipt = envelope.receiptMessage;
1029
+ }
1030
+ // Parse typing message
1031
+ if (envelope.typingMessage) {
1032
+ message.typing = envelope.typingMessage;
1033
+ }
1034
+ return message;
1035
+ }
562
1036
  // ############# NEW FEATURES - Missing signal-cli Commands #############
563
1037
  /**
564
1038
  * Remove a contact from the contact list.
@@ -604,12 +1078,29 @@ class SignalCli extends events_1.EventEmitter {
604
1078
  return statusResults;
605
1079
  }
606
1080
  /**
607
- * Send a payment notification to a recipient.
608
- * @param recipient - Phone number or group ID to send the notification to
609
- * @param paymentData - Payment notification data including receipt
610
- * @returns Send response with timestamp and other details
1081
+ * Send a payment notification to a recipient (MobileCoin).
1082
+ * Sends a notification about a cryptocurrency payment made through Signal's MobileCoin integration.
1083
+ *
1084
+ * @param recipient - The phone number or group ID of the recipient
1085
+ * @param paymentData - Payment notification data including receipt and optional note
1086
+ * @returns Send result with timestamp
1087
+ * @throws {Error} If receipt is invalid or sending fails
1088
+ *
1089
+ * @example
1090
+ * ```typescript
1091
+ * const receiptBlob = 'base64EncodedReceiptData...';
1092
+ * await signal.sendPaymentNotification('+33612345678', {
1093
+ * receipt: receiptBlob,
1094
+ * note: 'Thanks for dinner!'
1095
+ * });
1096
+ * ```
611
1097
  */
612
1098
  async sendPaymentNotification(recipient, paymentData) {
1099
+ this.logger.info(`Sending payment notification to ${recipient}`);
1100
+ (0, validators_1.validateRecipient)(recipient);
1101
+ if (!paymentData.receipt || paymentData.receipt.trim().length === 0) {
1102
+ throw new Error('Payment receipt is required');
1103
+ }
613
1104
  const params = {
614
1105
  receipt: paymentData.receipt,
615
1106
  account: this.account
@@ -662,13 +1153,18 @@ class SignalCli extends events_1.EventEmitter {
662
1153
  };
663
1154
  }
664
1155
  /**
665
- * Start the process of changing phone number.
666
- * @param newNumber - The new phone number to change to
667
- * @param voice - Whether to use voice verification instead of SMS
668
- * @param captcha - Captcha token if required
669
- * @returns Change number session information
1156
+ * Start the phone number change process.
1157
+ * Initiates SMS or voice verification for changing your account to a new phone number.
1158
+ * After calling this, you must verify the new number with finishChangeNumber().
1159
+ *
1160
+ * @param newNumber - The new phone number in E164 format (e.g., "+33612345678")
1161
+ * @param voice - Use voice verification instead of SMS (default: false)
1162
+ * @param captcha - Optional captcha token if required
1163
+ * @throws {Error} If not a primary device or rate limited
670
1164
  */
671
1165
  async startChangeNumber(newNumber, voice = false, captcha) {
1166
+ this.logger.info(`Starting change number to ${newNumber} (voice: ${voice})`);
1167
+ (0, validators_1.validatePhoneNumber)(newNumber);
672
1168
  const params = {
673
1169
  account: this.account,
674
1170
  number: newNumber,
@@ -676,22 +1172,28 @@ class SignalCli extends events_1.EventEmitter {
676
1172
  };
677
1173
  if (captcha)
678
1174
  params.captcha = captcha;
679
- const result = await this.sendJsonRpcRequest('startChangeNumber', params);
680
- return {
681
- session: result.session,
682
- newNumber,
683
- challenge: result.challenge
684
- };
1175
+ await this.sendJsonRpcRequest('startChangeNumber', params);
685
1176
  }
686
1177
  /**
687
- * Finish the phone number change process with verification code.
688
- * @param verificationCode - The verification code received via SMS/voice
689
- * @param pin - Registration lock PIN if enabled
1178
+ * Complete the phone number change process.
1179
+ * Verifies the code received via SMS or voice and changes your account to the new number.
1180
+ * Must be called after startChangeNumber().
1181
+ *
1182
+ * @param newNumber - The new phone number (same as startChangeNumber)
1183
+ * @param verificationCode - The verification code received via SMS or voice
1184
+ * @param pin - Optional registration lock PIN if one was set
1185
+ * @throws {Error} If verification fails or incorrect PIN
690
1186
  */
691
- async finishChangeNumber(verificationCode, pin) {
1187
+ async finishChangeNumber(newNumber, verificationCode, pin) {
1188
+ this.logger.info(`Finishing change number to ${newNumber}`);
1189
+ (0, validators_1.validatePhoneNumber)(newNumber);
1190
+ if (!verificationCode || verificationCode.trim().length === 0) {
1191
+ throw new Error('Verification code is required');
1192
+ }
692
1193
  const params = {
693
1194
  account: this.account,
694
- code: verificationCode
1195
+ number: newNumber,
1196
+ verificationCode
695
1197
  };
696
1198
  if (pin)
697
1199
  params.pin = pin;
@@ -857,6 +1359,40 @@ class SignalCli extends events_1.EventEmitter {
857
1359
  };
858
1360
  }
859
1361
  }
1362
+ /**
1363
+ * Set or update the username for this account.
1364
+ * Helper method that wraps updateAccount() for simpler username management.
1365
+ *
1366
+ * @param username - The username to set (without discriminator)
1367
+ * @returns Account update result with username and link
1368
+ *
1369
+ * @example
1370
+ * ```typescript
1371
+ * const result = await signal.setUsername('myusername');
1372
+ * console.log(`Username: ${result.username}`);
1373
+ * console.log(`Link: ${result.usernameLink}`);
1374
+ * ```
1375
+ */
1376
+ async setUsername(username) {
1377
+ return this.updateAccount({ username });
1378
+ }
1379
+ /**
1380
+ * Delete the current username from this account.
1381
+ * Helper method that wraps updateAccount() for simpler username deletion.
1382
+ *
1383
+ * @returns Account update result
1384
+ *
1385
+ * @example
1386
+ * ```typescript
1387
+ * const result = await signal.deleteUsername();
1388
+ * if (result.success) {
1389
+ * console.log('Username deleted successfully');
1390
+ * }
1391
+ * ```
1392
+ */
1393
+ async deleteUsername() {
1394
+ return this.updateAccount({ deleteUsername: true });
1395
+ }
860
1396
  /**
861
1397
  * Get raw attachment data as base64 string.
862
1398
  * @param options Attachment retrieval options
@@ -963,5 +1499,96 @@ class SignalCli extends events_1.EventEmitter {
963
1499
  const result = await this.sendJsonRpcRequest('listAccounts');
964
1500
  return result.accounts || [];
965
1501
  }
1502
+ /**
1503
+ * Extract profile information from a contact.
1504
+ * Parses givenName, familyName, mobileCoinAddress from profile data.
1505
+ *
1506
+ * @param contact - The contact object to parse
1507
+ * @returns Enhanced contact with extracted profile fields
1508
+ *
1509
+ * @example
1510
+ * ```typescript
1511
+ * const contacts = await signal.listContacts();
1512
+ * const enriched = signal.parseContactProfile(contacts[0]);
1513
+ * console.log(enriched.givenName, enriched.familyName);
1514
+ * ```
1515
+ */
1516
+ parseContactProfile(contact) {
1517
+ // signal-cli already provides these fields if available
1518
+ // This method normalizes and validates the data
1519
+ return {
1520
+ ...contact,
1521
+ givenName: contact.givenName || undefined,
1522
+ familyName: contact.familyName || undefined,
1523
+ mobileCoinAddress: contact.mobileCoinAddress || undefined,
1524
+ profileName: contact.profileName ||
1525
+ (contact.givenName && contact.familyName
1526
+ ? `${contact.givenName} ${contact.familyName}`
1527
+ : contact.givenName || contact.familyName),
1528
+ };
1529
+ }
1530
+ /**
1531
+ * Extract group membership details.
1532
+ * Parses pendingMembers, bannedMembers, inviteLink from group data.
1533
+ *
1534
+ * @param group - The group info to parse
1535
+ * @returns Enhanced group with extracted membership fields
1536
+ *
1537
+ * @example
1538
+ * ```typescript
1539
+ * const groups = await signal.listGroups();
1540
+ * const enriched = signal.parseGroupDetails(groups[0]);
1541
+ * console.log(enriched.pendingMembers, enriched.bannedMembers);
1542
+ * ```
1543
+ */
1544
+ parseGroupDetails(group) {
1545
+ return {
1546
+ ...group,
1547
+ // Normalize inviteLink field
1548
+ inviteLink: group.groupInviteLink || group.inviteLink,
1549
+ groupInviteLink: group.groupInviteLink || group.inviteLink,
1550
+ // Ensure arrays exist
1551
+ pendingMembers: group.pendingMembers || [],
1552
+ banned: group.banned || [],
1553
+ requestingMembers: group.requestingMembers || [],
1554
+ admins: group.admins || [],
1555
+ members: group.members || [],
1556
+ };
1557
+ }
1558
+ /**
1559
+ * Get enriched contacts list with parsed profile information.
1560
+ *
1561
+ * @returns Array of contacts with full profile data
1562
+ *
1563
+ * @example
1564
+ * ```typescript
1565
+ * const contacts = await signal.getContactsWithProfiles();
1566
+ * contacts.forEach(c => {
1567
+ * console.log(`${c.givenName} ${c.familyName} - ${c.mobileCoinAddress}`);
1568
+ * });
1569
+ * ```
1570
+ */
1571
+ async getContactsWithProfiles() {
1572
+ const contacts = await this.listContacts();
1573
+ return contacts.map(c => this.parseContactProfile(c));
1574
+ }
1575
+ /**
1576
+ * Get enriched groups list with parsed membership details.
1577
+ *
1578
+ * @param options - List groups options
1579
+ * @returns Array of groups with full membership data
1580
+ *
1581
+ * @example
1582
+ * ```typescript
1583
+ * const groups = await signal.getGroupsWithDetails();
1584
+ * groups.forEach(g => {
1585
+ * console.log(`${g.name}: ${g.members.length} members, ${g.pendingMembers.length} pending`);
1586
+ * });
1587
+ * ```
1588
+ */
1589
+ async getGroupsWithDetails(options = {}) {
1590
+ const groups = await this.listGroupsDetailed(options);
1591
+ return groups.map(g => this.parseGroupDetails(g));
1592
+ }
966
1593
  }
967
1594
  exports.SignalCli = SignalCli;