-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
2009 lines (1790 loc) · 70.6 KB
/
server.js
File metadata and controls
2009 lines (1790 loc) · 70.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Example API server for whatsapp-web.js (local repo)
- Provides endpoints:
GET /status - client status
GET /qr - latest QR code (image/png data-url)
GET /events - Server-Sent Events stream for WhatsApp events
POST /send - send text message { to, message }
POST /send-media - send media { to, filename, mimetype, data (base64) }
GET /chats - list chats
GET /contacts - list contacts
Prerequisites: run `npm install` inside this folder. The server uses the local package root (../../)
*/
const express = require('express');
const bodyParser = require('body-parser');
const qrcode = require('qrcode');
const crypto = require('crypto');
const Database = require('better-sqlite3');
// PostgreSQL client
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const os = require('os');
// load environment variables (create a .env with UI_CREDENTIALS=username:password)
require('dotenv').config();
const session = require('express-session');
let RedisStore;
let redisClient = null;
try {
if (process.env.REDIS_URL) {
const Redis = require('ioredis');
// connect-redis may export differently across versions (function or { default })
const connectRedisPkg = require('connect-redis');
let connectRedis = typeof connectRedisPkg === 'function' ? connectRedisPkg : (connectRedisPkg && connectRedisPkg.default) ? connectRedisPkg.default : null;
if (!connectRedis) throw new Error('connect-redis export not a function');
redisClient = new Redis(process.env.REDIS_URL);
// In newer versions of connect-redis, RedisStore is a class that needs instantiation in middleware
RedisStore = connectRedis;
}
} catch (e) {
console.warn('Redis not configured or not available:', e.message);
}
// Import whatsapp-web.js from repository root
const wwebjs = require('./lib');
const { Client, LocalAuth, MessageMedia } = wwebjs;
// Function to clean up stale Chrome locks from previous sessions
function cleanupSessionLocks() {
const sessionsDir = path.join(__dirname, 'sessions');
if (fs.existsSync(sessionsDir)) {
try {
// Recursive function to walk directories and delete SingletonLock files
const clean = (dir) => {
const files = fs.readdirSync(dir);
files.forEach(file => {
const fullPath = path.join(dir, file);
const stat = fs.lstatSync(fullPath);
if (stat.isDirectory()) {
clean(fullPath);
} else if (file === 'SingletonLock') {
try {
fs.unlinkSync(fullPath);
console.log('Removed stale lock file:', fullPath);
} catch (e) {
console.error('Failed to remove lock:', fullPath, e.message);
}
}
});
};
clean(sessionsDir);
} catch (e) {
console.error('Error cleaning up session locks:', e.message);
}
}
}
// Run cleanup immediately on startup
cleanupSessionLocks();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(bodyParser.json({ limit: '10mb' }));
app.use(bodyParser.urlencoded({ extended: true }));
// session middleware (use Redis store if REDIS_URL provided)
const sessionOpts = {
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: { secure: false }
};
if (RedisStore && redisClient) {
try {
// RedisStore can be a function (old versions) or a class (new versions)
app.use(session(Object.assign({}, sessionOpts, { store: new RedisStore({ client: redisClient }) })));
console.log('Using Redis session store');
} catch (e) {
console.warn('Failed to initialize Redis session store:', e.message, '- falling back to memory store');
app.use(session(sessionOpts));
}
} else {
app.use(session(sessionOpts));
}
function shouldProtectUi(req) {
const p = req.path || '';
const accept = (req.headers && req.headers.accept) || '';
if (p === '/' || p.endsWith('.html') || p.endsWith('/app.js') || accept.includes('text/html')) return true;
return false;
}
// UI auth middleware: if UI_CREDENTIALS is set, require a logged-in session for UI routes
function uiAuthMiddleware(req, res, next) {
const creds = process.env.UI_CREDENTIALS; // expected format username:password
if (!creds) return next();
if (!shouldProtectUi(req)) return next();
// allow login routes
if (req.path === '/login' || req.path === '/logout' || req.path.startsWith('/public') || req.path.startsWith('/assets')) return next();
if (req.session && req.session.loggedIn) return next();
// redirect to login page
return res.redirect('/login');
}
app.use(uiAuthMiddleware);
// Serve a small web UI from /public
app.use(express.static('public'));
// Swagger API Documentation (public, no auth required)
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger.config');
// Serve Swagger UI at /api-docs
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'WhatsApp API Documentation'
}));
// Initialize databases
const SQLITE_DB_FILE = 'whatsapp_messages.db';
let sqliteDb = null;
if (fs.existsSync(SQLITE_DB_FILE)) {
sqliteDb = new Database(SQLITE_DB_FILE);
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
from_user TEXT,
body TEXT,
timestamp INTEGER,
has_media INTEGER DEFAULT 0,
media_type TEXT,
is_location INTEGER DEFAULT 0,
is_contact INTEGER DEFAULT 0,
is_sticker INTEGER DEFAULT 0,
media_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_client_chat ON messages(client_id, chat_id);
CREATE TABLE IF NOT EXISTS clients_metadata (
id TEXT PRIMARY KEY,
api_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
}
// Setup Postgres pool using DATABASE_URL or default
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/whatsapp';
const pgPool = new Pool({ connectionString: DATABASE_URL });
// Ensure Postgres table exists
(async function ensurePg() {
const client = await pgPool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
from_user TEXT,
body TEXT,
timestamp BIGINT,
has_media BOOLEAN DEFAULT false,
media_type TEXT,
media_path TEXT,
is_location BOOLEAN DEFAULT false,
is_contact BOOLEAN DEFAULT false,
is_sticker BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS clients_metadata (
id TEXT PRIMARY KEY,
api_key TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_pg_client_chat ON messages(client_id, chat_id);`);
console.log('PostgreSQL tables created/verified');
// Now load cache after tables are created
await loadClientMetadataCache();
} catch (e) {
console.error('Failed to ensure Postgres tables:', e.message);
} finally {
client.release();
}
})();
// Media root folder (mounted from host via docker-compose `./data`)
const MEDIA_ROOT = path.join(__dirname, 'data');
try { fs.mkdirSync(MEDIA_ROOT, { recursive: true }); } catch (e) { }
// If an existing SQLite DB exists, migrate its messages to Postgres
async function migrateSqliteToPostgres() {
if (!sqliteDb) return;
try {
const rows = sqliteDb.prepare('SELECT id, client_id, chat_id, from_user, body, timestamp, has_media, media_type, media_path, is_location, is_contact, is_sticker FROM messages').all();
for (const r of rows) {
// ensure media file copied under MEDIA_ROOT if media_path is present
let mediaPath = null;
if (r.media_path) {
const src = path.isAbsolute(r.media_path) ? r.media_path : path.join(__dirname, r.media_path);
if (fs.existsSync(src)) {
const destDir = path.join(MEDIA_ROOT, r.client_id || 'unknown');
fs.mkdirSync(destDir, { recursive: true });
const filename = path.basename(src);
const dest = path.join(destDir, filename);
try { fs.copyFileSync(src, dest); mediaPath = path.join(r.client_id || 'unknown', filename); } catch (e) { console.warn('failed to copy media file during migration', src, e.message); }
}
}
try {
await pgPool.query(`INSERT INTO messages (id, client_id, chat_id, from_user, body, timestamp, has_media, media_type, media_path, is_location, is_contact, is_sticker) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (id) DO NOTHING`, [
r.id,
r.client_id,
r.chat_id,
r.from_user,
r.body,
r.timestamp,
r.has_media === 1,
r.media_type,
mediaPath || r.media_path,
r.is_location === 1,
r.is_contact === 1,
r.is_sticker === 1
]);
} catch (e) { console.warn('failed to migrate message', r.id, e.message); }
}
// Close and remove sqlite file
try { sqliteDb.close(); } catch (e) { }
try { fs.unlinkSync(SQLITE_DB_FILE); console.log('Removed old sqlite DB after migration'); } catch (e) { console.warn('failed to remove sqlite file', e.message); }
sqliteDb = null;
} catch (e) {
console.error('migration from sqlite to postgres failed:', e.message);
}
}
// Multi-client support
const clients = new Map();
const sseClients = new Set();
// API key management
function generateApiKey() {
return crypto.randomBytes(32).toString('hex');
}
// Helper functions for client metadata persistence
// Client metadata cache backed by Postgres
const clientMetadataCache = new Map();
async function loadClientMetadataCache() {
try {
const r = await pgPool.query('SELECT id, api_key FROM clients_metadata');
r.rows.forEach(row => clientMetadataCache.set(row.id, row.api_key));
console.log('Client metadata cache loaded from Postgres');
} catch (e) {
console.error('Failed to load client metadata cache from Postgres:', e.message);
}
}
function saveClientMetadata(clientId, apiKey) {
try {
clientMetadataCache.set(clientId, apiKey);
// persist in background
pgPool.query(`INSERT INTO clients_metadata (id, api_key) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET api_key = EXCLUDED.api_key`, [clientId, apiKey]).catch(e => console.error('Failed to persist client metadata to Postgres:', e.message));
} catch (e) {
console.error(`Failed to save client metadata for ${clientId}:`, e.message);
}
}
function loadClientMetadata(clientId) {
return clientMetadataCache.has(clientId) ? clientMetadataCache.get(clientId) : null;
}
function getAllClientMetadata() {
return Array.from(clientMetadataCache.entries()).map(([id, api_key]) => ({ id, api_key }));
}
function deleteClientMetadata(clientId) {
try {
clientMetadataCache.delete(clientId);
pgPool.query('DELETE FROM clients_metadata WHERE id = $1', [clientId]).catch(e => console.error('Failed to delete client metadata from Postgres:', e.message));
} catch (e) {
console.error(`Failed to delete client metadata for ${clientId}:`, e.message);
}
}
// Rate limiter: Redis-backed token counter if Redis available, else in-memory fixed window
const RATE_LIMIT_PER_MINUTE = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '60', 10);
const rateLimits = new Map();
async function checkRateLimitForKey(apiKey) {
const windowMs = 60 * 1000;
if (!apiKey) return { limited: false, limit: RATE_LIMIT_PER_MINUTE, remaining: RATE_LIMIT_PER_MINUTE };
if (redisClient) {
const key = `rate:${apiKey}`;
// INCR and set expiry if new
const count = await redisClient.incr(key);
if (count === 1) await redisClient.pexpire(key, windowMs);
const pttl = await redisClient.pttl(key); // ms remaining
const remaining = Math.max(0, RATE_LIMIT_PER_MINUTE - count);
const limited = count > RATE_LIMIT_PER_MINUTE;
return { limited, remaining, limit: RATE_LIMIT_PER_MINUTE, reset: Date.now() + (pttl > 0 ? pttl : 0) };
}
// fallback in-memory fixed window
const now = Date.now();
let entry = rateLimits.get(apiKey);
if (!entry || now - entry.windowStart > windowMs) {
entry = { windowStart: now, count: 0 };
}
entry.count++;
rateLimits.set(apiKey, entry);
const remaining = Math.max(0, RATE_LIMIT_PER_MINUTE - entry.count);
const limited = entry.count > RATE_LIMIT_PER_MINUTE;
return { limited, remaining, limit: RATE_LIMIT_PER_MINUTE, reset: entry.windowStart + windowMs };
}
async function requireApiKey(req, res, next) {
try {
const apiKey = req.query.api_key || req.headers['x-api-key'] || req.body.api_key;
// client may be provided explicitly, or inferred from API key
let clientId = req.query.client || req.body.client || null;
// If caller provided only an API key, try to find the client that owns it
if (apiKey && !clientId) {
for (const [id, state] of clients.entries()) {
if (state.apiKey === apiKey) { clientId = id; break; }
}
}
// fallback to default client if still not provided
clientId = clientId || 'default';
if (!clients.has(clientId)) return res.status(404).json({ error: 'client not found' });
const clientState = clients.get(clientId);
// if client has API key set, require it and ensure it matches the provided key
if (clientState.apiKey) {
if (!apiKey || clientState.apiKey !== apiKey) {
return res.status(401).json({ error: 'unauthorized: invalid or missing API key' });
}
}
// enforce rate limit per API key (async-aware)
const rl = await checkRateLimitForKey(apiKey);
res.setHeader('X-RateLimit-Limit', rl.limit);
res.setHeader('X-RateLimit-Remaining', rl.remaining);
res.setHeader('X-RateLimit-Reset', rl.reset);
if (rl.limited) return res.status(429).json({ error: 'rate limit exceeded' });
req.clientId = clientId;
return next();
} catch (e) {
return res.status(500).json({ error: e.message });
}
}
function sendSseEvent(event, data, clientId) {
const payloadObj = Object.assign({}, { clientId }, { payload: data });
const payload = JSON.stringify(payloadObj);
for (const entry of sseClients) {
const { res, filter } = entry;
try {
// if filter is set and doesn't match, skip
if (filter && filter !== clientId) continue;
res.write(`event: ${event}\n`);
res.write(`data: ${payload}\n\n`);
} catch (e) {
// ignore write errors
}
}
}
async function createClient(clientId) {
// Puppeteer in containers often needs --no-sandbox flags when running as root.
const puppeteerArgs = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-zygote'
];
const puppeteerOpts = {
headless: true,
args: puppeteerArgs
};
// allow overriding executable path via env (we install chromium in image)
if (process.env.PUPPETEER_EXECUTABLE_PATH) puppeteerOpts.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const client = new Client({
authStrategy: new LocalAuth({
clientId,
dataPath: path.join(__dirname, 'sessions')
}),
puppeteer: puppeteerOpts
});
const startTime = Date.now();
const state = {
client,
status: 'initializing',
lastQr: null,
apiKey: loadClientMetadata(clientId) || generateApiKey(),
startTime,
messagesSaved: 0,
uptime: () => Date.now() - startTime,
resourceUsage: { cpuPercent: 0, memoryUsage: process.memoryUsage() }
};
// Save client metadata to database for persistence across restarts
saveClientMetadata(clientId, state.apiKey);
client.on('qr', async qr => {
state.status = 'qr';
try { state.lastQr = await qrcode.toDataURL(qr); } catch (e) { state.lastQr = null; }
sendSseEvent('qr', { qr }, clientId);
});
client.on('ready', () => {
state.status = 'ready';
state.lastQr = null; // Clear QR code when ready
sendSseEvent('ready', { message: 'Client ready' }, clientId);
console.log(`WhatsApp client ${clientId} ready`);
});
client.on('authenticated', () => {
state.status = 'authenticated';
state.lastQr = null; // Clear QR code when authenticated
sendSseEvent('authenticated', {}, clientId);
});
client.on('auth_failure', msg => { state.status = 'auth_failure'; sendSseEvent('auth_failure', { msg }, clientId); });
client.on('disconnected', reason => { state.status = 'disconnected'; sendSseEvent('disconnected', { reason }, clientId); });
// message events with PostgreSQL persistence
client.on('message', async message => {
// Normalize message fields and provide safe fallbacks for DB insertion
const id = (message.id && message.id._serialized) || `${clientId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// Try to get chatId from message - be strict about non-null and non-empty
let chatId = null;
if (message.chatId && typeof message.chatId === 'string' && message.chatId.trim()) {
chatId = message.chatId;
} else if (message.from && typeof message.from === 'string' && message.from.trim()) {
chatId = message.from;
} else if (message.to && typeof message.to === 'string' && message.to.trim()) {
chatId = message.to;
} else {
// Last resort: use a hash of message id - this should never fail
chatId = `unknown_${clientId}_${Math.random().toString(36).slice(2, 8)}`;
console.warn('Message has no valid chatId/from/to, generated fallback:', { msgId: id, chatId });
}
const timestamp = message.timestamp || Math.floor(Date.now() / 1000);
// Capture media metadata if message has media
let mediaTypeTop = null; // top-level for UI/SSE: image, video, audio, application
let storedMimeType = null; // full mimetype for DB and Content-Type
if (message.hasMedia && message.media) {
try {
if (message.media.mimetype) {
storedMimeType = message.media.mimetype;
mediaTypeTop = message.media.mimetype.split('/')[0];
} else {
mediaTypeTop = 'unknown';
}
} catch (e) {
// ignore media extraction errors
}
}
const msgData = {
id,
from: message.from || null,
to: message.to || null,
body: message.body || null,
hasMedia: message.hasMedia || false,
mediaType: mediaTypeTop,
chatId,
timestamp,
isLocation: message.type === 'location',
isContact: message.type === 'contact_card',
isSticker: message.type === 'sticker'
};
// save to Postgres; if sqlite exists we'll migrate later
try {
let mediaPath = null;
// If message has media, try to download and save into host-mounted data folder
if (msgData.hasMedia) {
try {
const m = await message.downloadMedia();
if (m && m.data) {
// Update MIME type from downloaded media (more reliable than message.media)
if (m.mimetype) {
storedMimeType = m.mimetype;
mediaTypeTop = m.mimetype.split('/')[0];
}
// derive extension
let ext = '';
if (m.filename) ext = path.extname(m.filename);
else if (m.mimetype && m.mimetype.includes('/')) ext = '.' + m.mimetype.split('/')[1];
const clientDir = path.join(MEDIA_ROOT, clientId);
fs.mkdirSync(clientDir, { recursive: true });
const filename = `${msgData.id}${ext || ''}`;
const filePath = path.join(clientDir, filename);
fs.writeFileSync(filePath, Buffer.from(m.data, 'base64'));
// store relative path
mediaPath = path.join(clientId, filename);
}
} catch (e) {
console.warn('failed to download media for message', msgData.id, e.message);
}
}
// Insert into Postgres messages table
await pgPool.query(`
INSERT INTO messages (id, client_id, chat_id, from_user, body, timestamp, has_media, media_type, media_path, is_location, is_contact, is_sticker)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (id) DO UPDATE SET
body = EXCLUDED.body,
timestamp = EXCLUDED.timestamp
`, [
msgData.id,
clientId,
msgData.chatId,
msgData.from,
msgData.body,
msgData.timestamp,
msgData.hasMedia ? true : false,
storedMimeType,
mediaPath,
msgData.isLocation ? true : false,
msgData.isContact ? true : false,
msgData.isSticker ? true : false
]);
state.messagesSaved++;
} catch (e) {
console.error('error saving message to Postgres:', e.message || e);
}
sendSseEvent('message', msgData, clientId);
});
client.on('message_create', message => sendSseEvent('message_create', { id: message.id && message.id._serialized, from: message.from, body: message.body }, clientId));
client.on('message_revoke_everyone', (after, before) => sendSseEvent('message_revoke_everyone', { after: after && after._serialized, before: before && before._serialized }, clientId));
client.on('message_revoke_me', msgId => sendSseEvent('message_revoke_me', { msgId: msgId && msgId._serialized }, clientId));
client.on('message_ack', (msg, ack) => sendSseEvent('message_ack', { id: msg && msg.id && msg.id._serialized, ack }, clientId));
client.on('message_media_uploaded', message => sendSseEvent('message_media_uploaded', { id: message && message.id && message.id._serialized }, clientId));
client.on('message_reaction', (reaction) => {
sendSseEvent('message_reaction', {
messageId: reaction.msgId && reaction.msgId._serialized,
reaction: reaction.reaction,
from: reaction.from
}, clientId);
});
// group events
client.on('group_join', notification => sendSseEvent('group_join', { id: notification.id && notification.id._serialized, chatId: notification.chatId, type: notification.type }, clientId));
client.on('group_leave', notification => sendSseEvent('group_leave', { id: notification.id && notification.id._serialized }, clientId));
client.on('group_update', (notification) => {
sendSseEvent('group_update', {
id: notification.id && notification.id._serialized,
type: notification.type,
chatId: notification.chatId
}, clientId);
});
await client.initialize();
state.status = 'initializing';
clients.set(clientId, state);
return state;
}
// Load all clients from database and recreate them at startup
(async () => {
try {
await loadClientMetadataCache();
// migrate any existing sqlite data into Postgres
await migrateSqliteToPostgres();
const savedClients = getAllClientMetadata();
if (savedClients.length === 0) {
// No saved clients, create default client for first-time setup
await createClient('default');
} else {
// Recreate all saved clients from database
for (const { id } of savedClients) {
try {
await createClient(id);
console.log(`Restored client: ${id}`);
} catch (e) {
console.error(`Failed to restore client ${id}:`, e.message);
}
}
}
} catch (e) {
console.error('Error loading clients from database:', e.message);
// Fallback: create default client
await createClient('default').catch(err => console.error('failed create default client', err));
}
})();
// SSE events endpoint
app.get('/events', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
});
res.flushHeaders();
res.write('\n');
const filterClient = req.query.client || null; // optional filter for a specific client
const entry = { res, filter: filterClient };
sseClients.add(entry);
// send initial status for the requested client or summary
if (filterClient && clients.has(filterClient)) {
const st = clients.get(filterClient).status;
res.write(`event: status\n`);
res.write(`data: ${JSON.stringify({ clientId: filterClient, status: st })}\n\n`);
} else {
// send list of clients
const list = Array.from(clients.entries()).map(([id, s]) => ({ id, status: s.status }));
res.write(`event: clients\n`);
res.write(`data: ${JSON.stringify({ clients: list })}\n\n`);
}
req.on('close', () => { sseClients.delete(entry); });
});
// Login page and handlers
app.get('/login', (req, res) => {
// serve public/login.html from the public folder
res.sendFile(require('path').join(__dirname, 'public', 'login.html'));
});
app.post('/login', (req, res) => {
const creds = process.env.UI_CREDENTIALS || '';
const [u, p] = creds.split(':');
const username = req.body.username || '';
const password = req.body.password || '';
if (!creds) return res.status(400).send('UI_CREDENTIALS not configured');
if (username === u && password === p) {
req.session.loggedIn = true;
req.session.user = u;
return res.redirect('/');
}
return res.status(401).send('Invalid credentials');
});
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/login');
});
});
// Clients management endpoints
// List clients with telemetry
app.get('/clients', (req, res) => {
const list = Array.from(clients.entries()).map(([id, s]) => ({
id,
status: s.status,
apiKey: s.apiKey,
uptime: s.uptime(),
messagesSaved: s.messagesSaved,
memoryUsage: Math.round(s.resourceUsage.memoryUsage.heapUsed / 1024 / 1024) + ' MB'
}));
res.json({ clients: list });
});
// Rotate API key for a client without destroying session
app.post('/clients/:id/rotate-key', async (req, res) => {
const id = req.params.id;
if (!clients.has(id)) return res.status(404).json({ error: 'client not found' });
const state = clients.get(id);
// allow rotation if UI session is logged in
const allowedBySession = req.session && req.session.loggedIn;
// or if caller presents the current api key in body
const suppliedKey = req.body.current_api_key || req.query.current_api_key || req.headers['x-api-key'];
const allowedByKey = suppliedKey && state.apiKey && suppliedKey === state.apiKey;
if (!allowedBySession && !allowedByKey) return res.status(401).json({ error: 'unauthorized' });
const newKey = generateApiKey();
state.apiKey = newKey;
// update clients map
clients.set(id, state);
res.json({ success: true, apiKey: newKey });
});
// Helper: save outgoing message to Postgres
async function saveOutgoingMessage(clientId, sentMessage, messageBody, messageType = 'text', mimeType = null, mediaPath = null, toParam = null) {
try {
const msgId = sentMessage.id && sentMessage.id._serialized ? sentMessage.id._serialized : sentMessage.id;
const chatId = toParam || sentMessage.to || sentMessage.recipient || sentMessage.chatId;
const timestamp = sentMessage.timestamp || Math.floor(Date.now() / 1000);
if (!msgId || !chatId) {
console.warn('cannot save outgoing message: missing msgId or chatId', { msgId, chatId, sentMessage, toParam });
return;
}
let hasMedia = false;
let mediaTypeVal = null; // store full mimetype when available
let isLocationVal = false;
let isContactVal = false;
let isStickerVal = false;
let bodyText = messageBody || '';
// Determine message type
if (messageType === 'media') {
hasMedia = true;
mediaTypeVal = mimeType || null;
} else if (messageType === 'location') {
isLocationVal = true;
bodyText = messageBody || 'Location';
} else if (messageType === 'contact') {
isContactVal = true;
bodyText = messageBody || 'Contact';
} else if (messageType === 'sticker') {
isStickerVal = true;
hasMedia = true;
mediaTypeVal = mimeType || 'image/webp';
}
await pgPool.query(`
INSERT INTO messages (id, client_id, chat_id, from_user, body, timestamp, has_media, media_type, media_path, is_location, is_contact, is_sticker)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (id) DO UPDATE SET
body = EXCLUDED.body,
timestamp = EXCLUDED.timestamp
`, [
msgId,
clientId,
chatId,
'outgoing',
bodyText,
timestamp,
hasMedia,
mediaTypeVal,
mediaPath,
isLocationVal,
isContactVal,
isStickerVal
]);
console.log('saved outgoing message to postgres:', { msgId, clientId, chatId, bodyText });
} catch (e) {
console.error('ERROR saving outgoing message to Postgres:', e.message || e, e.stack);
}
}
// Create a new client session: { id: optional }
app.post('/clients', async (req, res) => {
const id = req.body.id || `c_${Date.now()}`;
if (clients.has(id)) return res.status(400).json({ error: 'client already exists' });
try {
const state = await createClient(id);
res.json({ success: true, id, apiKey: state.apiKey });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Delete client session
app.delete('/clients/:id', async (req, res) => {
const id = req.params.id;
if (!clients.has(id)) return res.status(404).json({ error: 'client not found' });
try {
const state = clients.get(id);
if (state && state.client && typeof state.client.destroy === 'function') await state.client.destroy();
clients.delete(id);
// cleanup messages from Postgres and delete media files
try {
const r = await pgPool.query('SELECT media_path FROM messages WHERE client_id = $1', [id]);
for (const row of r.rows) {
if (row.media_path) {
const fp = path.join(MEDIA_ROOT, row.media_path);
try { if (fs.existsSync(fp)) fs.unlinkSync(fp); } catch (e) { console.warn('failed to delete media file', fp, e.message); }
}
}
await pgPool.query('DELETE FROM messages WHERE client_id = $1', [id]);
} catch (e) { console.warn('error cleaning up messages for client', id, e.message); }
// cleanup client metadata from Postgres
deleteClientMetadata(id);
res.json({ success: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Status with telemetry
app.get('/status', requireApiKey, (req, res) => {
const clientId = req.clientId;
if (clients.has(clientId)) {
const s = clients.get(clientId);
return res.json({
clientId,
status: s.status,
uptime: s.uptime(),
messagesSaved: s.messagesSaved,
memoryUsage: Math.round(s.resourceUsage.memoryUsage.heapUsed / 1024 / 1024) + ' MB'
});
}
return res.status(404).json({ error: 'client not found' });
});
// Set user status message: PUT /status { message: 'Available' }
app.put('/status', requireApiKey, async (req, res) => {
try {
const clientId = req.clientId;
const { message } = req.body;
if (!message) {
return res.status(400).json({ error: 'message required' });
}
const c = clients.get(clientId).client;
await c.setStatus(message);
res.json({
success: true,
status: message
});
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// QR (data URL)
app.get('/qr', (req, res) => {
const clientId = req.query.client || 'default';
if (!clients.has(clientId)) return res.status(404).json({ error: 'client not found' });
const lastQr = clients.get(clientId).lastQr;
if (!lastQr) return res.status(404).json({ error: 'QR not available' });
const img = Buffer.from(lastQr.split(',')[1], 'base64');
res.set('Content-Type', 'image/png');
res.send(img);
});
// Send text message: { to: '6281234@s.whatsapp.net' or '6281234@c.us', message: 'hello', mentions?: ['id1@c.us', 'id2@c.us'] }
app.post('/send', requireApiKey, async (req, res) => {
const { to, message, mentions, quotedMessageId } = req.body;
const clientId = req.clientId;
if (!to || !message) return res.status(400).json({ error: 'to and message required' });
try {
const state = clients.get(clientId);
if (!state) return res.status(404).json({ error: 'client not found' });
const c = state.client;
console.log('DEBUG /send:', { to, message, mentions, quotedMessageId, clientId });
let sent;
try {
// Build options
const options = {};
if (mentions && Array.isArray(mentions) && mentions.length > 0) {
options.mentions = mentions;
}
if (quotedMessageId) {
options.quotedMessageId = quotedMessageId;
}
sent = await c.sendMessage(to, message, options);
console.log('DEBUG sent message FULL:', JSON.stringify(sent, null, 2));
await saveOutgoingMessage(clientId, sent, message, 'text', null, null, to);
state.messagesSaved++;
res.json({ success: true, id: sent.id && sent.id._serialized });
} catch (sendErr) {
console.error('ERROR in /send (sendMessage failed):', sendErr && sendErr.message);
const localId = `out_${Date.now()}`;
const fakeMsg = { id: localId, chatId: to, timestamp: Math.floor(Date.now() / 1000) };
await saveOutgoingMessage(clientId, fakeMsg, `[FAILED SEND] ${message}`, 'text', null, null, to);
state.messagesSaved++;
res.status(500).json({ error: sendErr.message });
}
} catch (e) {
console.error('ERROR in /send:', e.message);
res.status(500).json({ error: e.message });
}
});
// Send media: { to, filename, mimetype, data: base64 }
app.post('/send-media', requireApiKey, async (req, res) => {
const { to, filename, mimetype, data } = req.body;
const clientId = req.clientId;
if (!to || !data) return res.status(400).json({ error: 'to and data required' });
try {
const state = clients.get(clientId);
if (!state) return res.status(404).json({ error: 'client not found' });
const c = state.client;
const buffer = Buffer.from(data, 'base64');
const media = new MessageMedia(mimetype || 'application/octet-stream', buffer.toString('base64'), filename || 'file');
const sent = await c.sendMessage(to, media);
// Save media to host-mounted data folder
let mediaPath = null;
try {
const msgId = sent.id && sent.id._serialized ? sent.id._serialized : sent.id;
let ext = '';
if (filename) ext = path.extname(filename);
else if (mimetype && mimetype.includes('/')) ext = '.' + mimetype.split('/')[1];
const clientDir = path.join(MEDIA_ROOT, clientId);
fs.mkdirSync(clientDir, { recursive: true });
const fname = `${msgId}${ext || ''}`;
const filePath = path.join(clientDir, fname);
fs.writeFileSync(filePath, buffer);
mediaPath = path.join(clientId, fname);
} catch (e) {
console.warn('failed to save sent media for message', e.message);
}
// Save outgoing message to database
await saveOutgoingMessage(clientId, sent, filename || 'Media', 'media', mimetype || null, mediaPath, to);
state.messagesSaved++;
res.json({ success: true, id: sent.id && sent.id._serialized });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Send sticker: { to, filename, mimetype, data: base64 }
// (stickers use MessageMedia with image/webp mimetype)
app.post('/send-sticker', requireApiKey, async (req, res) => {
const { to, data } = req.body;
const clientId = req.clientId;
if (!to || !data) return res.status(400).json({ error: 'to and data required' });
try {
const state = clients.get(clientId);
if (!state) return res.status(404).json({ error: 'client not found' });
const c = state.client;
const buffer = Buffer.from(data, 'base64');
// Stickers are typically WebP images
const sticker = new MessageMedia('image/webp', buffer.toString('base64'), 'sticker.webp');
const sent = await c.sendMessage(to, sticker);
// Save sticker to host-mounted data folder
let mediaPath = null;
try {
const msgId = sent.id && sent.id._serialized ? sent.id._serialized : sent.id;
const clientDir = path.join(MEDIA_ROOT, clientId);
fs.mkdirSync(clientDir, { recursive: true });
const fname = `${msgId}.webp`;
const filePath = path.join(clientDir, fname);
fs.writeFileSync(filePath, buffer);
mediaPath = path.join(clientId, fname);
} catch (e) {
console.warn('failed to save sent sticker for message', e.message);
}
// Save outgoing message to database
await saveOutgoingMessage(clientId, sent, 'Sticker', 'sticker', 'image/webp', mediaPath, to);
state.messagesSaved++;
res.json({ success: true, id: sent.id && sent.id._serialized });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Send location: { to, latitude, longitude, address?: string }
app.post('/send-location', requireApiKey, async (req, res) => {
const { to, latitude, longitude, address } = req.body;
const clientId = req.clientId;
if (!to || latitude === undefined || longitude === undefined) {
return res.status(400).json({ error: 'to, latitude, and longitude required' });
}
try {
const state = clients.get(clientId);
if (!state) return res.status(404).json({ error: 'client not found' });
const c = state.client;
const location = new (require('whatsapp-web.js').Location)(latitude, longitude, address || '');
const sent = await c.sendMessage(to, location);
// Save outgoing message to database
const locationBody = address || `Location: ${latitude},${longitude}`;
await saveOutgoingMessage(clientId, sent, locationBody, 'location', null, null, to);
state.messagesSaved++;
res.json({ success: true, id: sent.id && sent.id._serialized });
} catch (e) {
res.status(500).json({ error: e.message });