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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user