neoagent 1.1.0 → 1.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.
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ const db = require('../db/database');
4
+ const { MemoryManager } = require('./memory/manager');
5
+ const { MCPClient } = require('./mcp/client');
6
+ const { BrowserController } = require('./browser/controller');
7
+ const { AgentEngine } = require('./ai/engine');
8
+ const { MultiStepOrchestrator } = require('./ai/multiStep');
9
+ const { MessagingManager } = require('./messaging/manager');
10
+ const { Scheduler } = require('./scheduler/cron');
11
+ const { setupWebSocket } = require('./websocket');
12
+ const { detectPromptInjection } = require('../utils/security');
13
+ const { normalizeWhatsAppId } = require('../utils/whatsapp');
14
+ const { randomUUID } = require('crypto');
15
+
16
+ async function startServices(app, io) {
17
+ try {
18
+ const memoryManager = new MemoryManager();
19
+ app.locals.memoryManager = memoryManager;
20
+
21
+ const mcpClient = new MCPClient();
22
+ app.locals.mcpClient = mcpClient;
23
+
24
+ const browserController = new BrowserController();
25
+ const headlessSetting = db.prepare('SELECT value FROM user_settings WHERE key = ? ORDER BY user_id LIMIT 1').get('headless_browser');
26
+ if (headlessSetting) {
27
+ const val = headlessSetting.value;
28
+ browserController.headless = val !== 'false' && val !== false && val !== '0';
29
+ }
30
+ app.locals.browserController = browserController;
31
+
32
+ const agentEngine = new AgentEngine(io, { memoryManager, mcpClient, browserController, messagingManager: null });
33
+ app.locals.agentEngine = agentEngine;
34
+
35
+ const multiStep = new MultiStepOrchestrator(agentEngine, io);
36
+ app.locals.multiStep = multiStep;
37
+
38
+ const messagingManager = new MessagingManager(io);
39
+ app.locals.messagingManager = messagingManager;
40
+ agentEngine.messagingManager = messagingManager;
41
+
42
+ messagingManager.restoreConnections().catch(err => console.error('[Messaging] Restore error:', err.message));
43
+
44
+ const users = db.prepare('SELECT id FROM users').all();
45
+ for (const u of users) {
46
+ mcpClient.loadFromDB(u.id).catch(err => console.error('[MCP] Auto-start error:', err.message));
47
+ }
48
+
49
+ const userQueues = {};
50
+ app.locals.userQueues = userQueues;
51
+
52
+ async function processMessage(userId, msg) {
53
+ if (!userQueues[userId]) userQueues[userId] = { running: false, pending: [] };
54
+ const q = userQueues[userId];
55
+
56
+ if (q.running) {
57
+ const last = q.pending[q.pending.length - 1];
58
+ if (last && last.platform === msg.platform && last.chatId === msg.chatId) {
59
+ last.content += '\n' + msg.content;
60
+ last.messageId = msg.messageId;
61
+ } else {
62
+ q.pending.push({ ...msg });
63
+ }
64
+ return;
65
+ }
66
+
67
+ q.running = true;
68
+ try {
69
+ await messagingManager.markRead(userId, msg.platform, msg.chatId, msg.messageId).catch(() => { });
70
+ await messagingManager.sendTyping(userId, msg.platform, msg.chatId, true).catch(() => { });
71
+
72
+ const mediaNote = msg.localMediaPath
73
+ ? `\nMedia attached at: ${msg.localMediaPath} (type: ${msg.mediaType}). You can reference or forward it with send_message media_path.`
74
+ : '';
75
+
76
+ if (detectPromptInjection(msg.content)) {
77
+ console.warn(`[Security] Possible prompt injection attempt from ${msg.sender} on ${msg.platform}: ${msg.content.slice(0, 200)}`);
78
+ }
79
+
80
+ const isVoiceCall = msg.platform === 'telnyx' && msg.mediaType === 'voice';
81
+ const isVoiceNote = !isVoiceCall && msg.mediaType === 'audio';
82
+ const isDiscordGuild = msg.platform === 'discord' && msg.isGroup;
83
+
84
+ const discordContext = (isDiscordGuild && Array.isArray(msg.channelContext) && msg.channelContext.length)
85
+ ? '\n\nRecent channel context (oldest → newest):\n' +
86
+ msg.channelContext.map(m => `[${m.author}]: ${m.content}`).join('\n')
87
+ : '';
88
+
89
+ const sttNote = isVoiceNote
90
+ ? '\n[Note: This message was sent as a voice note and transcribed via speech-to-text. The transcription may not be perfectly accurate.]'
91
+ : '';
92
+
93
+ const prompt = isVoiceCall
94
+ ? `You are on a live phone call. The caller (${msg.senderName || msg.sender}) said:\n<caller_speech>\n${msg.content}\n</caller_speech>\n\nRespond via send_message with platform="telnyx" and to="${msg.chatId}".`
95
+ : `You received a ${msg.platform} message from ${msg.senderName || msg.sender} (chat: ${msg.chatId}):\n<external_message>\n${msg.content}\n</external_message>${mediaNote}${discordContext}${sttNote}\n\nReply via send_message with platform="${msg.platform}" and to="${msg.chatId}".`;
96
+
97
+ let convRow = db.prepare(
98
+ 'SELECT id FROM conversations WHERE user_id = ? AND platform = ? AND platform_chat_id = ?'
99
+ ).get(userId, msg.platform, msg.chatId);
100
+
101
+ if (!convRow) {
102
+ const convId = randomUUID();
103
+ db.prepare(
104
+ 'INSERT INTO conversations (id, user_id, platform, platform_chat_id, title) VALUES (?, ?, ?, ?, ?)'
105
+ ).run(convId, userId, msg.platform, msg.chatId, `${msg.platform} — ${msg.senderName || msg.sender || msg.chatId}`);
106
+ convRow = { id: convId };
107
+ }
108
+
109
+ const runOpts = { triggerSource: 'messaging', conversationId: convRow.id, source: msg.platform, chatId: msg.chatId, context: { rawUserMessage: msg.content } };
110
+ if (msg.localMediaPath) runOpts.mediaAttachments = [{ path: msg.localMediaPath, type: msg.mediaType }];
111
+
112
+ await agentEngine.run(userId, prompt, runOpts);
113
+ } finally {
114
+ await messagingManager.sendTyping(userId, msg.platform, msg.chatId, false).catch(() => { });
115
+ q.running = false;
116
+ if (q.pending.length > 0) {
117
+ const next = q.pending.shift();
118
+ processMessage(userId, next);
119
+ }
120
+ }
121
+ }
122
+
123
+ messagingManager.registerHandler(async (userId, msg) => {
124
+ if (msg.platform !== 'discord' && msg.platform !== 'telegram') {
125
+ const whitelistRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
126
+ .get(userId, `platform_whitelist_${msg.platform}`);
127
+ if (whitelistRow) {
128
+ try {
129
+ const whitelist = JSON.parse(whitelistRow.value);
130
+ if (Array.isArray(whitelist) && whitelist.length > 0) {
131
+ const normalize = msg.platform === 'whatsapp'
132
+ ? normalizeWhatsAppId
133
+ : (id) => String(id || '').replace(/[^0-9+]/g, '');
134
+ const senderNorm = normalize(msg.sender || msg.chatId);
135
+ const allowed = whitelist.some((n) => normalize(n) === senderNorm);
136
+ if (!allowed) {
137
+ console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
138
+ io.to(`user:${userId}`).emit('messaging:blocked_sender', {
139
+ platform: msg.platform,
140
+ sender: msg.sender,
141
+ chatId: msg.chatId,
142
+ senderName: msg.senderName || null
143
+ });
144
+ return;
145
+ }
146
+ }
147
+ } catch { }
148
+ }
149
+ }
150
+
151
+ const upsertSetting = db.prepare('INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
152
+ upsertSetting.run(userId, 'last_platform', msg.platform);
153
+ upsertSetting.run(userId, 'last_chat_id', msg.chatId);
154
+
155
+ await processMessage(userId, msg);
156
+ });
157
+
158
+ const scheduler = new Scheduler(io, agentEngine);
159
+ app.locals.scheduler = scheduler;
160
+ agentEngine.scheduler = scheduler;
161
+ scheduler.start();
162
+
163
+ setupWebSocket(io, { agentEngine, messagingManager, mcpClient, scheduler, memoryManager, app });
164
+ app.locals.io = io;
165
+
166
+ console.log('All services initialized');
167
+ } catch (err) {
168
+ console.error('Service init error:', err);
169
+ }
170
+ }
171
+
172
+ module.exports = { startServices };
@@ -9,6 +9,18 @@ class BasePlatform extends EventEmitter {
9
9
  this.supportsGroups = false;
10
10
  this.supportsMedia = false;
11
11
  this.supportsVoice = false;
12
+ this.allowedEntries = new Set();
13
+ }
14
+
15
+ setAllowedEntries(entries) {
16
+ if (Array.isArray(entries)) {
17
+ this.allowedEntries = new Set(entries.map(String));
18
+ }
19
+ }
20
+
21
+ _checkAccess(id) {
22
+ if (this.allowedEntries.size === 0) return true;
23
+ return this.allowedEntries.has(String(id));
12
24
  }
13
25
 
14
26
  async connect() { throw new Error('connect() not implemented'); }
@@ -26,7 +26,9 @@ class DiscordPlatform extends BasePlatform {
26
26
  this.supportsMedia = false;
27
27
 
28
28
  this.token = config.token || '';
29
- this.allowedEntries = Array.isArray(config.allowedIds) ? config.allowedIds : [];
29
+ if (Array.isArray(config.allowedIds)) {
30
+ this.setAllowedEntries(config.allowedIds);
31
+ }
30
32
 
31
33
  this._client = null;
32
34
  this._botUser = null;
@@ -82,31 +84,23 @@ class DiscordPlatform extends BasePlatform {
82
84
 
83
85
  // ── Whitelist ──────────────────────────────────────────────────────────────
84
86
 
85
- /** Replaces the live entry list. Accepts prefixed strings. */
86
- setAllowedEntries(entries) {
87
- this.allowedEntries = Array.isArray(entries) ? entries : [];
88
- console.log(`[Discord] Whitelist updated: ${this.allowedEntries.length} entry(ies)`);
89
- }
87
+ // Inherits setAllowedEntries from BasePlatform
90
88
 
91
89
  /** Returns {allowed, requireMention} */
92
90
  _checkAccess(message) {
93
- const isDM = message.channel.type === ChannelType.DM;
91
+ // Empty whitelist: block everyone (add via the allow popup)
92
+ if (this.allowedEntries.size === 0) return { allowed: false, requireMention: false };
93
+
94
+ // Check prefixed entries
94
95
  const userId = message.author.id;
95
96
  const guildId = message.guildId || null;
96
97
  const channelId = message.channelId;
97
98
 
98
- // Empty whitelist: block everyone (add via the allow popup)
99
- if (!this.allowedEntries.length) return { allowed: false, requireMention: false };
100
-
101
- for (const entry of this.allowedEntries) {
102
- const colon = entry.indexOf(':');
103
- const type = colon > 0 ? entry.slice(0, colon) : 'user';
104
- const id = colon > 0 ? entry.slice(colon + 1) : entry;
99
+ if (super._checkAccess(`user:${userId}`)) return { allowed: true, requireMention: false };
100
+ if (super._checkAccess(userId)) return { allowed: true, requireMention: false }; // legacy
101
+ if (guildId && super._checkAccess(`guild:${guildId}`)) return { allowed: true, requireMention: true };
102
+ if (super._checkAccess(`channel:${channelId}`)) return { allowed: true, requireMention: true };
105
103
 
106
- if (type === 'user' && id === userId) return { allowed: true, requireMention: false };
107
- if (type === 'guild' && id === guildId) return { allowed: true, requireMention: true };
108
- if (type === 'channel' && id === channelId) return { allowed: true, requireMention: true };
109
- }
110
104
  return { allowed: false, requireMention: false };
111
105
  }
112
106
 
@@ -19,13 +19,15 @@ class TelegramPlatform extends BasePlatform {
19
19
  constructor(config = {}) {
20
20
  super('telegram', config);
21
21
  this.supportsGroups = true;
22
- this.supportsMedia = false;
22
+ this.supportsMedia = false;
23
23
 
24
- this.botToken = config.botToken || '';
25
- this.allowedEntries = Array.isArray(config.allowedIds) ? config.allowedIds : [];
24
+ this.botToken = config.botToken || '';
25
+ if (Array.isArray(config.allowedIds)) {
26
+ this.setAllowedEntries(config.allowedIds);
27
+ }
26
28
 
27
- this._bot = null;
28
- this._botUser = null;
29
+ this._bot = null;
30
+ this._botUser = null;
29
31
  // In-memory ring buffer of recent messages per raw chatId (Telegram has no fetch history API for bots)
30
32
  this._contextBuffers = new Map();
31
33
  this._contextMaxSize = 25;
@@ -36,7 +38,7 @@ class TelegramPlatform extends BasePlatform {
36
38
  async connect() {
37
39
  if (!this.botToken) throw new Error('Telegram bot token is required');
38
40
 
39
- if (this._bot) { try { await this._bot.stopPolling(); } catch {} this._bot = null; }
41
+ if (this._bot) { try { await this._bot.stopPolling(); } catch { } this._bot = null; }
40
42
 
41
43
  this._bot = new TelegramBot(this.botToken, { polling: true });
42
44
 
@@ -66,45 +68,37 @@ class TelegramPlatform extends BasePlatform {
66
68
  }
67
69
 
68
70
  async disconnect() {
69
- if (this._bot) { try { await this._bot.stopPolling(); } catch {} this._bot = null; }
70
- this.status = 'disconnected';
71
+ if (this._bot) { try { await this._bot.stopPolling(); } catch { } this._bot = null; }
72
+ this.status = 'disconnected';
71
73
  this._botUser = null;
72
74
  this.emit('disconnected', { manual: true });
73
75
  }
74
76
 
75
- async logout() { await this.disconnect(); }
76
- getStatus() { return this.status; }
77
- getAuthInfo() { return this._botUser ? { username: this._botUser.username, id: this._botUser.id } : null; }
77
+ async logout() { await this.disconnect(); }
78
+ getStatus() { return this.status; }
79
+ getAuthInfo() { return this._botUser ? { username: this._botUser.username, id: this._botUser.id } : null; }
78
80
 
79
81
  // ── Whitelist ──────────────────────────────────────────────────────────────
80
82
 
81
- setAllowedEntries(entries) {
82
- this.allowedEntries = Array.isArray(entries) ? entries : [];
83
- console.log(`[Telegram] Whitelist updated: ${this.allowedEntries.length} entry(ies)`);
84
- }
83
+ // Inherits setAllowedEntries from BasePlatform
85
84
 
86
85
  /** Returns {allowed, requireMention} */
87
86
  _checkAccess(msg) {
88
- const isPrivate = msg.chat.type === 'private';
89
- const userId = String(msg.from.id);
90
- const chatId = String(msg.chat.id); // negative for groups
87
+ const userId = String(msg.from.id);
88
+ const chatId = String(msg.chat.id); // negative for groups
91
89
 
92
- if (!this.allowedEntries.length) return { allowed: false, requireMention: false };
90
+ if (this.allowedEntries.size === 0) return { allowed: false, requireMention: false };
93
91
 
94
- for (const entry of this.allowedEntries) {
95
- const colon = entry.indexOf(':');
96
- const type = colon > 0 ? entry.slice(0, colon) : 'user';
97
- const id = colon > 0 ? entry.slice(colon + 1) : entry;
92
+ if (super._checkAccess(`user:${userId}`)) return { allowed: true, requireMention: false };
93
+ if (super._checkAccess(userId)) return { allowed: true, requireMention: false }; // legacy
94
+ if (super._checkAccess(`group:${chatId}`)) return { allowed: true, requireMention: true };
98
95
 
99
- if (type === 'user' && id === userId) return { allowed: true, requireMention: false };
100
- if (type === 'group' && id === chatId) return { allowed: true, requireMention: true };
101
- }
102
96
  return { allowed: false, requireMention: false };
103
97
  }
104
98
 
105
99
  _isMentioned(msg) {
106
100
  if (!this._botUser) return false;
107
- const text = msg.text || msg.caption || '';
101
+ const text = msg.text || msg.caption || '';
108
102
  const entities = msg.entities || msg.caption_entities || [];
109
103
  for (const e of entities) {
110
104
  if (e.type === 'mention') {
@@ -141,9 +135,9 @@ class TelegramPlatform extends BasePlatform {
141
135
  async _handleMessage(msg) {
142
136
  if (!msg.from || msg.from.is_bot) return;
143
137
 
144
- const isPrivate = msg.chat.type === 'private';
145
- const userId = String(msg.from.id);
146
- const rawChatId = String(msg.chat.id);
138
+ const isPrivate = msg.chat.type === 'private';
139
+ const userId = String(msg.from.id);
140
+ const rawChatId = String(msg.chat.id);
147
141
  const outputChatId = isPrivate ? `dm_${userId}` : rawChatId;
148
142
 
149
143
  const text = msg.text || msg.caption || '';
@@ -152,9 +146,9 @@ class TelegramPlatform extends BasePlatform {
152
146
 
153
147
  // Always record into context buffer (even blocked messages add context)
154
148
  this._addToContext(rawChatId, {
155
- author: senderName,
149
+ author: senderName,
156
150
  content: text || (msg.photo ? '[photo]' : msg.document ? '[document]' : '[empty]'),
157
- mine: false,
151
+ mine: false,
158
152
  });
159
153
 
160
154
  const { allowed, requireMention } = this._checkAccess(msg);
@@ -169,8 +163,8 @@ class TelegramPlatform extends BasePlatform {
169
163
  });
170
164
 
171
165
  this.emit('blocked_sender', {
172
- sender: userId,
173
- chatId: outputChatId,
166
+ sender: userId,
167
+ chatId: outputChatId,
174
168
  senderName,
175
169
  groupName: msg.chat.title || null,
176
170
  suggestions,
@@ -182,7 +176,7 @@ class TelegramPlatform extends BasePlatform {
182
176
  if (requireMention && !this._isMentioned(msg)) return;
183
177
 
184
178
  let content = requireMention ? this._stripMention(text) : text;
185
- if (!content && msg.photo) content = `[photo]`;
179
+ if (!content && msg.photo) content = `[photo]`;
186
180
  if (!content && msg.document) content = `[document: ${msg.document.file_name || 'file'}]`;
187
181
  if (!content) return;
188
182
 
@@ -193,18 +187,18 @@ class TelegramPlatform extends BasePlatform {
193
187
  const channelContext = (!isPrivate && requireMention) ? this._getContext(rawChatId) : null;
194
188
 
195
189
  this.emit('message', {
196
- platform: 'telegram',
197
- chatId: outputChatId,
198
- sender: userId,
199
- senderName: fullSenderName,
190
+ platform: 'telegram',
191
+ chatId: outputChatId,
192
+ sender: userId,
193
+ senderName: fullSenderName,
200
194
  content,
201
- mediaType: null,
202
- isGroup: !isPrivate,
203
- messageId: String(msg.message_id),
204
- timestamp: new Date(msg.date * 1000).toISOString(),
195
+ mediaType: null,
196
+ isGroup: !isPrivate,
197
+ messageId: String(msg.message_id),
198
+ timestamp: new Date(msg.date * 1000).toISOString(),
205
199
  channelContext,
206
- channelName: isPrivate ? null : (msg.chat.title || rawChatId),
207
- groupName: msg.chat.title || null,
200
+ channelName: isPrivate ? null : (msg.chat.title || rawChatId),
201
+ groupName: msg.chat.title || null,
208
202
  });
209
203
  }
210
204
 
@@ -222,9 +216,9 @@ class TelegramPlatform extends BasePlatform {
222
216
  // Store outgoing message in context buffer
223
217
  if (this._botUser) {
224
218
  this._addToContext(telegramChatId, {
225
- author: `[bot] ${this._botUser.username}`,
219
+ author: `[bot] ${this._botUser.username}`,
226
220
  content,
227
- mine: true,
221
+ mine: true,
228
222
  });
229
223
  }
230
224
 
@@ -1,6 +1,7 @@
1
1
  const { BasePlatform } = require('./base');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { toWhatsAppJid } = require('../../utils/whatsapp');
4
5
 
5
6
  const AUTH_DIR = path.join(__dirname, '..', '..', '..', 'data', 'whatsapp-auth');
6
7
 
@@ -208,10 +209,8 @@ class WhatsAppPlatform extends BasePlatform {
208
209
  throw new Error('WhatsApp not connected');
209
210
  }
210
211
 
211
- let jid = to;
212
- if (!jid.includes('@')) {
213
- jid = jid.replace(/[^0-9]/g, '') + '@s.whatsapp.net';
214
- }
212
+ const jid = toWhatsAppJid(to);
213
+ if (!jid) throw new Error('Invalid WhatsApp recipient');
215
214
 
216
215
  if (options.mediaPath) {
217
216
  const ext = path.extname(options.mediaPath).toLowerCase();
@@ -244,16 +243,16 @@ class WhatsAppPlatform extends BasePlatform {
244
243
 
245
244
  async markRead(chatId, messageId) {
246
245
  if (!this.sock) return;
247
- let jid = chatId;
248
- if (!jid.includes('@')) jid = jid + '@s.whatsapp.net';
246
+ const jid = toWhatsAppJid(chatId);
247
+ if (!jid) return;
249
248
  // readMessages expects full message keys; we do a best-effort read
250
249
  await this.sock.sendReadReceipt(jid, null, [messageId]).catch(() => { });
251
250
  }
252
251
 
253
252
  async sendTyping(chatId, isTyping) {
254
253
  if (!this.sock || this.status !== 'connected') return;
255
- let jid = chatId;
256
- if (!jid.includes('@')) jid = jid + '@s.whatsapp.net';
254
+ const jid = toWhatsAppJid(chatId);
255
+ if (!jid) return;
257
256
  await this.sock.sendPresenceUpdate(isTyping ? 'composing' : 'paused', jid).catch(() => { });
258
257
  }
259
258
 
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Intercepts console methods and broadcasts logs via Socket.IO
5
+ * @param {import('socket.io').Server} io
6
+ */
7
+ function setupConsoleInterceptor(io) {
8
+ const logHistory = [];
9
+ const MAX_LOG_HISTORY = 200;
10
+
11
+ function broadcastLog(type, args) {
12
+ const msg = Array.from(args).map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
13
+ const logEntry = { type, message: msg, timestamp: new Date().toISOString() };
14
+ logHistory.push(logEntry);
15
+ if (logHistory.length > MAX_LOG_HISTORY) logHistory.shift();
16
+
17
+ // Broadcast only to authenticated user rooms
18
+ for (const [, socket] of io.sockets.sockets) {
19
+ const uid = socket.request?.session?.userId;
20
+ if (uid) socket.emit('server:log', logEntry);
21
+ }
22
+ }
23
+
24
+ const originalConsole = {
25
+ log: console.log,
26
+ error: console.error,
27
+ warn: console.warn,
28
+ info: console.info
29
+ };
30
+
31
+ console.log = function (...args) { originalConsole.log.apply(console, args); broadcastLog('log', args); };
32
+ console.error = function (...args) { originalConsole.error.apply(console, args); broadcastLog('error', args); };
33
+ console.warn = function (...args) { originalConsole.warn.apply(console, args); broadcastLog('warn', args); };
34
+ console.info = function (...args) { originalConsole.info.apply(console, args); broadcastLog('info', args); };
35
+
36
+ io.on('connection', (socket) => {
37
+ socket.on('client:request_logs', () => {
38
+ if (!socket.request?.session?.userId) return;
39
+ socket.emit('server:log_history', logHistory);
40
+ });
41
+ });
42
+
43
+ return logHistory;
44
+ }
45
+
46
+ module.exports = { setupConsoleInterceptor };
@@ -0,0 +1,46 @@
1
+ function normalizeWhatsAppId(value) {
2
+ const raw = String(value || '').trim().toLowerCase();
3
+ if (!raw) return '';
4
+
5
+ const base = raw.includes('@') ? raw.split('@')[0] : raw;
6
+ const primary = base.includes(':') ? base.split(':')[0] : base;
7
+ const digits = primary.replace(/\D/g, '');
8
+ if (digits) return digits;
9
+
10
+ return primary;
11
+ }
12
+
13
+ function normalizeWhatsAppWhitelist(values) {
14
+ if (!Array.isArray(values)) return [];
15
+
16
+ const seen = new Set();
17
+ const normalized = [];
18
+ for (const value of values) {
19
+ const entry = normalizeWhatsAppId(value);
20
+ if (!entry || seen.has(entry)) continue;
21
+ seen.add(entry);
22
+ normalized.push(entry);
23
+ }
24
+ return normalized;
25
+ }
26
+
27
+ function toWhatsAppJid(value) {
28
+ const raw = String(value || '').trim().toLowerCase();
29
+ if (!raw) return '';
30
+ if (raw.includes('@')) {
31
+ const jid = raw.split(':')[0];
32
+ if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@g.us') || jid.endsWith('@lid')) {
33
+ return jid;
34
+ }
35
+ }
36
+
37
+ const normalized = normalizeWhatsAppId(raw);
38
+ if (!normalized) return '';
39
+ return `${normalized}@s.whatsapp.net`;
40
+ }
41
+
42
+ module.exports = {
43
+ normalizeWhatsAppId,
44
+ normalizeWhatsAppWhitelist,
45
+ toWhatsAppJid,
46
+ };