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.
- package/package.json +37 -0
- 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
|
+
});
|