1174 lines
38 KiB
Markdown
1174 lines
38 KiB
Markdown
===== package.json =====
|
|
```json
|
|
{
|
|
"name": "wapp",
|
|
"version": "1.0.0",
|
|
"description": "WhatsApp automation server with API and database",
|
|
"main": "server.js",
|
|
"scripts": {
|
|
"start": "node server.js",
|
|
"dev": "nodemon server.js",
|
|
"postinstall": "patch-package"
|
|
},
|
|
"dependencies": {
|
|
"dotenv": "^16.3.1",
|
|
"express": "^4.18.2",
|
|
"qrcode": "^1.5.4",
|
|
"qrcode-terminal": "^0.12.0",
|
|
"sequelize": "^6.32.1",
|
|
"socket.io": "^4.7.2",
|
|
"sqlite3": "^5.1.6",
|
|
"whatsapp-web.js": "github:pedroslopez/whatsapp-web.js"
|
|
},
|
|
"devDependencies": {
|
|
"nodemon": "^3.0.1",
|
|
"patch-package": "^8.0.1",
|
|
"postinstall-postinstall": "^2.1.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
===== README.md =====
|
|
```md
|
|
# WhatsApp Web.js Server
|
|
|
|
Complete WhatsApp automation server with REST API, WebSocket, and SQLite storage.
|
|
|
|
## Features
|
|
|
|
✅ **REST API** - Send messages, retrieve contacts/chats/messages
|
|
✅ **WebSocket** - Real-time event streaming
|
|
✅ **SQLite** - Persistent storage for all data
|
|
✅ **Media Storage** - Automatic media file handling
|
|
✅ **Authentication** - API key protection
|
|
✅ **QR Code Web UI** - Easy WhatsApp authentication via browser
|
|
✅ **All Original Commands** - Full command handler support
|
|
|
|
## Quick Start
|
|
|
|
### Local Development
|
|
|
|
```bash
|
|
npm install
|
|
npm start
|
|
```
|
|
|
|
Visit `http://localhost:3001/qr` to scan the WhatsApp QR code.
|
|
|
|
### Docker Deployment
|
|
|
|
```bash
|
|
docker-compose up -d --build
|
|
```
|
|
|
|
Visit `http://your-server:3001/qr` to authenticate WhatsApp.
|
|
|
|
## QR Code Authentication - Web-UI
|
|
|
|
The server provides a web interface for easy WhatsApp authentication:
|
|
|
|
- **URL**: `http://localhost:3001/qr`
|
|
- **Features**:
|
|
- Real-time QR code display
|
|
- Live connection status updates
|
|
- Auto-refresh on new QR codes
|
|
- Mobile-friendly design
|
|
|
|
The QR code is also printed in the console logs for terminal access.```
|
|
|
|
===== .env.example =====
|
|
```
|
|
# Server Configuration
|
|
PORT=3001
|
|
API_KEY=your-strong-api-key-here
|
|
|
|
# Database & Storage
|
|
DB_PATH=./data/whatsapp.db
|
|
MEDIA_PATH=./media
|
|
|
|
# WhatsApp Client
|
|
HEADLESS=false
|
|
REJECT_CALLS=true
|
|
|
|
# CORS (for WebSocket)
|
|
CORS_ORIGIN=*```
|
|
|
|
===== docker-compose.yml =====
|
|
```yaml
|
|
services:
|
|
wapp:
|
|
user: "1000:1000"
|
|
build:
|
|
context: /opt/apps/wapp
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "3001:3001"
|
|
container_name: wapp
|
|
restart: unless-stopped
|
|
networks:
|
|
- traefik_net
|
|
environment:
|
|
# Server configuration
|
|
- PORT=3001
|
|
- NODE_ENV=production
|
|
|
|
# API Security
|
|
- API_KEY=${API_KEY:-your-secure-api-key-here}
|
|
|
|
# Paths (pointing to volume mounts)
|
|
- DB_PATH=/app/data/whatsapp.db
|
|
- MEDIA_PATH=/app/media
|
|
|
|
# WhatsApp configuration
|
|
# Set HEADLESS=false to see the browser window (useful for debugging)
|
|
- HEADLESS=true
|
|
- REJECT_CALLS=false
|
|
|
|
# WebSocket URL for QR page (set this to your public URL when using Traefik - )
|
|
# Example: - WS_URL=https://wapp.appmodel.nl
|
|
- WS_URL=https://wapp.appmodel.nl
|
|
|
|
# CORS configuration
|
|
- CORS_ORIGIN=*
|
|
|
|
volumes:
|
|
# Persistent data volumes
|
|
- whatsapp-data:/app/data
|
|
- whatsapp-media:/app/media
|
|
- whatsapp-cache:/app/.wwebjs_cache
|
|
- whatsapp-auth:/app/.wwebjs_auth
|
|
|
|
# Uncomment labels below when ready to use Traefik
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.wapp.rule=Host(`wapp.appmodel.nl`)"
|
|
- "traefik.http.routers.wapp.entrypoints=websecure"
|
|
- "traefik.http.routers.wapp.tls=true"
|
|
- "traefik.http.routers.wapp.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.wapp.loadbalancer.server.port=3001"
|
|
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 40s
|
|
|
|
networks:
|
|
traefik_net:
|
|
external: true
|
|
name: traefik_net
|
|
|
|
volumes:
|
|
whatsapp-data:
|
|
driver: local
|
|
whatsapp-media:
|
|
driver: local
|
|
whatsapp-cache:
|
|
driver: local
|
|
whatsapp-auth:
|
|
driver: local
|
|
```
|
|
|
|
===== .dockerignore =====
|
|
```
|
|
node_modules
|
|
npm-debug.log
|
|
.env
|
|
.git
|
|
.gitignore
|
|
.idea
|
|
.wwebjs_auth
|
|
.wwebjs_cache
|
|
data
|
|
media
|
|
*.md
|
|
test
|
|
markmap.svg
|
|
```
|
|
|
|
===== server.js =====
|
|
```javascript
|
|
require('dotenv').config()
|
|
const express = require('express')
|
|
const http = require('http')
|
|
const socketIo = require('socket.io')
|
|
const { Sequelize, DataTypes, Op } = require('sequelize')
|
|
const { Client, LocalAuth, Location, Poll, List, Buttons } = require('whatsapp-web.js')
|
|
const path = require('path')
|
|
const fs = require('fs').promises
|
|
const crypto = require('crypto')
|
|
const qrcode = require('qrcode-terminal')
|
|
const QRCode = require('qrcode')
|
|
|
|
// --- CONFIGURATION ---
|
|
const config = {
|
|
port : process.env.PORT || 3001,
|
|
dbPath : process.env.DB_PATH || path.join(__dirname, 'data', 'whatsapp.db'),
|
|
mediaPath : process.env.MEDIA_PATH || path.join(__dirname, 'media'),
|
|
headless : process.env.HEADLESS !== 'false',
|
|
rejectCalls: process.env.REJECT_CALLS !== 'false',
|
|
apiKey : process.env.API_KEY || 'test-key'
|
|
}
|
|
|
|
// Ensure directories exist
|
|
async function ensureDirectories() {
|
|
await fs.mkdir(path.dirname(config.dbPath), { recursive: true })
|
|
await fs.mkdir(config.mediaPath, { recursive: true })
|
|
}
|
|
|
|
// Simple logger
|
|
const logger = {
|
|
info : (msg) => console.log(`[${ new Date().toISOString() }] INFO: ${ msg }`),
|
|
error: (msg, err) => console.error(`[${ new Date().toISOString() }] ERROR: ${ msg }`, err || ''),
|
|
warn : (msg) => console.warn(`[${ new Date().toISOString() }] WARN: ${ msg }`)
|
|
}
|
|
|
|
// --- DATABASE SETUP ---
|
|
const sequelize = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: config.dbPath,
|
|
logging: process.env.NODE_ENV === 'development' ? logger.info : false
|
|
})
|
|
|
|
// Models
|
|
const Contact = sequelize.define('Contact', {
|
|
id : { type: DataTypes.STRING, primaryKey: true },
|
|
phoneNumber : { type: DataTypes.STRING, allowNull: true },
|
|
name : DataTypes.STRING,
|
|
pushname : DataTypes.STRING,
|
|
isBlocked : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
isMyContact : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
labels : { type: DataTypes.JSON, defaultValue: [] },
|
|
profilePicUrl: DataTypes.STRING,
|
|
isGroup: { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
isNewsletter: { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
isUser: { type: DataTypes.BOOLEAN, defaultValue: true }
|
|
}, { indexes: [{ fields: ['phoneNumber'] }] })
|
|
|
|
const Chat = sequelize.define('Chat', {
|
|
id : { type: DataTypes.STRING, primaryKey: true },
|
|
name : DataTypes.STRING,
|
|
isGroup : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
isMuted : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
unreadCount: { type: DataTypes.INTEGER, defaultValue: 0 },
|
|
timestamp : DataTypes.INTEGER,
|
|
pinned : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
description: DataTypes.TEXT
|
|
}, { indexes: [{ fields: ['isGroup'] }] })
|
|
|
|
const Message = sequelize.define('Message', {
|
|
id : { type: DataTypes.STRING, primaryKey: true },
|
|
from : { type: DataTypes.STRING, allowNull: false },
|
|
to : { type: DataTypes.STRING, allowNull: false },
|
|
body : DataTypes.TEXT,
|
|
type : DataTypes.STRING,
|
|
timestamp: { type: DataTypes.INTEGER, index: true },
|
|
hasMedia : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
mediaId : DataTypes.STRING,
|
|
fromMe : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
ack : { type: DataTypes.INTEGER, defaultValue: 0 }
|
|
}, { indexes: [{ fields: ['from', 'timestamp'] }] })
|
|
|
|
const Media = sequelize.define('Media', {
|
|
id : { type: DataTypes.STRING, primaryKey: true },
|
|
messageId: { type: DataTypes.STRING, references: { model: Message, key: 'id' } },
|
|
mimetype : DataTypes.STRING,
|
|
filename : DataTypes.STRING,
|
|
filePath : DataTypes.STRING
|
|
})
|
|
|
|
const Call = sequelize.define('Call', {
|
|
id : { type: DataTypes.STRING, primaryKey: true },
|
|
from : DataTypes.STRING,
|
|
isVideo : { type: DataTypes.BOOLEAN, defaultValue: false },
|
|
timestamp: DataTypes.INTEGER,
|
|
rejected : { type: DataTypes.BOOLEAN, defaultValue: false }
|
|
})
|
|
|
|
Message.hasOne(Media, { foreignKey: 'messageId', as: 'media' })
|
|
|
|
// --- EXPRESS & SOCKET.IO ---
|
|
const app = express()
|
|
const server = http.createServer(app)
|
|
const io = socketIo(server, {
|
|
cors: { origin: process.env.CORS_ORIGIN || '*', methods: ['GET', 'POST'] }
|
|
})
|
|
|
|
app.use(express.json({ limit: '50mb' }))
|
|
|
|
// Middleware
|
|
function validateApiKey(req, res, next) {
|
|
const apiKey = req.headers['x-api-key'] || req.query.apiKey
|
|
if (!apiKey || apiKey !== config.apiKey) {
|
|
return res.status(401).json({ success: false, error: 'Unauthorized' })
|
|
}
|
|
next()
|
|
}
|
|
|
|
function checkWhatsAppReady(req, res, next) {
|
|
if (!client.info) return res.status(503).json({ success: false, error: 'Client not ready' })
|
|
next()
|
|
}
|
|
|
|
// --- REST API ---
|
|
app.post('/api/send', [validateApiKey, checkWhatsAppReady], async (req, res) => {
|
|
try {
|
|
const { phoneNumber, message } = req.body
|
|
if (!phoneNumber || !message) {
|
|
return res.status(400).json({ success: false, error: 'Missing phoneNumber or message' })
|
|
}
|
|
|
|
const chatId = phoneNumber.includes('@') ? phoneNumber : `${ phoneNumber }@c.us`
|
|
const sent = await client.sendMessage(chatId, message)
|
|
|
|
await Message.create({
|
|
id : sent.id._serialized,
|
|
from : client.info.wid._serialized,
|
|
to : chatId,
|
|
body : typeof message === 'string' ? message : JSON.stringify(message),
|
|
type : sent.type,
|
|
timestamp: sent.timestamp,
|
|
fromMe : true,
|
|
ack : sent.ack
|
|
})
|
|
|
|
io.emit('message_sent', { id: sent.id._serialized, phoneNumber, message })
|
|
res.json({ success: true, messageId: sent.id._serialized })
|
|
} catch (error) {
|
|
logger.error('Error sending message', error)
|
|
res.status(500).json({ success: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/contacts', [validateApiKey], async (req, res) => {
|
|
try {
|
|
const contacts = await Contact.findAll({ order: [['name', 'ASC']], limit: 1000 })
|
|
res.json({ success: true, data: contacts })
|
|
} catch (error) {
|
|
logger.error('Error fetching contacts', error)
|
|
res.status(500).json({ success: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/chats', [validateApiKey], async (req, res) => {
|
|
try {
|
|
const chats = await Chat.findAll({ order: [['timestamp', 'DESC']], limit: 1000 })
|
|
res.json({ success: true, data: chats })
|
|
} catch (error) {
|
|
logger.error('Error fetching chats', error)
|
|
res.status(500).json({ success: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/messages/:chatId', [validateApiKey], async (req, res) => {
|
|
try {
|
|
const { chatId } = req.params
|
|
const messages = await Message.findAll({
|
|
where : { [Op.or]: [{ from: chatId }, { to: chatId }] },
|
|
order : [['timestamp', 'DESC']],
|
|
limit : 100,
|
|
include: [{ model: Media, as: 'media' }]
|
|
})
|
|
res.json({ success: true, data: messages })
|
|
} catch (error) {
|
|
logger.error('Error fetching messages', error)
|
|
res.status(500).json({ success: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/status', [validateApiKey], async (req, res) => {
|
|
try {
|
|
const status = {
|
|
connected : !!client.info,
|
|
user : client.info,
|
|
uptime : process.uptime(),
|
|
wwebVersion: client.info ? await client.getWWebVersion() : null
|
|
}
|
|
res.json({ success: true, data: status })
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message })
|
|
}
|
|
})
|
|
|
|
app.get('/health', (req, res) => res.json({ status: 'OK', timestamp: Date.now() }))
|
|
|
|
// QR Code storage
|
|
let currentQR = null
|
|
let qrTimestamp = null
|
|
|
|
// QR Code HTML page
|
|
app.get('/qr', (req, res) => {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WhatsApp QR Code</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
background: white;
|
|
border-radius: 20px;
|
|
padding: 40px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
max-width: 500px;
|
|
width: 100%;
|
|
text-align: center;
|
|
}
|
|
h1 {
|
|
color: #333;
|
|
margin-bottom: 10px;
|
|
font-size: 28px;
|
|
}
|
|
.subtitle {
|
|
color: #666;
|
|
margin-bottom: 30px;
|
|
font-size: 14px;
|
|
}
|
|
#qr-container {
|
|
background: #f5f5f5;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
min-height: 300px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
#qr-code {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 10px;
|
|
}
|
|
.status {
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
margin: 20px 0;
|
|
font-weight: 500;
|
|
}
|
|
.status.waiting {
|
|
background: #fff3cd;
|
|
color: #856404;
|
|
}
|
|
.status.ready {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
}
|
|
.status.error {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
.info {
|
|
color: #666;
|
|
font-size: 13px;
|
|
line-items: 1.6;
|
|
}
|
|
.loader {
|
|
border: 4px solid #f3f3f3;
|
|
border-top: 4px solid #667eea;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto;
|
|
}
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
.timestamp {
|
|
font-size: 11px;
|
|
color: #999;
|
|
margin-top: 10px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🔗 WhatsApp Connection</h1>
|
|
<p class="subtitle">Scan the QR code with your WhatsApp mobile app</p>
|
|
|
|
<div id="status" class="status waiting">
|
|
Waiting for QR code...
|
|
</div>
|
|
|
|
<div id="qr-container">
|
|
<div class="loader"></div>
|
|
</div>
|
|
|
|
<div class="info">
|
|
<p><strong>How to scan:</strong></p>
|
|
<p>1. Open WhatsApp on your phone</p>
|
|
<p>2. Tap Menu (⋮) > Linked Devices</p>
|
|
<p>3. Tap "Link a Device"</p>
|
|
<p>4. Point your phone at this screen</p>
|
|
</div>
|
|
|
|
<div class="timestamp" id="timestamp"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const statusEl = document.getElementById('status');
|
|
const qrContainer = document.getElementById('qr-container');
|
|
const timestampEl = document.getElementById('timestamp');
|
|
let socket;
|
|
|
|
function connectWebSocket() {
|
|
socket = io('${process.env.WS_URL || ''}');
|
|
|
|
socket.on('connect', () => {
|
|
console.log('WebSocket connected');
|
|
});
|
|
|
|
socket.on('qr', (data) => {
|
|
console.log('QR code received');
|
|
statusEl.textContent = '📱 Scan this QR code with WhatsApp';
|
|
statusEl.className = 'status waiting';
|
|
qrContainer.innerHTML = '<img id="qr-code" src="/qr/image" alt="QR Code">';
|
|
timestampEl.textContent = 'Generated: ' + new Date(data.timestamp).toLocaleString();
|
|
});
|
|
|
|
socket.on('ready', (data) => {
|
|
console.log('WhatsApp ready');
|
|
statusEl.textContent = '✅ Connected successfully!';
|
|
statusEl.className = 'status ready';
|
|
qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is connected and ready</p>';
|
|
timestampEl.textContent = '';
|
|
});
|
|
|
|
socket.on('status', (data) => {
|
|
if (data.connected) {
|
|
statusEl.textContent = '✅ Already connected';
|
|
statusEl.className = 'status ready';
|
|
qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is already connected</p>';
|
|
}
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log('WebSocket disconnected');
|
|
statusEl.textContent = '⚠️ Connection lost - Reconnecting...';
|
|
statusEl.className = 'status error';
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
console.error('Socket error:', error);
|
|
statusEl.textContent = '❌ Error: ' + (error.message || 'Unknown error');
|
|
statusEl.className = 'status error';
|
|
});
|
|
}
|
|
|
|
// Initial check
|
|
fetch('/qr/check')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.hasQR) {
|
|
statusEl.textContent = '📱 Scan this QR code with WhatsApp';
|
|
statusEl.className = 'status waiting';
|
|
qrContainer.innerHTML = '<img id="qr-code" src="/qr/image" alt="QR Code">';
|
|
timestampEl.textContent = 'Generated: ' + new Date(data.timestamp).toLocaleString();
|
|
} else if (data.connected) {
|
|
statusEl.textContent = '✅ Already connected';
|
|
statusEl.className = 'status ready';
|
|
qrContainer.innerHTML = '<div style="font-size: 48px;">✅</div><p style="margin-top: 10px; color: #155724;">WhatsApp is already connected</p>';
|
|
timestampEl.textContent = '';
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Check failed:', err);
|
|
});
|
|
|
|
// Connect WebSocket for live updates
|
|
connectWebSocket();
|
|
|
|
// Auto-refresh QR image every 30 seconds if waiting
|
|
setInterval(() => {
|
|
const img = document.getElementById('qr-code');
|
|
if (img && statusEl.classList.contains('waiting')) {
|
|
img.src = '/qr/image?t=' + Date.now();
|
|
}
|
|
}, 30000);
|
|
</script>
|
|
<script src="/socket.io/socket.io.js"></script>
|
|
</body>
|
|
</html>
|
|
`
|
|
res.send(html)
|
|
})
|
|
|
|
// QR Code check endpoint
|
|
app.get('/qr/check', (req, res) => {
|
|
res.json({
|
|
hasQR: !!currentQR,
|
|
connected: !!client.info,
|
|
timestamp: qrTimestamp
|
|
})
|
|
})
|
|
|
|
// QR Code image endpoint
|
|
app.get('/qr/image', async (req, res) => {
|
|
try {
|
|
if (!currentQR) {
|
|
// Generate a placeholder
|
|
res.setHeader('Content-Type', 'image/png')
|
|
const placeholder = await QRCode.toBuffer('Waiting for QR code...', {
|
|
width: 300,
|
|
color: { dark: '#ccc', light: '#fff' }
|
|
})
|
|
return res.send(placeholder)
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'image/png')
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
const qrImage = await QRCode.toBuffer(currentQR, {
|
|
width: 300,
|
|
margin: 2,
|
|
color: { dark: '#000', light: '#fff' }
|
|
})
|
|
res.send(qrImage)
|
|
} catch (error) {
|
|
logger.error('Error generating QR image', error)
|
|
res.status(500).send('Error generating QR code')
|
|
}
|
|
})
|
|
|
|
// --- WEBSOCKET ---
|
|
io.on('connection', (socket) => {
|
|
logger.info(`WebSocket client connected: ${ socket.id }`)
|
|
socket.emit('status', { connected: !!client.info })
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
logger.info(`WebSocket client disconnected: ${ socket.id }, reason: ${ reason }`)
|
|
})
|
|
})
|
|
|
|
// --- WHATSAPP CLIENT ---
|
|
const client = new Client({
|
|
authStrategy: new LocalAuth({ clientId: 'whatsapp-server' }),
|
|
puppeteer : {
|
|
headless: config.headless,
|
|
args : ['--no-sandbox', '--disable-setuid-sandbox']
|
|
}
|
|
})
|
|
|
|
client.on('qr', (qr) => {
|
|
logger.info('QR received - scan to authenticate')
|
|
logger.info('QR Code available at: http://localhost:' + config.port + '/qr')
|
|
qrcode.generate(qr, { small: true })
|
|
|
|
// Store QR for web display
|
|
currentQR = qr
|
|
qrTimestamp = Date.now()
|
|
|
|
io.emit('qr', { qr, timestamp: qrTimestamp })
|
|
})
|
|
|
|
client.on('ready', async () => {
|
|
logger.info('WhatsApp client ready');
|
|
|
|
// Clear QR code once connected
|
|
currentQR = null
|
|
qrTimestamp = null
|
|
|
|
const wwebVersion = await client.getWWebVersion();
|
|
|
|
// Sync contacts with proper type detection
|
|
const contacts = await client.getContacts();
|
|
let contactsSynced = 0;
|
|
|
|
for (const contact of contacts) {
|
|
try {
|
|
const id = contact.id?._serialized || contact.id || '';
|
|
const name = contact.name || contact.pushname || 'Unknown';
|
|
|
|
// Determine contact type and extract identifier
|
|
let phoneNumber = null;
|
|
let isGroup = false;
|
|
let isNewsletter = false;
|
|
let isUser = false;
|
|
|
|
if (contact.id && typeof contact.id === 'object') {
|
|
if (contact.id.server === 'c.us') {
|
|
// Regular user contact
|
|
phoneNumber = contact.id.user || contact.number;
|
|
isUser = true;
|
|
} else if (contact.id.server === 'g.us') {
|
|
// Group chat
|
|
isGroup = true;
|
|
phoneNumber = contact.id._serialized; // Use group ID
|
|
} else if (contact.id.server === 'newsletter') {
|
|
// Newsletter
|
|
isNewsletter = true;
|
|
phoneNumber = contact.id._serialized; // Use newsletter ID
|
|
}
|
|
}
|
|
|
|
// Fallback
|
|
if (!phoneNumber) {
|
|
phoneNumber = contact.number || contact.id?._serialized || null;
|
|
isUser = true;
|
|
}
|
|
|
|
await Contact.upsert({
|
|
id: id,
|
|
phoneNumber: phoneNumber,
|
|
name: name,
|
|
pushname: contact.pushname || null,
|
|
isBlocked: contact.isBlocked || false,
|
|
isMyContact: contact.isMyContact || false,
|
|
labels: contact.labels || [],
|
|
profilePicUrl: contact.profilePicUrl || null,
|
|
isGroup: isGroup,
|
|
isNewsletter: isNewsletter,
|
|
isUser: isUser
|
|
});
|
|
contactsSynced++;
|
|
} catch (err) {
|
|
logger.warn(`Failed to sync contact ${contact.id?._serialized || 'unknown'}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Sync chats
|
|
const chats = await client.getChats();
|
|
let chatsSynced = 0;
|
|
|
|
for (const chat of chats) {
|
|
try {
|
|
await Chat.upsert({
|
|
id: chat.id._serialized,
|
|
name: chat.name,
|
|
isGroup: chat.isGroup,
|
|
isMuted: chat.isMuted || false,
|
|
unreadCount: chat.unreadCount || 0,
|
|
timestamp: chat.timestamp || 0,
|
|
pinned: chat.pinned || false,
|
|
description: chat.description || null
|
|
});
|
|
chatsSynced++;
|
|
} catch (err) {
|
|
logger.warn(`Failed to sync chat ${chat.id._serialized}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
logger.info(`Synced ${contactsSynced}/${contacts.length} contacts and ${chatsSynced}/${chats.length} chats`);
|
|
io.emit('ready', { version: wwebVersion, user: client.info });
|
|
});
|
|
|
|
client.on('message', async (msg) => {
|
|
try {
|
|
const messageData = {
|
|
id : msg.id._serialized,
|
|
from : msg.from,
|
|
to : msg.to || msg.from,
|
|
body : msg.body || '',
|
|
type : msg.type,
|
|
timestamp: msg.timestamp,
|
|
hasMedia : msg.hasMedia,
|
|
fromMe : msg.fromMe,
|
|
ack : msg.ack || 0
|
|
}
|
|
|
|
if (msg.location) {
|
|
Object.assign(messageData, {
|
|
locationLatitude : msg.location.latitude,
|
|
locationLongitude: msg.location.longitude,
|
|
locationName : msg.location.name,
|
|
locationAddress : msg.location.address
|
|
})
|
|
}
|
|
|
|
await Message.create(messageData)
|
|
|
|
if (msg.hasMedia) {
|
|
const media = await msg.downloadMedia()
|
|
const mediaId = crypto.randomUUID()
|
|
const fileExt = media.filename ? path.extname(media.filename) : media.mimetype.split('/')[1] || 'bin'
|
|
const filename = `${ mediaId }${ fileExt }`
|
|
const filePath = path.join(config.mediaPath, filename)
|
|
|
|
await fs.writeFile(filePath, media.data, 'base64')
|
|
await Media.create({
|
|
id : mediaId,
|
|
messageId: msg.id._serialized,
|
|
mimetype : media.mimetype,
|
|
filename : media.filename || filename,
|
|
filePath
|
|
})
|
|
}
|
|
|
|
// Update contact info for incoming messages (safely)
|
|
if (!msg.fromMe && config.syncOnReady) {
|
|
try {
|
|
// Only attempt if SYNC_ON_READY is true (you trust the API)
|
|
const contact = await msg.getContact()
|
|
await Contact.upsert({
|
|
id : contact.id._serialized,
|
|
phoneNumber : contact.number || contact.id.user,
|
|
name : contact.name || contact.pushname,
|
|
pushname : contact.pushname,
|
|
isBlocked : contact.isBlocked || false,
|
|
isMyContact : contact.isMyContact || false,
|
|
labels : contact.labels || [],
|
|
profilePicUrl: contact.profilePicUrl || null
|
|
})
|
|
} catch (err) {
|
|
// Silently fail - this is a known issue with whatsapp-web.js
|
|
logger.warn(`Failed to update contact: ${ err.message }`)
|
|
}
|
|
}
|
|
|
|
io.emit('message', {
|
|
id : msg.id._serialized,
|
|
from : msg.from,
|
|
body : msg.body,
|
|
type : msg.type,
|
|
timestamp: msg.timestamp,
|
|
hasMedia : msg.hasMedia,
|
|
fromMe : msg.fromMe
|
|
})
|
|
|
|
// --- ALL ORIGINAL COMMAND HANDLERS ---
|
|
if (msg.body === '!ping reply') {
|
|
await msg.reply('pong')
|
|
} else if (msg.body === '!ping') {
|
|
await client.sendMessage(msg.from, 'pong')
|
|
} else if (msg.body.startsWith('!sendto ')) {
|
|
const parts = msg.body.split(' ')
|
|
const number = parts[1].includes('@c.us') ? parts[1] : `${ parts[1] }@c.us`
|
|
await client.sendMessage(number, parts.slice(2).join(' '))
|
|
} else if (msg.body.startsWith('!subject ')) {
|
|
const chat = await msg.getChat()
|
|
if (chat.isGroup) await chat.setSubject(msg.body.slice(9))
|
|
} else if (msg.body.startsWith('!desc ')) {
|
|
const chat = await msg.getChat()
|
|
if (chat.isGroup) await chat.setDescription(msg.body.slice(6))
|
|
} else if (msg.body === '!leave') {
|
|
const chat = await msg.getChat()
|
|
if (chat.isGroup) await chat.leave()
|
|
} else if (msg.body.startsWith('!join ')) {
|
|
try {
|
|
await client.acceptInvite(msg.body.split(' ')[1])
|
|
await msg.reply('Joined the group!')
|
|
} catch (e) {
|
|
await msg.reply('Invalid invite code.')
|
|
}
|
|
} else if (msg.body === '!groupinfo') {
|
|
const chat = await msg.getChat()
|
|
if (chat.isGroup) {
|
|
await msg.reply(`*Group Details*\nName: ${ chat.name }\nDescription: ${ chat.description }\nParticipants: ${ chat.participants.length }`)
|
|
}
|
|
} else if (msg.body === '!chats') {
|
|
const chats = await client.getChats()
|
|
await client.sendMessage(msg.from, `The bot has ${ chats.length } chats open.`)
|
|
} else if (msg.body === '!info') {
|
|
const info = client.info
|
|
await client.sendMessage(msg.from, `*Connection Info*\nUser: ${ info.pushname }\nNumber: ${ info.wid.user }\nPlatform: ${ info.platform }`)
|
|
} else if (msg.body === '!mediainfo' && msg.hasMedia) {
|
|
const media = await msg.downloadMedia()
|
|
await msg.reply(`*Media Info*\nMime: ${ media.mimetype }\nSize: ${ media.data.length }`)
|
|
} else if (msg.body === '!location') {
|
|
await msg.reply(new Location(37.422, -122.084, { name: 'Googleplex' }))
|
|
} else if (msg.body === '!reaction') {
|
|
await msg.react('👍')
|
|
} else if (msg.body === '!sendpoll') {
|
|
await msg.reply(new Poll('Winter or Summer?', ['Winter', 'Summer']))
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('Error in message handler', error)
|
|
io.emit('error', { source: 'message_handler', error: error.message })
|
|
}
|
|
})
|
|
|
|
client.on('call', async (call) => {
|
|
logger.info(`Call from ${ call.from }`)
|
|
await Call.create({
|
|
id : call.id,
|
|
from : call.from,
|
|
isVideo : call.isVideo,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
rejected : config.rejectCalls
|
|
})
|
|
|
|
io.emit('call', { id: call.id, from: call.from, isVideo: call.isVideo })
|
|
|
|
if (config.rejectCalls) await call.reject()
|
|
await client.sendMessage(call.from, `[${ call.isVideo ? 'Video' : 'Audio' }] Call from ${ call.from } ${ config.rejectCalls ? '(Auto-rejected)' : '' }`)
|
|
})
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
logger.info('Shutting down...')
|
|
await client.destroy()
|
|
await sequelize.close()
|
|
server.close(() => process.exit(0))
|
|
})
|
|
|
|
// Start
|
|
async function start() {
|
|
await ensureDirectories()
|
|
await sequelize.authenticate()
|
|
await sequelize.sync({ force: false })
|
|
|
|
server.listen(config.port, () => {
|
|
logger.info(`🚀 Server running on port ${ config.port }`)
|
|
logger.info(`🔑 API Key: ${ config.apiKey }`)
|
|
logger.info(`💾 Database: ${ config.dbPath }`)
|
|
logger.info(`📱 Initializing WhatsApp...`)
|
|
})
|
|
|
|
client.initialize()
|
|
}
|
|
|
|
if (require.main === module) {
|
|
start()
|
|
}
|
|
|
|
module.exports = { app, server, io, client, sequelize }```
|
|
|
|
===== markmap.svg =====
|
|
```
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="markmap mm-xo0pfw-6" style="width: 100%; height: 100%;"><style>.markmap{--markmap-max-width: 9999px;--markmap-a-color: #0097e6;--markmap-a-hover-color: #00a8ff;--markmap-code-bg: #f0f0f0;--markmap-code-color: #555;--markmap-highlight-bg: #ffeaa7;--markmap-table-border: 1px solid currentColor;--markmap-font: 300 16px/20px sans-serif;--markmap-circle-open-bg: #fff;--markmap-text-color: #333;--markmap-highlight-node-bg: #ff02;font:var(--markmap-font);color:var(--markmap-text-color)}.markmap-link{fill:none}.markmap-node>circle{cursor:pointer}.markmap-foreign{display:inline-block}.markmap-foreign p{margin:0}.markmap-foreign a{color:var(--markmap-a-color)}.markmap-foreign a:hover{color:var(--markmap-a-hover-color)}.markmap-foreign code{padding:.25em;font-size:calc(1em - 2px);color:var(--markmap-code-color);background-color:var(--markmap-code-bg);border-radius:2px}.markmap-foreign pre{margin:0}.markmap-foreign pre>code{display:block}.markmap-foreign del{text-decoration:line-through}.markmap-foreign em{font-style:italic}.markmap-foreign strong{font-weight:700}.markmap-foreign mark{background:var(--markmap-highlight-bg)}.markmap-foreign table,.markmap-foreign th,.markmap-foreign td{border-collapse:collapse;border:var(--markmap-table-border)}.markmap-foreign img{display:inline-block}.markmap-foreign svg{fill:currentColor}.markmap-foreign>div{width:var(--markmap-max-width);text-align:left}.markmap-foreign>div>div{display:inline-block}.markmap-highlight rect{fill:var(--markmap-highlight-node-bg)}.markmap-dark .markmap{--markmap-code-bg: #1a1b26;--markmap-code-color: #ddd;--markmap-circle-open-bg: #444;--markmap-text-color: #eee}</style><g transform="translate(20.000000000000057,297.5232774674115) scale(1.4152700186219738)"><path class="markmap-link" data-depth="3" data-path="1.3.4" d="M367,24.25C407,24.25,407,27.563,447,27.563" stroke-width="1.375" stroke="rgb(214, 39, 40)"/><path class="markmap-link" data-depth="2" data-path="1.2" d="M194,11.25C234,11.25,234,-2.5,274,-2.5" stroke-width="1.75" stroke="rgb(255, 127, 14)"/><path class="markmap-link" data-depth="2" data-path="1.3" d="M194,11.25C234,11.25,234,24.25,274,24.25" stroke-width="1.75" stroke="rgb(44, 160, 44)"/><g class="markmap-highlight"/><g data-depth="2" data-path="1.2" class="markmap-node" transform="translate(274, -23.375)"><line stroke="#ff7f0e" stroke-width="1.75" x1="-1" x2="81" y1="20.875" y2="20.875"/><foreignObject class="markmap-foreign" x="8" y="0" style="opacity: 1;" width="63" height="20"><div xmlns="http://www.w3.org/1999/xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Features</div></div></foreignObject></g><g data-depth="3" data-path="1.3.4" class="markmap-node" transform="translate(447, -0.125)"><line stroke="#d62728" stroke-width="1.375" x1="-1" x2="92" y1="27.6875" y2="27.6875"/><foreignObject class="markmap-foreign" x="8" y="0" style="opacity: 1;" width="74" height="27"><div xmlns="http://www.w3.org/1999/xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><pre data-lines="15,17"><code class="language-bash">npm install</code></pre></div></div></foreignObject></g><g data-depth="2" data-path="1.3" class="markmap-node" transform="translate(274, 3.375)"><line stroke="#2ca02c" stroke-width="1.75" x1="-1" x2="95" y1="20.875" y2="20.875"/><circle stroke-width="1.5" r="6" stroke="#2ca02c" fill="var(--markmap-circle-open-bg)" cx="93" cy="20.875"/><foreignObject class="markmap-foreign" x="8" y="0" style="opacity: 1;" width="77" height="20"><div xmlns="http://www.w3.org/1999/xhtml"><div xmlns="http://www.w3.org/1999/xhtml">Installation</div></div></foreignObject></g><g data-depth="1" data-path="1" class="markmap-node" transform="translate(0, -10)"><line stroke="#1f77b4" stroke-width="2.5" x1="-1" x2="196" y1="21.25" y2="21.25"/><circle stroke-width="1.5" r="6" stroke="#1f77b4" fill="var(--markmap-circle-open-bg)" cx="194" cy="21.25"/><foreignObject class="markmap-foreign" x="8" y="0" style="opacity: 1;" width="178" height="20"><div xmlns="http://www.w3.org/1999/xhtml"><div xmlns="http://www.w3.org/1999/xhtml">WhatsApp Web.js Server</div></div></foreignObject></g></g></svg>```
|
|
|
|
===== dev-docker-compose.yml =====
|
|
```yaml
|
|
services:
|
|
wapp:
|
|
#user: "1000:1000"
|
|
build:
|
|
#context: /opt/apps/wapp
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
ports:
|
|
- "3001:3001"
|
|
container_name: wapp
|
|
restart: unless-stopped
|
|
networks:
|
|
- traefik_net
|
|
environment:
|
|
# Server configuration
|
|
- PORT=3001
|
|
- NODE_ENV=production
|
|
|
|
# API Security
|
|
- API_KEY=${API_KEY:-your-secure-api-key-here}
|
|
|
|
# Paths (pointing to volume mounts)
|
|
- DB_PATH=/app/data/whatsapp.db
|
|
- MEDIA_PATH=/app/media
|
|
|
|
# WhatsApp configuration
|
|
# Set HEADLESS=false to see the browser window (useful for debugging)
|
|
- HEADLESS=true
|
|
- REJECT_CALLS=false
|
|
|
|
# WebSocket URL for QR page (set this to your public URL when using Traefik - )
|
|
# Example: - WS_URL=https://wapp.appmodel.nl
|
|
- WS_URL=https://wapp.appmodel.nl
|
|
|
|
# CORS configuration
|
|
- CORS_ORIGIN=*
|
|
|
|
volumes:
|
|
# Persistent data volumes
|
|
- whatsapp-data:/app/data
|
|
- whatsapp-media:/app/media
|
|
- whatsapp-cache:/app/.wwebjs_cache
|
|
- whatsapp-auth:/app/.wwebjs_auth
|
|
|
|
# Uncomment labels below when ready to use Traefik
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.wapp.rule=Host(`wapp.appmodel.nl`)"
|
|
- "traefik.http.routers.wapp.entrypoints=websecure"
|
|
- "traefik.http.routers.wapp.tls=true"
|
|
- "traefik.http.routers.wapp.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.wapp.loadbalancer.server.port=3001"
|
|
|
|
healthcheck:
|
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 40s
|
|
|
|
networks:
|
|
traefik_net:
|
|
external: true
|
|
name: traefik_net
|
|
|
|
volumes:
|
|
whatsapp-data:
|
|
driver: local
|
|
whatsapp-media:
|
|
driver: local
|
|
whatsapp-cache:
|
|
driver: local
|
|
whatsapp-auth:
|
|
driver: local
|
|
```
|
|
|
|
===== Dockerfile =====
|
|
```
|
|
# ==================== BUILD STAGE ====================
|
|
FROM node:20-alpine AS builder
|
|
WORKDIR /app
|
|
|
|
# Install chromium dependencies for whatsapp-web.js
|
|
RUN apk add --no-cache \
|
|
chromium \
|
|
nss \
|
|
freetype \
|
|
harfbuzz \
|
|
ca-certificates \
|
|
ttf-freefont \
|
|
git
|
|
|
|
# Copy package files
|
|
COPY package*.json ./
|
|
|
|
# Install dependencies
|
|
RUN npm ci --production=false
|
|
|
|
# Copy patches directory if it exists
|
|
COPY patches ./patches
|
|
|
|
# Run postinstall (patch-package)
|
|
RUN npm run postinstall || true
|
|
|
|
# Copy application files
|
|
COPY server.js ./
|
|
|
|
# ==================== RUNTIME STAGE ====================
|
|
FROM node:20-alpine
|
|
WORKDIR /app
|
|
|
|
# Install chromium and runtime dependencies
|
|
RUN apk add --no-cache \
|
|
chromium \
|
|
nss \
|
|
freetype \
|
|
harfbuzz \
|
|
ca-certificates \
|
|
ttf-freefont
|
|
|
|
# Tell Puppeteer to use installed chromium
|
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|
|
|
#RUN groupadd -r whatsapp && useradd -r -g whatsapp whatsapp
|
|
#DEV Create non-root user
|
|
RUN addgroup -S whatsapp || true && \
|
|
adduser -D -S -G whatsapp whatsapp || true
|
|
|
|
# Copy dependencies from builder
|
|
COPY --from=builder --chown=whatsapp:whatsapp /app/node_modules ./node_modules
|
|
|
|
# Copy application files
|
|
COPY --chown=whatsapp:whatsapp server.js ./
|
|
COPY --chown=whatsapp:whatsapp package*.json ./
|
|
|
|
# Create volume directories
|
|
RUN mkdir -p /app/data /app/media /app/.wwebjs_cache /app/.wwebjs_auth && \
|
|
chown -R whatsapp:whatsapp /app
|
|
|
|
USER whatsapp
|
|
|
|
EXPOSE 3001
|
|
|
|
# Health check
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
CMD node -e "require('http').get('http://localhost:3001/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"
|
|
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
===== .aiignore =====
|
|
```
|
|
node_modules
|
|
.idea
|
|
.git
|
|
package-lock.json```
|
|
|
|
===== patches/whatsapp-web.js+1.21.0.patch =====
|
|
```
|
|
diff --git a/node_modules/whatsapp-web.js/src/util/Injected.js b/node_modules/whatsapp-web.js/src/util/Injected.js
|
|
index xxxxxxx..yyyyyyy 100644
|
|
--- a/node_modules/whatsapp-web.js/src/util/Injected.js
|
|
+++ b/node_modules/whatsapp-web.js/src/util/Injected.js
|
|
@@ -628,16 +628,28 @@ exports.ExposeStore = () => {
|
|
return Object.defineProperty(window, 'WWebJS', {
|
|
get() {
|
|
return {
|
|
getContactModel: (contact) => {
|
|
+ // Safe method extraction with fallbacks
|
|
+ const getContactMethod = (method) => {
|
|
+ if (!window.Store.ContactMethods || !window.Store.ContactMethods[method]) {
|
|
+ return null;
|
|
+ }
|
|
+ return window.Store.ContactMethods[method];
|
|
+ };
|
|
+
|
|
return {
|
|
id: contact.id,
|
|
name: contact.name,
|
|
type: contact.type,
|
|
- isMe: contact.isMe,
|
|
- isUser: contact.isUser,
|
|
- isGroup: contact.isGroup,
|
|
- isBroadcast: contact.isBroadcast,
|
|
- isBusiness: contact.isBusiness,
|
|
- isEnterprise: contact.isEnterprise,
|
|
+ isMe: contact.isMe || false,
|
|
+ isUser: contact.isUser || false,
|
|
+ isGroup: contact.isGroup || false,
|
|
+ isBroadcast: contact.isBroadcast || false,
|
|
+ isBusiness: contact.isBusiness || false,
|
|
+ isEnterprise: contact.isEnterprise || false,
|
|
+ isContactSyncEnabled: contact.isContactSyncEnabled || false,
|
|
+ isContactLocked: contact.isContactLocked || false,
|
|
verifiedLevel: contact.verifiedLevel,
|
|
verifiedName: contact.verifiedName,
|
|
pushname: contact.pushname,
|
|
@@ -646,11 +658,23 @@ exports.ExposeStore = () => {
|
|
isMe: contact.isMe,
|
|
isUser: contact.isUser,
|
|
isWAContact: contact.isWAContact,
|
|
- isMyContact: contact.isMyContact,
|
|
+ // Fallback for changed API
|
|
+ isMyContact: contact.isMyContact !== undefined
|
|
+ ? contact.isMyContact
|
|
+ : contact.type === 'in' || false,
|
|
isBlocked: contact.isBlocked,
|
|
- labels: contact.labels ? contact.labels.map(l => l.id) : []
|
|
+ labels: contact.labels ? contact.labels.map(l => l.id) : [],
|
|
+ // Additional properties
|
|
+ displayName: contact.displayName || contact.pushname || contact.name,
|
|
+ formattedName: contact.formattedName || contact.name,
|
|
+ formattedShortName: contact.formattedShortName || contact.name,
|
|
+ shortName: contact.shortName || contact.name,
|
|
+ isSaved: contact.isSaved !== undefined ? contact.isSaved : false,
|
|
+ isMuted: contact.muteExpiration !== undefined ? contact.muteExpiration > 0 : false,
|
|
+ muteExpiration: contact.muteExpiration || 0,
|
|
+ disappearingModeDuration: contact.disappearingModeDuration || 0
|
|
};
|
|
},
|
|
});
|
|
}
|
|
});
|
|
}```
|
|
|
|
===== DIRECTORY TREE =====
|
|
```text
|
|
```
|