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:
Claude AI
2026-01-30 20:48:56 +00:00
parent 1040debe2e
commit 5dd3499097
33 changed files with 3636 additions and 138 deletions

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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');
}

View 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);
}
}

View 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;
}