@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,163 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawnSync } = require("child_process");
4
+ const { createServer } = require("http");
5
+ const next = require("next");
6
+ const openModule = require("open");
7
+ const { Server } = require("socket.io");
8
+ const { getConfig } = require("./config");
9
+ const { initializeDatabase } = require("./database/init");
10
+ const { createApp } = require("./express/create-app");
11
+ const chatService = require("./services/chat-service");
12
+ const sessionService = require("./services/session-service");
13
+ const { registerSocketHandlers, userRoom } = require("./socket/register");
14
+ const { ensureRuntimeDirs, webDir } = require("./utils/paths");
15
+ const { SessionManager } = require("./whatsapp/session-manager");
16
+ const openBrowser = openModule.default || openModule;
17
+ function shouldProxyToBackend(req) {
18
+ const url = new URL(req.url || "/", "http://localhost");
19
+ return url.pathname === "/health" || url.pathname === "/version" || url.pathname === "/docs" || url.pathname.startsWith("/docs/");
20
+ }
21
+
22
+ async function proxyToBackend(req, res, config) {
23
+ const targetUrl = `${config.backendUrl}${req.url}`;
24
+ const response = await fetch(targetUrl, {
25
+ method: req.method,
26
+ headers: {
27
+ accept: req.headers.accept || "*/*"
28
+ }
29
+ });
30
+
31
+ res.statusCode = response.status;
32
+ response.headers.forEach((value, key) => {
33
+ res.setHeader(key, value);
34
+ });
35
+
36
+ const body = Buffer.from(await response.arrayBuffer());
37
+ res.end(body);
38
+ }
39
+
40
+ function ensureWebBuild(config) {
41
+ if (config.dev) {
42
+ return;
43
+ }
44
+
45
+ const buildIdPath = path.join(webDir, ".next", "BUILD_ID");
46
+ if (fs.existsSync(buildIdPath)) {
47
+ return;
48
+ }
49
+
50
+ console.log("Next.js production build not found. Building dashboard automatically...");
51
+ const result = spawnSync("npm", ["run", "build:web"], {
52
+ cwd: path.dirname(webDir),
53
+ stdio: "inherit",
54
+ env: process.env,
55
+ shell: true
56
+ });
57
+
58
+ if (result.status !== 0) {
59
+ throw new Error("Automatic Next.js build failed.");
60
+ }
61
+ }
62
+
63
+ async function startOpenWA({ dev = false } = {}) {
64
+ const config = getConfig({ dev });
65
+ process.env.NEXT_PUBLIC_API_URL = config.backendUrl;
66
+ process.env.NEXT_PUBLIC_SOCKET_URL = config.backendUrl;
67
+
68
+ ensureRuntimeDirs();
69
+ await initializeDatabase();
70
+ ensureWebBuild(config);
71
+
72
+ const nextApp = next({
73
+ dev: config.dev,
74
+ dir: webDir
75
+ });
76
+
77
+ await nextApp.prepare();
78
+ const nextHandler = nextApp.getRequestHandler();
79
+
80
+ const sessionManager = new SessionManager({ config });
81
+ const app = createApp({
82
+ config,
83
+ sessionManager
84
+ });
85
+
86
+ const backendServer = createServer(app);
87
+ const frontendServer = createServer(async (req, res) => {
88
+ try {
89
+ if (shouldProxyToBackend(req)) {
90
+ await proxyToBackend(req, res, config);
91
+ return;
92
+ }
93
+
94
+ await nextHandler(req, res);
95
+ } catch (error) {
96
+ res.statusCode = 502;
97
+ res.setHeader("content-type", "application/json");
98
+ res.end(JSON.stringify({ error: error.message }));
99
+ }
100
+ });
101
+ const io = new Server(backendServer, {
102
+ cors: {
103
+ origin: config.frontendUrl,
104
+ credentials: true
105
+ }
106
+ });
107
+
108
+ sessionManager.on("session-status", (payload) => {
109
+ io.to(userRoom(payload.userId)).emit("session_status_update", payload);
110
+ });
111
+
112
+ sessionManager.on("incoming-message", async (payload) => {
113
+ try {
114
+ const result = await chatService.storeIncomingMessage(payload);
115
+ io.to(userRoom(payload.userId)).emit("new_message", result.message);
116
+ io.to(userRoom(payload.userId)).emit("contact_list_update", result.chat);
117
+ } catch (error) {
118
+ console.error("Failed to persist incoming WhatsApp message.", error);
119
+ }
120
+ });
121
+
122
+ sessionManager.on("workspace-sync", (payload) => {
123
+ io.to(userRoom(payload.userId)).emit("workspace_synced", payload);
124
+ });
125
+
126
+ registerSocketHandlers({
127
+ io,
128
+ config,
129
+ sessionManager
130
+ });
131
+
132
+ await new Promise((resolve) => {
133
+ backendServer.listen(config.backendPort, config.host, resolve);
134
+ });
135
+
136
+ await new Promise((resolve) => {
137
+ frontendServer.listen(config.frontendPort, config.host, resolve);
138
+ });
139
+
140
+ const reconnectableSessions = await sessionService.listReconnectableSessions();
141
+ await sessionManager.hydrate(reconnectableSessions);
142
+
143
+ console.log("OpenWA is running 🚀\n");
144
+ console.log(`Dashboard: ${config.frontendUrl}`);
145
+ console.log(`Backend API: ${config.backendUrl}`);
146
+ console.log("WhatsApp Sessions: ready");
147
+ console.log("Socket: connected");
148
+ console.log("Database: connected");
149
+
150
+ if (config.autoOpenBrowser) {
151
+ try {
152
+ await openBrowser(config.frontendUrl);
153
+ } catch (error) {
154
+ console.warn(`Browser auto-open failed: ${error.message}`);
155
+ }
156
+ }
157
+
158
+ return { app, backendServer, frontendServer, io };
159
+ }
160
+
161
+ module.exports = {
162
+ startOpenWA
163
+ };
@@ -0,0 +1,131 @@
1
+ const crypto = require("crypto");
2
+ const { prisma } = require("../database/client");
3
+
4
+ function sanitizeUser(user) {
5
+ return {
6
+ id: user.id,
7
+ name: user.name,
8
+ email: user.email,
9
+ createdAt: user.createdAt
10
+ };
11
+ }
12
+
13
+ function hashApiKey(secret) {
14
+ return crypto.createHash("sha256").update(String(secret || "")).digest("hex");
15
+ }
16
+
17
+ function generateApiKeySecret() {
18
+ return `owa_live_${crypto.randomBytes(24).toString("hex")}`;
19
+ }
20
+
21
+ function sanitizeApiKey(record) {
22
+ return {
23
+ id: record.id,
24
+ name: record.name,
25
+ keyPrefix: record.keyPrefix,
26
+ last4: record.last4,
27
+ maskedKey: `${record.keyPrefix}...${record.last4}`,
28
+ lastUsedAt: record.lastUsedAt,
29
+ createdAt: record.createdAt,
30
+ updatedAt: record.updatedAt
31
+ };
32
+ }
33
+
34
+ async function retryOnSqliteTimeout(operation) {
35
+ try {
36
+ return await operation();
37
+ } catch (error) {
38
+ if (error?.code !== "P1008") {
39
+ throw error;
40
+ }
41
+
42
+ await new Promise((resolve) => setTimeout(resolve, 100));
43
+ return operation();
44
+ }
45
+ }
46
+
47
+ async function listApiKeys(userId) {
48
+ const apiKeys = await prisma.apiKey.findMany({
49
+ where: { userId },
50
+ orderBy: { createdAt: "desc" }
51
+ });
52
+
53
+ return apiKeys.map(sanitizeApiKey);
54
+ }
55
+
56
+ async function createApiKey(userId, { name }) {
57
+ const trimmedName = String(name || "").trim();
58
+ if (!trimmedName) {
59
+ throw new Error("API key name is required.");
60
+ }
61
+
62
+ const secret = generateApiKeySecret();
63
+ const record = await retryOnSqliteTimeout(() =>
64
+ prisma.apiKey.create({
65
+ data: {
66
+ userId,
67
+ name: trimmedName,
68
+ keyHash: hashApiKey(secret),
69
+ keyPrefix: secret.slice(0, 12),
70
+ last4: secret.slice(-4)
71
+ }
72
+ })
73
+ );
74
+
75
+ return {
76
+ apiKey: sanitizeApiKey(record),
77
+ secret
78
+ };
79
+ }
80
+
81
+ async function revokeApiKey(userId, apiKeyId) {
82
+ const result = await retryOnSqliteTimeout(() =>
83
+ prisma.apiKey.deleteMany({
84
+ where: {
85
+ id: apiKeyId,
86
+ userId
87
+ }
88
+ })
89
+ );
90
+
91
+ if (!result.count) {
92
+ throw new Error("API key not found.");
93
+ }
94
+
95
+ return { ok: true };
96
+ }
97
+
98
+ async function getUserFromApiKey(secret) {
99
+ if (!secret) {
100
+ return null;
101
+ }
102
+
103
+ const record = await prisma.apiKey.findUnique({
104
+ where: {
105
+ keyHash: hashApiKey(secret)
106
+ },
107
+ include: {
108
+ user: true
109
+ }
110
+ });
111
+
112
+ if (!record) {
113
+ return null;
114
+ }
115
+
116
+ await retryOnSqliteTimeout(() =>
117
+ prisma.apiKey.update({
118
+ where: { id: record.id },
119
+ data: { lastUsedAt: new Date() }
120
+ })
121
+ );
122
+
123
+ return sanitizeUser(record.user);
124
+ }
125
+
126
+ module.exports = {
127
+ createApiKey,
128
+ getUserFromApiKey,
129
+ listApiKeys,
130
+ revokeApiKey
131
+ };
@@ -0,0 +1,162 @@
1
+ const bcrypt = require("bcryptjs");
2
+ const jwt = require("jsonwebtoken");
3
+ const { prisma } = require("../database/client");
4
+ const { getUserFromApiKey } = require("./api-key-service");
5
+
6
+ async function retryOnSqliteTimeout(operation) {
7
+ let lastError = null;
8
+ for (const delayMs of [0, 100, 250, 500]) {
9
+ if (delayMs > 0) {
10
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
11
+ }
12
+
13
+ try {
14
+ return await operation();
15
+ } catch (error) {
16
+ if (error?.code !== "P1008") {
17
+ throw error;
18
+ }
19
+
20
+ lastError = error;
21
+ }
22
+ }
23
+
24
+ throw lastError;
25
+ }
26
+
27
+ function isSqliteTimeoutError(error) {
28
+ return error?.code === "P1008";
29
+ }
30
+
31
+ function sanitizeUser(user) {
32
+ return {
33
+ id: user.id,
34
+ name: user.name,
35
+ email: user.email,
36
+ createdAt: user.createdAt
37
+ };
38
+ }
39
+
40
+ function issueToken(user, config) {
41
+ return jwt.sign({ sub: user.id, email: user.email }, config.jwtSecret, {
42
+ expiresIn: "7d"
43
+ });
44
+ }
45
+
46
+ async function registerUser({ name, email, password, config }) {
47
+ const normalizedEmail = String(email || "").trim().toLowerCase();
48
+
49
+ if (!name || !normalizedEmail || !password) {
50
+ throw new Error("Name, email, and password are required.");
51
+ }
52
+
53
+ const existingUser = await retryOnSqliteTimeout(() =>
54
+ prisma.user.findUnique({
55
+ where: { email: normalizedEmail }
56
+ })
57
+ );
58
+
59
+ if (existingUser) {
60
+ throw new Error("Email is already registered.");
61
+ }
62
+
63
+ const passwordHash = await bcrypt.hash(password, 10);
64
+ const user = await prisma.user.create({
65
+ data: {
66
+ name: String(name).trim(),
67
+ email: normalizedEmail,
68
+ passwordHash
69
+ }
70
+ });
71
+
72
+ return {
73
+ token: issueToken(user, config),
74
+ user: sanitizeUser(user)
75
+ };
76
+ }
77
+
78
+ async function loginUser({ email, password, config }) {
79
+ const normalizedEmail = String(email || "").trim().toLowerCase();
80
+ const user = await retryOnSqliteTimeout(() =>
81
+ prisma.user.findUnique({
82
+ where: { email: normalizedEmail }
83
+ })
84
+ );
85
+
86
+ if (!user) {
87
+ throw new Error("Invalid email or password.");
88
+ }
89
+
90
+ const matches = await bcrypt.compare(password, user.passwordHash);
91
+ if (!matches) {
92
+ throw new Error("Invalid email or password.");
93
+ }
94
+
95
+ return {
96
+ token: issueToken(user, config),
97
+ user: sanitizeUser(user)
98
+ };
99
+ }
100
+
101
+ async function getUserFromToken(token, config) {
102
+ if (!token) {
103
+ return null;
104
+ }
105
+
106
+ const payload = jwt.verify(token, config.jwtSecret);
107
+ const user = await retryOnSqliteTimeout(() =>
108
+ prisma.user.findUnique({
109
+ where: { id: payload.sub }
110
+ })
111
+ );
112
+
113
+ return user ? sanitizeUser(user) : null;
114
+ }
115
+
116
+ function createAuthMiddleware(config, { allowApiKey = true } = {}) {
117
+ return async (req, res, next) => {
118
+ try {
119
+ const header = req.headers.authorization || "";
120
+ const bearerValue = header.startsWith("Bearer ") ? header.slice(7) : null;
121
+ const headerApiKey = req.headers["x-api-key"] || req.headers["x-openwa-api-key"] || null;
122
+ const apiKey = allowApiKey ? headerApiKey || (String(bearerValue || "").startsWith("owa_live_") ? bearerValue : null) : null;
123
+ const token = apiKey ? null : bearerValue;
124
+
125
+ if (!token && !apiKey) {
126
+ return res.status(401).json({ error: "Authentication required." });
127
+ }
128
+
129
+ const user = apiKey ? await getUserFromApiKey(apiKey) : await getUserFromToken(token, config);
130
+ if (!user) {
131
+ return res.status(401).json({ error: apiKey ? "Invalid API key." : "Invalid token." });
132
+ }
133
+
134
+ req.user = user;
135
+ return next();
136
+ } catch (error) {
137
+ if (isSqliteTimeoutError(error)) {
138
+ return res.status(503).json({ error: "Database is busy. Please try again." });
139
+ }
140
+
141
+ return res.status(401).json({ error: error.message });
142
+ }
143
+ };
144
+ }
145
+
146
+ function authMiddleware(config) {
147
+ return createAuthMiddleware(config, { allowApiKey: true });
148
+ }
149
+
150
+ function dashboardAuthMiddleware(config) {
151
+ return createAuthMiddleware(config, { allowApiKey: false });
152
+ }
153
+
154
+ module.exports = {
155
+ authMiddleware,
156
+ dashboardAuthMiddleware,
157
+ getUserFromToken,
158
+ isSqliteTimeoutError,
159
+ loginUser,
160
+ registerUser,
161
+ sanitizeUser
162
+ };