@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,541 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useRouter } from "next/router";
3
+ import { AppHead } from "@/components/AppHead";
4
+ import { ChatWindow } from "@/components/ChatWindow";
5
+ import { ContactList } from "@/components/ContactList";
6
+ import { ContactsPanel } from "@/components/ContactsPanel";
7
+ import { SettingsModal } from "@/components/SettingsModal";
8
+ import { apiFetch } from "@/lib/api";
9
+ import { createSocket } from "@/lib/socket";
10
+ import { useAppStore } from "@/store/useAppStore";
11
+
12
+ export default function DashboardPage() {
13
+ const router = useRouter();
14
+ const {
15
+ token,
16
+ user,
17
+ hydrateAuth,
18
+ logout,
19
+ setBootstrapData,
20
+ setMessages,
21
+ prependMessages,
22
+ setActiveChat,
23
+ upsertSession,
24
+ upsertChat,
25
+ addMessage,
26
+ updateMessageStatus,
27
+ updateMessage,
28
+ setSocket,
29
+ socket,
30
+ chats,
31
+ sessions,
32
+ activeChatId,
33
+ activeSessionId,
34
+ setActiveSession,
35
+ messagesByChat,
36
+ messageMetaByChat,
37
+ typingByChat
38
+ } = useAppStore();
39
+
40
+ const [loading, setLoading] = useState(true);
41
+ const [contactsLoading, setContactsLoading] = useState(false);
42
+ const [error, setError] = useState("");
43
+ const [sessionName, setSessionName] = useState("");
44
+ const [sessionPhone, setSessionPhone] = useState("");
45
+ const [chatQuery, setChatQuery] = useState("");
46
+ const [contactQuery, setContactQuery] = useState("");
47
+ const [messageQuery, setMessageQuery] = useState("");
48
+ const [loadingOlder, setLoadingOlder] = useState(false);
49
+ const [messagesLoading, setMessagesLoading] = useState(false);
50
+ const [contacts, setContacts] = useState([]);
51
+ const [apiKeys, setApiKeys] = useState([]);
52
+ const [apiKeysLoading, setApiKeysLoading] = useState(false);
53
+ const [apiKeyName, setApiKeyName] = useState("");
54
+ const [apiKeySecret, setApiKeySecret] = useState("");
55
+ const [startingContactId, setStartingContactId] = useState(null);
56
+ const [settingsOpen, setSettingsOpen] = useState(false);
57
+ const [contactsPanelOpen, setContactsPanelOpen] = useState(false);
58
+ const chatWindowRef = useRef(null);
59
+
60
+ const activeChat = useMemo(() => chats.find((chat) => chat.id === activeChatId) || null, [activeChatId, chats]);
61
+ const activeMessages = messagesByChat[activeChatId] || [];
62
+ const activeMeta = messageMetaByChat[activeChatId] || { hasMore: false, nextBefore: null };
63
+ const activeTyping = typingByChat[activeChatId];
64
+ const readySessions = sessions.filter((session) => session.status === "ready").length;
65
+
66
+ const loadContacts = useCallback(async () => {
67
+ if (!token) {
68
+ return;
69
+ }
70
+
71
+ setContactsLoading(true);
72
+ try {
73
+ const data = await apiFetch("/api/contacts", { token });
74
+ setContacts(data.contacts || []);
75
+ } catch (requestError) {
76
+ setError(requestError.message);
77
+ } finally {
78
+ setContactsLoading(false);
79
+ }
80
+ }, [token]);
81
+
82
+ const loadApiKeys = useCallback(async () => {
83
+ if (!token) {
84
+ return;
85
+ }
86
+
87
+ setApiKeysLoading(true);
88
+ try {
89
+ const data = await apiFetch("/api/api-keys", { token });
90
+ setApiKeys(data.apiKeys || []);
91
+ } catch (requestError) {
92
+ setError(requestError.message);
93
+ } finally {
94
+ setApiKeysLoading(false);
95
+ }
96
+ }, [token]);
97
+
98
+ const loadWorkspace = useCallback(async (showSpinner = false) => {
99
+ if (!token) {
100
+ return;
101
+ }
102
+
103
+ if (showSpinner) {
104
+ setLoading(true);
105
+ }
106
+
107
+ try {
108
+ const data = await apiFetch("/api/bootstrap", { token });
109
+ setBootstrapData(data);
110
+ } catch (requestError) {
111
+ setError(requestError.message);
112
+ if (requestError.status === 401) {
113
+ logout();
114
+ router.replace("/");
115
+ }
116
+ } finally {
117
+ if (showSpinner) {
118
+ setLoading(false);
119
+ }
120
+ }
121
+ }, [logout, router, setBootstrapData, token]);
122
+
123
+ useEffect(() => {
124
+ hydrateAuth();
125
+ }, [hydrateAuth]);
126
+
127
+ useEffect(() => {
128
+ if (!token) {
129
+ router.replace("/");
130
+ return;
131
+ }
132
+
133
+ Promise.all([loadWorkspace(true), loadContacts(), loadApiKeys()]).finally(() => {
134
+ setLoading(false);
135
+ });
136
+ }, [loadApiKeys, loadContacts, loadWorkspace, router, token]);
137
+
138
+ useEffect(() => {
139
+ if (!activeSessionId && sessions[0]?.id) {
140
+ setActiveSession(sessions[0].id);
141
+ }
142
+ }, [activeSessionId, sessions, setActiveSession]);
143
+
144
+ useEffect(() => {
145
+ if (!token) {
146
+ return undefined;
147
+ }
148
+
149
+ const socketClient = createSocket(token);
150
+ setSocket(socketClient);
151
+
152
+ socketClient.on("new_message", (message) => {
153
+ addMessage(message);
154
+ });
155
+
156
+ socketClient.on("message_status_update", (payload) => {
157
+ updateMessageStatus(payload);
158
+ });
159
+
160
+ socketClient.on("contact_list_update", (chat) => {
161
+ upsertChat(chat);
162
+ loadContacts();
163
+ });
164
+
165
+ socketClient.on("session_status_update", (session) => {
166
+ upsertSession(session);
167
+ });
168
+
169
+ socketClient.on("workspace_synced", () => {
170
+ loadWorkspace();
171
+ loadContacts();
172
+ });
173
+
174
+ socketClient.on("typing_event", (payload) => {
175
+ useAppStore.getState().setTyping(payload);
176
+ });
177
+
178
+ return () => {
179
+ socketClient.close();
180
+ setSocket(null);
181
+ };
182
+ }, [addMessage, loadContacts, loadWorkspace, setSocket, token, updateMessageStatus, upsertChat, upsertSession]);
183
+
184
+ useEffect(() => {
185
+ if (!activeChatId) {
186
+ setMessagesLoading(false);
187
+ return;
188
+ }
189
+
190
+ if (messagesByChat[activeChatId]) {
191
+ setMessagesLoading(false);
192
+ return;
193
+ }
194
+
195
+ if (!token) {
196
+ return;
197
+ }
198
+
199
+ setMessagesLoading(true);
200
+ apiFetch(`/api/chats/${activeChatId}/messages`, { token })
201
+ .then((data) => {
202
+ setMessages(activeChatId, data.messages, {
203
+ hasMore: Boolean(data.hasMore),
204
+ nextBefore: data.nextBefore || null
205
+ });
206
+ })
207
+ .catch((requestError) => {
208
+ setError(requestError.message);
209
+ })
210
+ .finally(() => {
211
+ setMessagesLoading(false);
212
+ });
213
+ }, [activeChatId, messagesByChat, setMessages, token]);
214
+
215
+ const handleCreateSession = async (event) => {
216
+ event.preventDefault();
217
+ setError("");
218
+
219
+ try {
220
+ const data = await apiFetch("/api/sessions", {
221
+ method: "POST",
222
+ token,
223
+ body: {
224
+ name: sessionName,
225
+ phoneNumber: sessionPhone
226
+ }
227
+ });
228
+
229
+ upsertSession(data.session);
230
+ setActiveSession(data.session.id);
231
+ setSessionName("");
232
+ setSessionPhone("");
233
+ setSettingsOpen(true);
234
+ await loadWorkspace();
235
+ } catch (requestError) {
236
+ setError(requestError.message);
237
+ }
238
+ };
239
+
240
+ const handleConnectSession = async (sessionId) => {
241
+ setError("");
242
+ try {
243
+ const data = await apiFetch(`/api/sessions/${sessionId}/connect`, {
244
+ method: "POST",
245
+ token
246
+ });
247
+
248
+ upsertSession(data.session);
249
+ setActiveSession(sessionId);
250
+ } catch (requestError) {
251
+ setError(requestError.message);
252
+ }
253
+ };
254
+
255
+ const handleDisconnectSession = async (sessionId) => {
256
+ setError("");
257
+ try {
258
+ const data = await apiFetch(`/api/sessions/${sessionId}/disconnect`, {
259
+ method: "POST",
260
+ token
261
+ });
262
+
263
+ upsertSession(data.session);
264
+ } catch (requestError) {
265
+ setError(requestError.message);
266
+ }
267
+ };
268
+
269
+ const handleCreateApiKey = async (event) => {
270
+ event.preventDefault();
271
+ setError("");
272
+
273
+ try {
274
+ const result = await apiFetch("/api/api-keys", {
275
+ method: "POST",
276
+ token,
277
+ body: {
278
+ name: apiKeyName
279
+ }
280
+ });
281
+
282
+ setApiKeySecret(result.secret);
283
+ setApiKeyName("");
284
+ setApiKeys((current) => [result.apiKey, ...current]);
285
+ setSettingsOpen(true);
286
+ } catch (requestError) {
287
+ setError(requestError.message);
288
+ }
289
+ };
290
+
291
+ const handleRevokeApiKey = async (apiKeyId) => {
292
+ setError("");
293
+
294
+ try {
295
+ await apiFetch(`/api/api-keys/${apiKeyId}`, {
296
+ method: "DELETE",
297
+ token
298
+ });
299
+
300
+ setApiKeys((current) => current.filter((item) => item.id !== apiKeyId));
301
+ } catch (requestError) {
302
+ setError(requestError.message);
303
+ }
304
+ };
305
+
306
+ const handleOpenChat = async (chatId) => {
307
+ setActiveChat(chatId);
308
+ setMessageQuery("");
309
+ socket?.emit("open_chat", { chatId });
310
+ };
311
+
312
+ const handleStartChat = async (contactId) => {
313
+ setStartingContactId(contactId);
314
+ setError("");
315
+
316
+ try {
317
+ const result = await apiFetch(`/api/contacts/${contactId}/open`, {
318
+ method: "POST",
319
+ token
320
+ });
321
+
322
+ upsertChat(result.chat);
323
+ await handleOpenChat(result.chat.id);
324
+ setContacts((current) => current.map((item) => (item.id === contactId ? { ...item, hasChat: true, chatId: result.chat.id } : item)));
325
+ setContactsPanelOpen(false);
326
+ setTimeout(() => {
327
+ chatWindowRef.current?.focusComposer();
328
+ }, 80);
329
+ } catch (requestError) {
330
+ setError(requestError.message);
331
+ } finally {
332
+ setStartingContactId(null);
333
+ }
334
+ };
335
+
336
+ const handleSendMessage = async ({ body, replyToId }) => {
337
+ if (!socket) {
338
+ throw new Error("Socket connection is not ready yet.");
339
+ }
340
+
341
+ await new Promise((resolve, reject) => {
342
+ socket.emit(
343
+ "send_message",
344
+ {
345
+ chatId: activeChatId,
346
+ body,
347
+ type: "text",
348
+ replyToId
349
+ },
350
+ (response) => {
351
+ if (response?.ok) {
352
+ resolve(response.message);
353
+ return;
354
+ }
355
+
356
+ reject(new Error(response?.error || "Failed to send message."));
357
+ }
358
+ );
359
+ });
360
+ };
361
+
362
+ const handleSendMedia = async ({ file, caption }) => {
363
+ if (!socket) {
364
+ throw new Error("Socket connection is not ready yet.");
365
+ }
366
+
367
+ const formData = new FormData();
368
+ formData.append("file", file);
369
+
370
+ const upload = await apiFetch("/api/media", {
371
+ method: "POST",
372
+ token,
373
+ formData
374
+ });
375
+
376
+ await new Promise((resolve, reject) => {
377
+ socket.emit(
378
+ "send_media",
379
+ {
380
+ chatId: activeChatId,
381
+ mediaFileId: upload.mediaFile.id,
382
+ body: caption,
383
+ type: upload.type
384
+ },
385
+ (response) => {
386
+ if (response?.ok) {
387
+ resolve(response.message);
388
+ return;
389
+ }
390
+
391
+ reject(new Error(response?.error || "Failed to send media."));
392
+ }
393
+ );
394
+ });
395
+ };
396
+
397
+ const handleTyping = (isTyping) => {
398
+ socket?.emit("typing", {
399
+ chatId: activeChatId,
400
+ isTyping
401
+ });
402
+ };
403
+
404
+ const handleLoadOlder = async () => {
405
+ if (!activeChatId || !activeMeta.hasMore || !activeMeta.nextBefore) {
406
+ return;
407
+ }
408
+
409
+ setLoadingOlder(true);
410
+ try {
411
+ const data = await apiFetch(`/api/chats/${activeChatId}/messages?before=${encodeURIComponent(activeMeta.nextBefore)}&take=30`, { token });
412
+ prependMessages(activeChatId, data.messages, {
413
+ hasMore: Boolean(data.hasMore),
414
+ nextBefore: data.nextBefore || null
415
+ });
416
+ } catch (requestError) {
417
+ setError(requestError.message);
418
+ } finally {
419
+ setLoadingOlder(false);
420
+ }
421
+ };
422
+
423
+ const handleDeleteMessage = async (messageId) => {
424
+ try {
425
+ const result = await apiFetch(`/api/messages/${messageId}`, {
426
+ method: "DELETE",
427
+ token
428
+ });
429
+ updateMessage(result.message);
430
+ upsertChat(result.chat);
431
+ } catch (requestError) {
432
+ setError(requestError.message);
433
+ }
434
+ };
435
+
436
+ const handleForwardMessage = async (messageId, targetChatId) => {
437
+ try {
438
+ const result = await apiFetch(`/api/messages/${messageId}/forward`, {
439
+ method: "POST",
440
+ token,
441
+ body: { targetChatId }
442
+ });
443
+ addMessage(result.message);
444
+ upsertChat(result.chat);
445
+ } catch (requestError) {
446
+ setError(requestError.message);
447
+ }
448
+ };
449
+
450
+ if (!token) {
451
+ return null;
452
+ }
453
+
454
+ return (
455
+ <>
456
+ <AppHead
457
+ title="Dashboard"
458
+ description="Dashboard OpenWA untuk mengelola percakapan, kontak, device, dan session WhatsApp."
459
+ />
460
+
461
+ <main className="h-screen overflow-hidden bg-[#161717] text-white">
462
+ <div className="flex h-full w-full overflow-hidden bg-[#161717]">
463
+ <ContactList
464
+ chats={chats}
465
+ activeChatId={activeChatId}
466
+ loading={loading}
467
+ onSelectChat={handleOpenChat}
468
+ currentUser={user}
469
+ query={chatQuery}
470
+ onQueryChange={setChatQuery}
471
+ />
472
+
473
+ <section className="flex min-w-0 flex-1 flex-col">
474
+ {error ? <div className="mx-4 mt-4 rounded-2xl border border-red-400/25 bg-red-500/10 px-4 py-3 text-sm text-red-100">{error}</div> : null}
475
+
476
+ <div className="flex min-h-0 flex-1">
477
+ <ChatWindow
478
+ ref={chatWindowRef}
479
+ chat={activeChat}
480
+ messages={activeMessages}
481
+ chats={chats}
482
+ typingState={activeTyping}
483
+ loading={loading}
484
+ messagesLoading={messagesLoading}
485
+ loadingOlder={loadingOlder}
486
+ hasMoreMessages={activeMeta.hasMore}
487
+ messageQuery={messageQuery}
488
+ onMessageQueryChange={setMessageQuery}
489
+ onLoadOlder={handleLoadOlder}
490
+ onSendMessage={handleSendMessage}
491
+ onSendMedia={handleSendMedia}
492
+ onTyping={handleTyping}
493
+ onDeleteMessage={handleDeleteMessage}
494
+ onForwardMessage={handleForwardMessage}
495
+ onOpenContacts={() => setContactsPanelOpen(true)}
496
+ onOpenSettings={() => setSettingsOpen(true)}
497
+ onLogout={() => {
498
+ logout();
499
+ router.replace("/");
500
+ }}
501
+ />
502
+
503
+ <ContactsPanel
504
+ contacts={contacts}
505
+ loading={contactsLoading}
506
+ open={contactsPanelOpen}
507
+ query={contactQuery}
508
+ onQueryChange={setContactQuery}
509
+ onStartChat={handleStartChat}
510
+ onClose={() => setContactsPanelOpen(false)}
511
+ startingContactId={startingContactId}
512
+ />
513
+ </div>
514
+ </section>
515
+ </div>
516
+ </main>
517
+
518
+ <SettingsModal
519
+ open={settingsOpen}
520
+ sessions={sessions}
521
+ activeSessionId={activeSessionId}
522
+ onClose={() => setSettingsOpen(false)}
523
+ onSelect={setActiveSession}
524
+ onConnect={handleConnectSession}
525
+ onDisconnect={handleDisconnectSession}
526
+ sessionName={sessionName}
527
+ sessionPhone={sessionPhone}
528
+ onSessionNameChange={setSessionName}
529
+ onSessionPhoneChange={setSessionPhone}
530
+ onCreateSession={handleCreateSession}
531
+ apiKeys={apiKeys}
532
+ apiKeysLoading={apiKeysLoading}
533
+ apiKeyName={apiKeyName}
534
+ apiKeySecret={apiKeySecret}
535
+ onApiKeyNameChange={setApiKeyName}
536
+ onCreateApiKey={handleCreateApiKey}
537
+ onRevokeApiKey={handleRevokeApiKey}
538
+ />
539
+ </>
540
+ );
541
+ }
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useRouter } from "next/router";
3
+ import { AppHead } from "@/components/AppHead";
4
+ import { AuthCard } from "@/components/AuthCard";
5
+ import { apiFetch } from "@/lib/api";
6
+ import { useAppStore } from "@/store/useAppStore";
7
+
8
+ export default function HomePage() {
9
+ const router = useRouter();
10
+ const { token, hydrateAuth, setAuth } = useAppStore();
11
+ const [mode, setMode] = useState("login");
12
+ const [error, setError] = useState("");
13
+ const [submitting, setSubmitting] = useState(false);
14
+
15
+ useEffect(() => {
16
+ hydrateAuth();
17
+ }, [hydrateAuth]);
18
+
19
+ useEffect(() => {
20
+ if (token) {
21
+ router.replace("/dashboard");
22
+ }
23
+ }, [router, token]);
24
+
25
+ const handleSubmit = async (values) => {
26
+ setSubmitting(true);
27
+ setError("");
28
+
29
+ try {
30
+ const result = await apiFetch(`/api/auth/${mode}`, {
31
+ method: "POST",
32
+ body: values
33
+ });
34
+
35
+ setAuth(result);
36
+ router.replace("/dashboard");
37
+ } catch (requestError) {
38
+ setError(requestError.message);
39
+ } finally {
40
+ setSubmitting(false);
41
+ }
42
+ };
43
+
44
+ return (
45
+ <>
46
+ <AppHead
47
+ title={mode === "login" ? "Login" : "Register"}
48
+ description="Sign in or register for your OpenWA workspace to manage sessions and chats from one dashboard."
49
+ />
50
+
51
+ <main className="min-h-screen bg-[linear-gradient(180deg,#0b141a_0%,#111b21_100%)] px-6 py-8">
52
+ <AuthCard
53
+ mode={mode}
54
+ error={error}
55
+ busy={submitting}
56
+ onModeChange={setMode}
57
+ onSubmit={handleSubmit}
58
+ />
59
+ </main>
60
+ </>
61
+ );
62
+ }
@@ -0,0 +1,10 @@
1
+ const path = require("path");
2
+
3
+ module.exports = {
4
+ plugins: {
5
+ tailwindcss: {
6
+ config: path.join(__dirname, "tailwind.config.js")
7
+ },
8
+ autoprefixer: {}
9
+ }
10
+ };
Binary file
Binary file
Binary file