@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,81 @@
1
+ const { prisma } = require("../database/client");
2
+
3
+ async function retryOnSqliteTimeout(operation) {
4
+ let lastError = null;
5
+ for (const delayMs of [0, 100, 250, 500]) {
6
+ if (delayMs > 0) {
7
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
8
+ }
9
+
10
+ try {
11
+ return await operation();
12
+ } catch (error) {
13
+ if (error?.code !== "P1008") {
14
+ throw error;
15
+ }
16
+
17
+ lastError = error;
18
+ }
19
+ }
20
+
21
+ throw lastError;
22
+ }
23
+
24
+ async function listUserSessions(userId) {
25
+ return prisma.whatsappSession.findMany({
26
+ where: { userId },
27
+ orderBy: { createdAt: "desc" }
28
+ });
29
+ }
30
+
31
+ async function createUserSession(userId, { name, phoneNumber }) {
32
+ if (!name) {
33
+ throw new Error("Session name is required.");
34
+ }
35
+
36
+ return prisma.whatsappSession.create({
37
+ data: {
38
+ userId,
39
+ name: String(name).trim(),
40
+ phoneNumber: phoneNumber ? String(phoneNumber).trim() : null,
41
+ status: "disconnected",
42
+ transportType: "wwebjs"
43
+ }
44
+ });
45
+ }
46
+
47
+ async function getSessionById(userId, sessionId) {
48
+ return prisma.whatsappSession.findFirst({
49
+ where: {
50
+ id: sessionId,
51
+ userId
52
+ }
53
+ });
54
+ }
55
+
56
+ async function listReconnectableSessions() {
57
+ return prisma.whatsappSession.findMany({
58
+ where: {
59
+ status: {
60
+ in: ["ready", "connecting"]
61
+ }
62
+ }
63
+ });
64
+ }
65
+
66
+ async function touchSessionState(sessionId, data) {
67
+ return retryOnSqliteTimeout(() =>
68
+ prisma.whatsappSession.update({
69
+ where: { id: sessionId },
70
+ data
71
+ })
72
+ );
73
+ }
74
+
75
+ module.exports = {
76
+ createUserSession,
77
+ getSessionById,
78
+ listReconnectableSessions,
79
+ listUserSessions,
80
+ touchSessionState
81
+ };
@@ -0,0 +1,127 @@
1
+ const { getUserFromToken } = require("../services/auth-service");
2
+ const chatService = require("../services/chat-service");
3
+
4
+ function userRoom(userId) {
5
+ return `user:${userId}`;
6
+ }
7
+
8
+ function registerSocketHandlers({ io, config, sessionManager }) {
9
+ io.use(async (socket, next) => {
10
+ try {
11
+ const token = socket.handshake.auth?.token;
12
+ const user = await getUserFromToken(token, config);
13
+
14
+ if (!user) {
15
+ return next(new Error("Unauthorized"));
16
+ }
17
+
18
+ socket.user = user;
19
+ return next();
20
+ } catch (error) {
21
+ return next(error);
22
+ }
23
+ });
24
+
25
+ io.on("connection", (socket) => {
26
+ socket.join(userRoom(socket.user.id));
27
+
28
+ socket.on("send_message", async (payload = {}, ack) => {
29
+ try {
30
+ const result = await chatService.createOutgoingMessage({
31
+ userId: socket.user.id,
32
+ chatId: payload.chatId,
33
+ body: payload.body,
34
+ type: payload.type || "text",
35
+ mediaFileId: payload.mediaFileId || null,
36
+ replyToId: payload.replyToId || null
37
+ });
38
+
39
+ io.to(userRoom(socket.user.id)).emit("new_message", result.message);
40
+ io.to(userRoom(socket.user.id)).emit("contact_list_update", result.chat);
41
+
42
+ if (result.message.sessionId) {
43
+ await sessionManager.sendMessage(result.message.sessionId, {
44
+ recipient: result.message.receiver,
45
+ body: result.message.body,
46
+ mediaFileId: result.message.mediaFileId,
47
+ mediaPath: result.message.mediaFile?.relativePath || null
48
+ });
49
+
50
+ await chatService.addMessageStatus(result.message.id, "delivered");
51
+ io.to(userRoom(socket.user.id)).emit("message_status_update", {
52
+ messageId: result.message.id,
53
+ status: "delivered"
54
+ });
55
+ }
56
+
57
+ if (ack) {
58
+ ack({ ok: true, message: result.message });
59
+ }
60
+ } catch (error) {
61
+ if (ack) {
62
+ ack({ ok: false, error: error.message });
63
+ }
64
+ }
65
+ });
66
+
67
+ socket.on("send_media", async (payload = {}, ack) => {
68
+ try {
69
+ const result = await chatService.createOutgoingMessage({
70
+ userId: socket.user.id,
71
+ chatId: payload.chatId,
72
+ body: payload.body,
73
+ type: payload.type || "document",
74
+ mediaFileId: payload.mediaFileId,
75
+ replyToId: payload.replyToId || null
76
+ });
77
+
78
+ io.to(userRoom(socket.user.id)).emit("new_message", result.message);
79
+ io.to(userRoom(socket.user.id)).emit("contact_list_update", result.chat);
80
+
81
+ if (result.message.sessionId) {
82
+ await sessionManager.sendMessage(result.message.sessionId, {
83
+ recipient: result.message.receiver,
84
+ body: result.message.body,
85
+ mediaFileId: result.message.mediaFileId,
86
+ mediaPath: result.message.mediaFile?.relativePath || null
87
+ });
88
+ }
89
+
90
+ if (ack) {
91
+ ack({ ok: true, message: result.message });
92
+ }
93
+ } catch (error) {
94
+ if (ack) {
95
+ ack({ ok: false, error: error.message });
96
+ }
97
+ }
98
+ });
99
+
100
+ socket.on("typing", (payload = {}) => {
101
+ io.to(userRoom(socket.user.id)).emit("typing_event", {
102
+ chatId: payload.chatId,
103
+ isTyping: Boolean(payload.isTyping),
104
+ userId: socket.user.id,
105
+ name: socket.user.name
106
+ });
107
+ });
108
+
109
+ socket.on("open_chat", async (payload = {}, ack) => {
110
+ try {
111
+ await chatService.markChatOpened(socket.user.id, payload.chatId);
112
+ if (ack) {
113
+ ack({ ok: true });
114
+ }
115
+ } catch (error) {
116
+ if (ack) {
117
+ ack({ ok: false, error: error.message });
118
+ }
119
+ }
120
+ });
121
+ });
122
+ }
123
+
124
+ module.exports = {
125
+ registerSocketHandlers,
126
+ userRoom
127
+ };
@@ -0,0 +1,34 @@
1
+ function initials(name) {
2
+ return String(name || "?")
3
+ .split(" ")
4
+ .map((part) => part[0])
5
+ .join("")
6
+ .slice(0, 2)
7
+ .toUpperCase();
8
+ }
9
+
10
+ function colorForSeed(seed) {
11
+ const palette = [
12
+ ["#25d366", "#d2f8e9"],
13
+ ["#00a884", "#dffaf1"],
14
+ ["#7c4dff", "#efe7ff"],
15
+ ["#ff8a65", "#ffe8e0"],
16
+ ["#5c6bc0", "#e7eaff"],
17
+ ["#26a69a", "#e0f7f4"]
18
+ ];
19
+
20
+ const source = String(seed || "");
21
+ const index = source.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0) % palette.length;
22
+ return palette[index];
23
+ }
24
+
25
+ function createAvatarDataUrl(name, seed = name) {
26
+ const label = initials(name);
27
+ const [background, foreground] = colorForSeed(seed);
28
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="48" fill="${background}"/><text x="48" y="55" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="30" font-weight="700" fill="${foreground}">${label}</text></svg>`;
29
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
30
+ }
31
+
32
+ module.exports = {
33
+ createAvatarDataUrl
34
+ };
@@ -0,0 +1,29 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const rootDir = path.resolve(__dirname, "..", "..");
5
+ const storageDir = path.join(rootDir, "storage");
6
+ const sessionsDir = path.join(storageDir, "sessions");
7
+ const mediaDir = path.join(storageDir, "media");
8
+ const databaseDir = path.join(storageDir, "database");
9
+ const prismaSchemaPath = path.join(rootDir, "prisma", "schema.prisma");
10
+ const webDir = path.join(rootDir, "web");
11
+
12
+ function ensureDir(dirPath) {
13
+ fs.mkdirSync(dirPath, { recursive: true });
14
+ }
15
+
16
+ function ensureRuntimeDirs() {
17
+ [storageDir, sessionsDir, mediaDir, databaseDir].forEach(ensureDir);
18
+ }
19
+
20
+ module.exports = {
21
+ rootDir,
22
+ storageDir,
23
+ sessionsDir,
24
+ mediaDir,
25
+ databaseDir,
26
+ prismaSchemaPath,
27
+ webDir,
28
+ ensureRuntimeDirs
29
+ };
@@ -0,0 +1,47 @@
1
+ const EventEmitter = require("events");
2
+ const QRCode = require("qrcode");
3
+
4
+ class MockAdapter extends EventEmitter {
5
+ constructor({ session }) {
6
+ super();
7
+ this.session = session;
8
+ this.connected = false;
9
+ }
10
+
11
+ async connect() {
12
+ this.emit("status", { status: "connecting", transportType: "mock" });
13
+ const qrCode = await QRCode.toDataURL(`openwa:${this.session.id}:${Date.now()}`);
14
+ this.emit("qr", { qrCode, transportType: "mock" });
15
+
16
+ setTimeout(() => {
17
+ this.connected = true;
18
+ this.emit("status", { status: "ready", transportType: "mock" });
19
+ }, 1200);
20
+ }
21
+
22
+ async disconnect() {
23
+ this.connected = false;
24
+ this.emit("status", { status: "disconnected", transportType: "mock" });
25
+ }
26
+
27
+ async sendMessage(payload) {
28
+ if (!this.connected) {
29
+ throw new Error("Mock session is not connected yet.");
30
+ }
31
+
32
+ setTimeout(() => {
33
+ this.emit("message", {
34
+ sender: payload.recipient,
35
+ displayName: this.session.name,
36
+ body: `Auto reply: ${payload.body || "Media received"}`,
37
+ type: payload.mediaFileId ? "document" : "text"
38
+ });
39
+ }, 1800);
40
+
41
+ return {
42
+ externalMessageId: `mock-${Date.now()}`
43
+ };
44
+ }
45
+ }
46
+
47
+ module.exports = { MockAdapter };
@@ -0,0 +1,263 @@
1
+ const EventEmitter = require("events");
2
+ const QRCode = require("qrcode");
3
+ const path = require("path");
4
+ const { sessionsDir, rootDir } = require("../../utils/paths");
5
+
6
+ class WwebjsAdapter extends EventEmitter {
7
+ constructor({ session }) {
8
+ super();
9
+ this.session = session;
10
+ this.client = null;
11
+ }
12
+
13
+ async resolveProfilePic(externalId) {
14
+ if (!this.client || !externalId) {
15
+ return {
16
+ url: null,
17
+ status: "missing",
18
+ reason: "client-not-ready-or-empty-id"
19
+ };
20
+ }
21
+
22
+ const result = await this.client.pupPage.evaluate(async (contactId) => {
23
+ try {
24
+ const chatWid = window.Store.WidFactory.createWid(contactId);
25
+ let profilePic = null;
26
+
27
+ if (typeof window.Store.ProfilePic.profilePicFind === "function") {
28
+ profilePic = await window.Store.ProfilePic.profilePicFind(chatWid);
29
+ }
30
+
31
+ if (!profilePic && typeof window.Store.ProfilePic.requestProfilePicFromServer === "function") {
32
+ profilePic = await window.Store.ProfilePic.requestProfilePicFromServer(chatWid);
33
+ }
34
+
35
+ return profilePic?.eurl || profilePic?.imgFull || profilePic?.img || null;
36
+ } catch (error) {
37
+ const message = String(error?.message || error || "");
38
+ if (message.includes("isNewsletter")) {
39
+ return {
40
+ __status: "newsletter-guard"
41
+ };
42
+ }
43
+
44
+ if (error?.name === "ServerStatusCodeError") {
45
+ return {
46
+ __status: "server-status"
47
+ };
48
+ }
49
+
50
+ return {
51
+ __error: {
52
+ name: String(error?.name || "Error"),
53
+ message
54
+ }
55
+ };
56
+ }
57
+ }, externalId);
58
+
59
+ if (result?.__error) {
60
+ return {
61
+ url: null,
62
+ status: "error",
63
+ reason: result.__error.message,
64
+ errorName: result.__error.name
65
+ };
66
+ }
67
+
68
+ if (result?.__status === "newsletter-guard") {
69
+ return {
70
+ url: null,
71
+ status: "newsletterGuard",
72
+ reason: "wwebjs-newsletter-guard"
73
+ };
74
+ }
75
+
76
+ if (result?.__status === "server-status") {
77
+ return {
78
+ url: null,
79
+ status: "serverStatus",
80
+ reason: "server-status-code-error"
81
+ };
82
+ }
83
+
84
+ if (!result) {
85
+ return {
86
+ url: null,
87
+ status: "missing",
88
+ reason: "no-profile-photo-returned"
89
+ };
90
+ }
91
+
92
+ return {
93
+ url: result,
94
+ status: "found",
95
+ reason: "profile-photo-found"
96
+ };
97
+ }
98
+
99
+ async resolveProfilePicUrl(externalId) {
100
+ const result = await this.resolveProfilePic(externalId);
101
+ if (result.status === "error") {
102
+ console.warn(`Failed to fetch WhatsApp profile photo for ${externalId}: ${result.reason}`);
103
+ }
104
+
105
+ return result.url;
106
+ }
107
+
108
+ async connect() {
109
+ const { Client, LocalAuth } = require("whatsapp-web.js");
110
+
111
+ this.client = new Client({
112
+ authStrategy: new LocalAuth({
113
+ clientId: this.session.id,
114
+ dataPath: sessionsDir
115
+ }),
116
+ puppeteer: {
117
+ headless: true,
118
+ args: ["--no-sandbox", "--disable-setuid-sandbox"]
119
+ }
120
+ });
121
+
122
+ this.client.on("qr", async (qr) => {
123
+ const qrCode = await QRCode.toDataURL(qr);
124
+ this.emit("qr", { qrCode, transportType: "wwebjs" });
125
+ });
126
+
127
+ this.client.on("loading_screen", () => {
128
+ this.emit("status", { status: "connecting", transportType: "wwebjs" });
129
+ });
130
+
131
+ this.client.on("ready", () => {
132
+ this.emit("status", { status: "ready", transportType: "wwebjs" });
133
+ });
134
+
135
+ this.client.on("disconnected", (reason) => {
136
+ this.emit("status", {
137
+ status: "disconnected",
138
+ transportType: "wwebjs",
139
+ lastError: typeof reason === "string" ? reason : "Disconnected"
140
+ });
141
+ });
142
+
143
+ this.client.on("auth_failure", (message) => {
144
+ this.emit("status", {
145
+ status: "error",
146
+ transportType: "wwebjs",
147
+ lastError: message
148
+ });
149
+ });
150
+
151
+ this.client.on("message", (message) => {
152
+ void (async () => {
153
+ const chatId = String(message.from || "");
154
+ const isPrivateChat = chatId.endsWith("@c.us");
155
+ const isGroupChat = chatId.endsWith("@g.us");
156
+
157
+ if (!isPrivateChat && !isGroupChat) {
158
+ return;
159
+ }
160
+
161
+ this.emit("message", {
162
+ sender: chatId,
163
+ displayName: message._data?.notifyName || message._data?.pushname || chatId,
164
+ avatarUrl: await this.resolveProfilePicUrl(chatId),
165
+ body: message.body,
166
+ type: "text"
167
+ });
168
+ })().catch((error) => {
169
+ console.error("Failed to process incoming WhatsApp message.", error);
170
+ });
171
+ });
172
+
173
+ await this.client.initialize();
174
+ }
175
+
176
+ async disconnect() {
177
+ if (this.client) {
178
+ await this.client.destroy();
179
+ this.client = null;
180
+ }
181
+ this.emit("status", { status: "disconnected", transportType: "wwebjs" });
182
+ }
183
+
184
+ async getSyncSnapshot() {
185
+ if (!this.client) {
186
+ throw new Error("WhatsApp client is not ready.");
187
+ }
188
+
189
+ const [contacts, chats] = await Promise.all([this.client.getContacts(), this.client.getChats()]);
190
+ const contactSnapshots = await Promise.all(
191
+ contacts.map(async (contact) => {
192
+ const externalId = contact.id?._serialized || "";
193
+ const avatarResult = await this.resolveProfilePic(externalId);
194
+
195
+ return {
196
+ externalId,
197
+ name: contact.name || contact.shortName || "",
198
+ pushname: contact.pushname || contact.name || "",
199
+ avatarUrl: avatarResult.url
200
+ };
201
+ })
202
+ );
203
+ const contactAvatarMap = new Map(contactSnapshots.map((contact) => [contact.externalId, contact.avatarUrl]));
204
+ const chatSnapshots = [];
205
+
206
+ for (const chat of chats) {
207
+ const externalId = chat.id?._serialized || "";
208
+ if (!externalId.endsWith("@c.us") && !externalId.endsWith("@g.us")) {
209
+ continue;
210
+ }
211
+
212
+ const messages = await chat.fetchMessages({ limit: 50 });
213
+ const chatAvatarResult = contactAvatarMap.get(externalId)
214
+ ? { url: contactAvatarMap.get(externalId), status: "found", reason: "reused-contact-avatar" }
215
+ : await this.resolveProfilePic(externalId);
216
+
217
+ chatSnapshots.push({
218
+ externalId,
219
+ name: chat.name || chat.formattedTitle || externalId,
220
+ pushname: chat.name || chat.formattedTitle || externalId,
221
+ avatarUrl: chatAvatarResult.url,
222
+ messages: messages.map((message) => ({
223
+ externalMessageId: message.id?._serialized || null,
224
+ sender: message.fromMe ? `user:${this.session.userId}` : message.author || message.from || externalId,
225
+ body: message.body || null,
226
+ type: message.type,
227
+ direction: message.fromMe ? "outbound" : "inbound",
228
+ ack: message.ack ?? 0,
229
+ createdAt: new Date((message.timestamp || Math.floor(Date.now() / 1000)) * 1000).toISOString()
230
+ }))
231
+ });
232
+ }
233
+
234
+ return {
235
+ contacts: contactSnapshots,
236
+ chats: chatSnapshots
237
+ };
238
+ }
239
+
240
+ async sendMessage(payload) {
241
+ if (!this.client) {
242
+ throw new Error("WhatsApp client is not ready.");
243
+ }
244
+
245
+ if (payload.mediaFileId) {
246
+ const { MessageMedia } = require("whatsapp-web.js");
247
+ const mediaPath = path.join(rootDir, "storage", payload.mediaPath || "");
248
+
249
+ const media = MessageMedia.fromFilePath(mediaPath);
250
+ const response = await this.client.sendMessage(payload.recipient, media, payload.body ? { caption: payload.body } : undefined);
251
+ return {
252
+ externalMessageId: response.id?._serialized || null
253
+ };
254
+ }
255
+
256
+ const response = await this.client.sendMessage(payload.recipient, payload.body || "");
257
+ return {
258
+ externalMessageId: response.id?._serialized || null
259
+ };
260
+ }
261
+ }
262
+
263
+ module.exports = { WwebjsAdapter };