@adens/openwa 0.1.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 (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +319 -0
  4. package/bin/openwa.js +11 -0
  5. package/favicon.ico +0 -0
  6. package/logo-long.png +0 -0
  7. package/logo-square.png +0 -0
  8. package/package.json +69 -0
  9. package/prisma/schema.prisma +182 -0
  10. package/server/config.js +29 -0
  11. package/server/database/client.js +11 -0
  12. package/server/database/init.js +28 -0
  13. package/server/express/create-app.js +349 -0
  14. package/server/express/openapi.js +853 -0
  15. package/server/index.js +163 -0
  16. package/server/services/api-key-service.js +131 -0
  17. package/server/services/auth-service.js +162 -0
  18. package/server/services/chat-service.js +1014 -0
  19. package/server/services/session-service.js +81 -0
  20. package/server/socket/register.js +127 -0
  21. package/server/utils/avatar.js +34 -0
  22. package/server/utils/paths.js +29 -0
  23. package/server/whatsapp/adapters/mock-adapter.js +47 -0
  24. package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
  25. package/server/whatsapp/session-manager.js +356 -0
  26. package/web/components/AppHead.js +14 -0
  27. package/web/components/AuthCard.js +170 -0
  28. package/web/components/BrandLogo.js +11 -0
  29. package/web/components/ChatWindow.js +875 -0
  30. package/web/components/ChatWindow.js.tmp +0 -0
  31. package/web/components/ContactList.js +97 -0
  32. package/web/components/ContactsPanel.js +90 -0
  33. package/web/components/EmojiPicker.js +108 -0
  34. package/web/components/MediaPreviewModal.js +146 -0
  35. package/web/components/MessageActionMenu.js +155 -0
  36. package/web/components/SessionSidebar.js +167 -0
  37. package/web/components/SettingsModal.js +266 -0
  38. package/web/components/Skeletons.js +73 -0
  39. package/web/jsconfig.json +10 -0
  40. package/web/lib/api.js +33 -0
  41. package/web/lib/socket.js +9 -0
  42. package/web/pages/_app.js +5 -0
  43. package/web/pages/dashboard.js +541 -0
  44. package/web/pages/index.js +62 -0
  45. package/web/postcss.config.js +10 -0
  46. package/web/public/favicon.ico +0 -0
  47. package/web/public/logo-long.png +0 -0
  48. package/web/public/logo-square.png +0 -0
  49. package/web/store/useAppStore.js +209 -0
  50. package/web/styles/globals.css +52 -0
  51. package/web/tailwind.config.js +36 -0
@@ -0,0 +1,1014 @@
1
+ const path = require("path");
2
+ const { prisma } = require("../database/client");
3
+ const { createAvatarDataUrl } = require("../utils/avatar");
4
+
5
+ let incomingMessageQueue = Promise.resolve();
6
+
7
+ // Retry operation with exponential backoff for SQLite "database is busy" errors (P1008).
8
+ // This helps handle transient database lock situations that can occur during high concurrency.
9
+ // Retries 4 times with delays: 0ms (immediate), 100ms, 250ms, 500ms before giving up.
10
+ async function retryOnSqliteTimeout(operation) {
11
+ let lastError = null;
12
+ for (const delayMs of [0, 100, 250, 500]) {
13
+ if (delayMs > 0) {
14
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
15
+ }
16
+
17
+ try {
18
+ return await operation();
19
+ } catch (error) {
20
+ if (error?.code !== "P1008") {
21
+ throw error;
22
+ }
23
+
24
+ lastError = error;
25
+ }
26
+ }
27
+
28
+ throw lastError;
29
+ }
30
+
31
+ function enqueueIncomingMessage(task) {
32
+ const nextRun = incomingMessageQueue.then(task, task);
33
+ incomingMessageQueue = nextRun.catch(() => {});
34
+ return nextRun;
35
+ }
36
+
37
+ function mapMessage(message) {
38
+ return {
39
+ id: message.id,
40
+ chatId: message.chatId,
41
+ sessionId: message.sessionId,
42
+ mediaFileId: message.mediaFileId,
43
+ replyToId: message.replyToId,
44
+ externalMessageId: message.externalMessageId,
45
+ sender: message.sender,
46
+ receiver: message.receiver,
47
+ body: message.body,
48
+ type: message.type,
49
+ direction: message.direction,
50
+ createdAt: message.createdAt,
51
+ updatedAt: message.updatedAt,
52
+ mediaFile: message.mediaFile || null,
53
+ replyTo: message.replyTo
54
+ ? {
55
+ id: message.replyTo.id,
56
+ body: message.replyTo.body,
57
+ type: message.replyTo.type,
58
+ sender: message.replyTo.sender,
59
+ direction: message.replyTo.direction,
60
+ mediaFile: message.replyTo.mediaFile || null
61
+ }
62
+ : null,
63
+ statuses: message.statuses || []
64
+ };
65
+ }
66
+
67
+ function mapChat(chat) {
68
+ const lastMessage = chat.messages?.[0] ? mapMessage(chat.messages[0]) : null;
69
+ return {
70
+ id: chat.id,
71
+ title: chat.title,
72
+ sessionId: chat.sessionId,
73
+ contact: {
74
+ id: chat.contact.id,
75
+ externalId: chat.contact.externalId,
76
+ displayName: chat.contact.displayName,
77
+ avatarUrl: chat.contact.avatarUrl,
78
+ unreadCount: chat.contact.unreadCount,
79
+ lastMessagePreview: chat.contact.lastMessagePreview,
80
+ lastMessageAt: chat.contact.lastMessageAt
81
+ },
82
+ lastMessage,
83
+ updatedAt: chat.updatedAt
84
+ };
85
+ }
86
+
87
+ function recentChatTimestamp(chat) {
88
+ return chat?.contact?.lastMessageAt || chat?.lastMessage?.createdAt || null;
89
+ }
90
+
91
+ function sortChatsByRecentActivity(chats) {
92
+ return [...chats].sort((left, right) => {
93
+ const leftRecent = recentChatTimestamp(left);
94
+ const rightRecent = recentChatTimestamp(right);
95
+
96
+ if (leftRecent && rightRecent) {
97
+ return new Date(rightRecent) - new Date(leftRecent);
98
+ }
99
+
100
+ if (rightRecent) {
101
+ return 1;
102
+ }
103
+
104
+ if (leftRecent) {
105
+ return -1;
106
+ }
107
+
108
+ return new Date(right.updatedAt) - new Date(left.updatedAt);
109
+ });
110
+ }
111
+
112
+ function escapeLike(value) {
113
+ return String(value).replace(/[%_]/g, (match) => `\\${match}`);
114
+ }
115
+
116
+ function fileLabelForType(type) {
117
+ const labels = {
118
+ image: "Image",
119
+ video: "Video",
120
+ audio: "Audio",
121
+ document: "Document",
122
+ sticker: "Sticker"
123
+ };
124
+
125
+ return labels[type] || "Attachment";
126
+ }
127
+
128
+ function sanitizedPreview(body, type) {
129
+ return body || fileLabelForType(type);
130
+ }
131
+
132
+ function isWhatsAppConversationId(externalId) {
133
+ return String(externalId || "").endsWith("@c.us") || String(externalId || "").endsWith("@g.us");
134
+ }
135
+
136
+ function pickDisplayName(...values) {
137
+ return values.map((value) => String(value || "").trim()).find(Boolean) || "Unknown";
138
+ }
139
+
140
+ function mapImportedMessageType(type) {
141
+ const normalized = String(type || "text").toLowerCase();
142
+ if (normalized === "image") return "image";
143
+ if (normalized === "video") return "video";
144
+ if (normalized === "audio" || normalized === "ptt" || normalized === "voice") return "audio";
145
+ if (normalized === "sticker") return "sticker";
146
+ if (normalized === "document") return "document";
147
+ return "text";
148
+ }
149
+
150
+ function importedStatuses(direction, ack) {
151
+ if (direction === "inbound") {
152
+ return [{ status: "delivered" }];
153
+ }
154
+
155
+ if (ack >= 3) {
156
+ return [{ status: "sent" }, { status: "delivered" }, { status: "read" }];
157
+ }
158
+
159
+ if (ack >= 2) {
160
+ return [{ status: "sent" }, { status: "delivered" }];
161
+ }
162
+
163
+ return [{ status: "sent" }];
164
+ }
165
+
166
+ async function loadChatSummary(chatId) {
167
+ const chat = await retryOnSqliteTimeout(() =>
168
+ prisma.chat.findUnique({
169
+ where: { id: chatId },
170
+ include: {
171
+ contact: true,
172
+ messages: {
173
+ orderBy: { createdAt: "desc" },
174
+ take: 1,
175
+ include: {
176
+ statuses: {
177
+ orderBy: { createdAt: "asc" }
178
+ },
179
+ mediaFile: true,
180
+ replyTo: {
181
+ include: {
182
+ mediaFile: true
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ })
189
+ );
190
+
191
+ return chat ? mapChat(chat) : null;
192
+ }
193
+
194
+ async function ensureWelcomeWorkspace(userId) {
195
+ const contact = await retryOnSqliteTimeout(() =>
196
+ prisma.contact.upsert({
197
+ where: {
198
+ userId_externalId: {
199
+ userId,
200
+ externalId: "openwa:assistant"
201
+ }
202
+ },
203
+ update: {},
204
+ create: {
205
+ userId,
206
+ externalId: "openwa:assistant",
207
+ displayName: "OpenWA Assistant",
208
+ avatarUrl: createAvatarDataUrl("OpenWA Assistant", "openwa:assistant")
209
+ }
210
+ })
211
+ );
212
+
213
+ const existingChat = await retryOnSqliteTimeout(() =>
214
+ prisma.chat.findUnique({
215
+ where: {
216
+ userId_contactId: {
217
+ userId,
218
+ contactId: contact.id
219
+ }
220
+ }
221
+ })
222
+ );
223
+
224
+ if (existingChat) {
225
+ return existingChat;
226
+ }
227
+
228
+ const chat = await retryOnSqliteTimeout(() =>
229
+ prisma.chat.create({
230
+ data: {
231
+ userId,
232
+ contactId: contact.id,
233
+ title: contact.displayName,
234
+ messages: {
235
+ create: {
236
+ sender: "system",
237
+ receiver: `user:${userId}`,
238
+ body: "Selamat datang di OpenWA. Tambahkan sesi WhatsApp baru untuk mulai menghubungkan nomor.",
239
+ type: "text",
240
+ direction: "inbound",
241
+ statuses: {
242
+ create: [{ status: "delivered" }, { status: "read" }]
243
+ }
244
+ }
245
+ }
246
+ }
247
+ })
248
+ );
249
+
250
+ await retryOnSqliteTimeout(() =>
251
+ prisma.contact.update({
252
+ where: { id: contact.id },
253
+ data: {
254
+ lastMessagePreview: "Selamat datang di OpenWA. Tambahkan sesi WhatsApp baru untuk mulai menghubungkan nomor.",
255
+ lastMessageAt: new Date(),
256
+ unreadCount: 0
257
+ }
258
+ })
259
+ );
260
+
261
+ return chat;
262
+ }
263
+
264
+ async function createSessionCompanionChat(userId, session) {
265
+ const externalId = `session:${session.id}:assistant`;
266
+ const displayName = `${session.name} Assistant`;
267
+ const contact = await retryOnSqliteTimeout(() =>
268
+ prisma.contact.upsert({
269
+ where: {
270
+ userId_externalId: {
271
+ userId,
272
+ externalId
273
+ }
274
+ },
275
+ update: {
276
+ displayName,
277
+ sessionId: session.id,
278
+ avatarUrl: createAvatarDataUrl(displayName, externalId)
279
+ },
280
+ create: {
281
+ userId,
282
+ sessionId: session.id,
283
+ externalId,
284
+ displayName,
285
+ avatarUrl: createAvatarDataUrl(displayName, externalId)
286
+ }
287
+ })
288
+ );
289
+
290
+ const existingChat = await prisma.chat.findUnique({
291
+ where: {
292
+ userId_contactId: {
293
+ userId,
294
+ contactId: contact.id
295
+ }
296
+ }
297
+ });
298
+
299
+ if (!existingChat) {
300
+ await prisma.chat.create({
301
+ data: {
302
+ userId,
303
+ sessionId: session.id,
304
+ contactId: contact.id,
305
+ title: displayName,
306
+ messages: {
307
+ create: {
308
+ sender: externalId,
309
+ receiver: `user:${userId}`,
310
+ body: `Session ${session.name} siap digunakan. Klik Connect untuk menghasilkan QR code.`,
311
+ type: "text",
312
+ direction: "inbound",
313
+ statuses: {
314
+ create: [{ status: "delivered" }]
315
+ }
316
+ }
317
+ }
318
+ }
319
+ });
320
+
321
+ await prisma.contact.update({
322
+ where: { id: contact.id },
323
+ data: {
324
+ lastMessagePreview: `Session ${session.name} siap digunakan. Klik Connect untuk menghasilkan QR code.`,
325
+ lastMessageAt: new Date()
326
+ }
327
+ });
328
+ }
329
+ }
330
+
331
+ async function listChats(userId, sessionId, search) {
332
+ const normalizedSearch = String(search || "").trim();
333
+ const chats = await retryOnSqliteTimeout(async () => {
334
+ return prisma.chat.findMany({
335
+ where: {
336
+ userId,
337
+ NOT: {
338
+ contact: {
339
+ externalId: {
340
+ endsWith: "@broadcast"
341
+ }
342
+ }
343
+ },
344
+ ...(sessionId ? { sessionId } : {}),
345
+ ...(normalizedSearch
346
+ ? {
347
+ OR: [
348
+ {
349
+ title: {
350
+ contains: normalizedSearch
351
+ }
352
+ },
353
+ {
354
+ contact: {
355
+ displayName: {
356
+ contains: normalizedSearch
357
+ }
358
+ }
359
+ },
360
+ {
361
+ contact: {
362
+ lastMessagePreview: {
363
+ contains: normalizedSearch
364
+ }
365
+ }
366
+ }
367
+ ]
368
+ }
369
+ : {})
370
+ },
371
+ include: {
372
+ contact: true,
373
+ messages: {
374
+ orderBy: { createdAt: "desc" },
375
+ take: 1,
376
+ include: {
377
+ statuses: {
378
+ orderBy: { createdAt: "asc" }
379
+ },
380
+ mediaFile: true,
381
+ replyTo: {
382
+ include: {
383
+ mediaFile: true
384
+ }
385
+ }
386
+ }
387
+ }
388
+ },
389
+ orderBy: { updatedAt: "desc" }
390
+ });
391
+ });
392
+
393
+ return sortChatsByRecentActivity(chats.map(mapChat));
394
+ }
395
+
396
+ async function listMessages(userId, chatId, options = {}) {
397
+ const limit = Math.max(1, Math.min(Number(options.take) || 50, 100));
398
+ const search = String(options.search || "").trim();
399
+ const before = options.before ? new Date(options.before) : null;
400
+
401
+ const chat = await retryOnSqliteTimeout(() =>
402
+ prisma.chat.findFirst({
403
+ where: {
404
+ id: chatId,
405
+ userId
406
+ }
407
+ })
408
+ );
409
+
410
+ if (!chat) {
411
+ throw new Error("Chat not found.");
412
+ }
413
+
414
+ const where = {
415
+ chatId,
416
+ ...(before && !Number.isNaN(before.getTime())
417
+ ? {
418
+ createdAt: {
419
+ lt: before
420
+ }
421
+ }
422
+ : {}),
423
+ ...(search
424
+ ? {
425
+ OR: [
426
+ {
427
+ body: {
428
+ contains: search
429
+ }
430
+ },
431
+ {
432
+ sender: {
433
+ contains: search
434
+ }
435
+ }
436
+ ]
437
+ }
438
+ : {})
439
+ };
440
+
441
+ const results = await retryOnSqliteTimeout(() =>
442
+ prisma.message.findMany({
443
+ where,
444
+ orderBy: { createdAt: "desc" },
445
+ take: limit + 1,
446
+ include: {
447
+ statuses: {
448
+ orderBy: { createdAt: "asc" }
449
+ },
450
+ mediaFile: true,
451
+ replyTo: {
452
+ include: {
453
+ mediaFile: true
454
+ }
455
+ }
456
+ }
457
+ })
458
+ );
459
+
460
+ const hasMore = results.length > limit;
461
+ const items = (hasMore ? results.slice(0, limit) : results).reverse();
462
+
463
+ return {
464
+ messages: items.map(mapMessage),
465
+ hasMore,
466
+ nextBefore: items.length > 0 ? items[0].createdAt : null
467
+ };
468
+ }
469
+
470
+ async function createMediaFile(userId, file) {
471
+ return prisma.mediaFile.create({
472
+ data: {
473
+ userId,
474
+ fileName: file.filename,
475
+ originalName: file.originalname,
476
+ mimeType: file.mimetype || "application/octet-stream",
477
+ size: file.size,
478
+ relativePath: path.join("media", file.filename).replaceAll("\\", "/")
479
+ }
480
+ });
481
+ }
482
+
483
+ async function touchContactPreview(contactId, preview, unreadDelta = 0) {
484
+ return prisma.contact.update({
485
+ where: { id: contactId },
486
+ data: {
487
+ lastMessagePreview: preview,
488
+ lastMessageAt: new Date(),
489
+ unreadCount: unreadDelta > 0 ? { increment: unreadDelta } : undefined
490
+ }
491
+ });
492
+ }
493
+
494
+ async function createOutgoingMessage({ userId, chatId, body, type = "text", mediaFileId = null, replyToId = null }) {
495
+ const chat = await retryOnSqliteTimeout(async () => {
496
+ return prisma.chat.findFirst({
497
+ where: { id: chatId, userId },
498
+ include: { contact: true }
499
+ });
500
+ });
501
+
502
+ if (!chat) {
503
+ throw new Error("Chat not found.");
504
+ }
505
+
506
+ if (replyToId) {
507
+ const replyToMessage = await retryOnSqliteTimeout(async () => {
508
+ return prisma.message.findFirst({
509
+ where: {
510
+ id: replyToId,
511
+ chatId: chat.id
512
+ }
513
+ });
514
+ });
515
+
516
+ if (!replyToMessage) {
517
+ throw new Error("Reply target not found.");
518
+ }
519
+ }
520
+
521
+ const message = await retryOnSqliteTimeout(async () => {
522
+ return prisma.message.create({
523
+ data: {
524
+ chatId: chat.id,
525
+ sessionId: chat.sessionId,
526
+ mediaFileId,
527
+ replyToId,
528
+ sender: `user:${userId}`,
529
+ receiver: chat.contact.externalId,
530
+ body: body || null,
531
+ type,
532
+ direction: "outbound",
533
+ statuses: {
534
+ create: [{ status: "sent" }]
535
+ }
536
+ },
537
+ include: {
538
+ statuses: {
539
+ orderBy: { createdAt: "asc" }
540
+ },
541
+ mediaFile: true,
542
+ replyTo: {
543
+ include: {
544
+ mediaFile: true
545
+ }
546
+ }
547
+ }
548
+ });
549
+ });
550
+
551
+ await retryOnSqliteTimeout(async () => {
552
+ return prisma.chat.update({
553
+ where: { id: chat.id },
554
+ data: { updatedAt: new Date() }
555
+ });
556
+ });
557
+ await touchContactPreview(chat.contactId, sanitizedPreview(body, type), 0);
558
+
559
+ return {
560
+ chat: await loadChatSummary(chat.id),
561
+ message: mapMessage(message)
562
+ };
563
+ }
564
+
565
+ async function addMessageStatus(messageId, status) {
566
+ return prisma.messageStatus.create({
567
+ data: {
568
+ messageId,
569
+ status
570
+ }
571
+ });
572
+ }
573
+
574
+ async function ensureChatForIncoming({ userId, sessionId, externalId, displayName, avatarUrl = null }) {
575
+ const contact = await ensureWhatsappContact({
576
+ userId,
577
+ sessionId,
578
+ externalId,
579
+ displayName,
580
+ avatarUrl
581
+ });
582
+
583
+ return ensureChatForContact(userId, contact.id);
584
+ }
585
+
586
+ async function ensureWhatsappContact({ userId, sessionId, externalId, displayName, avatarUrl = null }) {
587
+ const resolvedDisplayName = pickDisplayName(displayName, externalId);
588
+ const existing = await retryOnSqliteTimeout(() =>
589
+ prisma.contact.findUnique({
590
+ where: {
591
+ userId_externalId: {
592
+ userId,
593
+ externalId
594
+ }
595
+ }
596
+ })
597
+ );
598
+ const nextAvatarUrl = avatarUrl || existing?.avatarUrl || createAvatarDataUrl(resolvedDisplayName, externalId);
599
+
600
+ if (existing) {
601
+ return retryOnSqliteTimeout(() =>
602
+ prisma.contact.update({
603
+ where: { id: existing.id },
604
+ data: {
605
+ displayName: resolvedDisplayName,
606
+ sessionId,
607
+ avatarUrl: nextAvatarUrl
608
+ }
609
+ })
610
+ );
611
+ }
612
+
613
+ return retryOnSqliteTimeout(() =>
614
+ prisma.contact.create({
615
+ data: {
616
+ userId,
617
+ sessionId,
618
+ externalId,
619
+ displayName: resolvedDisplayName,
620
+ avatarUrl: nextAvatarUrl
621
+ }
622
+ })
623
+ );
624
+ }
625
+
626
+ async function ensureChatForContact(userId, contactId) {
627
+ const contact = await retryOnSqliteTimeout(() =>
628
+ prisma.contact.findFirst({
629
+ where: {
630
+ id: contactId,
631
+ userId
632
+ }
633
+ })
634
+ );
635
+
636
+ if (!contact) {
637
+ throw new Error("Contact not found.");
638
+ }
639
+
640
+ return retryOnSqliteTimeout(() =>
641
+ prisma.chat.upsert({
642
+ where: {
643
+ userId_contactId: {
644
+ userId,
645
+ contactId: contact.id
646
+ }
647
+ },
648
+ update: {
649
+ sessionId: contact.sessionId,
650
+ title: contact.displayName
651
+ },
652
+ create: {
653
+ userId,
654
+ sessionId: contact.sessionId,
655
+ contactId: contact.id,
656
+ title: contact.displayName
657
+ },
658
+ include: {
659
+ contact: true
660
+ }
661
+ })
662
+ );
663
+ }
664
+
665
+ async function createIncomingMessageWithRetry(data) {
666
+ return retryOnSqliteTimeout(() => prisma.message.create(data));
667
+ }
668
+
669
+ async function listContacts(userId, sessionId, search) {
670
+ const normalizedSearch = String(search || "").trim();
671
+ const contacts = await retryOnSqliteTimeout(() =>
672
+ prisma.contact.findMany({
673
+ where: {
674
+ userId,
675
+ ...(sessionId ? { sessionId } : {}),
676
+ AND: [
677
+ {
678
+ OR: [
679
+ { externalId: { endsWith: "@c.us" } },
680
+ { externalId: { endsWith: "@g.us" } }
681
+ ]
682
+ },
683
+ ...(normalizedSearch
684
+ ? [
685
+ {
686
+ OR: [
687
+ { displayName: { contains: normalizedSearch } },
688
+ { externalId: { contains: normalizedSearch } }
689
+ ]
690
+ }
691
+ ]
692
+ : [])
693
+ ]
694
+ },
695
+ include: {
696
+ chats: {
697
+ take: 1
698
+ }
699
+ },
700
+ orderBy: [
701
+ { lastMessageAt: "desc" },
702
+ { displayName: "asc" }
703
+ ]
704
+ })
705
+ );
706
+
707
+ return contacts.map((contact) => ({
708
+ id: contact.id,
709
+ externalId: contact.externalId,
710
+ displayName: contact.displayName,
711
+ avatarUrl: contact.avatarUrl,
712
+ lastMessagePreview: contact.lastMessagePreview,
713
+ lastMessageAt: contact.lastMessageAt,
714
+ unreadCount: contact.unreadCount,
715
+ sessionId: contact.sessionId,
716
+ hasChat: contact.chats.length > 0,
717
+ chatId: contact.chats[0]?.id || null
718
+ }));
719
+ }
720
+
721
+ async function openChatForContact(userId, contactId) {
722
+ const chat = await ensureChatForContact(userId, contactId);
723
+ return loadChatSummary(chat.id);
724
+ }
725
+
726
+ async function syncWhatsappSnapshot({ userId, sessionId, contacts = [], chats = [] }) {
727
+ for (const contactEntry of contacts) {
728
+ if (!isWhatsAppConversationId(contactEntry.externalId)) {
729
+ continue;
730
+ }
731
+
732
+ await ensureWhatsappContact({
733
+ userId,
734
+ sessionId,
735
+ externalId: contactEntry.externalId,
736
+ displayName: pickDisplayName(contactEntry.name, contactEntry.pushname, contactEntry.externalId),
737
+ avatarUrl: contactEntry.avatarUrl || null
738
+ });
739
+ }
740
+
741
+ for (const chatEntry of chats) {
742
+ if (!isWhatsAppConversationId(chatEntry.externalId)) {
743
+ continue;
744
+ }
745
+
746
+ const contact = await ensureWhatsappContact({
747
+ userId,
748
+ sessionId,
749
+ externalId: chatEntry.externalId,
750
+ displayName: pickDisplayName(chatEntry.name, chatEntry.pushname, chatEntry.externalId),
751
+ avatarUrl: chatEntry.avatarUrl || null
752
+ });
753
+
754
+ const chat = await ensureChatForContact(userId, contact.id);
755
+ const sortedMessages = [...(chatEntry.messages || [])].sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt));
756
+
757
+ for (const item of sortedMessages) {
758
+ if (!item.externalMessageId) {
759
+ continue;
760
+ }
761
+
762
+ const existing = await prisma.message.findFirst({
763
+ where: {
764
+ chatId: chat.id,
765
+ externalMessageId: item.externalMessageId
766
+ }
767
+ });
768
+
769
+ if (existing) {
770
+ continue;
771
+ }
772
+
773
+ const direction = item.direction === "outbound" ? "outbound" : "inbound";
774
+ const createdAt = new Date(item.createdAt);
775
+ await prisma.message.create({
776
+ data: {
777
+ chatId: chat.id,
778
+ sessionId,
779
+ externalMessageId: item.externalMessageId,
780
+ sender: direction === "outbound" ? `user:${userId}` : item.sender || chatEntry.externalId,
781
+ receiver: direction === "outbound" ? chatEntry.externalId : `user:${userId}`,
782
+ body: item.body || null,
783
+ type: mapImportedMessageType(item.type),
784
+ direction,
785
+ createdAt,
786
+ updatedAt: createdAt,
787
+ statuses: {
788
+ create: importedStatuses(direction, item.ack)
789
+ }
790
+ }
791
+ });
792
+ }
793
+
794
+ const latestMessage = await prisma.message.findFirst({
795
+ where: { chatId: chat.id },
796
+ orderBy: { createdAt: "desc" }
797
+ });
798
+
799
+ await prisma.chat.update({
800
+ where: { id: chat.id },
801
+ data: {
802
+ updatedAt: latestMessage?.createdAt || new Date()
803
+ }
804
+ });
805
+
806
+ await prisma.contact.update({
807
+ where: { id: contact.id },
808
+ data: {
809
+ lastMessagePreview: latestMessage ? sanitizedPreview(latestMessage.body, latestMessage.type) : contact.lastMessagePreview,
810
+ lastMessageAt: latestMessage?.createdAt || contact.lastMessageAt
811
+ }
812
+ });
813
+ }
814
+ }
815
+
816
+ async function storeIncomingMessage({ userId, sessionId, sender, displayName, avatarUrl = null, body, type = "text" }) {
817
+ return enqueueIncomingMessage(async () => {
818
+ const chat = await ensureChatForIncoming({
819
+ userId,
820
+ sessionId,
821
+ externalId: sender,
822
+ displayName: displayName || sender,
823
+ avatarUrl
824
+ });
825
+
826
+ const message = await createIncomingMessageWithRetry({
827
+ data: {
828
+ chatId: chat.id,
829
+ sessionId,
830
+ sender,
831
+ receiver: `user:${userId}`,
832
+ body,
833
+ type,
834
+ direction: "inbound",
835
+ statuses: {
836
+ create: [{ status: "delivered" }]
837
+ }
838
+ },
839
+ include: {
840
+ statuses: {
841
+ orderBy: { createdAt: "asc" }
842
+ },
843
+ mediaFile: true,
844
+ replyTo: {
845
+ include: {
846
+ mediaFile: true
847
+ }
848
+ }
849
+ }
850
+ });
851
+
852
+ await retryOnSqliteTimeout(() =>
853
+ prisma.chat.update({
854
+ where: { id: chat.id },
855
+ data: { updatedAt: new Date() }
856
+ })
857
+ );
858
+
859
+ await retryOnSqliteTimeout(() => touchContactPreview(chat.contactId, sanitizedPreview(body, type), 1));
860
+
861
+ return {
862
+ chat: await loadChatSummary(chat.id),
863
+ message: mapMessage(message)
864
+ };
865
+ });
866
+ }
867
+
868
+ async function markChatOpened(userId, chatId) {
869
+ const chat = await retryOnSqliteTimeout(() =>
870
+ prisma.chat.findFirst({
871
+ where: { id: chatId, userId }
872
+ })
873
+ );
874
+
875
+ if (!chat) {
876
+ throw new Error("Chat not found.");
877
+ }
878
+
879
+ await prisma.contact.update({
880
+ where: { id: chat.contactId },
881
+ data: { unreadCount: 0 }
882
+ });
883
+
884
+ const unreadMessages = await prisma.message.findMany({
885
+ where: {
886
+ chatId,
887
+ direction: "inbound"
888
+ },
889
+ select: { id: true }
890
+ });
891
+
892
+ await Promise.all(
893
+ unreadMessages.map((message) =>
894
+ prisma.messageStatus.create({
895
+ data: {
896
+ messageId: message.id,
897
+ status: "read"
898
+ }
899
+ })
900
+ )
901
+ );
902
+ }
903
+
904
+ async function deleteMessage(userId, messageId) {
905
+ const message = await retryOnSqliteTimeout(async () => {
906
+ return prisma.message.findFirst({
907
+ where: {
908
+ id: messageId,
909
+ sender: `user:${userId}`
910
+ },
911
+ include: {
912
+ chat: true
913
+ }
914
+ });
915
+ });
916
+
917
+ if (!message) {
918
+ throw new Error("Message not found or cannot be deleted.");
919
+ }
920
+
921
+ const updatedMessage = await retryOnSqliteTimeout(async () => {
922
+ return prisma.message.update({
923
+ where: { id: message.id },
924
+ data: {
925
+ body: "Pesan dihapus",
926
+ mediaFileId: null
927
+ },
928
+ include: {
929
+ statuses: {
930
+ orderBy: { createdAt: "asc" }
931
+ },
932
+ mediaFile: true,
933
+ replyTo: {
934
+ include: {
935
+ mediaFile: true
936
+ }
937
+ }
938
+ }
939
+ });
940
+ });
941
+
942
+ const latestMessage = await retryOnSqliteTimeout(async () => {
943
+ return prisma.message.findFirst({
944
+ where: { chatId: message.chatId },
945
+ orderBy: { createdAt: "desc" }
946
+ });
947
+ });
948
+
949
+ await retryOnSqliteTimeout(async () => {
950
+ return prisma.contact.update({
951
+ where: { id: message.chat.contactId },
952
+ data: {
953
+ lastMessagePreview: latestMessage ? sanitizedPreview(latestMessage.body, latestMessage.type) : "Belum ada pesan",
954
+ lastMessageAt: latestMessage?.createdAt || null
955
+ }
956
+ });
957
+ });
958
+
959
+ return {
960
+ chat: await loadChatSummary(message.chatId),
961
+ message: mapMessage(updatedMessage)
962
+ };
963
+ }
964
+
965
+ async function forwardMessage(userId, messageId, targetChatId) {
966
+ const sourceMessage = await retryOnSqliteTimeout(async () => {
967
+ return prisma.message.findFirst({
968
+ where: {
969
+ id: messageId,
970
+ chat: {
971
+ userId
972
+ }
973
+ },
974
+ include: {
975
+ mediaFile: true,
976
+ replyTo: {
977
+ include: {
978
+ mediaFile: true
979
+ }
980
+ }
981
+ }
982
+ });
983
+ });
984
+
985
+ if (!sourceMessage) {
986
+ throw new Error("Source message not found.");
987
+ }
988
+
989
+ return createOutgoingMessage({
990
+ userId,
991
+ chatId: targetChatId,
992
+ body: sourceMessage.body,
993
+ type: sourceMessage.type,
994
+ mediaFileId: sourceMessage.mediaFileId || null,
995
+ replyToId: null
996
+ });
997
+ }
998
+
999
+ module.exports = {
1000
+ addMessageStatus,
1001
+ createMediaFile,
1002
+ createOutgoingMessage,
1003
+ createSessionCompanionChat,
1004
+ deleteMessage,
1005
+ ensureWelcomeWorkspace,
1006
+ forwardMessage,
1007
+ listContacts,
1008
+ listChats,
1009
+ listMessages,
1010
+ markChatOpened,
1011
+ openChatForContact,
1012
+ syncWhatsappSnapshot,
1013
+ storeIncomingMessage
1014
+ };