@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/bin/openwa.js +11 -0
- package/favicon.ico +0 -0
- package/logo-long.png +0 -0
- package/logo-square.png +0 -0
- package/package.json +69 -0
- package/prisma/schema.prisma +182 -0
- package/server/config.js +29 -0
- package/server/database/client.js +11 -0
- package/server/database/init.js +28 -0
- package/server/express/create-app.js +349 -0
- package/server/express/openapi.js +853 -0
- package/server/index.js +163 -0
- package/server/services/api-key-service.js +131 -0
- package/server/services/auth-service.js +162 -0
- package/server/services/chat-service.js +1014 -0
- package/server/services/session-service.js +81 -0
- package/server/socket/register.js +127 -0
- package/server/utils/avatar.js +34 -0
- package/server/utils/paths.js +29 -0
- package/server/whatsapp/adapters/mock-adapter.js +47 -0
- package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
- package/server/whatsapp/session-manager.js +356 -0
- package/web/components/AppHead.js +14 -0
- package/web/components/AuthCard.js +170 -0
- package/web/components/BrandLogo.js +11 -0
- package/web/components/ChatWindow.js +875 -0
- package/web/components/ChatWindow.js.tmp +0 -0
- package/web/components/ContactList.js +97 -0
- package/web/components/ContactsPanel.js +90 -0
- package/web/components/EmojiPicker.js +108 -0
- package/web/components/MediaPreviewModal.js +146 -0
- package/web/components/MessageActionMenu.js +155 -0
- package/web/components/SessionSidebar.js +167 -0
- package/web/components/SettingsModal.js +266 -0
- package/web/components/Skeletons.js +73 -0
- package/web/jsconfig.json +10 -0
- package/web/lib/api.js +33 -0
- package/web/lib/socket.js +9 -0
- package/web/pages/_app.js +5 -0
- package/web/pages/dashboard.js +541 -0
- package/web/pages/index.js +62 -0
- package/web/postcss.config.js +10 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/logo-long.png +0 -0
- package/web/public/logo-square.png +0 -0
- package/web/store/useAppStore.js +209 -0
- package/web/styles/globals.css +52 -0
- 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
|
+
};
|