@link-os/whatsapp 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.
Files changed (54) hide show
  1. package/dist/client.d.ts +24 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +254 -64
  4. package/dist/client.js.map +1 -1
  5. package/dist/normalize.d.ts +17 -0
  6. package/dist/normalize.d.ts.map +1 -0
  7. package/dist/normalize.js +61 -0
  8. package/dist/normalize.js.map +1 -0
  9. package/package.json +2 -2
  10. package/src/client.ts +285 -67
  11. package/src/normalize.ts +70 -0
  12. package/tsconfig.tsbuildinfo +1 -1
  13. package/auth_info_baileys/app-state-sync-key-AAAAANpA.json +0 -1
  14. package/auth_info_baileys/app-state-sync-key-AAAAANpB.json +0 -1
  15. package/auth_info_baileys/app-state-sync-key-AAAAANpC.json +0 -1
  16. package/auth_info_baileys/app-state-sync-key-AAAAANpD.json +0 -1
  17. package/auth_info_baileys/app-state-sync-key-AAAAANpE.json +0 -1
  18. package/auth_info_baileys/app-state-sync-key-AAAAANpF.json +0 -1
  19. package/auth_info_baileys/app-state-sync-key-AAAAANpG.json +0 -1
  20. package/auth_info_baileys/app-state-sync-key-AAAAANpI.json +0 -1
  21. package/auth_info_baileys/app-state-sync-key-AAAAANpJ.json +0 -1
  22. package/auth_info_baileys/app-state-sync-key-ADMAANpI.json +0 -1
  23. package/auth_info_baileys/app-state-sync-version-regular_low.json +0 -1
  24. package/auth_info_baileys/creds.json +0 -1
  25. package/auth_info_baileys/pre-key-1.json +0 -1
  26. package/auth_info_baileys/pre-key-10.json +0 -1
  27. package/auth_info_baileys/pre-key-11.json +0 -1
  28. package/auth_info_baileys/pre-key-12.json +0 -1
  29. package/auth_info_baileys/pre-key-13.json +0 -1
  30. package/auth_info_baileys/pre-key-14.json +0 -1
  31. package/auth_info_baileys/pre-key-15.json +0 -1
  32. package/auth_info_baileys/pre-key-16.json +0 -1
  33. package/auth_info_baileys/pre-key-17.json +0 -1
  34. package/auth_info_baileys/pre-key-18.json +0 -1
  35. package/auth_info_baileys/pre-key-19.json +0 -1
  36. package/auth_info_baileys/pre-key-2.json +0 -1
  37. package/auth_info_baileys/pre-key-20.json +0 -1
  38. package/auth_info_baileys/pre-key-22.json +0 -1
  39. package/auth_info_baileys/pre-key-23.json +0 -1
  40. package/auth_info_baileys/pre-key-24.json +0 -1
  41. package/auth_info_baileys/pre-key-25.json +0 -1
  42. package/auth_info_baileys/pre-key-26.json +0 -1
  43. package/auth_info_baileys/pre-key-27.json +0 -1
  44. package/auth_info_baileys/pre-key-28.json +0 -1
  45. package/auth_info_baileys/pre-key-29.json +0 -1
  46. package/auth_info_baileys/pre-key-3.json +0 -1
  47. package/auth_info_baileys/pre-key-30.json +0 -1
  48. package/auth_info_baileys/pre-key-4.json +0 -1
  49. package/auth_info_baileys/pre-key-5.json +0 -1
  50. package/auth_info_baileys/pre-key-6.json +0 -1
  51. package/auth_info_baileys/pre-key-7.json +0 -1
  52. package/auth_info_baileys/pre-key-8.json +0 -1
  53. package/auth_info_baileys/pre-key-9.json +0 -1
  54. package/auth_info_baileys/session-918477881793.0.json +0 -1
package/src/client.ts CHANGED
@@ -10,12 +10,21 @@ import qrcode from 'qrcode-terminal';
10
10
  import pino from 'pino';
11
11
 
12
12
  import type { PlatformClient, UnifiedMessage, Platform } from '@link-os/types';
13
+ import { normalizeWhatsAppTarget } from './normalize.js';
13
14
 
14
15
  const VERSION = '0.1.0';
15
16
 
17
+ export interface AllowedContext {
18
+ allowedJid: string; // The JID is stored here
19
+ name: string;
20
+ type: string;
21
+ image?: string;
22
+ }
23
+
16
24
  export interface WhatsAppClientOptions {
17
25
  sessionId: string;
18
26
  authDir?: string;
27
+ allowedContexts?: AllowedContext[];
19
28
  }
20
29
 
21
30
  export class WhatsAppClient implements PlatformClient {
@@ -27,12 +36,28 @@ export class WhatsAppClient implements PlatformClient {
27
36
  private messageHandler?: (message: UnifiedMessage) => Promise<void>;
28
37
  private statusHandler?: (status: { type: string; data?: any }) => void;
29
38
  private stopped = false;
39
+ private isStarting = false;
40
+ private reconnectAttempts = 0;
41
+ private maxReconnectAttempts = 3;
42
+ private resetTimeout: NodeJS.Timeout | null = null;
43
+ private allowedJids: string[] = [];
44
+ // TODO: Implement persistent storage to save contacts across restarts.
45
+ // Currently, contacts are only in-memory and lost on restart.
30
46
 
31
47
  constructor(options: WhatsAppClientOptions) {
32
48
  this.options = {
33
49
  authDir: options.authDir || `.auth/whatsapp/${options.sessionId}`,
34
50
  ...options
35
51
  };
52
+
53
+ // Initialize allowedJids from allowedContexts
54
+ if (options.allowedContexts && options.allowedContexts.length > 0) {
55
+ this.allowedJids = options.allowedContexts
56
+ .map(ctx => normalizeWhatsAppTarget(ctx.allowedJid))
57
+ .filter((jid): jid is string => !!jid);
58
+ } else {
59
+ this.allowedJids = [];
60
+ }
36
61
  }
37
62
 
38
63
  on(event: 'message' | 'status', handler: any): void {
@@ -44,78 +69,124 @@ export class WhatsAppClient implements PlatformClient {
44
69
  }
45
70
 
46
71
  async start(): Promise<void> {
72
+ if (this.isStarting) {
73
+ return;
74
+ }
75
+ this.isStarting = true;
47
76
  this.stopped = false;
48
- const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir!);
49
- const { version } = await fetchLatestBaileysVersion();
50
-
51
- console.log(`Using Baileys version: ${version.join('.')}`);
52
-
53
- this.sock = (makeWASocket as any).default ? (makeWASocket as any).default({
54
- auth: {
55
- creds: state.creds,
56
- keys: (makeCacheableSignalKeyStore as any)(state.keys, this.logger),
57
- },
58
- version,
59
- logger: this.logger,
60
- printQRInTerminal: false,
61
- browser: ['linkos', 'cli', VERSION],
62
- syncFullHistory: false,
63
- markOnlineOnConnect: false,
64
- shouldIgnoreJid: (jid: string) => jid.includes('@broadcast') || jid.includes('@newsletter')
65
- }) : (makeWASocket as any)({
66
- auth: {
67
- creds: state.creds,
68
- keys: (makeCacheableSignalKeyStore as any)(state.keys, this.logger),
69
- },
70
- version,
71
- logger: this.logger,
72
- printQRInTerminal: false,
73
- browser: ['linkos', 'cli', VERSION],
74
- syncFullHistory: false,
75
- markOnlineOnConnect: false,
76
- shouldIgnoreJid: (jid: string) => jid.includes('@broadcast') || jid.includes('@newsletter')
77
- });
78
77
 
79
- if (this.sock.ws && typeof this.sock.ws.on === 'function') {
80
- this.sock.ws.on('error', (err: Error) => {
81
- console.error('WebSocket error:', err.message);
82
- });
78
+ // Clear any pending reset
79
+ if (this.resetTimeout) {
80
+ clearTimeout(this.resetTimeout);
81
+ this.resetTimeout = null;
83
82
  }
84
83
 
85
- this.sock.ev.on('connection.update', async (update: any) => {
86
- const { connection, lastDisconnect, qr } = update;
84
+ try {
85
+ console.log(`[WhatsAppClient] Initializing auth state for ${this.options.sessionId}...`);
86
+ const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir!);
87
+ console.log(`[WhatsAppClient] Fetching latest Baileys version...`);
88
+ const { version } = await fetchLatestBaileysVersion();
87
89
 
88
- if (qr) {
89
- console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
90
- qrcode.generate(qr, { small: true });
91
- if (this.statusHandler) {
92
- this.statusHandler({ type: 'qr', data: qr });
93
- }
90
+ console.log(`Using Baileys version: ${version.join('.')}`);
91
+ // ... (rest of start)
92
+ if (this.allowedJids.length > 0) {
93
+ console.log(`🔒 Allowlist enabled: ${this.allowedJids.length} IDs allowed.`);
94
94
  }
95
95
 
96
- if (connection === 'close') {
97
- const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
98
- const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
96
+ const isDefaultImport = !!(makeWASocket as any).default;
97
+ const sockOptions = {
98
+ auth: {
99
+ creds: state.creds,
100
+ keys: makeCacheableSignalKeyStore(state.keys, this.logger),
101
+ },
102
+ version,
103
+ logger: this.logger,
104
+ printQRInTerminal: false,
105
+ browser: ['Linkos', 'Chrome', '121.0.6167.140'] as [string, string, string],
106
+ syncFullHistory: false,
107
+ markOnlineOnConnect: true,
108
+ connectTimeoutMs: 60000,
109
+ defaultQueryTimeoutMs: 60000,
110
+ };
111
+
112
+ this.sock = isDefaultImport ? (makeWASocket as any).default(sockOptions) : makeWASocket(sockOptions);
99
113
 
100
- console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
114
+ if (this.sock.ws && typeof this.sock.ws.on === 'function') {
115
+ this.sock.ws.on('error', (err: Error) => {
116
+ console.error('WebSocket error:', err.message);
117
+ });
118
+ }
119
+
120
+ this.sock.ev.on('connection.update', async (update: any) => {
121
+ const { connection, lastDisconnect, qr } = update;
101
122
 
102
- if (shouldReconnect && !this.reconnecting && !this.stopped) {
103
- this.reconnecting = true;
104
- console.log('Reconnecting in 5 seconds...');
105
- setTimeout(() => {
106
- this.reconnecting = false;
107
- this.start();
108
- }, 5000);
123
+ if (qr) {
124
+ console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
125
+ qrcode.generate(qr, { small: true });
126
+ if (this.statusHandler) {
127
+ this.statusHandler({ type: 'qr', data: qr });
128
+ }
109
129
  }
110
- } else if (connection === 'open') {
111
- console.log('✅ Connected to WhatsApp');
112
- if (this.statusHandler) {
113
- this.statusHandler({ type: 'connected' });
130
+
131
+ if (connection === 'close') {
132
+ const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
133
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
134
+
135
+ console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
136
+
137
+ if (this.resetTimeout) {
138
+ clearTimeout(this.resetTimeout);
139
+ this.resetTimeout = null;
140
+ }
141
+
142
+ if (shouldReconnect && !this.reconnecting && !this.stopped) {
143
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
144
+ this.reconnectAttempts++;
145
+ this.reconnecting = true;
146
+ if (this.statusHandler) {
147
+ this.statusHandler({ type: 'reconnecting', data: { attempt: this.reconnectAttempts, max: this.maxReconnectAttempts } });
148
+ }
149
+ console.log(`Reconnecting (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}) in 5 seconds...`);
150
+ setTimeout(() => {
151
+ this.reconnecting = false;
152
+ this.start();
153
+ }, 5000);
154
+ } else {
155
+ console.error(`[WhatsApp] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Stopping.`);
156
+ if (this.statusHandler) {
157
+ this.statusHandler({ type: 'disconnected', data: { reason: 'max_retries' } });
158
+ }
159
+ this.stop();
160
+ }
161
+ } else if (this.statusHandler && !shouldReconnect) {
162
+ this.statusHandler({ type: 'disconnected' });
163
+ }
164
+ } else if (connection === 'open') {
165
+ console.log('✅ Connected to WhatsApp');
166
+ this.isStarting = false;
167
+
168
+ // Only reset attempts after 10 seconds of stable connection
169
+ if (this.resetTimeout) clearTimeout(this.resetTimeout);
170
+ this.resetTimeout = setTimeout(() => {
171
+ this.reconnectAttempts = 0;
172
+ this.resetTimeout = null;
173
+ }, 10000);
174
+
175
+ if (this.statusHandler) {
176
+ this.statusHandler({ type: 'connected' });
177
+ }
114
178
  }
115
- }
116
- });
179
+ });
180
+
181
+ this.sock.ev.on('creds.update', saveCreds);
117
182
 
118
- this.sock.ev.on('creds.update', saveCreds);
183
+ } catch (error) {
184
+ this.isStarting = false;
185
+ console.error('[WhatsApp] Failed to start:', error);
186
+ if (this.statusHandler) {
187
+ this.statusHandler({ type: 'error', data: error });
188
+ }
189
+ }
119
190
 
120
191
  this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
121
192
  if (type !== 'notify') return;
@@ -124,28 +195,89 @@ export class WhatsAppClient implements PlatformClient {
124
195
  if (msg.key.fromMe) continue;
125
196
  if (msg.key.remoteJid === 'status@broadcast') continue;
126
197
 
198
+ const remoteJid = msg.key.remoteJid;
199
+ const participant = msg.key.participant || remoteJid;
200
+ const isGroup = remoteJid?.endsWith('@g.us') || false;
201
+
202
+ // Allowlist Check
203
+ if (this.allowedJids.length > 0) {
204
+ const isAllowed = this.allowedJids.some(allowed =>
205
+ remoteJid?.includes(allowed) || participant?.includes(allowed)
206
+ );
207
+
208
+ if (!isAllowed) {
209
+ // console.log(`🚫 Ignoring message from unauthorized source: ${remoteJid}`);
210
+ continue;
211
+ }
212
+ }
213
+
214
+ // Group Mention Policy: Only respond if tagged
215
+ if (isGroup && this.sock?.user?.id) {
216
+ const botJid = normalizeWhatsAppTarget(this.sock.user.id);
217
+ const botLid = this.sock.user.lid ? normalizeWhatsAppTarget(this.sock.user.lid) : null;
218
+
219
+ const message = msg.message;
220
+ const contextInfo = message?.extendedTextMessage?.contextInfo ||
221
+ message?.imageMessage?.contextInfo ||
222
+ message?.videoMessage?.contextInfo ||
223
+ message?.documentMessage?.contextInfo ||
224
+ message?.audioMessage?.contextInfo ||
225
+ (message as any)?.contextInfo;
226
+
227
+ const mentions = contextInfo?.mentionedJid || [];
228
+
229
+ // Normalize all mentions and the bot ID for comparison
230
+ const isMentioned = mentions.some((m: string) => {
231
+ const normM = normalizeWhatsAppTarget(m);
232
+ const match = (botJid && normM === botJid) ||
233
+ (botLid && normM === botLid) ||
234
+ m === this.sock.user.id ||
235
+ (this.sock.user.lid && m === this.sock.user.lid);
236
+
237
+ return match;
238
+ });
239
+
240
+ // Check if it's a reply to the bot
241
+ const quotedParticipant = contextInfo?.participant;
242
+ const isReplyToBot = quotedParticipant && (
243
+ normalizeWhatsAppTarget(quotedParticipant) === botJid ||
244
+ normalizeWhatsAppTarget(quotedParticipant) === botLid ||
245
+ quotedParticipant === this.sock.user.id ||
246
+ (this.sock.user.lid && quotedParticipant === this.sock.user.lid)
247
+ );
248
+
249
+ if (!isMentioned && !isReplyToBot) {
250
+ continue;
251
+ }
252
+ }
253
+
127
254
  const content = this.extractMessageContent(msg);
128
255
  if (!content) continue;
129
256
 
130
257
  if (!this.messageHandler) continue;
131
258
 
132
- const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
133
-
134
259
  const unifiedMessage: UnifiedMessage = {
135
260
  id: msg.key.id || `wa_${Date.now()}`,
136
261
  platform: 'whatsapp',
137
- userId: msg.key.remoteJid?.replace('@s.whatsapp.net', '') || '',
262
+ userId: remoteJid || '',
138
263
  sessionId: this.options.sessionId,
139
264
  content,
140
265
  messageType: 'text',
141
266
  timestamp: new Date((msg.messageTimestamp as number) * 1000),
142
267
  metadata: {
143
- pushName: msg.pushName,
144
- jid: msg.key.remoteJid,
145
- isGroup
268
+ pushName: msg.pushName || 'Unknown User',
269
+ isGroup,
270
+ participant: isGroup ? participant : undefined,
271
+ jid: remoteJid
146
272
  }
147
273
  };
148
274
 
275
+ // Typing indicator (optional, but keep for UX if stable)
276
+ try {
277
+ // Only start typing if jid is valid
278
+ if (remoteJid) await this.startTyping(remoteJid);
279
+ } catch (e) { /* ignore */ }
280
+
149
281
  await this.messageHandler(unifiedMessage);
150
282
  }
151
283
  });
@@ -167,15 +299,101 @@ export class WhatsAppClient implements PlatformClient {
167
299
 
168
300
  async sendMessage(to: string, text: string): Promise<void> {
169
301
  if (!this.sock) throw new Error('Not connected');
170
- const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
302
+
303
+ const jid = normalizeWhatsAppTarget(to);
304
+ if (!jid) {
305
+ console.error(`[WhatsApp] Invalid message target: ${to}`);
306
+ return;
307
+ }
308
+
171
309
  await this.sock.sendMessage(jid, { text });
172
310
  }
173
311
 
312
+ async startTyping(jid: string): Promise<void> {
313
+ if (!this.sock) return;
314
+ await this.sock.sendPresenceUpdate('composing', jid);
315
+ }
316
+
317
+ async stopTyping(jid: string): Promise<void> {
318
+ if (!this.sock) return;
319
+ await this.sock.sendPresenceUpdate('paused', jid);
320
+ }
321
+
322
+ async reactToMessage(jid: string, key: any, emoji: string): Promise<void> {
323
+ if (!this.sock) return;
324
+ await this.sock.sendMessage(jid, { react: { text: emoji, key } });
325
+ }
326
+
327
+ async markRead(jid: string, key: any): Promise<void> {
328
+ if (!this.sock) return;
329
+ await this.sock.readMessages([key]);
330
+ }
331
+
332
+ async updateConfiguration(config: Partial<WhatsAppClientOptions>): Promise<void> {
333
+ if (config.allowedContexts) {
334
+ this.allowedJids = config.allowedContexts
335
+ .map(ctx => normalizeWhatsAppTarget(ctx.allowedJid))
336
+ .filter((jid): jid is string => !!jid);
337
+ console.log(`🔄 Configuration updated: Allowlist now has ${this.allowedJids.length} normalized IDs.`);
338
+ }
339
+ }
340
+
174
341
  async stop(): Promise<void> {
175
342
  this.stopped = true;
343
+ this.isStarting = false;
344
+ if (this.resetTimeout) {
345
+ clearTimeout(this.resetTimeout);
346
+ this.resetTimeout = null;
347
+ }
176
348
  if (this.sock) {
177
349
  this.sock.end(undefined);
178
350
  this.sock = null;
179
351
  }
180
352
  }
353
+
354
+ async deleteSession(): Promise<void> {
355
+ await this.stop();
356
+ if (this.options.authDir) {
357
+ const fs = await import('fs/promises');
358
+ try {
359
+ await fs.rm(this.options.authDir, { recursive: true, force: true });
360
+ console.log(`Deleted session directory: ${this.options.authDir}`);
361
+ } catch (error: any) {
362
+ console.error(`Failed to delete session directory: ${error.message}`);
363
+ }
364
+ }
365
+ }
366
+
367
+ async getAvailableContexts(): Promise<{ id: string; name: string; type: 'group' | 'user'; image?: string }[]> {
368
+ if (!this.sock) return [];
369
+
370
+ const contexts: { id: string; name: string; type: 'group' | 'user'; image?: string }[] = [];
371
+
372
+ try {
373
+ // 1. Groups from direct fetch (most reliable for groups)
374
+ const groups = await this.sock.groupFetchAllParticipating();
375
+ for (const [id, metadata] of Object.entries(groups)) {
376
+ contexts.push({
377
+ id,
378
+ name: (metadata as any).subject || 'Unknown Group',
379
+ type: 'group'
380
+ });
381
+ }
382
+ } catch (error) {
383
+ console.error('Failed to fetch contexts:', error);
384
+ }
385
+
386
+ // Fetch Profile Pictures (best effort, parallel)
387
+ await Promise.all(contexts.map(async (ctx) => {
388
+ try {
389
+ // Only fetch if we don't have one or to refresh
390
+ ctx.image = await this.sock.profilePictureUrl(ctx.id, 'preview');
391
+ } catch (e) {
392
+ // Ignore error (no profile pic or privacy restricted)
393
+ ctx.image = undefined;
394
+ }
395
+ }));
396
+
397
+ return contexts;
398
+ }
181
399
  }
@@ -0,0 +1,70 @@
1
+
2
+ /**
3
+ * WhatsApp JID Normalization following OpenClaw/Baileys patterns.
4
+ */
5
+
6
+ const WHATSAPP_USER_JID_RE = /^(\d+)(?::\d+)?@s\.whatsapp\.net$/i;
7
+ const WHATSAPP_LID_RE = /^(\d+)(?::\d+)?@lid$/i;
8
+
9
+ /**
10
+ * Clean phone numbers to digits only.
11
+ */
12
+ export function normalizeE164(value: string): string {
13
+ return value.replace(/\D/g, "");
14
+ }
15
+
16
+ function stripWhatsAppTargetPrefixes(value: string): string {
17
+ let candidate = value.trim();
18
+ for (; ;) {
19
+ const before = candidate;
20
+ candidate = candidate.replace(/^whatsapp:/i, "").trim();
21
+ if (candidate === before) {
22
+ return candidate;
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Check if the JID belongs to a group.
29
+ */
30
+ export function isWhatsAppGroupJid(value: string): boolean {
31
+ const candidate = stripWhatsAppTargetPrefixes(value);
32
+ const lower = candidate.toLowerCase();
33
+ return lower.endsWith("@g.us");
34
+ }
35
+
36
+ /**
37
+ * Normalize input string into a valid Baileys JID.
38
+ * Handles phone numbers, group IDs, and existing JIDs with device suffixes.
39
+ */
40
+ export function normalizeWhatsAppTarget(value: string): string | null {
41
+ const candidate = stripWhatsAppTargetPrefixes(value);
42
+ if (!candidate) return null;
43
+
44
+ // Handle Groups
45
+ if (isWhatsAppGroupJid(candidate)) {
46
+ const localPart = candidate.slice(0, candidate.length - "@g.us".length);
47
+ return `${localPart}@g.us`;
48
+ }
49
+
50
+ // Handle standard user JIDs (strip device suffixes like :5)
51
+ if (WHATSAPP_USER_JID_RE.test(candidate)) {
52
+ const match = candidate.match(WHATSAPP_USER_JID_RE);
53
+ return `${match![1]}@s.whatsapp.net`;
54
+ }
55
+
56
+ // Handle LIDs
57
+ if (WHATSAPP_LID_RE.test(candidate)) {
58
+ const match = candidate.match(WHATSAPP_LID_RE);
59
+ return `${match![1]}@lid`;
60
+ }
61
+
62
+ // Handle raw phone numbers or strings without @
63
+ if (!candidate.includes("@")) {
64
+ const normalized = normalizeE164(candidate);
65
+ // Only accept if it has at least 5 digits (sanity check for phone numbers)
66
+ return normalized.length >= 5 ? `${normalized}@s.whatsapp.net` : null;
67
+ }
68
+
69
+ return null;
70
+ }