diff --git a/.agents/skills/whatsapp-web/.claude-plugin/plugin.json b/.agents/skills/whatsapp-web/.claude-plugin/plugin.json
new file mode 100644
index 0000000..42acb3e
--- /dev/null
+++ b/.agents/skills/whatsapp-web/.claude-plugin/plugin.json
@@ -0,0 +1,35 @@
+{
+ "name": "whatsapp-web",
+ "version": "1.0.0",
+ "description": "Automate WhatsApp messaging, groups, and media sharing via web interface",
+ "author": "Canifi",
+ "category": "communication",
+ "permissions": [
+ "browser",
+ "notifications",
+ "env-access"
+ ],
+ "triggers": [
+ "whatsapp",
+ "whatsapp message",
+ "send whatsapp",
+ "whatsapp group",
+ "whatsapp status"
+ ],
+ "authentication": {
+ "type": "qr-code",
+ "loginUrl": "https://web.whatsapp.com",
+ "sessionIndicator": "#app .app-wrapper-web"
+ },
+ "requiredEnvVars": [
+ "WHATSAPP_LINKED"
+ ],
+ "capabilities": {
+ "messaging": true,
+ "groups": true,
+ "media": true,
+ "status": true,
+ "search": true,
+ "broadcast": true
+ }
+}
diff --git a/.agents/skills/whatsapp-web/SKILL.md b/.agents/skills/whatsapp-web/SKILL.md
new file mode 100644
index 0000000..3408a2e
--- /dev/null
+++ b/.agents/skills/whatsapp-web/SKILL.md
@@ -0,0 +1,142 @@
+---
+name: whatsapp-web
+description: Enables Claude to send messages, manage groups, and handle WhatsApp communications through the web interface
+version: 1.0.0
+author: Canifi
+category: communication
+---
+
+# WhatsApp Web Skill
+
+## Overview
+Automates WhatsApp Web interactions including messaging, group management, status updates, and media sharing through browser automation with QR code authentication.
+
+## Quick Install
+
+```bash
+curl -sSL https://canifi.com/skills/whatsapp-web/install.sh | bash
+```
+
+Or manually:
+```bash
+cp -r skills/whatsapp-web ~/.canifi/skills/
+```
+
+## Setup
+
+Configure via [canifi-env](https://canifi.com/setup/scripts):
+
+```bash
+# First, ensure canifi-env is installed:
+# curl -sSL https://canifi.com/install.sh | bash
+
+canifi-env set WHATSAPP_LINKED "true"
+```
+
+## Privacy & Authentication
+
+**Your credentials, your choice.** Canifi LifeOS respects your privacy.
+
+### Option 1: Manual Browser Login (Recommended)
+If you prefer not to share credentials with Claude Code:
+1. Complete the [Browser Automation Setup](/setup/automation) using CDP mode
+2. Login to the service manually in the Playwright-controlled Chrome window
+3. Claude will use your authenticated session without ever seeing your password
+
+### Option 2: Environment Variables
+If you're comfortable sharing credentials, you can store them locally:
+```bash
+canifi-env set SERVICE_EMAIL "your-email"
+canifi-env set SERVICE_PASSWORD "your-password"
+```
+
+**Note**: Credentials stored in canifi-env are only accessible locally on your machine and are never transmitted.
+
+## Capabilities
+- Send and receive text messages
+- Share images, videos, and documents
+- Create and manage groups
+- Update and view status/stories
+- Search messages and contacts
+- Star and archive chats
+- Manage group settings and admins
+- Handle broadcast lists
+
+## Usage Examples
+
+### Example 1: Send a Message
+```
+User: "Send a WhatsApp message to Sarah asking about dinner plans"
+Claude: I'll send that message on WhatsApp.
+- Navigate to web.whatsapp.com
+- Search for Sarah in contacts
+- Open chat and compose message
+- Send: "Hey Sarah, what are the dinner plans for tonight?"
+- Confirm message delivered (double checkmarks)
+```
+
+### Example 2: Create a Group
+```
+User: "Create a WhatsApp group called 'Weekend Trip' with John, Mike, and Lisa"
+Claude: I'll create that group.
+- Click New Group
+- Add John, Mike, and Lisa
+- Set group name "Weekend Trip"
+- Add group icon if available
+- Confirm group created
+```
+
+### Example 3: Share a File
+```
+User: "Send the project proposal PDF to the Work group on WhatsApp"
+Claude: I'll share that document.
+- Open Work group chat
+- Click attach file
+- Select and upload proposal PDF
+- Add caption if needed
+- Send file to group
+```
+
+### Example 4: Check Unread Messages
+```
+User: "Check my unread WhatsApp messages and summarize them"
+Claude: I'll check your unread messages.
+- Identify chats with unread indicators
+- Open each unread chat
+- Read and collect new messages
+- Present summary of conversations
+```
+
+## Authentication Flow
+1. Navigate to web.whatsapp.com via Playwright MCP
+2. If QR code shown, notify user via iMessage to scan with phone
+3. Wait for phone authentication (timeout: 60 seconds)
+4. Verify chat list loads successfully
+5. Maintain session via local storage
+6. Re-authenticate if session expires
+
+## Error Handling
+- **QR Code Timeout**: iMessage reminder to scan QR code
+- **Session Expired**: Notify user to re-link device
+- **Phone Disconnected**: Alert user that phone must be online
+- **Rate Limited**: Wait and implement backoff
+- **Contact Not Found**: Search by phone number or name variations
+- **Group Limit Reached**: Notify user of WhatsApp limits (1024 members)
+- **Media Failed**: Check file size and format, retry upload
+- **Connection Lost**: Wait for reconnection, notify if persistent
+
+## Self-Improvement Instructions
+When encountering new WhatsApp features:
+1. Document new UI elements and chat patterns
+2. Add support for new message types (polls, etc.)
+3. Log successful group management patterns
+4. Update for new WhatsApp Web features
+
+## Notes
+- WhatsApp Web requires phone to be connected to internet
+- End-to-end encryption maintained through web interface
+- Status/stories expire after 24 hours
+- Broadcast lists have recipient limits
+- Some features require WhatsApp Business
+- Voice and video calls not supported via web automation
+- Multi-device beta allows operation without phone online
diff --git a/.qwen/skills/whatsapp-web b/.qwen/skills/whatsapp-web
new file mode 120000
index 0000000..227c0a2
--- /dev/null
+++ b/.qwen/skills/whatsapp-web
@@ -0,0 +1 @@
+../../.agents/skills/whatsapp-web
\ No newline at end of file
diff --git a/bun.lock b/bun.lock
index 3a44dd2..fbfcb31 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,8 @@
"@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13",
+ "@mantine/dates": "^8.3.14",
+ "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13",
@@ -49,7 +51,7 @@
"uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0",
- "whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
+ "whatsapp-web.js": "^1.34.6",
"yaml": "^2.8.2",
},
"devDependencies": {
@@ -65,7 +67,7 @@
},
},
"packages": {
- "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
+ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -151,6 +153,10 @@
"@mantine/core": ["@mantine/core@8.3.13", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw=="],
+ "@mantine/dates": ["@mantine/dates@8.3.14", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg=="],
+
+ "@mantine/form": ["@mantine/form@8.3.14", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "klona": "^2.0.6" }, "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-LJUeab+oF+YzATrm/K03Z/QoVVYlaolWqLUZZj7XexNA4hS2/ycKyWT07YhGkdHTLXkf3DUtrg1sS77K7Oje8A=="],
+
"@mantine/hooks": ["@mantine/hooks@8.3.13", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg=="],
"@mantine/modals": ["@mantine/modals@8.3.13", "", { "peerDependencies": { "@mantine/core": "8.3.13", "@mantine/hooks": "8.3.13", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-5jIRJKEupQerHfPGcPHgQk+J6dGBO7spC66VgEZqCNRpbWhowCxBNGEW5LN1hZE9sLYBJg+z2MazPws4A1GohQ=="],
@@ -181,7 +187,7 @@
"@prisma/get-platform": ["@prisma/get-platform@6.19.2", "", { "dependencies": { "@prisma/debug": "6.19.2" } }, "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA=="],
- "@puppeteer/browsers": ["@puppeteer/browsers@2.11.1", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA=="],
+ "@puppeteer/browsers": ["@puppeteer/browsers@2.12.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
@@ -279,7 +285,7 @@
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
- "bare-fs": ["bare-fs@4.5.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw=="],
+ "bare-fs": ["bare-fs@4.5.3", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ=="],
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
@@ -325,7 +331,7 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
- "chromium-bidi": ["chromium-bidi@12.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg=="],
+ "chromium-bidi": ["chromium-bidi@13.1.0", "", { "dependencies": { "mitt": "^3.0.1", "puppeteer": "^24.36.0", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-IdGNojX6S04+wgJOALzvkkIyLelhEGqI8xSctwiYJJGSi9T2eBjwAQW2UjBD/mCXv/rUkNlH2+h7jz+58vT74A=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
@@ -391,7 +397,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
- "devtools-protocol": ["devtools-protocol@0.0.1534754", "", {}, "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ=="],
+ "devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
@@ -449,6 +455,8 @@
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
@@ -549,6 +557,8 @@
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
+ "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
+
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -685,9 +695,9 @@
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
- "puppeteer": ["puppeteer@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1534754", "puppeteer-core": "24.35.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-sbjB5JnJ+3nwgSdRM/bqkFXqLxRz/vsz0GRIeTlCk+j+fGpqaF2dId9Qp25rXz9zfhqnN9s0krek1M/C2GDKtA=="],
+ "puppeteer": ["puppeteer@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", "puppeteer-core": "24.37.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-s1jHugVhPtQjiJE6wUyonj4VEGWF+mfRDASqPMPsXgKcjZX0GaznBmcT9nLQ7bBL90phuQUqO4jiV5vTecZg4g=="],
- "puppeteer-core": ["puppeteer-core@24.35.0", "", { "dependencies": { "@puppeteer/browsers": "2.11.1", "chromium-bidi": "12.0.1", "debug": "^4.4.3", "devtools-protocol": "0.0.1534754", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.3.10", "ws": "^8.19.0" } }, "sha512-vt1zc2ME0kHBn7ZDOqLvgvrYD5bqNv5y2ZNXzYnCv8DEtZGw/zKhljlrGuImxptZ4rq+QI9dFGrUIYqG4/IQzA=="],
+ "puppeteer-core": ["puppeteer-core@24.37.0", "", { "dependencies": { "@puppeteer/browsers": "2.12.0", "chromium-bidi": "13.1.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.0", "ws": "^8.19.0" } }, "sha512-WoCBK36cBlbaxwuvPWhOp2+lR6O6ynHdDuvD8rEIkxPOPpUoMXSJuyiOWhHtexJBCLaMCAJk33QdYambvQl+og=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
@@ -841,7 +851,7 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
- "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.10", "", {}, "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw=="],
+ "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.0", "", {}, "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
@@ -849,7 +859,7 @@
"whatsapp-client-sdk": ["whatsapp-client-sdk@1.6.0", "", { "dependencies": { "axios": "^1.6.0", "form-data": "^4.0.0", "uuid": "^9.0.0" }, "peerDependencies": { "@supabase/supabase-js": "^2.0.0" }, "optionalPeers": ["@supabase/supabase-js"] }, "sha512-iAbdpv8tmw3RLPUxmw2ha8qbkKCuijbgl/HiDt4xpHgw3IWL3bB5o1SYX3qwuuEWwalXSNDiOu6W7lARJ8+XJw=="],
- "whatsapp-web.js": ["whatsapp-web.js@github:pedroslopez/whatsapp-web.js#dd9df40", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "pedroslopez-whatsapp-web.js-dd9df40"],
+ "whatsapp-web.js": ["whatsapp-web.js@1.34.6", "", { "dependencies": { "@pedroslopez/moduleraid": "^5.0.2", "fluent-ffmpeg": "2.1.3", "mime": "^3.0.0", "node-fetch": "^2.6.9", "node-webpmux": "3.1.7", "puppeteer": "^24.31.0" }, "optionalDependencies": { "archiver": "^5.3.1", "fs-extra": "^10.1.0", "unzipper": "^0.10.11" } }, "sha512-+zgLBqARcVfuCG7b80c7Gkt+4Yh8w+oDWx7lL2gTA6nlaykHBne7NwJ5yGe2r7O9IYraIzs6HiCzNGKfu9AUBg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
diff --git a/package.json b/package.json
index 0aa9b44..efee708 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,8 @@
"@elysiajs/swagger": "^1.3.1",
"@lglab/react-qr-code": "^1.4.9",
"@mantine/core": "^8.3.13",
+ "@mantine/dates": "^8.3.14",
+ "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.13",
"@mantine/modals": "^8.3.13",
"@mantine/notifications": "^8.3.13",
@@ -55,7 +57,7 @@
"uuid": "^13.0.0",
"whatsapp-api-js": "^6.2.1",
"whatsapp-client-sdk": "^1.6.0",
- "whatsapp-web.js": "github:pedroslopez/whatsapp-web.js#main",
+ "whatsapp-web.js": "^1.34.6",
"yaml": "^2.8.2"
},
"devDependencies": {
diff --git a/src/App.tsx b/src/App.tsx
index cb39c60..a7ca127 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,6 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
+import '@mantine/dates/styles.css';
import { Notifications } from "@mantine/notifications";
import { ModalsProvider } from "@mantine/modals";
import { MantineProvider } from "@mantine/core";
diff --git a/src/index.html b/src/index.html
index 2a957d0..8e872fb 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 3077f69..5934b4a 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,15 +1,163 @@
import clientRoutes from "@/clientRoutes";
-import { Button, Container } from "@mantine/core";
+import {
+ Button,
+ Container,
+ Text,
+ Title,
+ Stack,
+ Group,
+ SimpleGrid,
+ ThemeIcon,
+ Paper,
+ Box,
+ rem,
+ Divider,
+ Badge,
+} from "@mantine/core";
+import {
+ IconBrandWhatsapp,
+ IconRocket,
+ IconShieldCheck,
+ IconPlugConnected,
+ IconArrowRight,
+} from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
+const features = [
+ {
+ icon: IconBrandWhatsapp,
+ title: "WhatsApp Integration",
+ description: "Connect and automate WhatsApp messages effortlessly using wa-web.js technology.",
+ color: "green",
+ },
+ {
+ icon: IconPlugConnected,
+ title: "Webhooks & API",
+ description: "Integrate with your existing systems via powerful webhooks and a developer-friendly API.",
+ color: "blue",
+ },
+ {
+ icon: IconRocket,
+ title: "High Performance",
+ description: "Built on Bun and ElysiaJS for maximum speed and efficient resource management.",
+ color: "orange",
+ },
+ {
+ icon: IconShieldCheck,
+ title: "Secure & Reliable",
+ description: "Manage API keys and sessions securely with a robust management dashboard.",
+ color: "teal",
+ },
+];
+
export default function Home() {
const navigate = useNavigate();
+
return (
-
- Home
- navigate(clientRoutes["/sq/dashboard"])}>
- Go to SQ
-
-
+
+ {/* Hero Section */}
+
+
+
+
+
+
+
+
+ Master Your{" "}
+
+ WhatsApp
+ {" "}
+ Workflow
+
+
+
+
+ A robust, full-stack WhatsApp integration platform built with Bun.
+ Send messages, manage webhooks, and automate your communication
+ with ease.
+
+
+
+
+ }
+ onClick={() => navigate(clientRoutes["/sq/dashboard"])}
+ >
+ Get Started
+
+ navigate(clientRoutes["/login"])}
+ >
+ Login
+
+
+
+
+
+
+ {/* Features Section */}
+
+
+
+ {features.map((feature) => (
+
+
+
+
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+ ยฉ 2026 wajs-server. Built with Bun & Mantine.
+
+
+ Production Ready
+ v1.0.0
+
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index 8f95e7e..b07c9dd 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -1,28 +1,43 @@
import {
Button,
Container,
- Group,
- PasswordInput,
- Stack,
+ Paper,
Text,
TextInput,
+ PasswordInput,
+ Group,
+ Stack,
+ Title,
+ Center,
+ Box,
+ ThemeIcon,
} from "@mantine/core";
import { useEffect, useState } from "react";
+import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
+import { IconAt, IconLock, IconLogin, IconBrandWhatsapp } from "@tabler/icons-react";
import apiFetch from "../lib/apiFetch";
import clientRoutes from "@/clientRoutes";
import { Navigate } from "react-router-dom";
export default function Login() {
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
-
const [isAuthenticated, setIsAuthenticated] = useState(null);
+ const form = useForm({
+ initialValues: {
+ email: "",
+ password: "",
+ },
+ validate: {
+ email: (value) => (/^\S+@\S+$/.test(value) ? null : "Invalid email"),
+ password: (value) => (value.length < 1 ? "Password is required" : null),
+ },
+ });
+
useEffect(() => {
async function checkSession() {
try {
- // backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
@@ -32,54 +47,103 @@ export default function Login() {
checkSession();
}, []);
- const handleSubmit = async () => {
+ const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
try {
const response = await apiFetch.auth.login.post({
- email,
- password,
+ email: values.email,
+ password: values.password,
});
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
+ notifications.show({
+ title: "Login Successful",
+ message: "Welcome back!",
+ color: "green",
+ });
window.location.href = clientRoutes["/sq/dashboard"];
return;
}
if (response.error) {
- alert(JSON.stringify(response.error));
+ notifications.show({
+ title: "Login Failed",
+ message: (response.error as any)?.value?.message || "Invalid credentials",
+ color: "red",
+ });
}
} catch (error) {
console.error(error);
+ notifications.show({
+ title: "Error",
+ message: "An unexpected error occurred",
+ color: "red",
+ });
} finally {
setLoading(false);
}
};
- if (isAuthenticated === null) return null; // or loading spinner
+ if (isAuthenticated === null) return null;
if (isAuthenticated)
return ;
return (
-
-
- Login
- setEmail(e.target.value)}
- />
- setPassword(e.target.value)}
- />
-
-
- Login
-
-
-
-
+
+
+
+
+
+
+
+ Welcome Back!
+
+
+ Login to manage your WhatsApp integration
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/sq/dashboard/apikey/apikey_page.tsx b/src/pages/sq/dashboard/apikey/apikey_page.tsx
index 38fa504..ad1975d 100644
--- a/src/pages/sq/dashboard/apikey/apikey_page.tsx
+++ b/src/pages/sq/dashboard/apikey/apikey_page.tsx
@@ -1,306 +1,342 @@
+import apiFetch from "@/lib/apiFetch";
import {
+ ActionIcon,
+ Badge,
+ Box,
Button,
- Card,
+ Center,
Container,
+ Divider,
Group,
+ Loader,
+ Paper,
+ ScrollArea,
Stack,
Table,
Text,
TextInput,
- ScrollArea,
- Divider,
- Tooltip,
- Badge,
- Loader,
- ActionIcon,
- Center,
+ Title,
+ Tooltip
} from "@mantine/core";
-import { IconKey, IconPlus, IconTrash, IconCopy } from "@tabler/icons-react";
+import { DateInput } from "@mantine/dates";
+import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
+import {
+ IconCalendar,
+ IconCopy,
+ IconInfoCircle,
+ IconKey,
+ IconPlus,
+ IconSearch,
+ IconTrash,
+} from "@tabler/icons-react";
+import dayjs from "dayjs";
import { useEffect, useState } from "react";
-import { showNotification } from "@mantine/notifications";
-import apiFetch from "@/lib/apiFetch";
export default function ApiKeyPage() {
+ const [refresh, setRefresh] = useState(false);
+
+ const toggleRefresh = () => setRefresh((r) => !r);
+
return (
-
+
-
-
-
-
- API Key Management
-
+
+
+
+
+
+
+ API Key Management
+
+
+
+ Generate and manage secure access keys for your integrations.
+
+
+ }
+ >
+ Secure Access
+
-
- Secure Access
-
-
-
-
+
+
+
+
+
);
}
-function CreateApiKey() {
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [expiredAt, setExpiredAt] = useState("");
+function CreateApiKey({ onCreated }: { onCreated: () => void }) {
const [loading, setLoading] = useState(false);
- const [refresh, setRefresh] = useState(false);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!name.trim()) {
- showNotification({
- title: "Missing name",
- message: "Please enter a name for your API key",
+ const form = useForm({
+ initialValues: {
+ name: "",
+ description: "",
+ expiredAt: null as Date | null,
+ },
+ validate: {
+ name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
+ },
+ });
+
+ const handleSubmit = async (values: typeof form.values) => {
+ setLoading(true);
+ try {
+ const res = await apiFetch.api.apikey.create.post({
+ name: values.name,
+ description: values.description,
+ expiredAt: values.expiredAt ? values.expiredAt.toISOString() : undefined,
+ });
+
+ if (res.status === 200) {
+ form.reset();
+ notifications.show({
+ title: "Success",
+ message: "API key created successfully",
+ color: "teal",
+ });
+ onCreated();
+ } else {
+ notifications.show({
+ title: "Error",
+ message: (res.error as any)?.message || "Failed to create API key",
+ color: "red",
+ });
+ }
+ } catch (err) {
+ console.error(err);
+ notifications.show({
+ title: "Error",
+ message: "An unexpected error occurred",
color: "red",
});
- return;
- }
- setLoading(true);
- const res = await apiFetch.api.apikey.create.post({
- name,
- description,
- expiredAt,
- });
- setLoading(false);
- if (res.status === 200) {
- setName("");
- setDescription("");
- setExpiredAt("");
- showNotification({
- title: "Success",
- message: "API key created successfully",
- color: "teal",
- });
- setRefresh((r) => !r);
+ } finally {
+ setLoading(false);
}
};
return (
-
-
-
-
-
- Create New API Key
-
-
-
-
-
-
+
+
+
+
+ Create New API Key
+
+
+
-
-
+
+
+
);
}
function ListApiKey({ refresh }: { refresh: boolean }) {
const [apiKeys, setApiKeys] = useState([]);
const [loading, setLoading] = useState(true);
+ const [search, setSearch] = useState("");
- useEffect(() => {
- const fetchApiKeys = async () => {
- setLoading(true);
+ const fetchApiKeys = async () => {
+ setLoading(true);
+ try {
const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []);
}
+ } catch (err) {
+ console.error(err);
+ } finally {
setLoading(false);
- };
+ }
+ };
+
+ useEffect(() => {
fetchApiKeys();
}, [refresh]);
+ const filteredKeys = apiKeys.filter((key) =>
+ key.name.toLowerCase().includes(search.toLowerCase())
+ );
+
+ const handleDelete = async (id: string) => {
+ try {
+ const res = await apiFetch.api.apikey.delete.delete({ id });
+ if (res.status === 200) {
+ setApiKeys((prev) => prev.filter((a) => a.id !== id));
+ notifications.show({
+ title: "Deleted",
+ message: "API key removed successfully",
+ color: "red",
+ });
+ }
+ } catch (err) {
+ console.error(err);
+ notifications.show({
+ title: "Error",
+ message: "Failed to delete API key",
+ color: "red",
+ });
+ }
+ };
+
+ const handleCopy = (key: string) => {
+ navigator.clipboard.writeText(key);
+ notifications.show({
+ title: "Copied",
+ message: "API key copied to clipboard",
+ color: "teal",
+ icon: ,
+ });
+ };
+
return (
-
+
-
+
Active API Keys
+ }
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ size="xs"
+ w={250}
+ />
-
+
+
+
{loading ? (
-
-
+
+
+
+
+ Fetching your keys...
+
+
- ) : apiKeys.length === 0 ? (
-
- No API keys found
+ ) : filteredKeys.length === 0 ? (
+
+
+
+
+ {search ? "No keys match your search" : "No API keys created yet"}
+
+
) : (
-
+
Name
Description
- Expired
+ Expiration
Created
- Updated
- Actions
+
+ Actions
+
- {apiKeys.map((apiKey: any, index: number) => (
-
- {apiKey.name}
- {apiKey.description || "โ"}
+ {filteredKeys.map((apiKey: any) => (
+
- {apiKey.expiredAt
- ? new Date(apiKey.expiredAt).toISOString().split("T")[0]
- : "โ"}
+
+ {apiKey.name}
+
- {new Date(apiKey.createdAt).toISOString().split("T")[0]}
+
+ {apiKey.description || "No description"}
+
- {new Date(apiKey.updatedAt).toISOString().split("T")[0]}
+ {apiKey.expiredAt ? (
+
+ {dayjs(apiKey.expiredAt).format("MMM DD, YYYY")}
+
+ ) : (
+
+ Never
+
+ )}
-
-
-
+
+ {dayjs(apiKey.createdAt).format("MMM DD, YYYY")}
+
+
+
+
{
- navigator.clipboard.writeText(apiKey.key);
- showNotification({
- title: "Copied",
- message: "API key copied to clipboard",
- color: "teal",
- });
- }}
+ onClick={() => handleCopy(apiKey.key)}
+ size="lg"
>
-
+
{
- await apiFetch.api.apikey.delete.delete({
- id: apiKey.id,
- });
- setApiKeys((prev) =>
- prev.filter((a) => a.id !== apiKey.id),
- );
- showNotification({
- title: "Deleted",
- message: "API key removed successfully",
- color: "red",
- });
- }}
+ onClick={() => handleDelete(apiKey.id)}
+ size="lg"
>
@@ -314,6 +350,6 @@ function ListApiKey({ refresh }: { refresh: boolean }) {
)}
-
+
);
}
diff --git a/src/pages/sq/dashboard/dashboard_layout.tsx b/src/pages/sq/dashboard/dashboard_layout.tsx
index fecdc34..6dbc9b8 100644
--- a/src/pages/sq/dashboard/dashboard_layout.tsx
+++ b/src/pages/sq/dashboard/dashboard_layout.tsx
@@ -1,177 +1,113 @@
-import { useEffect, useState } from "react";
+import clientRoutes from "@/clientRoutes";
+import apiFetch from "@/lib/apiFetch";
import {
- ActionIcon,
AppShell,
Avatar,
- Button,
- Card,
+ Box,
+ Burger,
Divider,
- Flex,
Group,
+ Menu,
NavLink,
- Paper,
ScrollArea,
Stack,
Text,
+ ThemeIcon,
Title,
- Tooltip,
- Badge,
+ UnstyledButton,
+ rem
} from "@mantine/core";
-import { useLocalStorage } from "@mantine/hooks";
+import { useDisclosure } from "@mantine/hooks";
import {
- IconChevronLeft,
+ IconBrandWhatsapp,
+ IconChevronDown,
IconChevronRight,
IconDashboard,
+ IconHome,
IconKey,
- IconWebhook,
- IconBrandWhatsapp,
- IconUser,
IconLogout,
+ IconSettings,
+ IconWebhook,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
+import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
-import apiFetch from "@/lib/apiFetch";
-import clientRoutes from "@/clientRoutes";
-
-function Logout() {
- return (
-
- }
- onClick={async () => {
- await apiFetch.auth.logout.delete();
- localStorage.removeItem("token");
- window.location.href = "/login";
- }}
- >
- Logout
-
-
- );
-}
export default function DashboardLayout() {
- const [opened, setOpened] = useLocalStorage({
- key: "nav_open",
- defaultValue: true,
- });
+ const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
+ const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
+ const location = useLocation();
return (
-
-
-
-
- setOpened((v) => !v)}
- aria-label="Toggle navigation"
- style={{
- color: "#00FFC8",
- background: "rgba(0,255,200,0.1)",
- // boxShadow: "0 0 10px rgba(0,255,200,0.2)",
- }}
- >
- {opened ? : }
-
-
+
+
+
+
+
+
+
+
+
+
+ WAJS SERVER
+
+
-
-
+
+
+
+
+
+
+
+
-
+
+
-
-
-
-
- {!opened && (
-
- setOpened(true)}
- aria-label="Open navigation"
- style={{
- color: "#00FFFF",
- background: "rgba(0,255,200,0.1)",
- }}
- >
-
-
-
- )}
-
- Control Center
-
-
- Live
-
-
-
+
+
-
+
);
}
-function HostView() {
+function HostHeaderView() {
const [host, setHost] = useState(null);
+ const navigate = useNavigate();
useEffect(() => {
async function fetchHost() {
@@ -181,51 +117,49 @@ function HostView() {
fetchHost();
}, []);
+ const handleLogout = async () => {
+ await apiFetch.auth.logout.delete();
+ localStorage.removeItem("token");
+ window.location.href = "/login";
+ };
+
+ if (!host) return null;
+
return (
-
- {host ? (
-
-
-
+
+
+
+
+
{host.name?.[0]}
-
-
+
+
{host.name}
-
- {host.email}
-
-
-
-
-
-
- ) : (
-
- Host data unavailable
-
- )}
-
+
+
+
+
+
+
+
+ Application
+ }
+ >
+ Settings
+
+
+ }
+ >
+ Logout
+
+
+
);
}
@@ -237,61 +171,63 @@ function NavigationDashboard() {
{
path: "/sq/dashboard/dashboard",
label: "Overview",
- icon: ,
- desc: "Main dashboard insights",
+ icon: ,
+ },
+ {
+ path: "/sq/dashboard/wajs/wajs-home",
+ label: "WhatsApp Service",
+ icon: ,
+ },
+ {
+ path: "/sq/dashboard/wa-hook/wa-hook-home",
+ label: "Hook Activity",
+ icon: ,
+ },
+ {
+ path: "/sq/dashboard/webhook/webhook-home",
+ label: "Webhooks Config",
+ icon: ,
},
{
path: "/sq/dashboard/apikey/apikey",
label: "API Keys",
- icon: ,
- desc: "Manage and regenerate access tokens",
- },
- {
- path: "/sq/dashboard/wajs/wajs-home",
- label: "Wajs Integration",
- icon: ,
- desc: "WhatsApp session manager",
- },
- {
- path: "/sq/dashboard/webhook/webhook-home",
- label: "Webhooks",
- icon: ,
- desc: "Incoming and outgoing event handlers",
- },
- {
- path: clientRoutes["/sq/dashboard/wa-hook/wa-hook-home"],
- label: "WA Hook",
- icon: ,
- desc: "WA Hook",
+ icon: ,
},
];
return (
-
+
{items.map((item) => (
navigate(clientRoutes[item.path as keyof typeof clientRoutes])
}
- style={{
- borderRadius: "12px",
- color: "#EAEAEA",
- background: location.pathname.startsWith(item.path)
- ? "rgba(0,255,200,0.15)"
- : "transparent",
- transition: "background 0.2s ease",
- }}
- styles={{
- label: { fontWeight: 500, color: "#EAEAEA" },
- description: { color: "#9A9A9A" },
- }}
+ variant="light"
+ color="teal"
+ style={{ borderRadius: rem(8) }}
+ rightSection={ }
/>
))}
);
}
+
+function NavigationFooter() {
+ const navigate = useNavigate();
+ return (
+
+ }
+ label="Back to Home"
+ onClick={() => navigate("/")}
+ variant="subtle"
+ color="gray"
+ style={{ borderRadius: rem(8) }}
+ />
+
+ );
+}
diff --git a/src/pages/sq/dashboard/dashboard_page.tsx b/src/pages/sq/dashboard/dashboard_page.tsx
index 97210bc..d6249e6 100644
--- a/src/pages/sq/dashboard/dashboard_page.tsx
+++ b/src/pages/sq/dashboard/dashboard_page.tsx
@@ -1,7 +1,219 @@
+import {
+ Title,
+ Text,
+ Container,
+ Grid,
+ Paper,
+ Group,
+ Stack,
+ ThemeIcon,
+ Badge,
+ Button,
+ Box,
+ SimpleGrid,
+ List,
+ ThemeIcon as MantineThemeIcon,
+ rem,
+} from "@mantine/core";
+import {
+ IconDashboard,
+ IconBrandWhatsapp,
+ IconKey,
+ IconWebhook,
+ IconCircleCheck,
+ IconCircleX,
+ IconPlayerPlay,
+ IconArrowRight,
+ IconSettings,
+} from "@tabler/icons-react";
+import useSWR from "swr";
+import apiFetch from "@/lib/apiFetch";
+import clientRoutes from "@/clientRoutes";
+import { useNavigate } from "react-router-dom";
+
export default function Dashboard() {
+ const navigate = useNavigate();
+ const { data: waState } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
+ refreshInterval: 5000,
+ });
+
+ const isWaReady = waState?.data?.state?.ready;
+
return (
-
-
Dashboard
-
+
+ {/* Header Section */}
+
+
+
+
+ System Overview
+
+
+ Welcome to your WhatsApp Integration Control Center.
+
+
+
+ System {isWaReady ? "Online" : "Action Required"}
+
+
+
+
+ {/* Main Stats / Status Cards */}
+
+ navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
+ />
+ navigate(clientRoutes["/sq/dashboard/apikey/apikey"])}
+ />
+ navigate(clientRoutes["/sq/dashboard/webhook/webhook-home"])}
+ />
+
+
+
+ {/* Getting Started / Guide */}
+
+
+ Getting Started
+
+
+
+
+ }
+ >
+
+ Scan WhatsApp QR Code
+ Go to WhatsApp Service and scan the QR to link your device.
+
+
+ Generate API Key
+ Create a secure key to authenticate your external requests.
+
+
+ Configure Webhooks
+ Set up URLs to receive real-time notifications for incoming messages.
+
+
+ Start Automation
+ Your system is now ready to send and receive messages automatically.
+
+
+
+ }
+ onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
+ >
+ Go to WhatsApp Service
+
+
+
+
+
+ {/* Quick Tips / Info */}
+
+
+
+
+
+
+
+ Developer Pro Tip
+
+
+ You can use the API Key in the "Authorization" header as a Bearer token
+ to send messages programmatically via our REST API.
+
+
+
+
+ System Health
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface StatusCardProps {
+ title: string;
+ status: string;
+ color: string;
+ icon: React.ElementType;
+ description: string;
+ actionLabel: string;
+ onAction: () => void;
+}
+
+function StatusCard({ title, status, color, icon: Icon, description, actionLabel, onAction }: StatusCardProps) {
+ return (
+
+
+
+
+
+
+ {status}
+
+
+
+ {title}
+ {description}
+
+
+
+ {actionLabel}
+
+
+
+ );
+}
+
+function HealthItem({ label, status, color }: { label: string; status: string; color: string }) {
+ return (
+
+ {label}
+
+
+ {status}
+
+
);
}
diff --git a/src/pages/sq/dashboard/wa-hook/wa_hook_home.tsx b/src/pages/sq/dashboard/wa-hook/wa_hook_home.tsx
index 0c48957..72a9500 100644
--- a/src/pages/sq/dashboard/wa-hook/wa_hook_home.tsx
+++ b/src/pages/sq/dashboard/wa-hook/wa_hook_home.tsx
@@ -1,32 +1,42 @@
import apiFetch from "@/lib/apiFetch";
import {
+ ActionIcon,
+ Avatar,
+ Badge,
+ Box,
Button,
- Card,
- Container,
+ Center,
+ Divider,
Group,
Pagination,
+ Paper,
+ rem,
Skeleton,
Stack,
Text,
+ ThemeIcon,
Title,
- Badge,
- ScrollArea,
- Tooltip,
- Divider,
+ Tooltip
} from "@mantine/core";
import { useLocalStorage, useShallowEffect } from "@mantine/hooks";
-import { showNotification } from "@mantine/notifications";
+import { modals } from "@mantine/modals";
+import { notifications } from "@mantine/notifications";
import {
- IconRefresh,
- IconMessageCircle,
- IconUser,
- IconCalendar,
+ IconActivity,
IconHash,
- IconCode,
+ IconMessageCircle,
+ IconPhone,
+ IconRefresh,
+ IconRobot,
+ IconTrash,
+ IconUser
} from "@tabler/icons-react";
import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
import useSWR from "swr";
+dayjs.extend(relativeTime);
+
export default function WaHookHome() {
const [page, setPage] = useLocalStorage({ key: "wa-hook-page", defaultValue: 1 });
const { data, error, isLoading, mutate } = useSWR(
@@ -43,195 +53,202 @@ export default function WaHookHome() {
mutate();
}, [page]);
- async function handleReset() {
- await apiFetch["wa-hook"].reset.post();
- mutate();
- showNotification({
- title: "Reset Completed",
- message: "All WhatsApp Hook data has been cleared.",
- color: "teal",
+ const handleReset = () => {
+ modals.openConfirmModal({
+ title: "Clear Activity Logs",
+ centered: true,
+ children: (
+
+ Are you sure you want to clear all WhatsApp activity logs? This action cannot be undone.
+
+ ),
+ labels: { confirm: "Clear All", cancel: "Cancel" },
+ confirmProps: { color: "red" },
+ onConfirm: async () => {
+ await apiFetch["wa-hook"].reset.post();
+ mutate();
+ notifications.show({
+ title: "Logs Cleared",
+ message: "All activity data has been successfully removed.",
+ color: "teal",
+ });
+ },
});
- }
+ };
+
+ if (isLoading)
+ return (
+
+
+
+
+ );
- if (isLoading) return ;
if (error)
return (
-
-
- Failed to load webhook data.
-
-
+
+
+ Failed to load activity data
+ mutate()}>Retry
+
+
);
return (
-
-
-
-
-
- WhatsApp Hook Monitor
-
-
- Real-time webhook activity and message tracking
+
+
+
+
+
+
+
+
+
+ Hook Activity Monitor
+
+
+
+ Track real-time WhatsApp messages and AI flow responses.
-
+
+
+ mutate()}>
+
+
+
}
onClick={handleReset}
- leftSection={ }
- variant="gradient"
- gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 45 }}
- radius="xl"
- size="md"
>
- Reset Data
+ Clear Logs
-
+
+
+
-
+
+ {data?.data?.list?.length ? (
+ data.data.list.map((item) => {
+ let parsed: any = {};
+ try {
+ parsed = typeof item.data === 'string' ? JSON.parse(item.data) : item.data;
+ } catch (e) {
+ parsed = {};
+ }
- {/* {JSON.stringify(data?.data?.list, null, 2)} */}
+ return (
+
+
+
+
+
+
+
+
+
+ {parsed.name || "Unknown Sender"}
+
+
+
+
+ {parsed.number || "No number"}
+
+
+
+
+
+
+ {dayjs(item.createdAt).format("MMM DD, HH:mm:ss")}
+
+
+ {dayjs(item.createdAt).fromNow()}
+
+
+
-
- {data?.data?.list?.length ? (
- data.data.list.map((item) => {
- const parsed = JSON.parse((item.data as any) || "{}");
-
- return (
-
-
- {/* Nama & Nomor Pengirim */}
-
-
-
- {parsed.name || "Unknown Sender"} ({parsed.number || "No Number"})
+
+
+
+
+ Inbound Message
+ {parsed.question || "(Empty message)"}
+
- {/* Pertanyaan / Pesan */}
-
-
-
- {parsed.question || "(No question)"}
-
-
-
- {/* ID Record */}
-
-
-
- {item.id}
-
-
-
- {/* Timestamp */}
-
-
-
- {dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss")}
-
-
-
- {/* Flow ID */}
- {parsed.flowId && (
-
-
-
- Flow: {parsed.flowId}
-
+ {parsed.answer && (
+
+
+
+
+
+ AI Response
+
+
+ {parsed.flowId && (
+
+ Flow: {parsed.flowId}
+
+ )}
- )}
+ {parsed.answer}
+
+ )}
- {/* Jawaban */}
- {parsed.answer && (
-
-
-
- Bot Answer
-
-
- {parsed.answer}
-
-
-
- )}
-
-
- );
- })
- ) : (
-
-
- No webhook activity detected yet.
+
+
+
+
+ ID: {item.id}
+
+
+
+
+
+ );
+ })
+ ) : (
+
+
+
+
+ No hook activity detected yet.
-
- )}
-
-
-
-
- {
- setPage(value);
- mutate();
- }}
- radius="xl"
- withEdges
- color="teal"
- size="md"
- styles={{
- control: {
- backgroundColor: "#2D2D2D",
- border: "1px solid rgba(0,255,200,0.15)",
- color: "#EAEAEA",
- },
- }}
- />
-
+
+
+ )}
-
+
+
+ {
+ setPage(value);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ color="teal"
+ size="sm"
+ radius="md"
+ withEdges
+ />
+
+
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/sq/dashboard/wa-hook/wa_hook_layout.tsx b/src/pages/sq/dashboard/wa-hook/wa_hook_layout.tsx
index de154b6..0842749 100644
--- a/src/pages/sq/dashboard/wa-hook/wa_hook_layout.tsx
+++ b/src/pages/sq/dashboard/wa-hook/wa_hook_layout.tsx
@@ -7,7 +7,7 @@ export default function WaHookLayout() {
return (
-
Flow WA Hook
-
+ */}
diff --git a/src/pages/sq/dashboard/wajs/wajs_home.tsx b/src/pages/sq/dashboard/wajs/wajs_home.tsx
index ef25eae..92f8f8f 100644
--- a/src/pages/sq/dashboard/wajs/wajs_home.tsx
+++ b/src/pages/sq/dashboard/wajs/wajs_home.tsx
@@ -1,3 +1,326 @@
+import clientRoutes from "@/clientRoutes";
+import apiFetch from "@/lib/apiFetch";
+import {
+ Badge,
+ Box,
+ Button,
+ Center,
+ Divider,
+ Grid,
+ Group,
+ Loader,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Text,
+ ThemeIcon,
+ Title,
+ TextInput,
+ Textarea,
+ rem,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
+import {
+ IconArrowRight,
+ IconBolt,
+ IconChevronRight,
+ IconClock,
+ IconKey,
+ IconMessage2,
+ IconWebhook,
+ IconSend,
+ IconPhone,
+} from "@tabler/icons-react";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import useSWR from "swr";
+
+dayjs.extend(relativeTime);
+
export default function WajsHome() {
- return Wajs Home ;
+ const [sending, setSending] = useState(false);
+ const { data: hookData, isLoading: hooksLoading } = useSWR(
+ "/wa-hook/list?limit=5",
+ () => apiFetch["wa-hook"].list.get({ query: { limit: 5 } })
+ );
+
+ const form = useForm({
+ initialValues: {
+ number: "",
+ text: "",
+ },
+ validate: {
+ number: (value) => (value.length < 5 ? "Invalid phone number" : null),
+ text: (value) => (value.length < 1 ? "Message cannot be empty" : null),
+ },
+ });
+
+ const handleSendMessage = async (values: typeof form.values) => {
+ setSending(true);
+ try {
+ const { data, error } = await apiFetch.api.wa["send-text"].post(values);
+ if (error) {
+ notifications.show({
+ title: "Failed to send",
+ message: (error as any)?.value?.message || "Something went wrong",
+ color: "red",
+ });
+ } else {
+ notifications.show({
+ title: "Message Sent",
+ message: `Successfully sent to ${values.number}`,
+ color: "teal",
+ icon: ,
+ });
+ form.reset();
+ }
+ } catch (err) {
+ notifications.show({
+ title: "Error",
+ message: "Network error or server unreachable",
+ color: "red",
+ });
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const stats = [
+ {
+ title: "Incoming Messages",
+ value: hookData?.data && 'count' in hookData.data ? (hookData.data.count as number) * 10 : "...",
+ icon: IconMessage2,
+ color: "blue",
+ },
+ {
+ title: "Active Webhooks",
+ value: "8",
+ icon: IconWebhook,
+ color: "teal",
+ },
+ {
+ title: "API Keys",
+ value: "3",
+ icon: IconKey,
+ color: "violet",
+ },
+ ];
+
+ const recentHooks = (hookData?.data && 'list' in hookData.data ? hookData.data.list : []) as any[];
+
+ return (
+
+
+
+ Dashboard Overview
+
+
+ Monitor your WhatsApp integration activity and system health.
+
+
+
+
+ {stats.map((stat) => (
+
+
+
+
+ {stat.title}
+
+
+ {stat.value}
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ Recent Hook Activity
+
+ }
+ >
+ View All
+
+
+
+
+
+
+ {hooksLoading ? (
+
+
+
+ ) : recentHooks.length === 0 ? (
+
+
+ No recent activity found.
+
+
+ ) : (
+ recentHooks.map((hook: any) => (
+ ({
+ borderRadius: theme.radius.sm,
+ transition: "background-color 100ms ease",
+ "&:hover": {
+ backgroundColor: "var(--mantine-color-default-hover)",
+ },
+ })}
+ >
+
+
+
+ {hook.data?.type || "MESSAGE"}
+
+
+
+ {hook.data?.text || "Media message received"}
+
+
+ From: {hook.data?.number || "Unknown"}
+
+
+
+
+ {dayjs(hook.createdAt).fromNow()}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ Send Test Message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quick Actions
+
+
+ Commonly used tools and management options.
+
+
+
+ }
+ >
+ Manage Webhooks
+
+ }
+ >
+ API Keys
+
+ }
+ >
+ Activity Logs
+
+
+
+
+
+
+
+
+
+ System Online
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/src/pages/sq/dashboard/wajs/wajs_layout.tsx b/src/pages/sq/dashboard/wajs/wajs_layout.tsx
index 6fbed7a..95a5609 100644
--- a/src/pages/sq/dashboard/wajs/wajs_layout.tsx
+++ b/src/pages/sq/dashboard/wajs/wajs_layout.tsx
@@ -1,10 +1,32 @@
import { Navigate, Outlet } from "react-router-dom";
import useSWR from "swr";
import apiFetch from "@/lib/apiFetch";
-import { Badge, Button, Chip, Group, Pill, Stack, Text } from "@mantine/core";
+import {
+ Badge,
+ Button,
+ Group,
+ Stack,
+ Text,
+ Paper,
+ Title,
+ Divider,
+ ActionIcon,
+ Tooltip,
+ Box,
+ Loader,
+} from "@mantine/core";
import { useState } from "react";
import clientRoutes from "@/clientRoutes";
import { modals } from "@mantine/modals";
+import {
+ IconPlayerPlay,
+ IconRefresh,
+ IconScan,
+ IconCircleCheck,
+ IconAlertCircle,
+ IconSettings,
+ IconDeviceMobile,
+} from "@tabler/icons-react";
export default function WajsLayout() {
const [loading, setLoading] = useState(false);
@@ -13,59 +35,145 @@ export default function WajsLayout() {
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
- onSuccess(data, key, config) {
- console.log(data.data?.state);
- },
});
- if (!data?.data?.state) return ;
- if (data.data?.state.qr)
+ const state = data?.data?.state;
+
+ if (!state) return ;
+
+ if (state.qr) {
return ;
+ }
+
+ const handleStart = async () => {
+ setLoading(true);
+ await apiFetch.api.wa.start.post();
+ setLoading(false);
+ };
+
+ const handleRestart = async () => {
+ setLoading(true);
+ await apiFetch.api.wa.restart.post();
+ setLoading(false);
+ };
+
+ const handleRescan = () => {
+ modals.openConfirmModal({
+ title: "Rescan QR Code",
+ centered: true,
+ children: (
+
+ Are you sure you want to rescan the QR code? This will disconnect the
+ current session and require a new login.
+
+ ),
+ labels: { confirm: "Rescan Now", cancel: "Cancel" },
+ confirmProps: { color: "red" },
+ onConfirm: async () => {
+ setLoading(true);
+ await apiFetch.api.wa["force-start"].post();
+ setLoading(false);
+ },
+ });
+ };
+
return (
-
-
- {
- setLoading(true);
- apiFetch.api.wa.start.post();
- }}
- >
- {data.data?.state.ready ? "Ready" : "Start"}
-
- {
- setLoading(true);
- apiFetch.api.wa.restart.post();
- }}
- >
- Reconnect
-
- {
- setLoading(true);
- modals.openConfirmModal({
- title: "Rescan QR",
- children: Are you sure you want to rescan QR? ,
- confirmProps: { color: "red" },
- labels: {
- cancel: "Cancel",
- confirm: "Rescan QR",
- },
- onCancel: () => setLoading(false),
- onConfirm: () => {
- apiFetch.api.wa.restart.post();
- setLoading(false);
- },
- });
- }}
- >
- Rescan QR
-
-
-
+
+
+
+
+
+
+
+
+ WhatsApp Connection
+
+ {state.ready ? (
+ }
+ >
+ Connected & Ready
+
+ ) : state.isStarting ? (
+ }
+ >
+ Connecting...
+
+ ) : (
+ }
+ >
+ Disconnected
+
+ )}
+
+
+
+
+
+ {!state.ready && (
+ }
+ loading={loading || state.isStarting}
+ onClick={handleStart}
+ color="teal"
+ >
+ Start Service
+
+ )}
+
+
+ }
+ onClick={handleRestart}
+ loading={loading}
+ disabled={!state.ready && !state.isStarting}
+ >
+ Reconnect
+
+
+
+
+ }
+ onClick={handleRescan}
+ loading={loading}
+ >
+ Rescan QR
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/pages/sq/dashboard/webhook/webhook_create.tsx b/src/pages/sq/dashboard/webhook/webhook_create.tsx
index 833cbc3..84564e6 100644
--- a/src/pages/sq/dashboard/webhook/webhook_create.tsx
+++ b/src/pages/sq/dashboard/webhook/webhook_create.tsx
@@ -1,310 +1,201 @@
-import { useState, useMemo } from "react";
+import clientRoutes from "@/clientRoutes";
+import apiFetch from "@/lib/apiFetch";
import {
Button,
- Card,
Checkbox,
+ Divider,
Group,
+ Select,
Stack,
Text,
TextInput,
- Select,
- Divider,
Title,
+ Paper,
+ Box,
+ SimpleGrid,
+ rem,
+ ThemeIcon,
} from "@mantine/core";
+import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
-import { IconCode, IconCheck, IconX } from "@tabler/icons-react";
-import Editor from "@monaco-editor/react";
-import apiFetch from "@/lib/apiFetch";
+import {
+ IconCheck,
+ IconX,
+ IconArrowLeft,
+ IconLink,
+ IconKey,
+ IconWebhook,
+} from "@tabler/icons-react";
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
-import clientRoutes from "@/clientRoutes";
-
-// data.from': data.from,
-// data.fromNumber': data.fromNumber,
-// data.fromMe': data.fromMe,
-// data.body': data.body,
-// data.hasMedia': data.hasMedia,
-// data.type': data.type,
-// data.to': data.to,
-// data.deviceType': data.deviceType,
-// data.notifyName': data.notifyName,
-// data.media.data': data.media?.data ?? null,
-// data.media.mimetype': data.media?.mimetype ?? null,
-// data.media.filename': data.media?.filename ?? null,
-// data.media.filesize': data.media?.filesize ?? 0,
-
-const templateData = `
-Available variables:
-{{data.from}}, {{data.fromNumber}}, {{data.fromMe}}, {{data.body}}, {{data.hasMedia}}, {{data.type}}, {{data.to}}, {{data.deviceType}}, {{data.notifyName}}, {{data.media.data}}, {{data.media.mimetype}}, {{data.media.filename}}, {{data.media.filesize}}
-`;
export default function WebhookCreate() {
const navigate = useNavigate();
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [url, setUrl] = useState("");
- const [method, setMethod] = useState("POST");
- const [headers, setHeaders] = useState(
- JSON.stringify({ "Content-Type": "application/json" }, null, 2),
- );
- const [payload, setPayload] = useState("{}");
- const [apiToken, setApiToken] = useState("");
- const [enabled, setEnabled] = useState(true);
- const [replay, setReplay] = useState(false);
- const [replayKey, setReplayKey] = useState("");
+ const [loading, setLoading] = useState(false);
- const safeJson = (value: string) => {
+ const form = useForm({
+ initialValues: {
+ name: "",
+ description: "",
+ url: "",
+ method: "POST",
+ apiToken: "",
+ headers: JSON.stringify({ "Content-Type": "application/json" }, null, 2),
+ payload: "{}",
+ enabled: true,
+ replay: false,
+ replayKey: "",
+ },
+ validate: {
+ name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
+ url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
+ },
+ });
+
+ const handleSubmit = async (values: typeof form.values) => {
+ setLoading(true);
try {
- return JSON.stringify(JSON.parse(value || "{}"), null, 2);
- } catch {
- return value || "{}";
+ const { data } = await apiFetch.api.webhook.create.post(values);
+
+ if (data?.success) {
+ notifications.show({
+ title: "Success",
+ message: "Webhook created successfully",
+ color: "teal",
+ icon: ,
+ });
+ navigate(clientRoutes["/sq/dashboard/webhook"]);
+ } else {
+ throw new Error(data?.message || "Failed to create webhook");
+ }
+ } catch (err: any) {
+ notifications.show({
+ title: "Creation Failed",
+ message: err.message,
+ color: "red",
+ icon: ,
+ });
+ } finally {
+ setLoading(false);
}
};
- const previewCode = useMemo(() => {
- let headerObj: Record = {};
- try {
- headerObj = JSON.parse(headers);
- } catch {}
- if (apiToken) headerObj["Authorization"] = `Bearer ${apiToken}`;
- const prettyHeaders = safeJson(JSON.stringify(headerObj));
- const prettyPayload = safeJson(payload);
- const includeBody = ["POST", "PUT", "PATCH"].includes(method.toUpperCase());
-
- return `fetch("${url || "https://example.com/webhook"}", {
- method: "${method}",
- headers: ${prettyHeaders},${includeBody ? `\n body: ${prettyPayload},` : ""}
-})
- .then(res => res.json())
- .then(console.log)
- .catch(console.error);`;
- }, [url, method, headers, payload, apiToken]);
-
- async function onSubmit() {
- const { data } = await apiFetch.api.webhook.create.post({
- name,
- description,
- apiToken,
- url,
- method,
- headers,
- payload,
- enabled,
- replay,
- replayKey,
- });
-
- if (data?.success) {
- notifications.show({
- title: "Webhook Created",
- message: data.message,
- color: "teal",
- icon: ,
- });
-
- navigate(clientRoutes["/sq/dashboard/webhook"]);
- } else {
- notifications.show({
- title: "Creation Failed",
- message: data?.message || "Unable to create webhook",
- color: "red",
- icon: ,
- });
- }
- }
-
return (
-
-
-
-
- Create Webhook
-
-
-
-
-
-
- setName(e.target.value)}
- />
-
- setDescription(e.target.value)}
- />
-
- setUrl(e.target.value)}
- />
-
- setMethod(v || "POST")}
- data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
- value: v,
- label: v,
- }))}
- />
-
- {
- setApiToken(e.target.value);
- try {
- const current = JSON.parse(headers);
- if (!e.target.value) {
- delete current["Authorization"];
- } else {
- current["Authorization"] = `Bearer ${e.target.value}`;
- }
- setHeaders(JSON.stringify(current, null, 2));
- } catch {}
- }}
- />
-
- {/*
-
- Headers (JSON)
-
- setHeaders(val ?? "{}")}
- options={{
- minimap: { enabled: false },
- fontSize: 13,
- scrollBeyondLastLine: false,
- lineNumbers: "off",
- automaticLayout: true,
- }}
- />
- */}
-
- {/*
-
- Payload
-
-
- {templateData}
-
- setPayload(val ?? "{}")}
- options={{
- minimap: { enabled: false },
- fontSize: 13,
- scrollBeyondLastLine: false,
- automaticLayout: true,
- }}
- />
- */}
-
- setEnabled(e.currentTarget.checked)}
- color="teal"
- styles={{
- label: { color: "#EAEAEA" },
- }}
- />
- {/* setReplay(e.currentTarget.checked)}
- color="teal"
- styles={{
- label: { color: "#EAEAEA" },
- }}
- /> */}
- {/* setReplayKey(e.target.value)}
- /> */}
-
- {/*
-
-
- Request Preview
-
-
+
+
+
+
+
+ }
+ onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
+ p={0}
+ >
+ Back to List
+
+
+
+
+
+
+
+ Create Webhook
+
+
- */}
-
-
- navigate(clientRoutes["/sq/dashboard/webhook"])}
- variant="subtle"
- c="#EAEAEA"
- styles={{
- root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
- }}
- >
- Cancel
-
-
- Save Webhook
-
-
+
+
+
+
+
+
+
+
+
+
+
+ }
+ {...form.getInputProps("url")}
+ />
+
+
+
+ }
+ {...form.getInputProps("apiToken")}
+ />
+
+
+
+
+
+ Enable Webhook
+
+
+ Activate this webhook to start receiving events immediately.
+
+
+
+
+
+
+
+ navigate(clientRoutes["/sq/dashboard/webhook"])}
+ disabled={loading}
+ >
+ Cancel
+
+ }
+ >
+ Create Webhook
+
+
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/src/pages/sq/dashboard/webhook/webhook_edit.tsx b/src/pages/sq/dashboard/webhook/webhook_edit.tsx
index 01bb996..64a8575 100644
--- a/src/pages/sq/dashboard/webhook/webhook_edit.tsx
+++ b/src/pages/sq/dashboard/webhook/webhook_edit.tsx
@@ -9,330 +9,273 @@ import {
Stack,
Text,
TextInput,
- Title
+ Title,
+ Paper,
+ ActionIcon,
+ Tooltip,
+ Container,
+ Box,
+ Loader,
+ Center,
+ SimpleGrid,
+ rem,
} from "@mantine/core";
-import { useShallowEffect } from "@mantine/hooks";
+import { useForm } from "@mantine/form";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
-import { IconCheck, IconCode, IconX } from "@tabler/icons-react";
+import {
+ IconCheck,
+ IconCode,
+ IconX,
+ IconTrash,
+ IconArrowLeft,
+ IconLink,
+ IconKey,
+ IconInfoCircle,
+} from "@tabler/icons-react";
import type { WebHook } from "generated/prisma";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import useSWR from "swr";
export default function WebhookEdit() {
const [searchParams] = useSearchParams();
const id = searchParams.get("id");
+ const navigate = useNavigate();
+
const { data, error, isLoading, mutate } = useSWR(
- "/",
- () =>
- apiFetch.api.webhook
- .find({
- id: id!,
- })
- .get(),
- { dedupingInterval: 3000 },
+ id ? `/webhook/${id}` : null,
+ () => apiFetch.api.webhook.find({ id: id! }).get(),
+ { dedupingInterval: 3000 }
);
- const navigate = useNavigate();
- useShallowEffect(() => {
- mutate();
- }, [data]);
+ const handleDelete = () => {
+ modals.openConfirmModal({
+ title: "Remove Webhook",
+ centered: true,
+ children: (
+
+ Are you sure you want to remove this webhook? This action cannot be undone.
+
+ ),
+ labels: { confirm: "Delete Webhook", cancel: "Cancel" },
+ confirmProps: { color: "red" },
+ onConfirm: async () => {
+ try {
+ await apiFetch.api.webhook.remove({ id: id! }).delete();
+ notifications.show({
+ title: "Deleted",
+ message: "Webhook has been removed",
+ color: "red",
+ });
+ navigate(clientRoutes["/sq/dashboard/webhook"]);
+ } catch (err) {
+ notifications.show({
+ title: "Error",
+ message: "Failed to delete webhook",
+ color: "red",
+ });
+ }
+ },
+ });
+ };
- if (isLoading) return Loading...
;
- if (error) return Error: {error}
;
- if (!data?.data?.webhook) return No data
;
+ if (isLoading)
+ return (
+
+
+
+ );
- return (
-
-
- Edit Webhook
- {
- modals.openConfirmModal({
- title: "Remove Webhook",
- children: (
- Are you sure you want to remove this webhook?
- ),
- confirmProps: { color: "red" },
- labels: {
- cancel: "Cancel",
- confirm: "Remove",
- },
- onConfirm: () => {
- apiFetch.api.webhook
- .remove({
- id: id!,
- })
- .delete();
- navigate(clientRoutes["/sq/dashboard/webhook"]);
- },
- onCancel: () => {
- navigate(
- clientRoutes["/sq/dashboard/webhook/webhook-edit"] +
- "?id=" +
- id,
- );
- },
- });
- }}
- >
- Remove
-
-
-
-
- );
-}
-
-function EditView({ webhook }: { webhook: Partial | null }) {
- const navigate = useNavigate();
- const [name, setName] = useState(webhook?.name || "");
- const [description, setDescription] = useState(webhook?.description || "");
- const [url, setUrl] = useState(webhook?.url || "");
- const [method, setMethod] = useState(webhook?.method || "POST");
- const [headers, setHeaders] = useState(webhook?.headers || "{}");
-
- const [apiToken, setApiToken] = useState(webhook?.apiToken || "");
- const [enabled, setEnabled] = useState(webhook?.enabled );
-
-
- async function onSubmit() {
- if (!webhook?.id) {
- return notifications.show({
- title: "Webhook ID Not Found",
- message: "Unable to update webhook",
- color: "red",
- icon: ,
- });
- }
- const { data } = await apiFetch.api.webhook
- .update({
- id: webhook?.id,
- })
- .put({
- name,
- description,
- apiToken,
- url,
- method,
- headers,
- enabled: enabled || false,
- });
-
- if (data?.success) {
- notifications.show({
- title: "Webhook Created",
- message: data.message,
- color: "teal",
- icon: ,
- });
- navigate(clientRoutes["/sq/dashboard/webhook"]);
- } else {
- notifications.show({
- title: "Creation Failed",
- message: data?.message || "Unable to create webhook",
- color: "red",
- icon: ,
- });
- }
- }
-
- return (
-
-
-
-
- Edit Webhook
-
-
-
-
-
-
- setName(e.target.value)}
- />
-
- setDescription(e.target.value)}
- />
-
- setUrl(e.target.value)}
- />
-
- setMethod(v || "POST")}
- data={["POST", "GET", "PUT", "PATCH", "DELETE"].map((v) => ({
- value: v,
- label: v,
- }))}
- />
-
- {
- setApiToken(e.target.value);
- try {
- const current = JSON.parse(headers);
- if (!e.target.value) {
- delete current["Authorization"];
- } else {
- current["Authorization"] = `Bearer ${e.target.value}`;
- }
- setHeaders(JSON.stringify(current, null, 2));
- } catch {}
- }}
- />
-
- {/*
-
- Headers (JSON)
-
- setHeaders(val ?? "{}")}
- options={{
- minimap: { enabled: false },
- fontSize: 13,
- scrollBeyondLastLine: false,
- lineNumbers: "off",
- automaticLayout: true,
- }}
- />
- */}
-
- {/*
-
- Payload
-
-
- {templateData}
-
- setPayload(val ?? "{}")}
- options={{
- minimap: { enabled: false },
- fontSize: 13,
- scrollBeyondLastLine: false,
- automaticLayout: true,
- }}
- />
- */}
- setEnabled(e.target.checked as any)}
- color="teal"
- styles={{
- label: { color: "#EAEAEA" },
- }}
- />
-
- {/* setReplay(e.target.checked as any)}
- color="teal"
- styles={{
- label: { color: "#EAEAEA" },
- }}
- /> */}
-
- {/* setReplayKey(e.target.value)}
- /> */}
-
- {/*
-
-
- Request Preview
-
-
+ if (error || !data?.data?.webhook)
+ return (
+
+
+
+
+ Webhook not found or error loading data
+ navigate(-1)}>
+ Go Back
+
- */}
+
+
+ );
-
+ return (
+
+
+
+
+
+ }
+ onClick={() => navigate(clientRoutes["/sq/dashboard/webhook"])}
+ p={0}
+ >
+ Back to List
+
+
+
+ Edit Webhook
+
+
navigate(clientRoutes["/sq/dashboard/webhook"])}
- variant="subtle"
- c="#EAEAEA"
- styles={{
- root: { backgroundColor: "#2D2D2D", borderColor: "#00FFC8" },
- }}
+ variant="light"
+ color="red"
+ leftSection={ }
+ onClick={handleDelete}
>
- Cancel
-
-
- Save Webhook
+ Remove Webhook
-
+
+
+
+ mutate()} />
);
}
+
+function EditView({ webhook, onUpdated }: { webhook: Partial; onUpdated: () => void }) {
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(false);
+
+ const form = useForm({
+ initialValues: {
+ name: webhook.name || "",
+ description: webhook.description || "",
+ url: webhook.url || "",
+ method: webhook.method || "POST",
+ apiToken: webhook.apiToken || "",
+ enabled: webhook.enabled ?? true,
+ },
+ validate: {
+ name: (value) => (value.trim().length < 3 ? "Name must be at least 3 characters" : null),
+ url: (value) => (/^https?:\/\/.+/.test(value) ? null : "Invalid webhook URL"),
+ },
+ });
+
+ const handleSubmit = async (values: typeof form.values) => {
+ setLoading(true);
+ try {
+ const { data } = await apiFetch.api.webhook
+ .update({ id: webhook.id! })
+ .put({
+ ...values,
+ headers: webhook.headers || "{}", // Maintain headers if any
+ });
+
+ if (data?.success) {
+ notifications.show({
+ title: "Success",
+ message: "Webhook updated successfully",
+ color: "teal",
+ icon: ,
+ });
+ onUpdated();
+ navigate(clientRoutes["/sq/dashboard/webhook"]);
+ } else {
+ throw new Error(data?.message || "Failed to update");
+ }
+ } catch (err: any) {
+ notifications.show({
+ title: "Update Failed",
+ message: err.message,
+ color: "red",
+ icon: ,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ {...form.getInputProps("url")}
+ />
+
+
+
+ }
+ {...form.getInputProps("apiToken")}
+ />
+
+
+
+
+
+ Enable Webhook
+
+
+ When disabled, the system will stop sending events to this endpoint.
+
+
+
+
+
+
+
+ navigate(clientRoutes["/sq/dashboard/webhook"])}
+ disabled={loading}
+ >
+ Cancel
+
+ }
+ >
+ Update Webhook
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/sq/dashboard/webhook/webhook_home.tsx b/src/pages/sq/dashboard/webhook/webhook_home.tsx
index 7af8043..3dd56df 100644
--- a/src/pages/sq/dashboard/webhook/webhook_home.tsx
+++ b/src/pages/sq/dashboard/webhook/webhook_home.tsx
@@ -12,6 +12,11 @@ import {
Stack,
Divider,
Button,
+ Box,
+ SimpleGrid,
+ Paper,
+ rem,
+ ThemeIcon,
} from "@mantine/core";
import {
IconLink,
@@ -23,6 +28,9 @@ import {
IconEdit,
IconPlus,
IconMessageReply,
+ IconWebhook,
+ IconWorld,
+ IconExternalLink,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import useSWR from "swr";
@@ -34,7 +42,7 @@ import { useShallowEffect } from "@mantine/hooks";
export default function WebhookHome() {
const navigate = useNavigate();
const { data, error, isLoading, mutate } = useSWR(
- "/",
+ "/webhook-list",
apiFetch.api.webhook.list.get,
{ dedupingInterval: 3000, refreshInterval: 3000 },
);
@@ -45,216 +53,209 @@ export default function WebhookHome() {
mutate();
}, []);
- function ButtonCreate() {
- return (
-
- }
- variant="gradient"
- gradient={{ from: "#00FFC8", to: "#00FFFF", deg: 135 }}
- style={{
- color: "#191919",
- fontWeight: 600,
- // boxShadow: "0 0 12px rgba(0,255,200,0.25)",
- transition: "transform 0.2s ease, box-shadow 0.2s ease",
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.transform = "translateY(-2px)";
- e.currentTarget.style.boxShadow = "0 0 20px rgba(0,255,200,0.4)";
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.transform = "translateY(0)";
- e.currentTarget.style.boxShadow = "0 0 12px rgba(0,255,200,0.25)";
- }}
- onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
- >
- Create Webhook
-
-
- );
- }
+ const handleRefresh = () => {
+ mutate();
+ notifications.show({
+ title: "Refreshing Data",
+ message: "Webhook list has been updated.",
+ color: "teal",
+ });
+ };
if (isLoading)
return (
-
-
+
+
+
+ Loading your webhooks...
+
);
if (error)
return (
-
-
- Failed to load webhooks. Please try again.
-
-
- );
-
- if (!webhooks.length)
- return (
-
-
-
- No webhooks found
-
-
- Connect your first webhook to start managing events
-
-
-
+
+
+
+
+ Failed to load webhooks
+ mutate()}>
+ Try Again
+
+
+
);
return (
-
-
- Webhook Manager
-
-
-
-
-
- {
- mutate();
- notifications.show({
- title: "Refreshing data",
- message: "Webhook list is being updated...",
- color: "teal",
- });
- }}
- >
-
-
-
-
-
-
- {webhooks.map((webhook) => (
-
-
-
-
-
- {webhook.name}
-
-
-
-
- navigate(
- `${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
- )
- }
- >
-
-
+
+
+
+
+
+
+
+ Webhook Manager
+
+
+ Configure external endpoints to receive real-time event notifications.
+
+
+
+
+
+
+
+
+ }
+ color="teal"
+ onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
+ >
+ Add Webhook
+
+
+
+
+
-
-
-
- ) : (
-
- )
- }
- >
- {webhook.enabled ? "Active" : "Disabled"}
-
- }
- >
- {webhook.replay ? "Replay" : "Not Replay"}
-
-
-
- {webhook.description}
+ {!webhooks.length ? (
+
+
+
+
+
+
+ No Webhooks Configured
+
+ Start by adding your first endpoint to receive WhatsApp events.
-
-
+
+ }
+ onClick={() => navigate("/sq/dashboard/webhook/webhook-create")}
+ >
+ Create Your First Webhook
+
+
+
+ ) : (
+
+ {webhooks.map((webhook) => (
+
+
+
+
+ {webhook.name || "Unnamed Webhook"}
+
+
+
+ {webhook.enabled ? "Active" : "Disabled"}
+
+ {webhook.replay && (
+ }>
+ Replay
+
+ )}
+
+
-
-
-
-
- Method:
-
-
- {webhook.method}
-
+
+
+ navigate(
+ `${clientRoutes["/sq/dashboard/webhook/webhook-edit"]}?id=${webhook.id}`,
+ )
+ }
+ >
+
+
+
-
-
-
- URL:
-
-
- {webhook.url}
-
-
+
+ {webhook.description || "No description provided for this webhook."}
+
-
-
-
- API Token:
-
-
- {webhook.apiToken?.slice(0, 6) + "..." || "โ"}
-
-
+
- {/*
-
- Headers:
-
-
- {Object.keys(webhook.headers || {}).length
- ? webhook.headers
- : "No headers configured"}
-
- */}
-
- {/*
-
- Payload:
-
-
- {Object.keys(webhook.payload || {}).length
- ? webhook.payload
- : "Empty payload"}
-
- */}
-
-
- ))}
-
+
+
+
+
+
+
+ ))}
+
+ )}
);
}
+
+function DetailRow({ icon: Icon, label, value, color, isLink }: any) {
+ return (
+
+
+
+
+
+
+ {label}
+
+
+ {value}
+ {isLink && }
+
+
+
+ );
+}
diff --git a/src/pages/sq/dashboard/webhook/webhook_layout.tsx b/src/pages/sq/dashboard/webhook/webhook_layout.tsx
index b2bcbd1..262e8cf 100644
--- a/src/pages/sq/dashboard/webhook/webhook_layout.tsx
+++ b/src/pages/sq/dashboard/webhook/webhook_layout.tsx
@@ -1,18 +1,5 @@
-import {
- Button,
- Group,
- Stack,
- Title,
- Tooltip,
- Divider,
- Container,
- Paper,
-} from "@mantine/core";
-import { IconPlus } from "@tabler/icons-react";
-import { useNavigate, Outlet } from "react-router-dom";
+import { Outlet } from "react-router-dom";
export default function WebhookLayout() {
- const navigate = useNavigate();
-
return ;
}
diff --git a/src/pages/wajs/qrcode.tsx b/src/pages/wajs/qrcode.tsx
index e842603..740dc3a 100644
--- a/src/pages/wajs/qrcode.tsx
+++ b/src/pages/wajs/qrcode.tsx
@@ -1,22 +1,140 @@
import apiFetch from "@/lib/apiFetch";
import { ReactQRCode } from "@lglab/react-qr-code";
-import { Card, Container, Group } from "@mantine/core";
+import {
+ Card,
+ Container,
+ Group,
+ Stack,
+ Title,
+ Text,
+ Paper,
+ Box,
+ Button,
+ ThemeIcon,
+ List,
+ Center,
+ Loader,
+ rem,
+} from "@mantine/core";
+import {
+ IconBrandWhatsapp,
+ IconDeviceMobile,
+ IconSettings,
+ IconQrcode,
+ IconArrowLeft,
+ IconCircleCheck,
+} from "@tabler/icons-react";
import useSWR from "swr";
+import { useNavigate, Navigate } from "react-router-dom";
+import clientRoutes from "@/clientRoutes";
+
export default function QrcodePage() {
- const { data } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
+ const navigate = useNavigate();
+ const { data, isLoading } = useSWR("/wa/qr", apiFetch.api.wa.qr.get, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
refreshInterval: 3000,
});
+
+ const { data: stateData } = useSWR("/wa/state", apiFetch.api.wa.state.get, {
+ refreshInterval: 3000,
+ });
+
+ // Redirect to dashboard if already connected
+ if (stateData?.data?.state?.ready) {
+ return ;
+ }
+
+ const qrValue = data?.data?.qr;
+
return (
-
- QrCode
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Link WhatsApp Device
+
+
+ Scan the QR code below to connect your WhatsApp account to the server.
+
+
+
+
+
+
+
+ {qrValue ? (
+
+ ) : (
+
+
+
+
+ Generating QR Code...
+
+
+
+ )}
+
+
+
+
+ How to connect:
+
+
+
+
+ }
+ >
+ Open WhatsApp on your phone
+
+ Tap Menu or Settings and select Linked Devices
+
+ Tap on Link a Device
+ Point your phone to this screen to capture the code
+
+
+
+
+
+
+ }
+ onClick={() => navigate(clientRoutes["/sq/dashboard/wajs/wajs-home"])}
+ >
+ Back to Dashboard
+
+
+
+
+
);
}
diff --git a/src/server/lib/wa/wa_service.ts b/src/server/lib/wa/wa_service.ts
index 53f0ec0..1b81d5e 100644
--- a/src/server/lib/wa/wa_service.ts
+++ b/src/server/lib/wa/wa_service.ts
@@ -8,239 +8,239 @@ import { prisma } from '../prisma';
type HookData =
- | { eventType: "qr"; qr: string }
- | { eventType: "start" }
- | { eventType: "ready" }
- | { eventType: "disconnected"; reason?: string }
- | { eventType: "reconnect" }
- | { eventType: "auth_failure"; msg: string }
- | { eventType: "message" } & Partial;
+ | { eventType: "qr"; qr: string }
+ | { eventType: "start" }
+ | { eventType: "ready" }
+ | { eventType: "disconnected"; reason?: string }
+ | { eventType: "reconnect" }
+ | { eventType: "auth_failure"; msg: string }
+ | { eventType: "message" } & Partial;
async function handleHook(data: HookData) {
- const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
- if (webHooks.length === 0) return;
- await Promise.allSettled(
- webHooks.map(async (hook) => {
- try {
- log(`๐ Mengirim webhook ke ${hook.name} ${hook.url}`);
+ const webHooks = await prisma.webHook.findMany({ where: { enabled: true } });
+ if (webHooks.length === 0) return;
+ await Promise.allSettled(
+ webHooks.map(async (hook) => {
+ try {
+ log(`๐ Mengirim webhook ke ${hook.name} ${hook.url}`);
- let res: Response = {} as Response;
- res = await fetch(hook.url, {
- method: hook.method,
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${hook.apiToken}`,
- },
- body: JSON.stringify(data),
- });
+ let res: Response = {} as Response;
+ res = await fetch(hook.url, {
+ method: hook.method,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${hook.apiToken}`,
+ },
+ body: JSON.stringify(data),
+ });
- const json = await res.text();
- logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
-
- } catch (err) {
- logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
- logger.error(`[ERROR] ${hook.name}: ${err}`);
- }
- })
- )
+ const json = await res.text();
+ logger.info(`[RESPONSE] ${hook.name} ${hook.url}: ${json}`);
+
+ } catch (err) {
+ logger.error(`[ERROR] ${hook.name} ${hook.url}:`);
+ logger.error(`[ERROR] ${hook.name}: ${err}`);
+ }
+ })
+ )
}
// === STATE GLOBAL ===
const state = {
- client: null as Client | null,
- reconnectTimeout: null as NodeJS.Timeout | null,
- isReconnecting: false,
- isStarting: false,
- qr: null as string | null,
- ready: false,
- async restart() {
- log('๐ Restart manual diminta...');
- await destroyClient();
- await startClient();
- },
+ client: null as Client | null,
+ reconnectTimeout: null as NodeJS.Timeout | null,
+ isReconnecting: false,
+ isStarting: false,
+ qr: null as string | null,
+ ready: false,
+ async restart() {
+ log('๐ Restart manual diminta...');
+ await destroyClient();
+ await startClient();
+ },
- async forceStart() {
- log('โ ๏ธ Force start โ menghapus cache dan session auth...');
- await destroyClient();
- await safeRm("./.wwebjs_auth");
- await safeRm("./wwebjs_cache");
- await startClient();
- },
- async stop() {
- log('๐ Stop manual diminta...');
- await destroyClient();
- },
+ async forceStart() {
+ log('โ ๏ธ Force start โ menghapus cache dan session auth...');
+ await destroyClient();
+ await safeRm("./.wwebjs_auth");
+ await safeRm("./wwebjs_cache");
+ await startClient();
+ },
+ async stop() {
+ log('๐ Stop manual diminta...');
+ await destroyClient();
+ },
};
// === UTIL ===
function log(...args: any[]) {
- console.log(`[${new Date().toISOString()}]`, ...args);
+ console.log(`[${new Date().toISOString()}]`, ...args);
}
async function safeRm(path: string) {
- try {
- await fs.rm(path, { recursive: true, force: true });
- } catch (err) {
- log(`โ ๏ธ Gagal hapus ${path}:`, err);
- }
+ try {
+ await fs.rm(path, { recursive: true, force: true });
+ } catch (err) {
+ log(`โ ๏ธ Gagal hapus ${path}:`, err);
+ }
}
// === CLEANUP CLIENT ===
async function destroyClient() {
- if (state.reconnectTimeout) {
- clearTimeout(state.reconnectTimeout);
- state.reconnectTimeout = null;
- }
- if (state.client) {
- try {
- state.client.removeAllListeners();
- await state.client.destroy();
- log('๐งน Client lama dihentikan & listener dibersihkan');
- } catch (err) {
- log('โ ๏ธ Gagal destroy client:', err);
- }
- state.client = null;
- state.ready = false;
+ if (state.reconnectTimeout) {
+ clearTimeout(state.reconnectTimeout);
+ state.reconnectTimeout = null;
+ }
+ if (state.client) {
+ try {
+ state.client.removeAllListeners();
+ await state.client.destroy();
+ log('๐งน Client lama dihentikan & listener dibersihkan');
+ } catch (err) {
+ log('โ ๏ธ Gagal destroy client:', err);
}
+ state.client = null;
+ state.ready = false;
+ }
}
let connectedAt: number | null = null;
// === PEMBUATAN CLIENT ===
async function startClient() {
- if (state.isStarting || state.isReconnecting) {
- log('โณ startClient diabaikan โ proses sedang berjalan...');
- return;
+ if (state.isStarting || state.isReconnecting) {
+ log('โณ startClient diabaikan โ proses sedang berjalan...');
+ return;
+ }
+ state.isStarting = true;
+
+ await destroyClient();
+
+ log('๐ Memulai WhatsApp client...');
+ handleHook({ eventType: "start" });
+
+ const client = new Client({
+ authStrategy: new LocalAuth({
+ dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
+ }),
+ puppeteer: {
+ headless: true,
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-gpu',
+ ],
+ },
+ webVersionCache: {
+ path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
+ type: 'local',
}
- state.isStarting = true;
+ });
- await destroyClient();
+ state.client = client;
- log('๐ Memulai WhatsApp client...');
- handleHook({ eventType: "start" });
+ // === EVENT LISTENERS ===
+ client.on('qr', (qr) => {
+ state.qr = qr;
+ qrcode.generate(qr, { small: true });
+ log('๐ QR code baru diterbitkan');
+ handleHook({ eventType: "qr", qr });
+ });
- const client = new Client({
- authStrategy: new LocalAuth({
- dataPath: process.env.WWEBJS_AUTH || path.join(process.cwd(), '.wwebjs_auth')
- }),
- puppeteer: {
- headless: true,
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--disable-dev-shm-usage',
- '--disable-gpu',
- ],
- },
- webVersionCache: {
- path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
- type: 'local',
- }
- });
-
- state.client = client;
-
- // === EVENT LISTENERS ===
- client.on('qr', (qr) => {
- state.qr = qr;
- qrcode.generate(qr, { small: true });
- log('๐ QR code baru diterbitkan');
- handleHook({ eventType: "qr", qr });
- });
-
- client.on('ready', () => {
- connectedAt = Date.now();
- log('โ
WhatsApp client siap digunakan!');
- state.ready = true;
- state.isReconnecting = false;
- state.isStarting = false;
- state.qr = null;
- handleHook({ eventType: "ready" });
- if (state.reconnectTimeout) {
- clearTimeout(state.reconnectTimeout);
- state.reconnectTimeout = null;
- }
- });
-
- client.on('auth_failure', (msg) => {
- log('โ Autentikasi gagal:', msg);
- state.ready = false;
- handleHook({ eventType: "auth_failure", msg });
- });
-
- client.on('disconnected', async (reason) => {
- log('โ ๏ธ Client terputus:', reason);
- state.ready = false;
- handleHook({ eventType: "disconnected", reason });
-
- if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
-
- state.isReconnecting = true;
- log('โณ Mencoba reconnect dalam 5 detik...');
-
- state.reconnectTimeout = setTimeout(async () => {
- handleHook({ eventType: "reconnect" });
- await startClient();
- }, 5000);
- });
-
-
- client.on('message', handleIncomingMessage);
-
- // === INISIALISASI ===
- try {
- await client.initialize();
- } catch (err) {
- log('โ Gagal inisialisasi client:', err);
- log('โณ Mencoba reconnect dalam 10 detik...');
- state.reconnectTimeout = setTimeout(async () => {
- state.isReconnecting = false;
- await startClient();
- }, 10000);
- handleHook({ eventType: "reconnect" });
- } finally {
- state.isStarting = false;
+ client.on('ready', () => {
+ connectedAt = Date.now();
+ log('โ
WhatsApp client siap digunakan!');
+ state.ready = true;
+ state.isReconnecting = false;
+ state.isStarting = false;
+ state.qr = null;
+ handleHook({ eventType: "ready" });
+ if (state.reconnectTimeout) {
+ clearTimeout(state.reconnectTimeout);
+ state.reconnectTimeout = null;
}
+ });
+
+ client.on('auth_failure', (msg) => {
+ log('โ Autentikasi gagal:', msg);
+ state.ready = false;
+ handleHook({ eventType: "auth_failure", msg });
+ });
+
+ client.on('disconnected', async (reason) => {
+ log('โ ๏ธ Client terputus:', reason);
+ state.ready = false;
+ handleHook({ eventType: "disconnected", reason });
+
+ if (state.reconnectTimeout) clearTimeout(state.reconnectTimeout);
+
+ state.isReconnecting = true;
+ log('โณ Mencoba reconnect dalam 5 detik...');
+
+ state.reconnectTimeout = setTimeout(async () => {
+ handleHook({ eventType: "reconnect" });
+ await startClient();
+ }, 5000);
+ });
+
+
+ client.on('message', handleIncomingMessage);
+
+ // === INISIALISASI ===
+ try {
+ await client.initialize();
+ } catch (err) {
+ log('โ Gagal inisialisasi client:', err);
+ log('โณ Mencoba reconnect dalam 10 detik...');
+ state.reconnectTimeout = setTimeout(async () => {
+ state.isReconnecting = false;
+ await startClient();
+ }, 10000);
+ handleHook({ eventType: "reconnect" });
+ } finally {
+ state.isStarting = false;
+ }
}
// === HANDLER PESAN MASUK ===
async function handleIncomingMessage(msg: WAWebJS.Message) {
- const chat = await msg.getChat();
+ const chat = await msg.getChat();
- // await chat.sendStateTyping();
- log(`๐ฌ Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
+ // await chat.sendStateTyping();
+ log(`๐ฌ Pesan dari ${msg.from}: ${msg.body || '[MEDIA]'}`);
- if (!connectedAt) return;
- if (msg.timestamp * 1000 < connectedAt) return;
+ if (!connectedAt) return;
+ if (msg.timestamp * 1000 < connectedAt) return;
- if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
- log(`๐ซ Pesan dari grup/status diabaikan (${msg.from})`);
- return;
- }
+ if (msg.from.endsWith('@g.us') || msg.isStatus || msg.from === 'status@broadcast') {
+ log(`๐ซ Pesan dari grup/status diabaikan (${msg.from})`);
+ return;
+ }
- if (msg.hasMedia) {
- const media = await msg.downloadMedia();
- (msg as any).media = media;
- }
+ if (msg.hasMedia) {
+ const media = await msg.downloadMedia();
+ (msg as any).media = media;
+ }
- handleHook({ eventType: "message", ...msg })
+ handleHook({ eventType: "message", ...msg })
}
// === CLEANUP SAAT EXIT ===
process.on('SIGINT', () => {
- log('๐ SIGINT diterima, menutup client...');
- destroyClient().then(() => {
- process.exit(0);
- }).catch((err) => {
- log('โ ๏ธ Error saat destroyClient:', err);
- process.exit(1);
- });
+ log('๐ SIGINT diterima, menutup client...');
+ destroyClient().then(() => {
+ process.exit(0);
+ }).catch((err) => {
+ log('โ ๏ธ Error saat destroyClient:', err);
+ process.exit(1);
+ });
});
@@ -249,5 +249,5 @@ const getState = () => state;
export { destroyClient, getState, startClient };
if (import.meta.main) {
- await startClient();
+ await startClient();
}
diff --git a/x.ts b/x.ts
new file mode 100644
index 0000000..22de453
--- /dev/null
+++ b/x.ts
@@ -0,0 +1,39 @@
+import { Client, LocalAuth } from "whatsapp-web.js";
+import path from "path";
+import qrcode from 'qrcode-terminal';
+
+
+const client = new Client({
+ authStrategy: new LocalAuth(),
+ puppeteer: {
+ headless: true,
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-gpu',
+ ],
+ },
+ webVersionCache: {
+ path: process.env.WWEBJS_CACHE || path.join(process.cwd(), '.wwebjs_cache'),
+ type: 'local',
+ }
+});
+
+client.on('qr', (qr: string) => {
+ // Generate and scan this code with your phone
+ console.log('QR RECEIVED', qr);
+ qrcode.generate(qr, { small: true });
+});
+
+client.on('ready', () => {
+ console.log('Client is ready!');
+});
+
+client.on('message', (msg: any) => {
+ if (msg.body == '!ping') {
+ msg.reply('pong');
+ }
+});
+
+client.initialize();
\ No newline at end of file
diff --git a/x.tsx b/x.tsx
deleted file mode 100644
index f950452..0000000
--- a/x.tsx
+++ /dev/null
@@ -1,218 +0,0 @@
-// import Elysia, { t, type Context } from "elysia";
-// import { startClient, getState } from "../lib/wa/wa_service";
-// import _ from "lodash";
-// import mime from "mime-types";
-// import { MessageMedia } from "whatsapp-web.js";
-
-// const checkClientReady = () => {
-// /**
-// * Mengecek kesiapan klien WhatsApp.
-// * Fungsi ini mengambil state saat ini dari WhatsApp service dan memeriksa
-// * apakah klien sudah siap dan terhubung ke WhatsApp Web.
-// *
-// * @returns {Object} - Objek dengan properti client jika klien siap,
-// * atau error dan status jika klien belum siap
-// */
-// const state = getState();
-// if (!state.ready || !state.client) return { error: "WhatsApp client is not ready", status: 400 };
-// return { client: state.client };
-// };
-
-
-// const WaRoute = new Elysia({
-// prefix: "/wa",
-// tags: ["WhatsApp"]
-// })
-// .post("/start", () => {
-// startClient();
-// return { message: "WhatsApp route started" };
-// }, {
-// detail: {
-// summary: "Start WhatsApp Client",
-// description: "Initialize and start the WhatsApp Web client connection"
-// }
-// })
-// .get("/qr", () => ({ qr: getState().qr }), {
-// detail: {
-// summary: "Get QR Code",
-// description: "Retrieve the current QR code for WhatsApp Web authentication. Scan this QR code with your WhatsApp mobile app to connect."
-// }
-// })
-// .get("/ready", () => ({ ready: getState().ready }), {
-// detail: {
-// summary: "Check Ready Status",
-// description: "Check if the WhatsApp client is ready and authenticated"
-// }
-// })
-// .post("/restart", () => {
-// getState().restart();
-// return { message: "WhatsApp route restarted" };
-// }, {
-// detail: {
-// summary: "Restart WhatsApp Client",
-// description: "Restart the WhatsApp Web client connection. This will disconnect and reconnect the client."
-// }
-// })
-// .post("/force-start", () => {
-// getState().forceStart();
-// return { message: "WhatsApp route force started" };
-// }, {
-// detail: {
-// summary: "Force Start WhatsApp Client",
-// description: "Force start the WhatsApp Web client, bypassing any existing connection checks"
-// }
-// })
-// .post("/stop", () => {
-// getState().stop();
-// return { message: "WhatsApp route stopped" };
-// }, {
-// detail: {
-// summary: "Stop WhatsApp Client",
-// description: "Stop and disconnect the WhatsApp Web client"
-// }
-// })
-// .get("/state", () => ({ state: _.omit(getState(), "client") }), {
-// detail: {
-// summary: "Get Client State",
-// description: "Retrieve the current state of the WhatsApp client including connection status, QR code availability, and other metadata (excludes client object)"
-// }
-// })
-// .post("/send-text", async ({ body }) => {
-// const check = checkClientReady();
-// if (check.error) return { message: check.error };
-
-// const chat = await check.client!.getChatById(`${body.number}@c.us`);
-// await chat.sendMessage(body.text);
-// return { success: true, message: chat.id };
-// }, {
-// body: t.Object({
-// number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
-// text: t.String({ minLength: 1, examples: ["Hello World"], description: "Text message content to send" }),
-// }),
-// detail: {
-// summary: "Send Text Message",
-// description: "Send a text message to a WhatsApp contact. The phone number should be in international format without the + sign (e.g., 6281234567890 for Indonesia)."
-// }
-// })
-// .post("/send-media", async ({ body }) => {
-// const check = checkClientReady();
-// if (check.error) return { message: check.error };
-
-// try {
-// const { number, caption, media } = body;
-// const { data, filename, mimetype } = media;
-
-// const mimeType = mimetype || mime.lookup(filename) || "application/octet-stream";
-// const fileName = filename || `file.${mime.extension(mimeType) || "bin"}`;
-// const waMedia = new MessageMedia(mimeType, data, fileName);
-
-// const sendOptions: any = { caption };
-
-// if (mimeType.startsWith("audio/")) {
-// sendOptions.sendAudioAsVoice = mimeType.includes("ogg") || mimeType.includes("opus");
-// } else if (!mimeType.startsWith("image/") && !mimeType.startsWith("video/")) {
-// sendOptions.sendMediaAsDocument = true;
-// }
-
-// await check.client!.sendMessage(`${number}@c.us`, waMedia, sendOptions);
-// return {
-// success: true,
-// message: `โ
Media sent to ${number}`,
-// info: { filename: fileName, mimetype: mimeType },
-// };
-// } catch (err: any) {
-// return { success: false, message: "โ Failed to send media", error: err.message };
-// }
-// }, {
-// body: t.Object({
-// number: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
-// caption: t.Optional(t.String({ maxLength: 255, examples: ["Hello World"], description: "Optional caption for the media" })),
-// media: t.Object({
-// data: t.String({ examples: ["iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC..."], description: "Base64 encoded media data" }),
-// filename: t.String({ minLength: 1, maxLength: 255, examples: ["file.png"], description: "Original filename with extension" }),
-// mimetype: t.String({ minLength: 1, maxLength: 255, examples: ["image/png"], description: "MIME type of the media file" }),
-// }, { description: "Media object containing base64 data, filename, and mimetype" }),
-// }),
-// detail: {
-// summary: "Send Media Message",
-// description: "Send media (image, audio, video, PDF, or any file) to a WhatsApp contact. Audio files (ogg/opus) are sent as voice messages. Non-image/video files are sent as documents."
-// }
-// })
-// .get("/code", async (ctx: Context) => {
-// const { nom, text } = ctx.query;
-// if (!nom || !text) {
-// ctx.set.status = 400;
-// return { message: "[QUERY] Nomor dan teks harus diisi" };
-// }
-
-// const check = checkClientReady();
-// if (check.error) {
-// ctx.set.status = 400;
-// return { message: `[READY] ${check.error}` };
-// }
-
-// const chat = await check.client!.sendMessage(`${nom}@c.us`, text);
-// return { message: "โ
Message sent", info: chat.id };
-// }, {
-// query: t.Object({
-// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Recipient phone number in international format without + sign" }),
-// text: t.String({ examples: ["Hello World"], description: "Text message content to send" }),
-// }),
-// detail: {
-// summary: "Send Text via GET",
-// description: "Send a text message to a WhatsApp contact using GET request with query parameters. Useful for simple integrations or webhooks."
-// }
-// })
-// .post("/send-seen", async (ctx: Context) => {
-// const { nom } = ctx.query;
-// if (!nom) {
-// ctx.set.status = 400;
-// return { message: "[QUERY] Nomor harus diisi" };
-// }
-
-// const check = checkClientReady();
-// if (check.error) {
-// ctx.set.status = 400;
-// return { message: `[READY] ${check.error}` };
-// }
-
-// const chat = await check.client!.getChatById(`${nom}@c.us`);
-// // await chat.sendSeen();
-// return { message: "โ
Seen sent", info: chat.id };
-// }, {
-// query: t.Object({
-// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Phone number of the chat to mark as seen" }),
-// }),
-// detail: {
-// summary: "Mark Chat as Seen",
-// description: "Mark all messages in a chat as seen/read. This will show blue ticks to the sender indicating the messages have been read."
-// }
-// })
-// .post("/send-typing", async ({ query, set }) => {
-// if (!query.nom) {
-// set.status = 400;
-// return { message: "[QUERY] Nomor harus diisi" };
-// }
-
-// const check = checkClientReady();
-// if (check.error) {
-// set.status = 400;
-// return { message: `[READY] ${check.error}` };
-// }
-
-// const chat = await check.client!.getChatById(`${query.nom}@c.us`);
-// await chat.sendStateTyping();
-// return { message: "โ
Typing sent", info: chat.id };
-// }, {
-// query: t.Object({
-// nom: t.String({ minLength: 10, maxLength: 15, examples: ["6281234567890"], description: "Phone number of the chat to show typing indicator" }),
-// }),
-// detail: {
-// summary: "Send Typing Indicator",
-// description: "Show 'typing...' indicator in a chat. The recipient will see that you are typing a message."
-// }
-// })
-
-// export default WaRoute;
-
-export {}
\ No newline at end of file