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

@@ -8,21 +8,23 @@ server {
try_files $uri $uri/ /index.html;
}
location /api {
location /api/ {
rewrite ^/api/(.*) /$1 break;
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth {
proxy_pass http://api-gateway:8000;
proxy_set_header Host $host;
}
location /ws {
proxy_pass http://whatsapp-core:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /media/ {
proxy_pass http://whatsapp-core:3001/media/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View File

@@ -49,6 +49,7 @@ interface Message {
direction: 'inbound' | 'outbound';
type: string;
content: string | null;
media_url: string | null;
created_at: string;
is_internal_note: boolean;
sent_by: string | null;
@@ -368,6 +369,63 @@ export default function Inbox(): JSX.Element {
);
}
function renderMessageContent(msg: Message): JSX.Element {
// Render media based on type
if (msg.media_url) {
const mediaType = msg.type?.toUpperCase();
if (mediaType === 'IMAGE') {
return (
<>
<img
src={msg.media_url}
alt="Imagen"
style={{
maxWidth: '100%',
maxHeight: 300,
borderRadius: 4,
marginBottom: msg.content ? 8 : 0,
}}
onClick={() => window.open(msg.media_url!, '_blank')}
/>
{msg.content && msg.content !== '[Image]' && (
<Text style={{ color: 'inherit', display: 'block' }}>{msg.content}</Text>
)}
</>
);
}
if (mediaType === 'AUDIO') {
return (
<audio controls style={{ maxWidth: '100%' }}>
<source src={msg.media_url} type="audio/ogg" />
Tu navegador no soporta audio.
</audio>
);
}
if (mediaType === 'VIDEO') {
return (
<video controls style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }}>
<source src={msg.media_url} type="video/mp4" />
Tu navegador no soporta video.
</video>
);
}
if (mediaType === 'DOCUMENT') {
return (
<a href={msg.media_url} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit' }}>
📄 {msg.content || 'Documento'}
</a>
);
}
}
// Default text content
return <Text style={{ color: 'inherit' }}>{msg.content}</Text>;
}
function renderMessage(msg: Message): JSX.Element {
return (
<div
@@ -391,7 +449,7 @@ export default function Inbox(): JSX.Element {
<FileTextOutlined /> Nota interna
</Text>
)}
<Text style={{ color: 'inherit' }}>{msg.content}</Text>
{renderMessageContent(msg)}
</div>
<Text
type="secondary"

View File

@@ -18,6 +18,8 @@ import {
ReloadOutlined,
DeleteOutlined,
QrcodeOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
@@ -28,7 +30,7 @@ interface WhatsAppAccount {
id: string;
name: string;
phone_number: string | null;
status: 'connecting' | 'connected' | 'disconnected';
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
qr_code: string | null;
created_at: string;
}
@@ -76,6 +78,32 @@ export default function WhatsAppAccounts() {
},
});
const pauseMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.post(`/api/whatsapp/accounts/${id}/pause`);
},
onSuccess: () => {
message.success('Conexión pausada');
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
},
onError: () => {
message.error('Error al pausar');
},
});
const resumeMutation = useMutation({
mutationFn: async (id: string) => {
await apiClient.post(`/api/whatsapp/accounts/${id}/resume`);
},
onSuccess: () => {
message.success('Reconectando...');
queryClient.invalidateQueries({ queryKey: ['whatsapp-accounts'] });
},
onError: () => {
message.error('Error al reanudar');
},
});
const handleShowQR = async (account: WhatsAppAccount) => {
const data = await apiClient.get<WhatsAppAccount>(`/api/whatsapp/accounts/${account.id}`);
setQrModal(data);
@@ -102,13 +130,15 @@ export default function WhatsAppAccounts() {
connected: 'green',
connecting: 'orange',
disconnected: 'red',
paused: 'gold',
};
const labels: Record<string, string> = {
connected: 'Conectado',
connecting: 'Conectando',
disconnected: 'Desconectado',
paused: 'Pausado',
};
return <Tag color={colors[status]}>{labels[status]}</Tag>;
return <Tag color={colors[status]}>{labels[status] || status}</Tag>;
},
},
{
@@ -116,7 +146,7 @@ export default function WhatsAppAccounts() {
key: 'actions',
render: (_: any, record: WhatsAppAccount) => (
<Space>
{record.status !== 'connected' && (
{record.status !== 'connected' && record.status !== 'paused' && (
<Button
icon={<QrcodeOutlined />}
onClick={() => handleShowQR(record)}
@@ -124,6 +154,33 @@ export default function WhatsAppAccounts() {
Ver QR
</Button>
)}
{record.status === 'connected' && (
<Button
icon={<PauseCircleOutlined />}
onClick={() => {
Modal.confirm({
title: '¿Pausar conexión?',
content: 'La conexión se pausará pero podrás reanudarla después sin escanear QR.',
okText: 'Pausar',
onOk: () => pauseMutation.mutate(record.id),
});
}}
style={{ color: '#faad14', borderColor: '#faad14' }}
>
Pausar
</Button>
)}
{(record.status === 'paused' || record.status === 'disconnected') && (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => resumeMutation.mutate(record.id)}
loading={resumeMutation.isPending}
style={{ background: '#25D366', borderColor: '#25D366' }}
>
Reanudar
</Button>
)}
<Button
danger
icon={<DeleteOutlined />}