sena-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.
Files changed (2) hide show
  1. package/package.json +37 -0
  2. package/server.js +867 -0
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "sena-whatsapp-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Sena WhatsApp Bridge — Baileys-based WhatsApp Web client with HTTP API",
5
+ "main": "server.js",
6
+ "files": [
7
+ "server.js"
8
+ ],
9
+ "bin": {
10
+ "sena-whatsapp-bridge": "server.js"
11
+ },
12
+ "pkg": {
13
+ "targets": [
14
+ "node20"
15
+ ],
16
+ "outputPath": "dist",
17
+ "assets": []
18
+ },
19
+ "scripts": {
20
+ "start": "node server.js",
21
+ "build:win-x64": "npx @yao-pkg/pkg . --target node20-win-x64 --output dist/sena-whatsapp-win-x64.exe",
22
+ "build:win-arm64": "npx @yao-pkg/pkg . --target node20-win-arm64 --output dist/sena-whatsapp-win-arm64.exe",
23
+ "build:mac-x64": "npx @yao-pkg/pkg . --target node20-macos-x64 --output dist/sena-whatsapp-mac-x64",
24
+ "build:mac-arm64": "npx @yao-pkg/pkg . --target node20-macos-arm64 --output dist/sena-whatsapp-mac-arm64",
25
+ "build:linux-x64": "npx @yao-pkg/pkg . --target node20-linux-x64 --output dist/sena-whatsapp-linux-x64",
26
+ "build:linux-arm64": "npx @yao-pkg/pkg . --target node20-linux-arm64 --output dist/sena-whatsapp-linux-arm64",
27
+ "build:all": "npm run build:win-x64 && npm run build:win-arm64 && npm run build:mac-x64 && npm run build:mac-arm64 && npm run build:linux-x64 && npm run build:linux-arm64"
28
+ },
29
+ "dependencies": {
30
+ "@whiskeysockets/baileys": "^7.0.0-rc.9",
31
+ "pino": "^9.6.0",
32
+ "qrcode": "^1.5.4"
33
+ },
34
+ "devDependencies": {
35
+ "@yao-pkg/pkg": "^6.14.1"
36
+ }
37
+ }
package/server.js ADDED
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Polyfill globalThis.crypto for Node 18 (required by Baileys)
5
+ if (!globalThis.crypto) {
6
+ const { webcrypto } = require("crypto");
7
+ globalThis.crypto = webcrypto;
8
+ }
9
+
10
+ /**
11
+ * Sena WhatsApp Bridge
12
+ *
13
+ * A standalone Baileys-based WhatsApp Web client that exposes an HTTP API.
14
+ * Designed to be spawned by the Sena backend (whatsapp_bridge_manager.py)
15
+ * or run as a standalone executable.
16
+ *
17
+ * Environment variables:
18
+ * PORT — HTTP port (default: 3001)
19
+ * WEBHOOK_URL — URL to POST inbound messages to
20
+ * BRIDGE_URL — Override the URL the bridge registers as (default: http://localhost:PORT)
21
+ * API_KEY — Optional API key for webhook auth
22
+ * LOG_LEVEL — Logging level (default: info)
23
+ * SESSION_DIR — Directory for session data (default: ./sessions)
24
+ */
25
+
26
+ const http = require("http");
27
+ const path = require("path");
28
+ const fs = require("fs");
29
+ const {
30
+ default: makeWASocket,
31
+ useMultiFileAuthState,
32
+ DisconnectReason,
33
+ fetchLatestBaileysVersion,
34
+ makeCacheableSignalKeyStore,
35
+ } = require("@whiskeysockets/baileys");
36
+
37
+ // In-memory LID → phone map. Populated from contacts events, persisted to disk.
38
+ let lidToPhone = {};
39
+ function loadLidMap() {
40
+ try {
41
+ if (fs.existsSync(LID_MAP_PATH)) {
42
+ lidToPhone = JSON.parse(fs.readFileSync(LID_MAP_PATH, 'utf8'));
43
+ console.log(`[bridge] Loaded ${Object.keys(lidToPhone).length} LID mappings from lid_map.json`);
44
+ }
45
+ } catch (e) {
46
+ console.error('[bridge] Failed to load LID map:', e.message);
47
+ lidToPhone = {};
48
+ }
49
+ // Also load individual lid-mapping-*_reverse.json files (LID → phone)
50
+ try {
51
+ const files = fs.readdirSync(SESSION_DIR).filter(f => f.endsWith('_reverse.json') && f.startsWith('lid-mapping-'));
52
+ for (const f of files) {
53
+ try {
54
+ const lid = f.replace('lid-mapping-', '').replace('_reverse.json', '');
55
+ const phone = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, f), 'utf8'));
56
+ if (lid && phone && !lidToPhone[lid]) {
57
+ lidToPhone[lid] = phone;
58
+ }
59
+ } catch (_) {}
60
+ }
61
+ if (Object.keys(lidToPhone).length > 0) {
62
+ console.log(`[bridge] Total LID mappings after loading: ${Object.keys(lidToPhone).length}`);
63
+ }
64
+ } catch (_) {}
65
+ }
66
+
67
+ let _lidSaveTimer = null;
68
+ function saveLidMap() {
69
+ if (_lidSaveTimer) clearTimeout(_lidSaveTimer);
70
+ _lidSaveTimer = setTimeout(() => {
71
+ try {
72
+ fs.writeFileSync(LID_MAP_PATH, JSON.stringify(lidToPhone), 'utf8');
73
+ // Also write individual files for compatibility
74
+ for (const [lid, phone] of Object.entries(lidToPhone)) {
75
+ try {
76
+ fs.writeFileSync(path.join(SESSION_DIR, `lid-mapping-${phone}.json`), JSON.stringify(lid), 'utf8');
77
+ fs.writeFileSync(path.join(SESSION_DIR, `lid-mapping-${lid}_reverse.json`), JSON.stringify(phone), 'utf8');
78
+ } catch (_) {}
79
+ }
80
+ } catch (e) {
81
+ console.error('[bridge] Failed to save LID map:', e.message);
82
+ }
83
+ }, 2000);
84
+ }
85
+
86
+ const QRCode = require("qrcode");
87
+ const pino = require("pino");
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Config
91
+ // ---------------------------------------------------------------------------
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // CLI arg parsing (--server, --token for remote mode)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function parseArgs() {
98
+ const args = process.argv.slice(2);
99
+ const parsed = {};
100
+ for (let i = 0; i < args.length; i++) {
101
+ if (args[i] === "--server" && args[i + 1]) {
102
+ parsed.server = args[++i].replace(/\/+$/, "");
103
+ } else if (args[i] === "--token" && args[i + 1]) {
104
+ parsed.token = args[++i];
105
+ } else if (args[i] === "--help" || args[i] === "-h") {
106
+ console.log(`
107
+ Sena WhatsApp Bridge
108
+
109
+ Usage:
110
+ npx sena-whatsapp-bridge --server https://yoursite.senaerp.com --token <token>
111
+
112
+ Options:
113
+ --server <url> Sena server URL (enables remote mode)
114
+ --token <token> Bridge authentication token (required with --server)
115
+ --help Show this help message
116
+
117
+ Environment variables:
118
+ PORT HTTP port (default: 3001)
119
+ LOG_LEVEL Logging level (default: info)
120
+ SESSION_DIR Directory for session data (default: ./sessions)
121
+ `);
122
+ process.exit(0);
123
+ }
124
+ }
125
+ return parsed;
126
+ }
127
+
128
+ const CLI_ARGS = parseArgs();
129
+ const REMOTE_MODE = !!(CLI_ARGS.server && CLI_ARGS.token);
130
+ const REMOTE_SERVER = CLI_ARGS.server || "";
131
+ const BRIDGE_TOKEN = CLI_ARGS.token || "";
132
+
133
+ const PORT = parseInt(process.env.PORT || "3001", 10);
134
+ const LOG_LEVEL = process.env.LOG_LEVEL || "info";
135
+ const SESSION_DIR = process.env.SESSION_DIR || path.join(__dirname, "sessions");
136
+ const LID_MAP_PATH = path.join(SESSION_DIR, 'lid_map.json');
137
+ loadLidMap();
138
+
139
+ // In remote mode, derive WEBHOOK_URL and API_KEY from server + token
140
+ const WEBHOOK_URL = REMOTE_MODE
141
+ ? `${REMOTE_SERVER}/api/method/sena_agents_backend.sena_agents_backend.api.whatsapp.receive_webhook`
142
+ : (process.env.WEBHOOK_URL || "");
143
+ const API_KEY = REMOTE_MODE ? BRIDGE_TOKEN : (process.env.API_KEY || "");
144
+
145
+ const logger = pino({ level: LOG_LEVEL === "debug" ? "debug" : "warn" });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Message store & retry cache (fixes "Waiting for this message" issue)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ // In-memory message store: maps serialized key -> message content.
152
+ // When WhatsApp requests a retry (pre-key re-negotiation), Baileys needs
153
+ // the original message content to re-encrypt and resend.
154
+ const messageStore = new Map();
155
+ const MAX_STORE_SIZE = 5000;
156
+
157
+ function storeMessage(key, message) {
158
+ const id = `${key.remoteJid}:${key.id}`;
159
+ messageStore.set(id, message);
160
+ if (messageStore.size > MAX_STORE_SIZE) {
161
+ const firstKey = messageStore.keys().next().value;
162
+ messageStore.delete(firstKey);
163
+ }
164
+ }
165
+
166
+ async function getMessage(key) {
167
+ const id = `${key.remoteJid}:${key.id}`;
168
+ return messageStore.get(id) || undefined;
169
+ }
170
+
171
+ // Simple retry counter cache (tracks message retry counts across restarts)
172
+ const msgRetryCounterMap = new Map();
173
+ const msgRetryCounterCache = {
174
+ get: (key) => msgRetryCounterMap.get(key),
175
+ set: (key, val) => msgRetryCounterMap.set(key, val),
176
+ del: (key) => msgRetryCounterMap.delete(key),
177
+ };
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // State
181
+ // ---------------------------------------------------------------------------
182
+
183
+ let sock = null;
184
+ let qrDataUrl = "";
185
+ let connectionStatus = "disconnected"; // disconnected | qr_ready | connected
186
+ let connectedPhone = "";
187
+ let lastError = "";
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Baileys connection
191
+ // ---------------------------------------------------------------------------
192
+
193
+ async function startBaileys() {
194
+ // Ensure session directory exists
195
+ if (!fs.existsSync(SESSION_DIR)) {
196
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
197
+ }
198
+
199
+ const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
200
+ const { version } = await fetchLatestBaileysVersion();
201
+
202
+ sock = makeWASocket({
203
+ version,
204
+ auth: {
205
+ creds: state.creds,
206
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
207
+ },
208
+ logger,
209
+ browser: ["Sena Agent", "Chrome", "1.0.0"],
210
+ markOnlineOnConnect: false,
211
+ printQRInTerminal: true,
212
+ generateHighQualityLinkPreview: false,
213
+ syncFullHistory: false,
214
+ msgRetryCounterCache,
215
+ getMessage,
216
+ });
217
+
218
+ // Save credentials on update
219
+ sock.ev.on("creds.update", saveCreds);
220
+
221
+ // Build LID → phone map from contacts
222
+ function absorbContacts(contacts) {
223
+ let changed = false;
224
+ for (const c of contacts) {
225
+ if (c.id && c.id.endsWith("@lid") && c.phoneNumber) {
226
+ const lid = c.id.split("@")[0];
227
+ const phone = c.phoneNumber.replace("+", "").replace(/\s|-/g, "");
228
+ if (lidToPhone[lid] !== phone) {
229
+ lidToPhone[lid] = phone;
230
+ changed = true;
231
+ }
232
+ }
233
+ }
234
+ if (changed) saveLidMap();
235
+ }
236
+ sock.ev.on("contacts.set", ({ contacts }) => absorbContacts(contacts));
237
+ sock.ev.on("contacts.update", (updates) => absorbContacts(updates));
238
+ sock.ev.on("contacts.upsert", (contacts) => absorbContacts(contacts));
239
+
240
+ // Connection updates
241
+ sock.ev.on("connection.update", async (update) => {
242
+ const { connection, lastDisconnect, qr } = update;
243
+
244
+ if (qr) {
245
+ try {
246
+ qrDataUrl = await QRCode.toDataURL(qr, { width: 256, margin: 2 });
247
+ connectionStatus = "qr_ready";
248
+ console.log("[bridge] QR code generated");
249
+ pushToServer({ type: "qr", qr_data_url: qrDataUrl });
250
+ } catch (err) {
251
+ console.error("[bridge] QR generation failed:", err.message);
252
+ }
253
+ }
254
+
255
+ if (connection === "close") {
256
+ const statusCode =
257
+ lastDisconnect?.error?.output?.statusCode;
258
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
259
+
260
+ console.log(
261
+ `[bridge] Connection closed. Status: ${statusCode}. Reconnect: ${shouldReconnect}`
262
+ );
263
+
264
+ connectionStatus = "disconnected";
265
+ qrDataUrl = "";
266
+ connectedPhone = "";
267
+ pushToServer({ type: "status", status: "disconnected" });
268
+
269
+ if (shouldReconnect) {
270
+ lastError = `Disconnected (code ${statusCode}), reconnecting...`;
271
+ setTimeout(startBaileys, 3000);
272
+ } else {
273
+ lastError = "Logged out. Delete sessions/ folder and restart to re-link.";
274
+ // Clear session data on logout
275
+ try {
276
+ fs.rmSync(SESSION_DIR, { recursive: true, force: true });
277
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
278
+ } catch (_) {}
279
+ }
280
+ }
281
+
282
+ if (connection === "open") {
283
+ connectionStatus = "connected";
284
+ qrDataUrl = "";
285
+ lastError = "";
286
+ // Extract phone number from JID
287
+ const me = sock.user;
288
+ connectedPhone = me?.id?.split(":")[0] || me?.id?.split("@")[0] || "";
289
+ console.log(`[bridge] Connected as ${connectedPhone}`);
290
+ pushToServer({ type: "status", status: "connected", phone: connectedPhone });
291
+ startPollLoop();
292
+ }
293
+ });
294
+
295
+ // Inbound messages
296
+ sock.ev.on("messages.upsert", async ({ messages, type }) => {
297
+ if (type !== "notify") return;
298
+
299
+ // Store all messages (including outgoing) for retry resolution
300
+ for (const msg of messages) {
301
+ if (msg.message) {
302
+ storeMessage(msg.key, msg.message);
303
+ }
304
+ }
305
+
306
+ for (const msg of messages) {
307
+ // Allow self-chat (phone texting itself) through, but skip other outgoing messages
308
+ const remoteJid = msg.key.remoteJid || '';
309
+ const remoteId = remoteJid.split('@')[0] || '';
310
+ const isLidJid = remoteJid.endsWith('@lid');
311
+ // For self-chat: remoteJid may be phone@s.whatsapp.net OR lid@lid
312
+ // Check both: direct phone match, or LID that resolves to connected phone
313
+ // Also try disk lookup if in-memory map doesn't have the LID yet (fresh start)
314
+ let isSelfChat = remoteId === connectedPhone
315
+ || (isLidJid && lidToPhone[remoteId] === connectedPhone);
316
+ if (!isSelfChat && isLidJid && msg.key.fromMe) {
317
+ try {
318
+ const reverseFile = path.join(SESSION_DIR, `lid-mapping-${remoteId}_reverse.json`);
319
+ if (fs.existsSync(reverseFile)) {
320
+ const mapped = JSON.parse(fs.readFileSync(reverseFile, 'utf8'));
321
+ if (mapped === connectedPhone) {
322
+ lidToPhone[remoteId] = mapped;
323
+ isSelfChat = true;
324
+ }
325
+ }
326
+ } catch (_) {}
327
+ }
328
+ if (msg.key.fromMe) {
329
+ if (!isSelfChat) continue;
330
+ console.log(`[bridge] Self-chat message detected (remoteJid=${remoteJid}, connectedPhone=${connectedPhone})`);
331
+ }
332
+
333
+ const text =
334
+ msg.message?.conversation ||
335
+ msg.message?.extendedTextMessage?.text ||
336
+ "";
337
+
338
+ if (!text) continue;
339
+
340
+ const from = msg.key.remoteJid || "";
341
+ // Extract raw identifier from JID (may be LID or phone number)
342
+ const rawId = from.split("@")[0];
343
+ const isLid = from.endsWith("@lid");
344
+ // Resolve to actual phone: check lid map, then sock.contacts, then fall back to rawId
345
+ let phone = rawId;
346
+ if (isLid) {
347
+ if (lidToPhone[rawId]) {
348
+ phone = lidToPhone[rawId];
349
+ } else {
350
+ // Try sock.contacts (populated by Baileys from session/sync)
351
+ const contact = sock.contacts && sock.contacts[from];
352
+ const contactPhone = contact?.phoneNumber;
353
+ if (contactPhone) {
354
+ phone = contactPhone.replace("+", "").replace(/\s|-/g, "");
355
+ lidToPhone[rawId] = phone;
356
+ saveLidMap();
357
+ console.log(`[bridge] Resolved LID ${rawId} → ${phone} via sock.contacts`);
358
+ } else {
359
+ // Try individual mapping file on disk
360
+ try {
361
+ const reverseFile = path.join(SESSION_DIR, `lid-mapping-${rawId}_reverse.json`);
362
+ if (fs.existsSync(reverseFile)) {
363
+ const mapped = JSON.parse(fs.readFileSync(reverseFile, 'utf8'));
364
+ if (mapped) {
365
+ phone = mapped;
366
+ lidToPhone[rawId] = phone;
367
+ console.log(`[bridge] Resolved LID ${rawId} → ${phone} via mapping file`);
368
+ }
369
+ }
370
+ } catch (_) {}
371
+ }
372
+ }
373
+ }
374
+ // Chat ID for group or individual
375
+ const chatId = from;
376
+ // Sender JID (for groups this is the participant, for 1:1 it's the remoteJid)
377
+ const fromJid = msg.key.participant || from;
378
+ // Push name
379
+ const fromName = msg.pushName || "";
380
+
381
+ console.log(`[bridge] Message from ${phone} (lid=${isLid ? rawId : "n/a"}): ${text.substring(0, 80)}`);
382
+
383
+ if (WEBHOOK_URL) {
384
+ sendWebhook({
385
+ from: phone,
386
+ from_lid: isLid ? rawId : "",
387
+ from_jid: fromJid,
388
+ chat_id: chatId,
389
+ from_name: fromName,
390
+ message: text,
391
+ message_id: msg.key.id || "",
392
+ });
393
+ }
394
+ }
395
+ });
396
+
397
+ // Outbound message status updates (sent / delivered / read)
398
+ sock.ev.on("messages.update", (updates) => {
399
+ if (!WEBHOOK_URL) return;
400
+ const STATUS_MAP = { 2: "sent", 3: "delivered", 4: "read" };
401
+ for (const { key, update } of updates) {
402
+ const status = update.status;
403
+ if (!status || status < 2) continue;
404
+ const label = STATUS_MAP[status];
405
+ if (!label) continue;
406
+ console.log(`[bridge] Status update: ${key.id} → ${label}`);
407
+ sendWebhook({
408
+ type: "status_update",
409
+ message_id: key.id || "",
410
+ remote_jid: key.remoteJid || "",
411
+ status: label,
412
+ });
413
+ }
414
+ });
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Webhook delivery
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function sendWebhook(payload) {
422
+ const body = JSON.stringify(payload);
423
+ const url = new URL(WEBHOOK_URL);
424
+ const options = {
425
+ hostname: url.hostname,
426
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
427
+ path: url.pathname + url.search,
428
+ method: "POST",
429
+ headers: {
430
+ "Content-Type": "application/json",
431
+ "Content-Length": Buffer.byteLength(body),
432
+ },
433
+ };
434
+
435
+ if (API_KEY) {
436
+ options.headers["X-Webhook-Secret"] = API_KEY;
437
+ }
438
+
439
+ const proto = url.protocol === "https:" ? require("https") : http;
440
+ const req = proto.request(options, (res) => {
441
+ let data = "";
442
+ res.on("data", (chunk) => (data += chunk));
443
+ res.on("end", () => {
444
+ if (res.statusCode >= 400) {
445
+ console.error(`[bridge] Webhook failed: HTTP ${res.statusCode} ${data.substring(0, 200)}`);
446
+ }
447
+ });
448
+ });
449
+ req.on("error", (err) => {
450
+ console.error(`[bridge] Webhook error: ${err.message}`);
451
+ });
452
+ req.write(body);
453
+ req.end();
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Remote mode: push to server + poll for outbound
458
+ // ---------------------------------------------------------------------------
459
+
460
+ function pushToServer(data) {
461
+ if (!REMOTE_MODE) return;
462
+ data.token = BRIDGE_TOKEN;
463
+ const body = JSON.stringify(data);
464
+ const pushUrl = `${REMOTE_SERVER}/api/method/sena_agents_backend.sena_agents_backend.api.whatsapp.bridge_push`;
465
+ const url = new URL(pushUrl);
466
+ const proto = url.protocol === "https:" ? require("https") : http;
467
+ const req = proto.request(
468
+ {
469
+ hostname: url.hostname,
470
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
471
+ path: url.pathname,
472
+ method: "POST",
473
+ headers: {
474
+ "Content-Type": "application/json",
475
+ "Content-Length": Buffer.byteLength(body),
476
+ },
477
+ },
478
+ (res) => {
479
+ let d = "";
480
+ res.on("data", (chunk) => (d += chunk));
481
+ res.on("end", () => {
482
+ if (res.statusCode >= 400) {
483
+ console.error(`[remote] push failed: HTTP ${res.statusCode} ${d.substring(0, 200)}`);
484
+ }
485
+ });
486
+ }
487
+ );
488
+ req.on("error", (err) => {
489
+ console.error(`[remote] push error: ${err.message}`);
490
+ });
491
+ req.write(body);
492
+ req.end();
493
+ }
494
+
495
+ let _pollTimer = null;
496
+
497
+ function startPollLoop() {
498
+ if (!REMOTE_MODE) return;
499
+ console.log("[remote] Starting poll loop for outbound messages");
500
+
501
+ async function poll() {
502
+ const pollUrl = `${REMOTE_SERVER}/api/method/sena_agents_backend.sena_agents_backend.api.whatsapp.bridge_poll?token=${encodeURIComponent(BRIDGE_TOKEN)}`;
503
+ try {
504
+ const url = new URL(pollUrl);
505
+ const proto = url.protocol === "https:" ? require("https") : http;
506
+ const data = await new Promise((resolve, reject) => {
507
+ const req = proto.request(
508
+ {
509
+ hostname: url.hostname,
510
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
511
+ path: url.pathname + url.search,
512
+ method: "GET",
513
+ headers: { "X-Bridge-Token": BRIDGE_TOKEN },
514
+ },
515
+ (res) => {
516
+ let body = "";
517
+ res.on("data", (chunk) => (body += chunk));
518
+ res.on("end", () => {
519
+ try {
520
+ // Frappe wraps responses in {"message": ...}
521
+ const parsed = JSON.parse(body);
522
+ resolve(parsed.message || parsed);
523
+ } catch (e) {
524
+ reject(new Error(`Invalid JSON: ${body.substring(0, 100)}`));
525
+ }
526
+ });
527
+ }
528
+ );
529
+ req.on("error", reject);
530
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error("Poll timeout")); });
531
+ req.end();
532
+ });
533
+
534
+ if (data.type === "send") {
535
+ console.log(`[remote] Outbound send: to=${data.to}`);
536
+ const result = await sendMessage(data.to, data.text || data.message || "");
537
+ pushToServer({
538
+ type: "send_result",
539
+ request_id: data.request_id,
540
+ ok: !result.error,
541
+ message_id: result.messageId || "",
542
+ error: result.error || "",
543
+ });
544
+ } else if (data.type === "send_media") {
545
+ console.log(`[remote] Outbound media: to=${data.to} type=${data.media_type}`);
546
+ const result = await sendMedia(
547
+ data.to,
548
+ data.media_url,
549
+ data.media_type || "document",
550
+ data.caption || ""
551
+ );
552
+ pushToServer({
553
+ type: "send_result",
554
+ request_id: data.request_id,
555
+ ok: !result.error,
556
+ message_id: result.messageId || "",
557
+ error: result.error || "",
558
+ });
559
+ }
560
+ // else type === "noop" — nothing to do
561
+ } catch (err) {
562
+ console.error(`[remote] Poll error: ${err.message}`);
563
+ }
564
+
565
+ _pollTimer = setTimeout(poll, 1500);
566
+ }
567
+
568
+ poll();
569
+ }
570
+
571
+ function stopPollLoop() {
572
+ if (_pollTimer) {
573
+ clearTimeout(_pollTimer);
574
+ _pollTimer = null;
575
+ }
576
+ }
577
+
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Send message
581
+ // ---------------------------------------------------------------------------
582
+
583
+ async function sendMessage(to, text) {
584
+ if (!sock || connectionStatus !== "connected") {
585
+ return { error: "Not connected to WhatsApp" };
586
+ }
587
+
588
+ try {
589
+ const result = await sock.sendMessage(to, { text });
590
+ // Store sent message for retry resolution
591
+ if (result?.key) {
592
+ storeMessage(result.key, result.message);
593
+ }
594
+ return { messageId: result?.key?.id || "" };
595
+ } catch (err) {
596
+ return { error: err.message };
597
+ }
598
+ }
599
+
600
+ async function sendMedia(to, mediaUrl, mediaType, caption) {
601
+ if (!sock || connectionStatus !== "connected") {
602
+ return { error: "Not connected to WhatsApp" };
603
+ }
604
+
605
+ try {
606
+ let msgContent;
607
+ if (mediaType === "image") {
608
+ msgContent = { image: { url: mediaUrl }, caption: caption || undefined };
609
+ } else if (mediaType === "video") {
610
+ msgContent = { video: { url: mediaUrl }, caption: caption || undefined };
611
+ } else if (mediaType === "audio") {
612
+ // Send as voice note (ptt = push-to-talk)
613
+ msgContent = { audio: { url: mediaUrl }, ptt: true, mimetype: "audio/ogg; codecs=opus" };
614
+ } else {
615
+ // document
616
+ const fileName = mediaUrl.split("/").pop() || "document";
617
+ msgContent = {
618
+ document: { url: mediaUrl },
619
+ mimetype: "application/octet-stream",
620
+ fileName,
621
+ caption: caption || undefined,
622
+ };
623
+ }
624
+
625
+ const result = await sock.sendMessage(to, msgContent);
626
+ // Store sent message for retry resolution
627
+ if (result?.key) {
628
+ storeMessage(result.key, result.message);
629
+ }
630
+ return { messageId: result?.key?.id || "" };
631
+ } catch (err) {
632
+ return { error: err.message };
633
+ }
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // HTTP Server
638
+ // ---------------------------------------------------------------------------
639
+
640
+ function parseBody(req) {
641
+ return new Promise((resolve, reject) => {
642
+ let body = "";
643
+ req.on("data", (chunk) => (body += chunk));
644
+ req.on("end", () => {
645
+ try {
646
+ resolve(body ? JSON.parse(body) : {});
647
+ } catch (err) {
648
+ reject(new Error("Invalid JSON"));
649
+ }
650
+ });
651
+ req.on("error", reject);
652
+ });
653
+ }
654
+
655
+ function sendJSON(res, statusCode, data) {
656
+ const body = JSON.stringify(data);
657
+ res.writeHead(statusCode, {
658
+ "Content-Type": "application/json",
659
+ "Access-Control-Allow-Origin": "*",
660
+ });
661
+ res.end(body);
662
+ }
663
+
664
+ const server = http.createServer(async (req, res) => {
665
+ // CORS preflight
666
+ if (req.method === "OPTIONS") {
667
+ res.writeHead(204, {
668
+ "Access-Control-Allow-Origin": "*",
669
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
670
+ "Access-Control-Allow-Headers": "Content-Type, X-Webhook-Secret",
671
+ });
672
+ res.end();
673
+ return;
674
+ }
675
+
676
+ const url = req.url?.split("?")[0] || "/";
677
+
678
+ // GET /status
679
+ if (url === "/status" && req.method === "GET") {
680
+ sendJSON(res, 200, {
681
+ status: connectionStatus,
682
+ phone: connectedPhone,
683
+ has_qr: connectionStatus === "qr_ready" && qrDataUrl !== "",
684
+ error: lastError,
685
+ });
686
+ return;
687
+ }
688
+
689
+ // GET /qr
690
+ if (url === "/qr" && req.method === "GET") {
691
+ if (connectionStatus === "qr_ready" && qrDataUrl) {
692
+ sendJSON(res, 200, { available: true, qr_data_url: qrDataUrl });
693
+ } else {
694
+ sendJSON(res, 200, { available: false, qr_data_url: "" });
695
+ }
696
+ return;
697
+ }
698
+
699
+ // POST /send
700
+ if (url === "/send" && req.method === "POST") {
701
+ try {
702
+ const data = await parseBody(req);
703
+ const to = data.to;
704
+ if (!to) {
705
+ sendJSON(res, 400, { error: "Missing 'to' field" });
706
+ return;
707
+ }
708
+
709
+ let result;
710
+ if (data.media_url) {
711
+ result = await sendMedia(
712
+ to,
713
+ data.media_url,
714
+ data.media_type || "document",
715
+ data.caption || ""
716
+ );
717
+ } else {
718
+ const text = data.text || data.message || "";
719
+ if (!text) {
720
+ sendJSON(res, 400, { error: "Missing 'text' field" });
721
+ return;
722
+ }
723
+ result = await sendMessage(to, text);
724
+ }
725
+
726
+ if (result.error) {
727
+ sendJSON(res, 500, result);
728
+ } else {
729
+ sendJSON(res, 200, result);
730
+ }
731
+ } catch (err) {
732
+ sendJSON(res, 400, { error: err.message });
733
+ }
734
+ return;
735
+ }
736
+
737
+ // POST /resolve-numbers — forward-lookup phone numbers to get LID JIDs
738
+ if (url === "/resolve-numbers" && req.method === "POST") {
739
+ try {
740
+ const data = await parseBody(req);
741
+ const phones = data.phones || [];
742
+ if (!Array.isArray(phones) || phones.length === 0) {
743
+ sendJSON(res, 400, { error: "Missing 'phones' array" });
744
+ return;
745
+ }
746
+ if (!sock || connectionStatus !== "connected") {
747
+ sendJSON(res, 503, { error: "Not connected to WhatsApp" });
748
+ return;
749
+ }
750
+
751
+ const results = {};
752
+ let newMappings = 0;
753
+ for (const phone of phones) {
754
+ try {
755
+ const jids = await sock.onWhatsApp(phone + "@s.whatsapp.net");
756
+ if (jids && jids.length > 0 && jids[0].exists) {
757
+ const jid = jids[0].jid; // e.g. "181316456869929@lid" or "919841797623@s.whatsapp.net"
758
+ const jidId = jid.split("@")[0];
759
+ const isLid = jid.endsWith("@lid");
760
+ results[phone] = { jid, isLid };
761
+ if (isLid && lidToPhone[jidId] !== phone) {
762
+ lidToPhone[jidId] = phone;
763
+ newMappings++;
764
+ }
765
+ } else {
766
+ results[phone] = { jid: null, isLid: false };
767
+ }
768
+ } catch (err) {
769
+ results[phone] = { error: err.message };
770
+ }
771
+ }
772
+ if (newMappings > 0) {
773
+ saveLidMap();
774
+ console.log(`[bridge] resolve-numbers: ${newMappings} new LID mappings saved`);
775
+ }
776
+ sendJSON(res, 200, { resolved: results, total_mappings: Object.keys(lidToPhone).length });
777
+ } catch (err) {
778
+ sendJSON(res, 400, { error: err.message });
779
+ }
780
+ return;
781
+ }
782
+
783
+ // GET /health
784
+ if (url === "/health" && req.method === "GET") {
785
+ sendJSON(res, 200, { ok: true, uptime: process.uptime() });
786
+ return;
787
+ }
788
+
789
+ // 404
790
+ sendJSON(res, 404, { error: "Not found" });
791
+ });
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Start
795
+ // ---------------------------------------------------------------------------
796
+
797
+ server.listen(PORT, () => {
798
+ console.log(`[bridge] Sena WhatsApp Bridge listening on port ${PORT}`);
799
+
800
+ if (REMOTE_MODE) {
801
+ console.log(`[bridge] Remote mode: server=${REMOTE_SERVER}`);
802
+ console.log(`[bridge] Webhook URL: ${WEBHOOK_URL}`);
803
+ pushToServer({ type: "status", status: "starting" });
804
+ } else if (WEBHOOK_URL) {
805
+ console.log(`[bridge] Webhook URL: ${WEBHOOK_URL}`);
806
+
807
+ // Self-register with backend (local mode only)
808
+ const bridgeUrl = process.env.BRIDGE_URL || `http://localhost:${PORT}`;
809
+ const registerUrl = WEBHOOK_URL.replace("receive_webhook", "register_bridge");
810
+ (async () => {
811
+ try {
812
+ const body = JSON.stringify({ bridge_url: bridgeUrl });
813
+ const url = new URL(registerUrl);
814
+ const proto = url.protocol === "https:" ? require("https") : http;
815
+ await new Promise((resolve, reject) => {
816
+ const req = proto.request(
817
+ {
818
+ hostname: url.hostname,
819
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
820
+ path: url.pathname + url.search,
821
+ method: "POST",
822
+ headers: {
823
+ "Content-Type": "application/json",
824
+ "Content-Length": Buffer.byteLength(body),
825
+ },
826
+ },
827
+ (res) => {
828
+ let data = "";
829
+ res.on("data", (chunk) => (data += chunk));
830
+ res.on("end", () => {
831
+ console.log(`[bridge] Registered with backend: ${data}`);
832
+ resolve();
833
+ });
834
+ }
835
+ );
836
+ req.on("error", reject);
837
+ req.write(body);
838
+ req.end();
839
+ });
840
+ } catch (err) {
841
+ console.error(`[bridge] Failed to register with backend: ${err.message}`);
842
+ }
843
+ })();
844
+ }
845
+
846
+ startBaileys().catch((err) => {
847
+ console.error("[bridge] Failed to start Baileys:", err.message);
848
+ lastError = err.message;
849
+ });
850
+ });
851
+
852
+ // Graceful shutdown
853
+ process.on("SIGTERM", () => {
854
+ console.log("[bridge] SIGTERM received, shutting down...");
855
+ stopPollLoop();
856
+ pushToServer({ type: "status", status: "disconnected" });
857
+ sock?.end?.();
858
+ server.close(() => process.exit(0));
859
+ });
860
+
861
+ process.on("SIGINT", () => {
862
+ console.log("[bridge] SIGINT received, shutting down...");
863
+ stopPollLoop();
864
+ pushToServer({ type: "status", status: "disconnected" });
865
+ sock?.end?.();
866
+ server.close(() => process.exit(0));
867
+ });