nastech-whatsapp-bridge 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.
package/allowlist.js ADDED
@@ -0,0 +1,88 @@
1
+ import path from 'path';
2
+ import { existsSync, readFileSync } from 'fs';
3
+
4
+ export function normalizeWhatsAppIdentifier(value) {
5
+ return String(value || '')
6
+ .trim()
7
+ .replace(/:.*@/, '@')
8
+ .replace(/@.*/, '')
9
+ .replace(/^\+/, '');
10
+ }
11
+
12
+ export function parseAllowedUsers(rawValue) {
13
+ return new Set(
14
+ String(rawValue || '')
15
+ .split(',')
16
+ .map((value) => normalizeWhatsAppIdentifier(value))
17
+ .filter(Boolean)
18
+ );
19
+ }
20
+
21
+ function readMappingFile(sessionDir, identifier, suffix = '') {
22
+ const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
23
+ if (!existsSync(filePath)) {
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
29
+ const normalized = normalizeWhatsAppIdentifier(parsed);
30
+ return normalized || null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function expandWhatsAppIdentifiers(identifier, sessionDir) {
37
+ const normalized = normalizeWhatsAppIdentifier(identifier);
38
+ if (!normalized) {
39
+ return new Set();
40
+ }
41
+
42
+ // Walk both phone->LID and LID->phone mapping files so allowlists can use
43
+ // either form transparently in bot mode.
44
+ const resolved = new Set();
45
+ const queue = [normalized];
46
+
47
+ while (queue.length > 0) {
48
+ const current = queue.shift();
49
+ if (!current || resolved.has(current)) {
50
+ continue;
51
+ }
52
+
53
+ resolved.add(current);
54
+
55
+ for (const suffix of ['', '_reverse']) {
56
+ const mapped = readMappingFile(sessionDir, current, suffix);
57
+ if (mapped && !resolved.has(mapped)) {
58
+ queue.push(mapped);
59
+ }
60
+ }
61
+ }
62
+
63
+ return resolved;
64
+ }
65
+
66
+ export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
67
+ // Empty allowlist = NO ONE allowed (secure default, #8389). Operators
68
+ // who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
69
+ // Previous behaviour (empty → return true) let any stranger DM the
70
+ // bridge and trigger a Python-side pairing-code reply.
71
+ if (!allowedUsers || allowedUsers.size === 0) {
72
+ return false;
73
+ }
74
+
75
+ // "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)
76
+ if (allowedUsers.has('*')) {
77
+ return true;
78
+ }
79
+
80
+ const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
81
+ for (const alias of aliases) {
82
+ if (allowedUsers.has(alias)) {
83
+ return true;
84
+ }
85
+ }
86
+
87
+ return false;
88
+ }
@@ -0,0 +1,80 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+
7
+ import {
8
+ expandWhatsAppIdentifiers,
9
+ matchesAllowedUser,
10
+ normalizeWhatsAppIdentifier,
11
+ parseAllowedUsers,
12
+ } from './allowlist.js';
13
+
14
+ test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
15
+ assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
16
+ assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
17
+ assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
18
+ });
19
+
20
+ test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
21
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'nastech-wa-allowlist-'));
22
+
23
+ try {
24
+ writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
25
+ writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
26
+
27
+ const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
28
+ assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
29
+ } finally {
30
+ rmSync(sessionDir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
35
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'nastech-wa-allowlist-'));
36
+
37
+ try {
38
+ writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
39
+ writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
40
+
41
+ const allowedUsers = parseAllowedUsers('+19175395595');
42
+ assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
43
+ assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
44
+ } finally {
45
+ rmSync(sessionDir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('matchesAllowedUser treats * as allow-all wildcard', () => {
50
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'nastech-wa-allowlist-'));
51
+
52
+ try {
53
+ const allowedUsers = parseAllowedUsers('*');
54
+ assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', allowedUsers, sessionDir), true);
55
+ assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
56
+ } finally {
57
+ rmSync(sessionDir, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
62
+ // Regression guard: empty allowlist used to return true (allow-everyone),
63
+ // which let any stranger DM the bridge and trigger a Python-side
64
+ // pairing-code reply. Secure default is now "reject unless explicitly
65
+ // configured"; operators who want an open bot must set `*`.
66
+ const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'nastech-wa-allowlist-'));
67
+
68
+ try {
69
+ const empty = parseAllowedUsers('');
70
+ assert.equal(empty.size, 0);
71
+ assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
72
+ assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
73
+
74
+ // Null/undefined allowlist (defensive) also rejects.
75
+ assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
76
+ assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
77
+ } finally {
78
+ rmSync(sessionDir, { recursive: true, force: true });
79
+ }
80
+ });
package/bridge.js ADDED
@@ -0,0 +1,750 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NasTech Agent WhatsApp Bridge
4
+ *
5
+ * Standalone Node.js process that connects to WhatsApp via Baileys
6
+ * and exposes HTTP endpoints for the Python gateway adapter.
7
+ *
8
+ * Endpoints (matches gateway/platforms/whatsapp.py expectations):
9
+ * GET /messages - Long-poll for new incoming messages
10
+ * POST /send - Send a message { chatId, message, replyTo? }
11
+ * POST /edit - Edit a sent message { chatId, messageId, message }
12
+ * POST /send-media - Send media natively { chatId, filePath, mediaType?, caption?, fileName? }
13
+ * POST /typing - Send typing indicator { chatId }
14
+ * GET /chat/:id - Get chat info
15
+ * GET /health - Health check
16
+ *
17
+ * Usage:
18
+ * node bridge.js --port 3000 --session ~/.nastech/whatsapp/session
19
+ */
20
+
21
+ import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys';
22
+ import express from 'express';
23
+ import { Boom } from '@hapi/boom';
24
+ import pino from 'pino';
25
+ import path from 'path';
26
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
27
+ import { fileURLToPath } from 'url';
28
+ import { randomBytes, createHash } from 'crypto';
29
+ import { execSync } from 'child_process';
30
+ import { tmpdir } from 'os';
31
+ import qrcode from 'qrcode-terminal';
32
+ import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
33
+
34
+ // Parse CLI args
35
+ const args = process.argv.slice(2);
36
+ function getArg(name, defaultVal) {
37
+ const idx = args.indexOf(`--${name}`);
38
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
39
+ }
40
+
41
+ const WHATSAPP_DEBUG =
42
+ typeof process !== 'undefined' &&
43
+ process.env &&
44
+ typeof process.env.WHATSAPP_DEBUG === 'string' &&
45
+ ['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase());
46
+
47
+ const PORT = parseInt(getArg('port', '3000'), 10);
48
+ const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.nastech', 'whatsapp', 'session'));
49
+ // Cache directories: the Python gateway passes the profile-aware paths via
50
+ // env (NASTECH_HOME-aware, new cache/ layout). Fall back to the legacy
51
+ // hardcoded locations for bridges launched outside the gateway.
52
+ const IMAGE_CACHE_DIR = process.env.NASTECH_IMAGE_CACHE_DIR
53
+ || path.join(process.env.HOME || '~', '.nastech', 'image_cache');
54
+ const DOCUMENT_CACHE_DIR = process.env.NASTECH_DOCUMENT_CACHE_DIR
55
+ || path.join(process.env.HOME || '~', '.nastech', 'document_cache');
56
+ const AUDIO_CACHE_DIR = process.env.NASTECH_AUDIO_CACHE_DIR
57
+ || path.join(process.env.HOME || '~', '.nastech', 'audio_cache');
58
+
59
+ // Self-hash of this script file. Reported in /health so the Python gateway
60
+ // can detect a running bridge that predates the current bridge.js and
61
+ // restart it instead of silently reusing stale code (stale-bridge trap:
62
+ // `nastech update` updates bridge.js on disk but a long-lived bridge process
63
+ // keeps serving the old behavior forever).
64
+ let SCRIPT_HASH = '';
65
+ try {
66
+ SCRIPT_HASH = createHash('sha256')
67
+ .update(readFileSync(fileURLToPath(import.meta.url)))
68
+ .digest('hex')
69
+ .slice(0, 16);
70
+ } catch {}
71
+ const PAIR_ONLY = args.includes('--pair-only');
72
+ const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
73
+ const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
74
+ const DEFAULT_REPLY_PREFIX = '⚕ *NasTech Agent*\n────────────\n';
75
+ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
76
+ ? DEFAULT_REPLY_PREFIX
77
+ : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
78
+ const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10);
79
+ const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10);
80
+ // Per-call timeout for sock.sendMessage(). Baileys occasionally hangs forever
81
+ // when uploading media to WhatsApp servers (and, less often, on text sends),
82
+ // which pins the bridge's HTTP handler until the upstream aiohttp timeout
83
+ // fires. Fail fast instead so the gateway can surface a real error and retry.
84
+ const SEND_TIMEOUT_MS = parseInt(process.env.WHATSAPP_SEND_TIMEOUT_MS || '60000', 10);
85
+
86
+ function sleep(ms) {
87
+ return new Promise(resolve => setTimeout(resolve, ms));
88
+ }
89
+
90
+ function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) {
91
+ let timer;
92
+ const timeoutPromise = new Promise((_, reject) => {
93
+ timer = setTimeout(
94
+ () => reject(new Error(`sendMessage timed out after ${timeoutMs / 1000}s`)),
95
+ timeoutMs,
96
+ );
97
+ });
98
+ return Promise.race([sock.sendMessage(chatId, payload), timeoutPromise])
99
+ .finally(() => clearTimeout(timer));
100
+ }
101
+
102
+ function formatOutgoingMessage(message) {
103
+ // In bot mode, messages come from a different number so the prefix is
104
+ // redundant — the sender identity is already clear. Only prepend in
105
+ // self-chat mode where bot and user share the same number.
106
+ if (WHATSAPP_MODE !== 'self-chat') return message;
107
+ return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
108
+ }
109
+
110
+ function splitLongMessage(message, maxLength = MAX_MESSAGE_LENGTH) {
111
+ const text = String(message || '');
112
+ if (!text) return [];
113
+ if (!Number.isFinite(maxLength) || maxLength < 1 || text.length <= maxLength) {
114
+ return [text];
115
+ }
116
+
117
+ const chunks = [];
118
+ let remaining = text;
119
+ while (remaining.length > maxLength) {
120
+ let splitAt = remaining.lastIndexOf('\n', maxLength);
121
+ if (splitAt < Math.floor(maxLength / 2)) {
122
+ splitAt = remaining.lastIndexOf(' ', maxLength);
123
+ }
124
+ if (splitAt < 1) splitAt = maxLength;
125
+
126
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
127
+ remaining = remaining.slice(splitAt).trimStart();
128
+ }
129
+ if (remaining) chunks.push(remaining);
130
+ return chunks;
131
+ }
132
+
133
+ function trackSentMessageId(sent) {
134
+ if (sent?.key?.id) {
135
+ recentlySentIds.add(sent.key.id);
136
+ if (recentlySentIds.size > MAX_RECENT_IDS) {
137
+ recentlySentIds.delete(recentlySentIds.values().next().value);
138
+ }
139
+ }
140
+ }
141
+
142
+ function normalizeWhatsAppId(value) {
143
+ if (!value) return '';
144
+ return String(value).replace(':', '@');
145
+ }
146
+
147
+ function getMessageContent(msg) {
148
+ const content = msg?.message || {};
149
+ if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
150
+ if (content.viewOnceMessage?.message) return content.viewOnceMessage.message;
151
+ if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message;
152
+ if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message;
153
+ if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate;
154
+ if (content.buttonsMessage) return content.buttonsMessage;
155
+ if (content.listMessage) return content.listMessage;
156
+ return content;
157
+ }
158
+
159
+ function getContextInfo(messageContent) {
160
+ if (!messageContent || typeof messageContent !== 'object') return {};
161
+ for (const value of Object.values(messageContent)) {
162
+ if (value && typeof value === 'object' && value.contextInfo) {
163
+ return value.contextInfo;
164
+ }
165
+ }
166
+ return {};
167
+ }
168
+
169
+ mkdirSync(SESSION_DIR, { recursive: true });
170
+
171
+ // Build LID → phone reverse map from session files (lid-mapping-{phone}.json)
172
+ function buildLidMap() {
173
+ const map = {};
174
+ try {
175
+ for (const f of readdirSync(SESSION_DIR)) {
176
+ const m = f.match(/^lid-mapping-(\d+)\.json$/);
177
+ if (!m) continue;
178
+ const phone = m[1];
179
+ const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8'));
180
+ if (lid) map[String(lid)] = phone;
181
+ }
182
+ } catch {}
183
+ return map;
184
+ }
185
+ let lidToPhone = buildLidMap();
186
+
187
+ const logger = pino({ level: 'warn' });
188
+
189
+ // Message queue for polling
190
+ const messageQueue = [];
191
+ const MAX_QUEUE_SIZE = 100;
192
+
193
+ // Track recently sent message IDs to prevent echo-back loops with media
194
+ const recentlySentIds = new Set();
195
+ const MAX_RECENT_IDS = 50;
196
+
197
+ let sock = null;
198
+ let connectionState = 'disconnected';
199
+
200
+ async function startSocket() {
201
+ const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
202
+ const { version } = await fetchLatestBaileysVersion();
203
+
204
+ sock = makeWASocket({
205
+ version,
206
+ auth: state,
207
+ logger,
208
+ printQRInTerminal: false,
209
+ browser: ['NasTech Agent', 'Chrome', '120.0'],
210
+ syncFullHistory: false,
211
+ markOnlineOnConnect: false,
212
+ // Required for Baileys 7.x: without this, incoming messages that need
213
+ // E2EE session re-establishment are silently dropped (msg.message === null)
214
+ getMessage: async (key) => {
215
+ // We don't maintain a message store, so return a placeholder.
216
+ // This is enough for Baileys to complete the retry handshake.
217
+ return { conversation: '' };
218
+ },
219
+ });
220
+
221
+ sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); });
222
+
223
+ sock.ev.on('connection.update', (update) => {
224
+ const { connection, lastDisconnect, qr } = update;
225
+
226
+ if (qr) {
227
+ console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
228
+ qrcode.generate(qr, { small: true });
229
+ console.log('\nWaiting for scan...\n');
230
+ }
231
+
232
+ if (connection === 'close') {
233
+ const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
234
+ connectionState = 'disconnected';
235
+
236
+ if (reason === DisconnectReason.loggedOut) {
237
+ console.log('❌ Logged out. Delete session and restart to re-authenticate.');
238
+ process.exit(1);
239
+ } else {
240
+ // 515 = restart requested (common after pairing). Always reconnect.
241
+ if (reason === 515) {
242
+ console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
243
+ } else {
244
+ console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
245
+ }
246
+ setTimeout(startSocket, reason === 515 ? 1000 : 3000);
247
+ }
248
+ } else if (connection === 'open') {
249
+ connectionState = 'connected';
250
+ console.log('✅ WhatsApp connected!');
251
+ if (PAIR_ONLY) {
252
+ console.log('✅ Pairing complete. Credentials saved.');
253
+ // Give Baileys a moment to flush creds, then exit cleanly
254
+ setTimeout(() => process.exit(0), 2000);
255
+ }
256
+ }
257
+ });
258
+
259
+ sock.ev.on('messages.upsert', async ({ messages, type }) => {
260
+ // In self-chat mode, your own messages commonly arrive as 'append' rather
261
+ // than 'notify'. Accept both and filter agent echo-backs below.
262
+ if (type !== 'notify' && type !== 'append') return;
263
+
264
+ const botIds = Array.from(new Set([
265
+ normalizeWhatsAppId(sock.user?.id),
266
+ normalizeWhatsAppId(sock.user?.lid),
267
+ ].filter(Boolean)));
268
+
269
+ for (const msg of messages) {
270
+ if (!msg.message) continue;
271
+
272
+ const chatId = msg.key.remoteJid;
273
+ if (WHATSAPP_DEBUG) {
274
+ try {
275
+ console.log(JSON.stringify({
276
+ event: 'upsert', type,
277
+ fromMe: !!msg.key.fromMe, chatId,
278
+ senderId: msg.key.participant || chatId,
279
+ messageKeys: Object.keys(msg.message || {}),
280
+ }));
281
+ } catch {}
282
+ }
283
+ const senderId = msg.key.participant || chatId;
284
+ const isGroup = chatId.endsWith('@g.us');
285
+ const senderNumber = senderId.replace(/@.*/, '');
286
+
287
+ // Handle fromMe messages based on mode
288
+ if (msg.key.fromMe) {
289
+ if (isGroup || chatId.includes('status')) continue;
290
+
291
+ if (WHATSAPP_MODE === 'bot') {
292
+ // Bot mode: separate number. ALL fromMe are echo-backs of our own replies — skip.
293
+ continue;
294
+ }
295
+
296
+ // Self-chat mode: only allow messages in the user's own self-chat
297
+ // WhatsApp now uses LID (Linked Identity Device) format: 67427329167522@lid
298
+ // AND classic format: 34652029134@s.whatsapp.net
299
+ // sock.user has both: { id: "number:10@s.whatsapp.net", lid: "lid_number:10@lid" }
300
+ const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, '');
301
+ const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
302
+ const chatNumber = chatId.replace(/@.*/, '');
303
+ const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
304
+ if (!isSelfChat) continue;
305
+ }
306
+
307
+ // Handle !fromMe messages (from other people) based on mode.
308
+ // Self-chat mode only responds to the user's own messages to
309
+ // themselves — stranger DMs / group pings must never reach the
310
+ // Python gateway, otherwise a pairing-code reply fires in response
311
+ // to arbitrary incoming messages (#8389).
312
+ if (!msg.key.fromMe) {
313
+ if (WHATSAPP_MODE === 'self-chat') {
314
+ try {
315
+ console.log(JSON.stringify({
316
+ event: 'ignored',
317
+ reason: 'self_chat_mode_rejects_non_self',
318
+ chatId,
319
+ senderId,
320
+ }));
321
+ } catch {}
322
+ continue;
323
+ }
324
+ if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
325
+ try {
326
+ console.log(JSON.stringify({
327
+ event: 'ignored',
328
+ reason: 'allowlist_mismatch',
329
+ chatId,
330
+ senderId,
331
+ }));
332
+ } catch {}
333
+ continue;
334
+ }
335
+ }
336
+
337
+ const messageContent = getMessageContent(msg);
338
+ const contextInfo = getContextInfo(messageContent);
339
+ const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean)));
340
+ const quotedMessageId = contextInfo?.stanzaId || null;
341
+ const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null;
342
+ const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null;
343
+ const hasQuotedMessage = !!contextInfo?.quotedMessage;
344
+
345
+ // Extract message body
346
+ let body = '';
347
+ let hasMedia = false;
348
+ let mediaType = '';
349
+ const mediaUrls = [];
350
+
351
+ if (messageContent.conversation) {
352
+ body = messageContent.conversation;
353
+ } else if (messageContent.extendedTextMessage?.text) {
354
+ body = messageContent.extendedTextMessage.text;
355
+ } else if (messageContent.imageMessage) {
356
+ body = messageContent.imageMessage.caption || '';
357
+ hasMedia = true;
358
+ mediaType = 'image';
359
+ try {
360
+ const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
361
+ const mime = messageContent.imageMessage.mimetype || 'image/jpeg';
362
+ const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
363
+ const ext = extMap[mime] || '.jpg';
364
+ mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
365
+ const filePath = path.join(IMAGE_CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`);
366
+ writeFileSync(filePath, buf);
367
+ mediaUrls.push(filePath);
368
+ } catch (err) {
369
+ console.error('[bridge] Failed to download image:', err.message);
370
+ }
371
+ } else if (messageContent.videoMessage) {
372
+ body = messageContent.videoMessage.caption || '';
373
+ hasMedia = true;
374
+ mediaType = 'video';
375
+ try {
376
+ const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
377
+ const mime = messageContent.videoMessage.mimetype || 'video/mp4';
378
+ const ext = mime.includes('mp4') ? '.mp4' : '.mkv';
379
+ mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
380
+ const filePath = path.join(DOCUMENT_CACHE_DIR, `vid_${randomBytes(6).toString('hex')}${ext}`);
381
+ writeFileSync(filePath, buf);
382
+ mediaUrls.push(filePath);
383
+ } catch (err) {
384
+ console.error('[bridge] Failed to download video:', err.message);
385
+ }
386
+ } else if (messageContent.audioMessage || messageContent.pttMessage) {
387
+ hasMedia = true;
388
+ mediaType = messageContent.pttMessage ? 'ptt' : 'audio';
389
+ try {
390
+ const audioMsg = messageContent.pttMessage || messageContent.audioMessage;
391
+ const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
392
+ const mime = audioMsg.mimetype || 'audio/ogg';
393
+ const ext = mime.includes('ogg') ? '.ogg' : mime.includes('mp4') ? '.m4a' : '.ogg';
394
+ mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
395
+ const filePath = path.join(AUDIO_CACHE_DIR, `aud_${randomBytes(6).toString('hex')}${ext}`);
396
+ writeFileSync(filePath, buf);
397
+ mediaUrls.push(filePath);
398
+ } catch (err) {
399
+ console.error('[bridge] Failed to download audio:', err.message);
400
+ }
401
+ } else if (messageContent.documentMessage) {
402
+ body = messageContent.documentMessage.caption || '';
403
+ hasMedia = true;
404
+ mediaType = 'document';
405
+ const fileName = messageContent.documentMessage.fileName || 'document';
406
+ try {
407
+ const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
408
+ mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
409
+ const safeFileName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
410
+ const filePath = path.join(DOCUMENT_CACHE_DIR, `doc_${randomBytes(6).toString('hex')}_${safeFileName}`);
411
+ writeFileSync(filePath, buf);
412
+ mediaUrls.push(filePath);
413
+ } catch (err) {
414
+ console.error('[bridge] Failed to download document:', err.message);
415
+ }
416
+ }
417
+
418
+ // For media without caption, use a placeholder so the API message is never empty
419
+ if (hasMedia && !body) {
420
+ body = `[${mediaType} received]`;
421
+ }
422
+
423
+ // Ignore NasTech' own reply messages in self-chat mode to avoid loops.
424
+ if (msg.key.fromMe && ((REPLY_PREFIX && body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
425
+ if (WHATSAPP_DEBUG) {
426
+ try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
427
+ }
428
+ continue;
429
+ }
430
+
431
+ // Skip empty messages
432
+ if (!body && !hasMedia) {
433
+ if (WHATSAPP_DEBUG) {
434
+ try {
435
+ console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
436
+ } catch (err) {
437
+ console.error('Failed to log empty message event:', err);
438
+ }
439
+ }
440
+ continue;
441
+ }
442
+
443
+ const event = {
444
+ messageId: msg.key.id,
445
+ chatId,
446
+ senderId,
447
+ senderName: msg.pushName || senderNumber,
448
+ chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
449
+ isGroup,
450
+ body,
451
+ hasMedia,
452
+ mediaType,
453
+ mediaUrls,
454
+ mentionedIds,
455
+ quotedMessageId,
456
+ quotedParticipant,
457
+ quotedRemoteJid,
458
+ hasQuotedMessage,
459
+ botIds,
460
+ timestamp: msg.messageTimestamp,
461
+ };
462
+
463
+ messageQueue.push(event);
464
+ if (messageQueue.length > MAX_QUEUE_SIZE) {
465
+ messageQueue.shift();
466
+ }
467
+ }
468
+ });
469
+ }
470
+
471
+ // HTTP server
472
+ const app = express();
473
+ app.use(express.json());
474
+
475
+ // Host-header validation — defends against DNS rebinding.
476
+ // The bridge binds loopback-only (127.0.0.1) but a victim browser on
477
+ // the same machine could be tricked into fetching from an attacker
478
+ // hostname that TTL-flips to 127.0.0.1. Reject any request whose Host
479
+ // header doesn't resolve to a loopback alias.
480
+ // See GHSA-ppp5-vxwm-4cf7.
481
+ const _ACCEPTED_HOST_VALUES = new Set([
482
+ 'localhost',
483
+ '127.0.0.1',
484
+ '[::1]',
485
+ '::1',
486
+ ]);
487
+
488
+ app.use((req, res, next) => {
489
+ const raw = (req.headers.host || '').trim();
490
+ if (!raw) {
491
+ return res.status(400).json({ error: 'Missing Host header' });
492
+ }
493
+ // Strip port suffix: "localhost:3000" → "localhost"
494
+ const hostOnly = (raw.includes(':')
495
+ ? raw.substring(0, raw.lastIndexOf(':'))
496
+ : raw
497
+ ).replace(/^\[|\]$/g, '').toLowerCase();
498
+ if (!_ACCEPTED_HOST_VALUES.has(hostOnly)) {
499
+ return res.status(400).json({
500
+ error: 'Invalid Host header. Bridge accepts loopback hosts only.',
501
+ });
502
+ }
503
+ next();
504
+ });
505
+
506
+ // Poll for new messages (long-poll style)
507
+ app.get('/messages', (req, res) => {
508
+ const msgs = messageQueue.splice(0, messageQueue.length);
509
+ res.json(msgs);
510
+ });
511
+
512
+ // Send a message
513
+ app.post('/send', async (req, res) => {
514
+ if (!sock || connectionState !== 'connected') {
515
+ return res.status(503).json({ error: 'Not connected to WhatsApp' });
516
+ }
517
+
518
+ const { chatId, message, replyTo } = req.body;
519
+ if (!chatId || !message) {
520
+ return res.status(400).json({ error: 'chatId and message are required' });
521
+ }
522
+
523
+ try {
524
+ const chunks = splitLongMessage(formatOutgoingMessage(message));
525
+ const messageIds = [];
526
+ for (let i = 0; i < chunks.length; i += 1) {
527
+ const sent = await sendWithTimeout(chatId, { text: chunks[i] });
528
+ trackSentMessageId(sent);
529
+ if (sent?.key?.id) messageIds.push(sent.key.id);
530
+ if (chunks.length > 1 && i < chunks.length - 1) {
531
+ await sleep(CHUNK_DELAY_MS);
532
+ }
533
+ }
534
+
535
+ res.json({
536
+ success: true,
537
+ messageId: messageIds[messageIds.length - 1],
538
+ messageIds,
539
+ });
540
+ } catch (err) {
541
+ res.status(500).json({ error: err.message });
542
+ }
543
+ });
544
+
545
+ // Edit a previously sent message
546
+ app.post('/edit', async (req, res) => {
547
+ if (!sock || connectionState !== 'connected') {
548
+ return res.status(503).json({ error: 'Not connected to WhatsApp' });
549
+ }
550
+
551
+ const { chatId, messageId, message } = req.body;
552
+ if (!chatId || !messageId || !message) {
553
+ return res.status(400).json({ error: 'chatId, messageId, and message are required' });
554
+ }
555
+
556
+ try {
557
+ const key = { id: messageId, fromMe: true, remoteJid: chatId };
558
+ const chunks = splitLongMessage(formatOutgoingMessage(message));
559
+ const messageIds = [];
560
+
561
+ await sendWithTimeout(chatId, { text: chunks[0], edit: key });
562
+ if (chunks.length > 1) {
563
+ for (let i = 1; i < chunks.length; i += 1) {
564
+ const sent = await sendWithTimeout(chatId, { text: chunks[i] });
565
+ trackSentMessageId(sent);
566
+ if (sent?.key?.id) messageIds.push(sent.key.id);
567
+ if (i < chunks.length - 1) {
568
+ await sleep(CHUNK_DELAY_MS);
569
+ }
570
+ }
571
+ }
572
+
573
+ res.json({ success: true, messageIds });
574
+ } catch (err) {
575
+ res.status(500).json({ error: err.message });
576
+ }
577
+ });
578
+
579
+ // MIME type map and media type inference for /send-media
580
+ const MIME_MAP = {
581
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
582
+ webp: 'image/webp', gif: 'image/gif',
583
+ mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
584
+ mkv: 'video/x-matroska', '3gp': 'video/3gpp',
585
+ pdf: 'application/pdf',
586
+ doc: 'application/msword',
587
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
588
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
589
+ };
590
+
591
+ function inferMediaType(ext) {
592
+ if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
593
+ if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
594
+ if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
595
+ return 'document';
596
+ }
597
+
598
+ // Send media (image, video, document) natively
599
+ app.post('/send-media', async (req, res) => {
600
+ if (!sock || connectionState !== 'connected') {
601
+ return res.status(503).json({ error: 'Not connected to WhatsApp' });
602
+ }
603
+
604
+ const { chatId, filePath, mediaType, caption, fileName } = req.body;
605
+ if (!chatId || !filePath) {
606
+ return res.status(400).json({ error: 'chatId and filePath are required' });
607
+ }
608
+
609
+ try {
610
+ if (!existsSync(filePath)) {
611
+ return res.status(404).json({ error: `File not found: ${filePath}` });
612
+ }
613
+
614
+ const buffer = readFileSync(filePath);
615
+ const ext = filePath.toLowerCase().split('.').pop();
616
+ const type = mediaType || inferMediaType(ext);
617
+ let msgPayload;
618
+
619
+ switch (type) {
620
+ case 'image':
621
+ msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
622
+ break;
623
+ case 'video':
624
+ msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
625
+ break;
626
+ case 'audio': {
627
+ // WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
628
+ // If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
629
+ // silently convert to ogg/opus via ffmpeg so ptt is always honoured.
630
+ let audioBuffer = buffer;
631
+ let audioExt = ext;
632
+ const needsConversion = !['ogg', 'opus'].includes(ext);
633
+ let tmpPath = null;
634
+ if (needsConversion) {
635
+ tmpPath = path.join(tmpdir(), `nastech_voice_${randomBytes(6).toString('hex')}.ogg`);
636
+ try {
637
+ execSync(
638
+ `ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
639
+ { timeout: 30000, stdio: 'pipe' }
640
+ );
641
+ audioBuffer = readFileSync(tmpPath);
642
+ audioExt = 'ogg';
643
+ } catch (convErr) {
644
+ // ffmpeg not available or conversion failed — fall back to original format
645
+ console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
646
+ } finally {
647
+ try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
648
+ }
649
+ }
650
+ const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
651
+ msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
652
+ break;
653
+ }
654
+ case 'document':
655
+ default:
656
+ msgPayload = {
657
+ document: buffer,
658
+ fileName: fileName || path.basename(filePath),
659
+ caption: caption || undefined,
660
+ mimetype: MIME_MAP[ext] || 'application/octet-stream',
661
+ };
662
+ break;
663
+ }
664
+
665
+ const sent = await sendWithTimeout(chatId, msgPayload);
666
+
667
+ trackSentMessageId(sent);
668
+
669
+ res.json({ success: true, messageId: sent?.key?.id });
670
+ } catch (err) {
671
+ res.status(500).json({ error: err.message });
672
+ }
673
+ });
674
+
675
+ // Typing indicator
676
+ app.post('/typing', async (req, res) => {
677
+ if (!sock || connectionState !== 'connected') {
678
+ return res.status(503).json({ error: 'Not connected' });
679
+ }
680
+
681
+ const { chatId } = req.body;
682
+ if (!chatId) return res.status(400).json({ error: 'chatId required' });
683
+
684
+ try {
685
+ await sock.sendPresenceUpdate('composing', chatId);
686
+ res.json({ success: true });
687
+ } catch (err) {
688
+ res.json({ success: false });
689
+ }
690
+ });
691
+
692
+ // Chat info
693
+ app.get('/chat/:id', async (req, res) => {
694
+ const chatId = req.params.id;
695
+ const isGroup = chatId.endsWith('@g.us');
696
+
697
+ if (isGroup && sock) {
698
+ try {
699
+ const metadata = await sock.groupMetadata(chatId);
700
+ return res.json({
701
+ name: metadata.subject,
702
+ isGroup: true,
703
+ participants: metadata.participants.map(p => p.id),
704
+ });
705
+ } catch {
706
+ // Fall through to default
707
+ }
708
+ }
709
+
710
+ res.json({
711
+ name: chatId.replace(/@.*/, ''),
712
+ isGroup,
713
+ participants: [],
714
+ });
715
+ });
716
+
717
+ // Health check
718
+ app.get('/health', (req, res) => {
719
+ res.json({
720
+ status: connectionState,
721
+ queueLength: messageQueue.length,
722
+ uptime: process.uptime(),
723
+ scriptHash: SCRIPT_HASH,
724
+ });
725
+ });
726
+
727
+ // Start
728
+ if (PAIR_ONLY) {
729
+ // Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
730
+ console.log('📱 WhatsApp pairing mode');
731
+ console.log(`📁 Session: ${SESSION_DIR}`);
732
+ console.log();
733
+ startSocket();
734
+ } else {
735
+ app.listen(PORT, '127.0.0.1', () => {
736
+ console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
737
+ console.log(`📁 Session stored in: ${SESSION_DIR}`);
738
+ if (ALLOWED_USERS.size > 0) {
739
+ console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
740
+ } else if (WHATSAPP_MODE === 'self-chat') {
741
+ console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
742
+ } else {
743
+ console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
744
+ console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
745
+ console.log(` or WHATSAPP_ALLOWED_USERS=* for an explicit open bot.`);
746
+ }
747
+ console.log();
748
+ startSocket();
749
+ });
750
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "nastech-whatsapp-bridge",
3
+ "version": "1.0.0",
4
+ "description": "WhatsApp bridge for NasTech Agent using Baileys",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "node bridge.js"
8
+ },
9
+ "dependencies": {
10
+ "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
11
+ "express": "^4.22.2",
12
+ "qrcode-terminal": "^0.12.0",
13
+ "pino": "^9.0.0"
14
+ },
15
+ "overrides": {
16
+ "protobufjs": "^7.5.5"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org/"
21
+ }
22
+ }