feat: Major WhatsApp integration update with Odoo and pause/resume
## Frontend - Add media display (images, audio, video, docs) in Inbox - Add pause/resume functionality for WhatsApp accounts - Fix media URLs to use nginx proxy (relative URLs) ## API Gateway - Add /accounts/:id/pause and /accounts/:id/resume endpoints - Fix media URL handling for browser access ## WhatsApp Core - Add pauseSession() - disconnect without logout - Add resumeSession() - reconnect using saved credentials - Add media download and storage for incoming messages - Serve media files via /media/ static route ## Odoo Module (odoo_whatsapp_hub) - Add Chat Hub interface with DOLLARS theme (dark, 3-column layout) - Add WhatsApp/DRRR theme switcher for chat view - Add "ABRIR CHAT" button in conversation form - Add send_message_from_chat() method - Add security/ir.model.access.csv - Fix CSS scoping to avoid breaking Odoo UI - Update webhook to handle message events properly ## Documentation - Add docs/CONTEXTO_DESARROLLO.md with complete project context ## Infrastructure - Add whatsapp_media Docker volume - Configure nginx proxy for /media/ route - Update .gitignore to track src/sessions/ source files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
"lint": "eslint src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.16",
|
||||
"@whiskeysockets/baileys": "^6.7.17",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"ioredis": "^5.4.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -50,6 +50,28 @@ export function createRouter(sessionManager: SessionManager): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Pause session (disconnect without logout)
|
||||
router.post('/sessions/:accountId/pause', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
await sessionManager.pauseSession(accountId);
|
||||
res.json({ success: true, status: 'paused' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Resume session
|
||||
router.post('/sessions/:accountId/resume', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
const session = await sessionManager.resumeSession(accountId);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session
|
||||
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { createServer } from 'http';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { SessionManager } from './sessions/SessionManager';
|
||||
@@ -21,8 +23,12 @@ async function main() {
|
||||
path: '/ws',
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Serve media files statically
|
||||
app.use('/media', express.static(path.join(process.cwd(), 'media')));
|
||||
|
||||
const sessionManager = new SessionManager('./sessions');
|
||||
const router = createRouter(sessionManager);
|
||||
app.use('/api', router);
|
||||
@@ -36,11 +42,14 @@ async function main() {
|
||||
|
||||
// Forward to API Gateway
|
||||
try {
|
||||
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
|
||||
const response = await fetch(`${API_GATEWAY_URL}/api/whatsapp/internal/whatsapp/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.error({ status: response.status }, 'API Gateway rejected event');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to forward event to API Gateway');
|
||||
}
|
||||
|
||||
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal file
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
WASocket,
|
||||
proto,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage,
|
||||
getContentType,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import pino from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { SessionStore, SessionInfo, SessionEvent } from './types';
|
||||
|
||||
const logger = pino({ level: 'info' });
|
||||
|
||||
export class SessionManager extends EventEmitter {
|
||||
private sessions: Map<string, SessionStore> = new Map();
|
||||
private sessionsPath: string;
|
||||
private mediaPath: string;
|
||||
|
||||
constructor(sessionsPath: string = './sessions') {
|
||||
super();
|
||||
this.sessionsPath = sessionsPath;
|
||||
this.mediaPath = './media';
|
||||
if (!fs.existsSync(sessionsPath)) {
|
||||
fs.mkdirSync(sessionsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.mediaPath)) {
|
||||
fs.mkdirSync(this.mediaPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(accountId: string, name: string, phoneNumber?: string): Promise<SessionInfo> {
|
||||
if (this.sessions.has(accountId)) {
|
||||
const existing = this.sessions.get(accountId)!;
|
||||
return existing.info;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(this.sessionsPath, accountId);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
|
||||
|
||||
const info: SessionInfo = {
|
||||
accountId,
|
||||
phoneNumber: null,
|
||||
name,
|
||||
status: 'connecting',
|
||||
qrCode: null,
|
||||
pairingCode: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const store: SessionStore = { socket: null, info };
|
||||
this.sessions.set(accountId, store);
|
||||
|
||||
// Get latest version
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
logger.info({ version }, 'Using WA version');
|
||||
|
||||
const socket = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
logger: pino({ level: 'warn' }),
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
browser: ['WhatsApp Central', 'Desktop', '1.0.0'],
|
||||
connectTimeoutMs: 60000,
|
||||
retryRequestDelayMs: 2000,
|
||||
});
|
||||
|
||||
store.socket = socket;
|
||||
|
||||
socket.ev.on('creds.update', saveCreds);
|
||||
|
||||
socket.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
logger.info({ connection, hasQR: !!qr }, 'Connection update');
|
||||
|
||||
if (qr) {
|
||||
try {
|
||||
const qrDataUrl = await QRCode.toDataURL(qr);
|
||||
info.qrCode = qrDataUrl;
|
||||
info.status = 'connecting';
|
||||
logger.info({ accountId }, 'QR code generated');
|
||||
this.emitEvent({ type: 'qr', accountId, data: { qrCode: qrDataUrl } });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to generate QR');
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
|
||||
logger.info({ reason, shouldReconnect }, `Connection closed for ${accountId}`);
|
||||
|
||||
info.status = 'disconnected';
|
||||
info.qrCode = null;
|
||||
|
||||
if (shouldReconnect) {
|
||||
// Delete session data and try fresh
|
||||
this.sessions.delete(accountId);
|
||||
setTimeout(() => {
|
||||
logger.info(`Retrying session ${accountId}...`);
|
||||
this.createSession(accountId, name, phoneNumber);
|
||||
}, 5000);
|
||||
} else {
|
||||
logger.info(`Session ${accountId} logged out`);
|
||||
this.emitEvent({ type: 'disconnected', accountId, data: { reason: 'logged_out' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
info.status = 'connected';
|
||||
info.qrCode = null;
|
||||
info.phoneNumber = socket.user?.id?.split(':')[0] || null;
|
||||
logger.info(`Session ${accountId} connected: ${info.phoneNumber}`);
|
||||
this.emitEvent({ type: 'connected', accountId, data: { phoneNumber: info.phoneNumber } });
|
||||
}
|
||||
});
|
||||
|
||||
// Request pairing code if phone number provided
|
||||
if (phoneNumber && !state.creds.registered) {
|
||||
try {
|
||||
const code = await socket.requestPairingCode(phoneNumber);
|
||||
info.pairingCode = code;
|
||||
logger.info({ accountId, code }, 'Pairing code generated');
|
||||
this.emitEvent({ type: 'pairing_code', accountId, data: { code } });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to request pairing code');
|
||||
}
|
||||
}
|
||||
|
||||
socket.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
const remoteJid = msg.key.remoteJid || '';
|
||||
|
||||
// Skip group messages (groups end with @g.us)
|
||||
if (remoteJid.endsWith('@g.us')) continue;
|
||||
|
||||
// Skip broadcast lists
|
||||
if (remoteJid === 'status@broadcast') continue;
|
||||
|
||||
// Skip non-user messages (like system messages)
|
||||
if (!remoteJid.endsWith('@s.whatsapp.net')) continue;
|
||||
|
||||
// Detect message type and handle media
|
||||
let mediaUrl: string | null = null;
|
||||
let mediaType: string = 'text';
|
||||
const msgContent = msg.message;
|
||||
|
||||
if (msgContent) {
|
||||
const contentType = getContentType(msgContent);
|
||||
|
||||
if (contentType && ['imageMessage', 'audioMessage', 'videoMessage', 'documentMessage', 'stickerMessage'].includes(contentType)) {
|
||||
try {
|
||||
// Download media
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg,
|
||||
'buffer',
|
||||
{},
|
||||
{
|
||||
logger,
|
||||
reuploadRequest: socket.updateMediaMessage,
|
||||
}
|
||||
);
|
||||
|
||||
if (buffer) {
|
||||
// Determine file extension
|
||||
const extensions: Record<string, string> = {
|
||||
imageMessage: 'jpg',
|
||||
audioMessage: 'ogg',
|
||||
videoMessage: 'mp4',
|
||||
documentMessage: 'pdf',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
const ext = extensions[contentType] || 'bin';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const filepath = path.join(this.mediaPath, filename);
|
||||
|
||||
// Save file
|
||||
fs.writeFileSync(filepath, buffer as Buffer);
|
||||
mediaUrl = `/media/${filename}`;
|
||||
mediaType = contentType.replace('Message', '');
|
||||
|
||||
logger.info({ accountId, filename, mediaType }, 'Media downloaded');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to download media');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageData = {
|
||||
id: msg.key.id,
|
||||
from: remoteJid,
|
||||
fromMe: msg.key.fromMe || false,
|
||||
pushName: msg.pushName,
|
||||
message: msg.message,
|
||||
timestamp: msg.messageTimestamp,
|
||||
mediaUrl,
|
||||
mediaType,
|
||||
};
|
||||
|
||||
this.emitEvent({ type: 'message', accountId, data: messageData });
|
||||
}
|
||||
});
|
||||
|
||||
socket.ev.on('messages.update', (updates) => {
|
||||
for (const update of updates) {
|
||||
if (update.update.status) {
|
||||
this.emitEvent({
|
||||
type: 'message_status',
|
||||
accountId,
|
||||
data: {
|
||||
id: update.key.id,
|
||||
remoteJid: update.key.remoteJid,
|
||||
status: update.update.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async disconnectSession(accountId: string): Promise<void> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store || !store.socket) return;
|
||||
|
||||
await store.socket.logout();
|
||||
store.socket = null;
|
||||
store.info.status = 'disconnected';
|
||||
store.info.qrCode = null;
|
||||
}
|
||||
|
||||
async pauseSession(accountId: string): Promise<void> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store || !store.socket) return;
|
||||
|
||||
// Close connection without logout (keeps credentials)
|
||||
store.socket.end(undefined);
|
||||
store.socket = null;
|
||||
store.info.status = 'paused';
|
||||
store.info.qrCode = null;
|
||||
|
||||
logger.info({ accountId }, 'Session paused');
|
||||
this.emitEvent({ type: 'paused', accountId, data: {} });
|
||||
}
|
||||
|
||||
async resumeSession(accountId: string): Promise<SessionInfo> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store) {
|
||||
throw new Error(`Session ${accountId} not found`);
|
||||
}
|
||||
|
||||
if (store.info.status === 'connected') {
|
||||
return store.info;
|
||||
}
|
||||
|
||||
logger.info({ accountId }, 'Resuming session');
|
||||
|
||||
// Recreate the session using existing credentials
|
||||
const name = store.info.name;
|
||||
this.sessions.delete(accountId);
|
||||
|
||||
return this.createSession(accountId, name);
|
||||
}
|
||||
|
||||
async deleteSession(accountId: string): Promise<void> {
|
||||
await this.disconnectSession(accountId);
|
||||
this.sessions.delete(accountId);
|
||||
|
||||
const sessionPath = path.join(this.sessionsPath, accountId);
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
fs.rmSync(sessionPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
getSession(accountId: string): SessionInfo | null {
|
||||
const store = this.sessions.get(accountId);
|
||||
return store?.info || null;
|
||||
}
|
||||
|
||||
getAllSessions(): SessionInfo[] {
|
||||
return Array.from(this.sessions.values()).map((s) => s.info);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
accountId: string,
|
||||
to: string,
|
||||
content: proto.IMessage
|
||||
): Promise<proto.WebMessageInfo | null> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store?.socket || store.info.status !== 'connected') {
|
||||
throw new Error(`Session ${accountId} not connected`);
|
||||
}
|
||||
|
||||
const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
|
||||
const result = await store.socket.sendMessage(jid, content as any);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
private emitEvent(event: SessionEvent): void {
|
||||
this.emit('session_event', event);
|
||||
}
|
||||
}
|
||||
31
services/whatsapp-core/src/sessions/types.ts
Normal file
31
services/whatsapp-core/src/sessions/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { WASocket } from '@whiskeysockets/baileys';
|
||||
|
||||
export interface SessionInfo {
|
||||
accountId: string;
|
||||
phoneNumber: string | null;
|
||||
name: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
|
||||
qrCode: string | null;
|
||||
pairingCode: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
socket: WASocket | null;
|
||||
info: SessionInfo;
|
||||
}
|
||||
|
||||
export type SessionEventType =
|
||||
| 'qr'
|
||||
| 'pairing_code'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'paused'
|
||||
| 'message'
|
||||
| 'message_status';
|
||||
|
||||
export interface SessionEvent {
|
||||
type: SessionEventType;
|
||||
accountId: string;
|
||||
data: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user