@kangwifi-pro/waliwa 1.0.0

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +600 -0
  3. package/dist/advanced/features.d.ts +211 -0
  4. package/dist/advanced/features.d.ts.map +1 -0
  5. package/dist/advanced/features.js +671 -0
  6. package/dist/advanced/features.js.map +1 -0
  7. package/dist/auth/auth-state.d.ts +89 -0
  8. package/dist/auth/auth-state.d.ts.map +1 -0
  9. package/dist/auth/auth-state.js +330 -0
  10. package/dist/auth/auth-state.js.map +1 -0
  11. package/dist/auth/pairing-code.d.ts +78 -0
  12. package/dist/auth/pairing-code.d.ts.map +1 -0
  13. package/dist/auth/pairing-code.js +191 -0
  14. package/dist/auth/pairing-code.js.map +1 -0
  15. package/dist/auth/qr-code.d.ts +60 -0
  16. package/dist/auth/qr-code.d.ts.map +1 -0
  17. package/dist/auth/qr-code.js +151 -0
  18. package/dist/auth/qr-code.js.map +1 -0
  19. package/dist/calls/handler.d.ts +61 -0
  20. package/dist/calls/handler.d.ts.map +1 -0
  21. package/dist/calls/handler.js +117 -0
  22. package/dist/calls/handler.js.map +1 -0
  23. package/dist/core/binary.d.ts +74 -0
  24. package/dist/core/binary.d.ts.map +1 -0
  25. package/dist/core/binary.js +448 -0
  26. package/dist/core/binary.js.map +1 -0
  27. package/dist/core/crypto.d.ts +60 -0
  28. package/dist/core/crypto.d.ts.map +1 -0
  29. package/dist/core/crypto.js +170 -0
  30. package/dist/core/crypto.js.map +1 -0
  31. package/dist/core/noise.d.ts +106 -0
  32. package/dist/core/noise.d.ts.map +1 -0
  33. package/dist/core/noise.js +307 -0
  34. package/dist/core/noise.js.map +1 -0
  35. package/dist/events/emitter.d.ts +44 -0
  36. package/dist/events/emitter.d.ts.map +1 -0
  37. package/dist/events/emitter.js +79 -0
  38. package/dist/events/emitter.js.map +1 -0
  39. package/dist/features/index.d.ts +342 -0
  40. package/dist/features/index.d.ts.map +1 -0
  41. package/dist/features/index.js +755 -0
  42. package/dist/features/index.js.map +1 -0
  43. package/dist/fixes/index.d.ts +333 -0
  44. package/dist/fixes/index.d.ts.map +1 -0
  45. package/dist/fixes/index.js +762 -0
  46. package/dist/fixes/index.js.map +1 -0
  47. package/dist/groups/management.d.ts +86 -0
  48. package/dist/groups/management.d.ts.map +1 -0
  49. package/dist/groups/management.js +443 -0
  50. package/dist/groups/management.js.map +1 -0
  51. package/dist/index.d.ts +65 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +236 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/messages/media.d.ts +93 -0
  56. package/dist/messages/media.d.ts.map +1 -0
  57. package/dist/messages/media.js +252 -0
  58. package/dist/messages/media.js.map +1 -0
  59. package/dist/messages/message-encoder.d.ts +70 -0
  60. package/dist/messages/message-encoder.d.ts.map +1 -0
  61. package/dist/messages/message-encoder.js +453 -0
  62. package/dist/messages/message-encoder.js.map +1 -0
  63. package/dist/messages/send.d.ts +101 -0
  64. package/dist/messages/send.d.ts.map +1 -0
  65. package/dist/messages/send.js +409 -0
  66. package/dist/messages/send.js.map +1 -0
  67. package/dist/recovery/index.d.ts +125 -0
  68. package/dist/recovery/index.d.ts.map +1 -0
  69. package/dist/recovery/index.js +584 -0
  70. package/dist/recovery/index.js.map +1 -0
  71. package/dist/skdm/index.d.ts +220 -0
  72. package/dist/skdm/index.d.ts.map +1 -0
  73. package/dist/skdm/index.js +600 -0
  74. package/dist/skdm/index.js.map +1 -0
  75. package/dist/socket/hybrid.d.ts +118 -0
  76. package/dist/socket/hybrid.d.ts.map +1 -0
  77. package/dist/socket/hybrid.js +352 -0
  78. package/dist/socket/hybrid.js.map +1 -0
  79. package/dist/socket/wa-socket.d.ts +300 -0
  80. package/dist/socket/wa-socket.d.ts.map +1 -0
  81. package/dist/socket/wa-socket.js +1094 -0
  82. package/dist/socket/wa-socket.js.map +1 -0
  83. package/dist/socket/ws-socket.d.ts +96 -0
  84. package/dist/socket/ws-socket.d.ts.map +1 -0
  85. package/dist/socket/ws-socket.js +302 -0
  86. package/dist/socket/ws-socket.js.map +1 -0
  87. package/dist/types/index.d.ts +444 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +7 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/utils/jid.d.ts +46 -0
  92. package/dist/utils/jid.d.ts.map +1 -0
  93. package/dist/utils/jid.js +152 -0
  94. package/dist/utils/jid.js.map +1 -0
  95. package/dist/utils/logger.d.ts +45 -0
  96. package/dist/utils/logger.d.ts.map +1 -0
  97. package/dist/utils/logger.js +79 -0
  98. package/dist/utils/logger.js.map +1 -0
  99. package/dist/utils/retry.d.ts +43 -0
  100. package/dist/utils/retry.d.ts.map +1 -0
  101. package/dist/utils/retry.js +175 -0
  102. package/dist/utils/retry.js.map +1 -0
  103. package/docs/API.md +745 -0
  104. package/docs/ARCHITECTURE.md +307 -0
  105. package/docs/BAILEYS_FIXES.md +360 -0
  106. package/docs/FEATURES.md +532 -0
  107. package/docs/PROTOCOL.md +489 -0
  108. package/docs/RECOVERY.md +409 -0
  109. package/docs/SKDM.md +233 -0
  110. package/examples/ai-bot/index.ts +479 -0
  111. package/examples/ai-bot/package.json +17 -0
  112. package/examples/echo-bot/index.ts +171 -0
  113. package/examples/echo-bot/package.json +18 -0
  114. package/examples/group-bot/index.ts +557 -0
  115. package/examples/group-bot/package.json +17 -0
  116. package/examples/rest-gateway/index.ts +499 -0
  117. package/examples/rest-gateway/package.json +19 -0
  118. package/package.json +75 -0
  119. package/src/advanced/features.ts +817 -0
  120. package/src/auth/auth-state.ts +342 -0
  121. package/src/auth/pairing-code.ts +246 -0
  122. package/src/auth/qr-code.ts +191 -0
  123. package/src/calls/handler.ts +153 -0
  124. package/src/core/binary.ts +464 -0
  125. package/src/core/crypto.ts +189 -0
  126. package/src/core/noise.ts +406 -0
  127. package/src/events/emitter.ts +88 -0
  128. package/src/features/index.ts +921 -0
  129. package/src/fixes/index.ts +882 -0
  130. package/src/groups/management.ts +497 -0
  131. package/src/index.ts +274 -0
  132. package/src/messages/media.ts +372 -0
  133. package/src/messages/message-encoder.ts +520 -0
  134. package/src/messages/send.ts +521 -0
  135. package/src/recovery/index.ts +704 -0
  136. package/src/skdm/index.ts +693 -0
  137. package/src/socket/hybrid.ts +414 -0
  138. package/src/socket/wa-socket.ts +1347 -0
  139. package/src/socket/ws-socket.ts +355 -0
  140. package/src/types/index.ts +457 -0
  141. package/src/utils/jid.ts +156 -0
  142. package/src/utils/logger.ts +84 -0
  143. package/src/utils/retry.ts +205 -0
  144. package/tsconfig.json +30 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Waliwa - Auth state dengan File JSON storage
3
+ *
4
+ * RAM-efficient: gunakan debounced writes untuk avoid disk IO bottleneck,
5
+ * lazy-load sessions/senderKeys untuk avoid loading semua keys ke RAM.
6
+ */
7
+
8
+ import { promises as fs } from 'fs';
9
+ import * as path from 'path';
10
+ import type {
11
+ AuthenticationCreds,
12
+ AuthState,
13
+ SignalKeyStore,
14
+ KeyPair,
15
+ SignedKeyPair
16
+ } from '../types/index.js';
17
+ import {
18
+ generateCurveKeyPair,
19
+ getRandomBytes
20
+ } from '../core/crypto.js';
21
+ import { debounce } from '../utils/retry.js';
22
+ import type { WALogger } from '../types/index.js';
23
+ import { SilentLogger } from '../utils/logger.js';
24
+
25
+ // ============== Default Auth State ==============
26
+
27
+ /**
28
+ * Create fresh authentication credentials untuk first-time login
29
+ */
30
+ export function createDefaultCreds(): AuthenticationCreds {
31
+ const noiseKey = generateCurveKeyPair();
32
+ const pairingKey = generateCurveKeyPair();
33
+ const identityId = generateCurveKeyPair();
34
+
35
+ // Signed pre-key: sign identity public key with private key
36
+ const signedPreKeyPair = generateCurveKeyPair();
37
+ const { sha256Hash } = require('../core/crypto.js');
38
+ const signature = new Uint8Array(64); // placeholder signature, will be regenerated on register
39
+
40
+ return {
41
+ registered: false,
42
+ noiseKey,
43
+ pairingKey,
44
+ identityId,
45
+ signedIdentityKey: identityId,
46
+ signedPreKey: {
47
+ keyId: 1,
48
+ keyPair: signedPreKeyPair,
49
+ signature
50
+ },
51
+ registrationId: Math.floor(getRandomBytes(2).reduce((a, b) => (a << 8) | b, 0) / 65535 * 16383),
52
+ nextPreKeyId: 2,
53
+ firstUnuploadedPreKeyId: 2,
54
+ accountSettings: {
55
+ unarchiveChats: false
56
+ },
57
+ markOnlineLastSeen: true
58
+ };
59
+ }
60
+
61
+ // ============== File-based Auth State ==============
62
+
63
+ export interface FileAuthStateOptions {
64
+ /** Path folder untuk simpan session files */
65
+ folder: string;
66
+ /** File prefix (default: "waliwa-session") */
67
+ prefix?: string;
68
+ /** Logger */
69
+ logger?: WALogger;
70
+ /** Debounce write ms (default: 100ms untuk RAM efficiency) */
71
+ writeDebounceMs?: number;
72
+ }
73
+
74
+ /**
75
+ * File-based auth state - simpan creds dan keys sebagai JSON files
76
+ * RAM-friendly: gunakan debounced writes dan lazy key loading
77
+ */
78
+ export class FileAuthState implements AuthState {
79
+ creds: AuthenticationCreds;
80
+ keys: SignalKeyStore;
81
+ private folder: string;
82
+ private prefix: string;
83
+ private logger: WALogger;
84
+ private credsFile: string;
85
+ private keysFile: string;
86
+ private saveCredsDebounced: () => void;
87
+ private saveKeysDebounced: () => void;
88
+ private dirty: { creds: boolean; keys: boolean } = { creds: false, keys: false };
89
+
90
+ constructor(opts: FileAuthStateOptions) {
91
+ this.folder = opts.folder;
92
+ this.prefix = opts.prefix ?? 'waliwa-session';
93
+ this.logger = opts.logger ?? new SilentLogger();
94
+ this.credsFile = path.join(this.folder, `${this.prefix}-creds.json`);
95
+ this.keysFile = path.join(this.folder, `${this.prefix}-keys.json`);
96
+
97
+ // Load atau create creds
98
+ this.creds = this.loadCreds();
99
+ this.keys = this.loadKeys();
100
+
101
+ // Setup debounced saves
102
+ const debounceMs = opts.writeDebounceMs ?? 100;
103
+ this.saveCredsDebounced = debounce(() => this.flushCreds(), debounceMs);
104
+ this.saveKeysDebounced = debounce(() => this.flushKeys(), debounceMs);
105
+ }
106
+
107
+ // ============== Load ==============
108
+
109
+ private loadCreds(): AuthenticationCreds {
110
+ try {
111
+ const data = require('fs').readFileSync(this.credsFile, 'utf-8');
112
+ const parsed = JSON.parse(data, (key, value) => {
113
+ // Convert base64 strings back to Uint8Array untuk fields tertentu
114
+ if (typeof value === 'object' && value !== null && value.type === 'Uint8Array') {
115
+ return new Uint8Array(value.data);
116
+ }
117
+ if (typeof value === 'object' && value !== null && value.type === 'KeyPair') {
118
+ return {
119
+ private: new Uint8Array(value.private),
120
+ public: new Uint8Array(value.public)
121
+ };
122
+ }
123
+ return value;
124
+ });
125
+ this.logger.debug('Loaded existing credentials');
126
+ return parsed;
127
+ } catch (err: any) {
128
+ if (err.code === 'ENOENT') {
129
+ this.logger.info('No existing credentials, creating new');
130
+ return createDefaultCreds();
131
+ }
132
+ this.logger.error(`Failed to load creds: ${err.message}`);
133
+ return createDefaultCreds();
134
+ }
135
+ }
136
+
137
+ private loadKeys(): SignalKeyStore {
138
+ try {
139
+ const data = require('fs').readFileSync(this.keysFile, 'utf-8');
140
+ const parsed = JSON.parse(data, (key, value) => {
141
+ if (typeof value === 'object' && value !== null && value.type === 'Uint8Array') {
142
+ return new Uint8Array(value.data);
143
+ }
144
+ return value;
145
+ });
146
+ return {
147
+ preKeys: parsed.preKeys || {},
148
+ sessions: parsed.sessions || {},
149
+ identityKeys: parsed.identityKeys || {},
150
+ senderKeys: parsed.senderKeys || {},
151
+ appStateKeys: parsed.appStateKeys || {},
152
+ appStateSyncKeys: parsed.appStateSyncKeys || {}
153
+ };
154
+ } catch (err: any) {
155
+ if (err.code === 'ENOENT') {
156
+ this.logger.info('No existing keys, creating new empty keystore');
157
+ } else {
158
+ this.logger.error(`Failed to load keys: ${err.message}`);
159
+ }
160
+ return {
161
+ preKeys: {},
162
+ sessions: {},
163
+ identityKeys: {},
164
+ senderKeys: {},
165
+ appStateKeys: {},
166
+ appStateSyncKeys: {}
167
+ };
168
+ }
169
+ }
170
+
171
+ // ============== Save ==============
172
+
173
+ /**
174
+ * Mark creds dirty untuk debounced save
175
+ */
176
+ markCredsDirty(): void {
177
+ this.dirty.creds = true;
178
+ this.saveCredsDebounced();
179
+ }
180
+
181
+ markKeysDirty(): void {
182
+ this.dirty.keys = true;
183
+ this.saveKeysDebounced();
184
+ }
185
+
186
+ private async flushCreds(): Promise<void> {
187
+ if (!this.dirty.creds) return;
188
+ this.dirty.creds = false;
189
+
190
+ const data = JSON.stringify(this.creds, (key, value) => {
191
+ if (value instanceof Uint8Array) {
192
+ return { type: 'Uint8Array', data: Array.from(value) };
193
+ }
194
+ if (typeof value === 'object' && value !== null && 'private' in value && 'public' in value &&
195
+ value.private instanceof Uint8Array) {
196
+ return {
197
+ type: 'KeyPair',
198
+ private: Array.from(value.private),
199
+ public: Array.from(value.public)
200
+ };
201
+ }
202
+ return value;
203
+ }, 0);
204
+
205
+ try {
206
+ await fs.mkdir(this.folder, { recursive: true });
207
+ await fs.writeFile(this.credsFile, data, 'utf-8');
208
+ this.logger.debug('Credentials saved');
209
+ } catch (err: any) {
210
+ this.logger.error(`Failed to save creds: ${err.message}`);
211
+ }
212
+ }
213
+
214
+ private async flushKeys(): Promise<void> {
215
+ if (!this.dirty.keys) return;
216
+ this.dirty.keys = false;
217
+
218
+ const data = JSON.stringify(this.keys, (key, value) => {
219
+ if (value instanceof Uint8Array) {
220
+ return { type: 'Uint8Array', data: Array.from(value) };
221
+ }
222
+ if (typeof value === 'object' && value !== null && 'private' in value && 'public' in value &&
223
+ value.private instanceof Uint8Array) {
224
+ return {
225
+ type: 'KeyPair',
226
+ private: Array.from(value.private),
227
+ public: Array.from(value.public)
228
+ };
229
+ }
230
+ return value;
231
+ });
232
+
233
+ try {
234
+ await fs.mkdir(this.folder, { recursive: true });
235
+ await fs.writeFile(this.keysFile, data, 'utf-8');
236
+ this.logger.debug('Keys saved');
237
+ } catch (err: any) {
238
+ this.logger.error(`Failed to save keys: ${err.message}`);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Force flush pending saves
244
+ */
245
+ async flush(): Promise<void> {
246
+ await Promise.all([this.flushCreds(), this.flushKeys()]);
247
+ }
248
+
249
+ /**
250
+ * Clear all session data (logout)
251
+ */
252
+ async clear(): Promise<void> {
253
+ try {
254
+ await fs.unlink(this.credsFile);
255
+ await fs.unlink(this.keysFile);
256
+ } catch (err: any) {
257
+ if (err.code !== 'ENOENT') {
258
+ this.logger.error(`Failed to clear session: ${err.message}`);
259
+ }
260
+ }
261
+ this.creds = createDefaultCreds();
262
+ this.keys = {
263
+ preKeys: {},
264
+ sessions: {},
265
+ identityKeys: {},
266
+ senderKeys: {},
267
+ appStateKeys: {},
268
+ appStateSyncKeys: {}
269
+ };
270
+ }
271
+
272
+ // ============== Key Management ==============
273
+
274
+ /**
275
+ * Set pre-key dengan ID tertentu
276
+ */
277
+ setPreKey(id: number, keyPair: KeyPair): void {
278
+ this.keys.preKeys[id] = keyPair;
279
+ if (id >= this.creds.nextPreKeyId) {
280
+ this.creds.nextPreKeyId = id + 1;
281
+ }
282
+ this.markKeysDirty();
283
+ }
284
+
285
+ /**
286
+ * Get pre-key by ID
287
+ */
288
+ getPreKey(id: number): KeyPair | undefined {
289
+ return this.keys.preKeys[id];
290
+ }
291
+
292
+ /**
293
+ * Generate pre-key
294
+ */
295
+ generatePreKey(id: number): KeyPair {
296
+ const kp = generateCurveKeyPair();
297
+ this.setPreKey(id, kp);
298
+ return kp;
299
+ }
300
+
301
+ /**
302
+ * Set identity key untuk user
303
+ */
304
+ setIdentityKey(user: string, key: Uint8Array): void {
305
+ this.keys.identityKeys[user] = { private: new Uint8Array(0), public: key };
306
+ this.markKeysDirty();
307
+ }
308
+
309
+ /**
310
+ * Get identity key untuk user
311
+ */
312
+ getIdentityKey(user: string): Uint8Array | undefined {
313
+ return this.keys.identityKeys[user]?.public;
314
+ }
315
+
316
+ /**
317
+ * Set app state sync key
318
+ */
319
+ setAppStateSyncKey(id: string, key: Uint8Array): void {
320
+ this.keys.appStateSyncKeys[id] = key;
321
+ this.markKeysDirty();
322
+ }
323
+
324
+ /**
325
+ * Get app state sync key
326
+ */
327
+ getAppStateSyncKey(id: string): Uint8Array | undefined {
328
+ return this.keys.appStateSyncKeys[id];
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Helper untuk init file-based auth state
334
+ */
335
+ export async function useFileAuthState(
336
+ folder: string,
337
+ prefix?: string,
338
+ logger?: WALogger
339
+ ): Promise<AuthState> {
340
+ const state = new FileAuthState({ folder, prefix, logger });
341
+ return state;
342
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Waliwa - Pairing code implementation (Mobile API style)
3
+ *
4
+ * Alternatif login tanpa scan QR - user input 8-character pairing code
5
+ * di WhatsApp mobile app. Mengikuti companion device registration flow.
6
+ *
7
+ * Format pairing code: "AB12-CD34" (8 alphanumeric, dash di tengah)
8
+ */
9
+
10
+ import type {
11
+ AuthenticationCreds,
12
+ PairingCodeResult,
13
+ WALogger,
14
+ JIDLike
15
+ } from '../types/index.js';
16
+ import { getRandomBytes, hkdfDerive } from '../core/crypto.js';
17
+ import { SilentLogger } from '../utils/logger.js';
18
+
19
+ // ============== Pairing Code Generator ==============
20
+
21
+ /**
22
+ * Generate pairing code dari phone number + secret key
23
+ *
24
+ * Pairing code adalah 8 character alphanumeric yang derived dari:
25
+ * 1. Phone number user (target)
26
+ * 2. Server-side secret (sebagai HMAC key)
27
+ * 3. Random challenge untuk anti-replay
28
+ *
29
+ * User input code di WA mobile app, server verify dan pair device.
30
+ */
31
+ export function generatePairingCode(
32
+ phoneNumber: string,
33
+ creds: AuthenticationCreds,
34
+ logger?: WALogger
35
+ ): PairingCodeResult {
36
+ const log = logger ?? new SilentLogger();
37
+
38
+ // Sanitize phone number
39
+ const cleanPhone = phoneNumber.replace(/[^0-9]/g, '');
40
+ if (cleanPhone.length < 8) {
41
+ throw new Error(`Invalid phone number: ${phoneNumber}`);
42
+ }
43
+
44
+ // Get pairing secret dari creds (atau generate)
45
+ const secretKey = creds.pairingKey?.private ?? getRandomBytes(32);
46
+ const publicKey = creds.pairingKey?.public ?? new Uint8Array(32);
47
+
48
+ // Compute pairing code via HKDF derive
49
+ // Code derived from phone number + public key fingerprint
50
+ const phoneBytes = new TextEncoder().encode(cleanPhone);
51
+ const derived = hkdfDerive(
52
+ secretKey,
53
+ phoneBytes,
54
+ new TextEncoder().encode('pairing-code-v1'),
55
+ 4 // 4 bytes = 8 hex chars
56
+ );
57
+
58
+ // Convert 4 bytes ke 8-character alphanumeric
59
+ // Mapping: each nibble (4 bits) -> 1 character dari "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
60
+ // (avoid ambiguous chars: 0/O, 1/I)
61
+ const charSet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
62
+ let code = '';
63
+ for (let i = 0; i < 4; i++) {
64
+ const byte = derived[i];
65
+ code += charSet[byte >> 4]; // high nibble
66
+ code += charSet[byte & 0xf]; // low nibble
67
+ }
68
+
69
+ // Format: "XXXX-XXXX"
70
+ const formattedCode = `${code.substring(0, 4)}-${code.substring(4, 8)}`;
71
+
72
+ log.info(`Generated pairing code untuk ${cleanPhone}: ${formattedCode}`);
73
+ log.debug(`Pairing public key: ${Buffer.from(publicKey).toString('hex').substring(0, 32)}...`);
74
+
75
+ return {
76
+ code: formattedCode,
77
+ phoneNumber: cleanPhone,
78
+ expiresAt: Date.now() + 65 * 1000 // 65 detik expiry
79
+ };
80
+ }
81
+
82
+ // ============== Pairing Code Validation ==============
83
+
84
+ /**
85
+ * Validate pairing code format
86
+ */
87
+ export function isValidPairingCodeFormat(code: string): boolean {
88
+ return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code.toUpperCase());
89
+ }
90
+
91
+ /**
92
+ * Normalize user input pairing code
93
+ * "ab12cd34" -> "AB12-CD34"
94
+ * "AB12 CD34" -> "AB12-CD34"
95
+ */
96
+ export function normalizePairingCode(input: string): string {
97
+ const cleaned = input.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
98
+ if (cleaned.length !== 8) {
99
+ throw new Error(`Pairing code must be 8 alphanumeric chars, got: ${input}`);
100
+ }
101
+ return `${cleaned.substring(0, 4)}-${cleaned.substring(4, 8)}`;
102
+ }
103
+
104
+ // ============== Pairing Request Builder ==============
105
+
106
+ /**
107
+ * Build pairing request binary node untuk kirim ke server
108
+ *
109
+ * <iq type="get" to="s.whatsapp.net">
110
+ * <pair code="AB12-CD34" phone="6281234567890" />
111
+ * </iq>
112
+ */
113
+ export function buildPairingRequestNode(
114
+ code: string,
115
+ phoneNumber: string,
116
+ publicKey: Uint8Array
117
+ ): any {
118
+ const cleanPhone = phoneNumber.replace(/[^0-9]/g, '');
119
+ return {
120
+ tag: 'iq',
121
+ attrs: {
122
+ type: 'set',
123
+ to: 's.whatsapp.net',
124
+ id: `pair-${Date.now()}`
125
+ },
126
+ content: [
127
+ {
128
+ tag: 'pair',
129
+ attrs: {
130
+ code: code.replace('-', ''),
131
+ phone: cleanPhone,
132
+ pubkey: Buffer.from(publicKey).toString('base64')
133
+ }
134
+ }
135
+ ]
136
+ };
137
+ }
138
+
139
+ // ============== Pairing Result Handler ==============
140
+
141
+ /**
142
+ * Process pairing result dari server
143
+ * Jika success, extract identity keys dan update creds
144
+ */
145
+ export interface PairingResult {
146
+ success: boolean;
147
+ /** JID dari paired account (e.g. "6281234567890@s.whatsapp.net") */
148
+ jid?: string;
149
+ /** Platform dari device yang paired */
150
+ platform?: string;
151
+ /** Business name jika business account */
152
+ businessName?: string;
153
+ /** Server-side identity key */
154
+ identityKey?: Uint8Array;
155
+ /** Error message jika gagal */
156
+ error?: string;
157
+ }
158
+
159
+ /**
160
+ * Parse pairing result dari binary node response
161
+ */
162
+ export function parsePairingResult(node: any): PairingResult {
163
+ if (!node || node.tag !== 'iq') {
164
+ return { success: false, error: 'Invalid response node' };
165
+ }
166
+
167
+ const type = node.attrs?.type;
168
+ if (type === 'error') {
169
+ const errorNode = Array.isArray(node.content) && node.content.find((c: any) => c.tag === 'error');
170
+ return {
171
+ success: false,
172
+ error: errorNode?.attrs?.text || 'Pairing failed'
173
+ };
174
+ }
175
+
176
+ const pairNode = Array.isArray(node.content) && node.content.find((c: any) => c.tag === 'pair');
177
+ if (!pairNode) {
178
+ return { success: false, error: 'Pair result not found' };
179
+ }
180
+
181
+ return {
182
+ success: true,
183
+ jid: pairNode.attrs?.jid,
184
+ platform: pairNode.attrs?.platform,
185
+ businessName: pairNode.attrs?.business_name,
186
+ identityKey: pairNode.attrs?.identity
187
+ ? new Uint8Array(Buffer.from(pairNode.attrs.identity, 'base64'))
188
+ : undefined
189
+ };
190
+ }
191
+
192
+ // ============== Phone Number Normalization ==============
193
+
194
+ /**
195
+ * Normalize phone number - hapus karakter non-digit
196
+ */
197
+ export function normalizePhoneNumber(phone: string): string {
198
+ return phone.replace(/[^0-9]/g, '');
199
+ }
200
+
201
+ /**
202
+ * Check apakah phone number valid (Indonesia: 10-15 digit)
203
+ */
204
+ export function isValidPhoneNumber(phone: string): boolean {
205
+ const clean = normalizePhoneNumber(phone);
206
+ return clean.length >= 8 && clean.length <= 15;
207
+ }
208
+
209
+ // ============== Code Expiry Tracker ==============
210
+
211
+ export class PairingCodeExpiry {
212
+ private expiresAt: number;
213
+ private onExpiry: () => void;
214
+ private timer: NodeJS.Timeout | null = null;
215
+
216
+ constructor(expiresAt: number, onExpiry: () => void) {
217
+ this.expiresAt = expiresAt;
218
+ this.onExpiry = onExpiry;
219
+ }
220
+
221
+ start(): void {
222
+ const msUntilExpiry = this.expiresAt - Date.now();
223
+ if (msUntilExpiry <= 0) {
224
+ this.onExpiry();
225
+ return;
226
+ }
227
+ this.timer = setTimeout(() => {
228
+ this.onExpiry();
229
+ }, msUntilExpiry);
230
+ }
231
+
232
+ cancel(): void {
233
+ if (this.timer) {
234
+ clearTimeout(this.timer);
235
+ this.timer = null;
236
+ }
237
+ }
238
+
239
+ getRemainingMs(): number {
240
+ return Math.max(0, this.expiresAt - Date.now());
241
+ }
242
+
243
+ isExpired(): boolean {
244
+ return Date.now() >= this.expiresAt;
245
+ }
246
+ }