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 +88 -0
- package/allowlist.test.mjs +80 -0
- package/bridge.js +750 -0
- package/package.json +22 -0
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
|
+
}
|