2053 lines
64 KiB
Markdown
2053 lines
64 KiB
Markdown
# Fase 6: Módulo Odoo (odoo_whatsapp_hub) - Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Crear un módulo nativo de Odoo que permita enviar/recibir WhatsApp, ver historial de conversaciones, y automatizar envíos desde Odoo.
|
|
|
|
**Architecture:** Módulo Odoo 17 con modelos para sincronizar datos de WhatsApp Central, controladores para webhooks bidireccionales, wizards para envío de mensajes, y widget OWL para chat en tiempo real embebido en res.partner.
|
|
|
|
**Tech Stack:** Python 3.10+, Odoo 17, OWL (Odoo Web Library), XML views, PostgreSQL
|
|
|
|
---
|
|
|
|
## Contexto
|
|
|
|
### Integración con WhatsApp Central
|
|
- WhatsApp Central expone API en `http://api-gateway:8000`
|
|
- El módulo Odoo se comunica via HTTP con la API
|
|
- Webhooks bidireccionales para sincronización en tiempo real
|
|
|
|
### Estructura del Módulo
|
|
```
|
|
odoo_whatsapp_hub/
|
|
├── __manifest__.py
|
|
├── __init__.py
|
|
├── models/
|
|
│ ├── __init__.py
|
|
│ ├── res_partner.py
|
|
│ ├── whatsapp_account.py
|
|
│ ├── whatsapp_conversation.py
|
|
│ └── whatsapp_message.py
|
|
├── controllers/
|
|
│ ├── __init__.py
|
|
│ └── webhook.py
|
|
├── wizards/
|
|
│ ├── __init__.py
|
|
│ ├── send_whatsapp.py
|
|
│ └── mass_whatsapp.py
|
|
├── views/
|
|
│ ├── res_partner_views.xml
|
|
│ ├── whatsapp_account_views.xml
|
|
│ ├── whatsapp_conversation_views.xml
|
|
│ ├── whatsapp_menu.xml
|
|
│ └── send_whatsapp_wizard.xml
|
|
├── static/src/
|
|
│ ├── js/
|
|
│ │ └── chat_widget.js
|
|
│ ├── css/
|
|
│ │ └── whatsapp.css
|
|
│ └── xml/
|
|
│ └── chat_widget.xml
|
|
├── security/
|
|
│ └── ir.model.access.csv
|
|
└── data/
|
|
└── whatsapp_data.xml
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Module Structure and Manifest
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/__manifest__.py`
|
|
- Create: `odoo_whatsapp_hub/__init__.py`
|
|
- Create: `odoo_whatsapp_hub/models/__init__.py`
|
|
- Create: `odoo_whatsapp_hub/controllers/__init__.py`
|
|
- Create: `odoo_whatsapp_hub/wizards/__init__.py`
|
|
|
|
**Step 1: Create __manifest__.py**
|
|
|
|
```python
|
|
{
|
|
'name': 'WhatsApp Hub',
|
|
'version': '17.0.1.0.0',
|
|
'category': 'Marketing',
|
|
'summary': 'Integración WhatsApp Central para envío y recepción de mensajes',
|
|
'description': '''
|
|
Módulo de integración con WhatsApp Central:
|
|
- Enviar mensajes WhatsApp desde cualquier registro
|
|
- Ver historial de conversaciones en contactos
|
|
- Widget de chat en tiempo real
|
|
- Envío masivo a múltiples contactos
|
|
- Automatizaciones basadas en eventos
|
|
''',
|
|
'author': 'Consultoria AS',
|
|
'website': 'https://consultoria-as.com',
|
|
'license': 'LGPL-3',
|
|
'depends': ['base', 'contacts', 'mail'],
|
|
'data': [
|
|
'security/ir.model.access.csv',
|
|
'data/whatsapp_data.xml',
|
|
'views/whatsapp_menu.xml',
|
|
'views/whatsapp_account_views.xml',
|
|
'views/whatsapp_conversation_views.xml',
|
|
'views/res_partner_views.xml',
|
|
'wizards/send_whatsapp_wizard.xml',
|
|
],
|
|
'assets': {
|
|
'web.assets_backend': [
|
|
'odoo_whatsapp_hub/static/src/css/whatsapp.css',
|
|
'odoo_whatsapp_hub/static/src/js/chat_widget.js',
|
|
'odoo_whatsapp_hub/static/src/xml/chat_widget.xml',
|
|
],
|
|
},
|
|
'installable': True,
|
|
'application': True,
|
|
'auto_install': False,
|
|
}
|
|
```
|
|
|
|
**Step 2: Create __init__.py files**
|
|
|
|
`odoo_whatsapp_hub/__init__.py`:
|
|
```python
|
|
from . import models
|
|
from . import controllers
|
|
from . import wizards
|
|
```
|
|
|
|
`odoo_whatsapp_hub/models/__init__.py`:
|
|
```python
|
|
from . import res_partner
|
|
from . import whatsapp_account
|
|
from . import whatsapp_conversation
|
|
from . import whatsapp_message
|
|
```
|
|
|
|
`odoo_whatsapp_hub/controllers/__init__.py`:
|
|
```python
|
|
from . import webhook
|
|
```
|
|
|
|
`odoo_whatsapp_hub/wizards/__init__.py`:
|
|
```python
|
|
from . import send_whatsapp
|
|
from . import mass_whatsapp
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/
|
|
git commit -m "feat(odoo): create module structure and manifest"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: WhatsApp Account Model
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/models/whatsapp_account.py`
|
|
|
|
**Step 1: Create whatsapp_account.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
import requests
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppAccount(models.Model):
|
|
_name = 'whatsapp.account'
|
|
_description = 'WhatsApp Account'
|
|
_order = 'name'
|
|
|
|
name = fields.Char(string='Nombre', required=True)
|
|
phone_number = fields.Char(string='Número de Teléfono')
|
|
status = fields.Selection([
|
|
('disconnected', 'Desconectado'),
|
|
('connecting', 'Conectando'),
|
|
('connected', 'Conectado'),
|
|
], string='Estado', default='disconnected', readonly=True)
|
|
qr_code = fields.Text(string='Código QR')
|
|
external_id = fields.Char(string='ID Externo', help='ID en WhatsApp Central')
|
|
api_url = fields.Char(
|
|
string='URL API',
|
|
default='http://localhost:8000',
|
|
required=True,
|
|
)
|
|
api_key = fields.Char(string='API Key')
|
|
is_default = fields.Boolean(string='Cuenta por Defecto')
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Compañía',
|
|
default=lambda self: self.env.company,
|
|
)
|
|
conversation_count = fields.Integer(
|
|
string='Conversaciones',
|
|
compute='_compute_conversation_count',
|
|
)
|
|
|
|
@api.depends()
|
|
def _compute_conversation_count(self):
|
|
for account in self:
|
|
account.conversation_count = self.env['whatsapp.conversation'].search_count([
|
|
('account_id', '=', account.id)
|
|
])
|
|
|
|
@api.model
|
|
def get_default_account(self):
|
|
"""Get default WhatsApp account"""
|
|
account = self.search([('is_default', '=', True)], limit=1)
|
|
if not account:
|
|
account = self.search([], limit=1)
|
|
return account
|
|
|
|
def action_sync_status(self):
|
|
"""Sync status from WhatsApp Central"""
|
|
self.ensure_one()
|
|
if not self.external_id:
|
|
raise UserError('Esta cuenta no está vinculada a WhatsApp Central')
|
|
|
|
try:
|
|
response = requests.get(
|
|
f'{self.api_url}/api/whatsapp/accounts/{self.external_id}',
|
|
headers=self._get_headers(),
|
|
timeout=10,
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
self.write({
|
|
'status': data.get('status', 'disconnected'),
|
|
'phone_number': data.get('phone_number'),
|
|
'qr_code': data.get('qr_code'),
|
|
})
|
|
except Exception as e:
|
|
_logger.error(f'Error syncing WhatsApp account: {e}')
|
|
raise UserError(f'Error de conexión: {e}')
|
|
|
|
def action_view_conversations(self):
|
|
"""Open conversations for this account"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Conversaciones',
|
|
'res_model': 'whatsapp.conversation',
|
|
'view_mode': 'tree,form',
|
|
'domain': [('account_id', '=', self.id)],
|
|
'context': {'default_account_id': self.id},
|
|
}
|
|
|
|
def _get_headers(self):
|
|
"""Get API headers"""
|
|
headers = {'Content-Type': 'application/json'}
|
|
if self.api_key:
|
|
headers['Authorization'] = f'Bearer {self.api_key}'
|
|
return headers
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
if vals.get('is_default'):
|
|
self.search([('is_default', '=', True)]).write({'is_default': False})
|
|
return super().create(vals)
|
|
|
|
def write(self, vals):
|
|
if vals.get('is_default'):
|
|
self.search([('is_default', '=', True), ('id', 'not in', self.ids)]).write({'is_default': False})
|
|
return super().write(vals)
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/models/whatsapp_account.py
|
|
git commit -m "feat(odoo): add WhatsApp account model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: WhatsApp Conversation Model
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/models/whatsapp_conversation.py`
|
|
|
|
**Step 1: Create whatsapp_conversation.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppConversation(models.Model):
|
|
_name = 'whatsapp.conversation'
|
|
_description = 'WhatsApp Conversation'
|
|
_order = 'last_message_at desc'
|
|
_rec_name = 'display_name'
|
|
|
|
external_id = fields.Char(string='ID Externo', index=True)
|
|
account_id = fields.Many2one(
|
|
'whatsapp.account',
|
|
string='Cuenta WhatsApp',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Contacto',
|
|
ondelete='set null',
|
|
)
|
|
phone_number = fields.Char(string='Teléfono', required=True, index=True)
|
|
contact_name = fields.Char(string='Nombre del Contacto')
|
|
status = fields.Selection([
|
|
('bot', 'Bot'),
|
|
('waiting', 'En Espera'),
|
|
('active', 'Activa'),
|
|
('resolved', 'Resuelta'),
|
|
], string='Estado', default='bot')
|
|
assigned_user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Agente Asignado',
|
|
)
|
|
last_message_at = fields.Datetime(string='Último Mensaje')
|
|
last_message_preview = fields.Char(
|
|
string='Último Mensaje',
|
|
compute='_compute_last_message',
|
|
)
|
|
message_ids = fields.One2many(
|
|
'whatsapp.message',
|
|
'conversation_id',
|
|
string='Mensajes',
|
|
)
|
|
message_count = fields.Integer(
|
|
string='Mensajes',
|
|
compute='_compute_message_count',
|
|
)
|
|
display_name = fields.Char(
|
|
string='Nombre',
|
|
compute='_compute_display_name',
|
|
store=True,
|
|
)
|
|
unread_count = fields.Integer(
|
|
string='No Leídos',
|
|
compute='_compute_unread_count',
|
|
)
|
|
|
|
@api.depends('partner_id', 'contact_name', 'phone_number')
|
|
def _compute_display_name(self):
|
|
for conv in self:
|
|
if conv.partner_id:
|
|
conv.display_name = conv.partner_id.name
|
|
elif conv.contact_name:
|
|
conv.display_name = conv.contact_name
|
|
else:
|
|
conv.display_name = conv.phone_number
|
|
|
|
@api.depends('message_ids')
|
|
def _compute_message_count(self):
|
|
for conv in self:
|
|
conv.message_count = len(conv.message_ids)
|
|
|
|
@api.depends('message_ids.content')
|
|
def _compute_last_message(self):
|
|
for conv in self:
|
|
last_msg = conv.message_ids[:1]
|
|
if last_msg:
|
|
content = last_msg.content or ''
|
|
conv.last_message_preview = content[:50] + '...' if len(content) > 50 else content
|
|
else:
|
|
conv.last_message_preview = ''
|
|
|
|
@api.depends('message_ids.is_read')
|
|
def _compute_unread_count(self):
|
|
for conv in self:
|
|
conv.unread_count = len(conv.message_ids.filtered(
|
|
lambda m: m.direction == 'inbound' and not m.is_read
|
|
))
|
|
|
|
def action_open_chat(self):
|
|
"""Open chat view for this conversation"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': self.display_name,
|
|
'res_model': 'whatsapp.conversation',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_mark_resolved(self):
|
|
"""Mark conversation as resolved"""
|
|
self.write({'status': 'resolved'})
|
|
|
|
def action_assign_to_me(self):
|
|
"""Assign conversation to current user"""
|
|
self.write({
|
|
'assigned_user_id': self.env.user.id,
|
|
'status': 'active',
|
|
})
|
|
|
|
@api.model
|
|
def find_or_create_by_phone(self, phone, account_id, contact_name=None):
|
|
"""Find or create conversation by phone number"""
|
|
conversation = self.search([
|
|
('phone_number', '=', phone),
|
|
('account_id', '=', account_id),
|
|
('status', '!=', 'resolved'),
|
|
], limit=1)
|
|
|
|
if not conversation:
|
|
# Try to find partner
|
|
partner = self.env['res.partner'].search([
|
|
'|',
|
|
('phone', 'ilike', phone[-10:]),
|
|
('mobile', 'ilike', phone[-10:]),
|
|
], limit=1)
|
|
|
|
conversation = self.create({
|
|
'phone_number': phone,
|
|
'account_id': account_id,
|
|
'contact_name': contact_name,
|
|
'partner_id': partner.id if partner else False,
|
|
})
|
|
|
|
return conversation
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/models/whatsapp_conversation.py
|
|
git commit -m "feat(odoo): add WhatsApp conversation model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: WhatsApp Message Model
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/models/whatsapp_message.py`
|
|
|
|
**Step 1: Create whatsapp_message.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
import requests
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppMessage(models.Model):
|
|
_name = 'whatsapp.message'
|
|
_description = 'WhatsApp Message'
|
|
_order = 'create_date desc'
|
|
|
|
external_id = fields.Char(string='ID Externo', index=True)
|
|
conversation_id = fields.Many2one(
|
|
'whatsapp.conversation',
|
|
string='Conversación',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
direction = fields.Selection([
|
|
('inbound', 'Entrante'),
|
|
('outbound', 'Saliente'),
|
|
], string='Dirección', required=True)
|
|
message_type = fields.Selection([
|
|
('text', 'Texto'),
|
|
('image', 'Imagen'),
|
|
('audio', 'Audio'),
|
|
('video', 'Video'),
|
|
('document', 'Documento'),
|
|
('location', 'Ubicación'),
|
|
('contact', 'Contacto'),
|
|
('sticker', 'Sticker'),
|
|
], string='Tipo', default='text')
|
|
content = fields.Text(string='Contenido')
|
|
media_url = fields.Char(string='URL Media')
|
|
status = fields.Selection([
|
|
('pending', 'Pendiente'),
|
|
('sent', 'Enviado'),
|
|
('delivered', 'Entregado'),
|
|
('read', 'Leído'),
|
|
('failed', 'Fallido'),
|
|
], string='Estado', default='pending')
|
|
is_read = fields.Boolean(string='Leído', default=False)
|
|
sent_by_id = fields.Many2one(
|
|
'res.users',
|
|
string='Enviado por',
|
|
)
|
|
error_message = fields.Text(string='Error')
|
|
|
|
@api.model
|
|
def create(self, vals):
|
|
message = super().create(vals)
|
|
# Update conversation last_message_at
|
|
if message.conversation_id:
|
|
message.conversation_id.write({
|
|
'last_message_at': fields.Datetime.now(),
|
|
})
|
|
return message
|
|
|
|
def action_resend(self):
|
|
"""Resend failed message"""
|
|
self.ensure_one()
|
|
if self.status != 'failed':
|
|
return
|
|
|
|
self._send_to_whatsapp_central()
|
|
|
|
def _send_to_whatsapp_central(self):
|
|
"""Send message via WhatsApp Central API"""
|
|
self.ensure_one()
|
|
account = self.conversation_id.account_id
|
|
|
|
try:
|
|
response = requests.post(
|
|
f'{account.api_url}/api/whatsapp/conversations/{self.conversation_id.external_id}/messages',
|
|
headers=account._get_headers(),
|
|
json={
|
|
'type': self.message_type,
|
|
'content': self.content,
|
|
'media_url': self.media_url,
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
self.write({
|
|
'external_id': data.get('id'),
|
|
'status': 'sent',
|
|
'error_message': False,
|
|
})
|
|
else:
|
|
self.write({
|
|
'status': 'failed',
|
|
'error_message': response.text,
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.error(f'Error sending WhatsApp message: {e}')
|
|
self.write({
|
|
'status': 'failed',
|
|
'error_message': str(e),
|
|
})
|
|
|
|
@api.model
|
|
def send_message(self, conversation_id, content, message_type='text', media_url=None):
|
|
"""Helper to send a new message"""
|
|
message = self.create({
|
|
'conversation_id': conversation_id,
|
|
'direction': 'outbound',
|
|
'message_type': message_type,
|
|
'content': content,
|
|
'media_url': media_url,
|
|
'sent_by_id': self.env.user.id,
|
|
})
|
|
message._send_to_whatsapp_central()
|
|
return message
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/models/whatsapp_message.py
|
|
git commit -m "feat(odoo): add WhatsApp message model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Extend res.partner Model
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/models/res_partner.py`
|
|
|
|
**Step 1: Create res_partner.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
|
|
|
|
class ResPartner(models.Model):
|
|
_inherit = 'res.partner'
|
|
|
|
whatsapp_conversation_ids = fields.One2many(
|
|
'whatsapp.conversation',
|
|
'partner_id',
|
|
string='Conversaciones WhatsApp',
|
|
)
|
|
whatsapp_conversation_count = fields.Integer(
|
|
string='Conversaciones',
|
|
compute='_compute_whatsapp_conversation_count',
|
|
)
|
|
whatsapp_last_conversation_id = fields.Many2one(
|
|
'whatsapp.conversation',
|
|
string='Última Conversación',
|
|
compute='_compute_whatsapp_last_conversation',
|
|
)
|
|
whatsapp_unread_count = fields.Integer(
|
|
string='Mensajes No Leídos',
|
|
compute='_compute_whatsapp_unread_count',
|
|
)
|
|
|
|
@api.depends('whatsapp_conversation_ids')
|
|
def _compute_whatsapp_conversation_count(self):
|
|
for partner in self:
|
|
partner.whatsapp_conversation_count = len(partner.whatsapp_conversation_ids)
|
|
|
|
@api.depends('whatsapp_conversation_ids.last_message_at')
|
|
def _compute_whatsapp_last_conversation(self):
|
|
for partner in self:
|
|
conversations = partner.whatsapp_conversation_ids.sorted(
|
|
'last_message_at', reverse=True
|
|
)
|
|
partner.whatsapp_last_conversation_id = conversations[:1].id if conversations else False
|
|
|
|
@api.depends('whatsapp_conversation_ids.unread_count')
|
|
def _compute_whatsapp_unread_count(self):
|
|
for partner in self:
|
|
partner.whatsapp_unread_count = sum(
|
|
partner.whatsapp_conversation_ids.mapped('unread_count')
|
|
)
|
|
|
|
def action_open_whatsapp_conversations(self):
|
|
"""Open WhatsApp conversations for this partner"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': f'Conversaciones - {self.name}',
|
|
'res_model': 'whatsapp.conversation',
|
|
'view_mode': 'tree,form',
|
|
'domain': [('partner_id', '=', self.id)],
|
|
'context': {'default_partner_id': self.id},
|
|
}
|
|
|
|
def action_send_whatsapp(self):
|
|
"""Open wizard to send WhatsApp message"""
|
|
self.ensure_one()
|
|
phone = self.mobile or self.phone
|
|
if not phone:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'message': 'El contacto no tiene número de teléfono',
|
|
'type': 'warning',
|
|
}
|
|
}
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Enviar WhatsApp',
|
|
'res_model': 'whatsapp.send.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_partner_id': self.id,
|
|
'default_phone': phone,
|
|
},
|
|
}
|
|
|
|
def action_open_whatsapp_chat(self):
|
|
"""Open or create WhatsApp conversation"""
|
|
self.ensure_one()
|
|
phone = self.mobile or self.phone
|
|
if not phone:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'message': 'El contacto no tiene número de teléfono',
|
|
'type': 'warning',
|
|
}
|
|
}
|
|
|
|
# Get default account
|
|
account = self.env['whatsapp.account'].get_default_account()
|
|
if not account:
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'message': 'No hay cuenta WhatsApp configurada',
|
|
'type': 'warning',
|
|
}
|
|
}
|
|
|
|
# Find or create conversation
|
|
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
|
|
phone=phone,
|
|
account_id=account.id,
|
|
contact_name=self.name,
|
|
)
|
|
conversation.partner_id = self.id
|
|
|
|
return conversation.action_open_chat()
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/models/res_partner.py
|
|
git commit -m "feat(odoo): extend res.partner with WhatsApp fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Send WhatsApp Wizard
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/wizards/send_whatsapp.py`
|
|
- Create: `odoo_whatsapp_hub/wizards/mass_whatsapp.py`
|
|
|
|
**Step 1: Create send_whatsapp.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class WhatsAppSendWizard(models.TransientModel):
|
|
_name = 'whatsapp.send.wizard'
|
|
_description = 'Send WhatsApp Message'
|
|
|
|
partner_id = fields.Many2one('res.partner', string='Contacto')
|
|
phone = fields.Char(string='Teléfono', required=True)
|
|
account_id = fields.Many2one(
|
|
'whatsapp.account',
|
|
string='Cuenta WhatsApp',
|
|
required=True,
|
|
default=lambda self: self.env['whatsapp.account'].get_default_account(),
|
|
)
|
|
message_type = fields.Selection([
|
|
('text', 'Texto'),
|
|
('image', 'Imagen'),
|
|
('document', 'Documento'),
|
|
], string='Tipo', default='text', required=True)
|
|
content = fields.Text(string='Mensaje', required=True)
|
|
media_url = fields.Char(string='URL del Archivo')
|
|
attachment_id = fields.Many2one('ir.attachment', string='Adjunto')
|
|
|
|
# For sending from other models
|
|
res_model = fields.Char(string='Modelo Origen')
|
|
res_id = fields.Integer(string='ID Origen')
|
|
|
|
@api.onchange('attachment_id')
|
|
def _onchange_attachment_id(self):
|
|
if self.attachment_id:
|
|
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
|
self.media_url = f"{base_url}/web/content/{self.attachment_id.id}"
|
|
|
|
def action_send(self):
|
|
"""Send the WhatsApp message"""
|
|
self.ensure_one()
|
|
|
|
if not self.account_id:
|
|
raise UserError('Seleccione una cuenta de WhatsApp')
|
|
|
|
# Find or create conversation
|
|
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
|
|
phone=self.phone,
|
|
account_id=self.account_id.id,
|
|
contact_name=self.partner_id.name if self.partner_id else None,
|
|
)
|
|
|
|
if self.partner_id:
|
|
conversation.partner_id = self.partner_id
|
|
|
|
# Send message
|
|
self.env['whatsapp.message'].send_message(
|
|
conversation_id=conversation.id,
|
|
content=self.content,
|
|
message_type=self.message_type,
|
|
media_url=self.media_url,
|
|
)
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'message': 'Mensaje enviado correctamente',
|
|
'type': 'success',
|
|
}
|
|
}
|
|
|
|
def action_send_and_open(self):
|
|
"""Send message and open conversation"""
|
|
self.action_send()
|
|
|
|
conversation = self.env['whatsapp.conversation'].search([
|
|
('phone_number', '=', self.phone),
|
|
('account_id', '=', self.account_id.id),
|
|
], limit=1, order='id desc')
|
|
|
|
if conversation:
|
|
return conversation.action_open_chat()
|
|
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
```
|
|
|
|
**Step 2: Create mass_whatsapp.py**
|
|
|
|
```python
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class WhatsAppMassWizard(models.TransientModel):
|
|
_name = 'whatsapp.mass.wizard'
|
|
_description = 'Send Mass WhatsApp Message'
|
|
|
|
account_id = fields.Many2one(
|
|
'whatsapp.account',
|
|
string='Cuenta WhatsApp',
|
|
required=True,
|
|
default=lambda self: self.env['whatsapp.account'].get_default_account(),
|
|
)
|
|
partner_ids = fields.Many2many(
|
|
'res.partner',
|
|
string='Contactos',
|
|
required=True,
|
|
)
|
|
content = fields.Text(string='Mensaje', required=True)
|
|
use_template = fields.Boolean(string='Usar Variables')
|
|
|
|
# Statistics
|
|
total_count = fields.Integer(
|
|
string='Total Contactos',
|
|
compute='_compute_stats',
|
|
)
|
|
valid_count = fields.Integer(
|
|
string='Con Teléfono',
|
|
compute='_compute_stats',
|
|
)
|
|
|
|
@api.depends('partner_ids')
|
|
def _compute_stats(self):
|
|
for wizard in self:
|
|
wizard.total_count = len(wizard.partner_ids)
|
|
wizard.valid_count = len(wizard.partner_ids.filtered(
|
|
lambda p: p.mobile or p.phone
|
|
))
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
res = super().default_get(fields_list)
|
|
|
|
# Get partners from context (when called from list view)
|
|
active_ids = self.env.context.get('active_ids', [])
|
|
active_model = self.env.context.get('active_model')
|
|
|
|
if active_model == 'res.partner' and active_ids:
|
|
res['partner_ids'] = [(6, 0, active_ids)]
|
|
|
|
return res
|
|
|
|
def action_send(self):
|
|
"""Send WhatsApp to all selected partners"""
|
|
self.ensure_one()
|
|
|
|
if not self.account_id:
|
|
raise UserError('Seleccione una cuenta de WhatsApp')
|
|
|
|
sent_count = 0
|
|
failed_count = 0
|
|
|
|
for partner in self.partner_ids:
|
|
phone = partner.mobile or partner.phone
|
|
if not phone:
|
|
failed_count += 1
|
|
continue
|
|
|
|
try:
|
|
# Interpolate message if using template
|
|
content = self.content
|
|
if self.use_template:
|
|
content = content.replace('{{name}}', partner.name or '')
|
|
content = content.replace('{{email}}', partner.email or '')
|
|
|
|
# Find or create conversation
|
|
conversation = self.env['whatsapp.conversation'].find_or_create_by_phone(
|
|
phone=phone,
|
|
account_id=self.account_id.id,
|
|
contact_name=partner.name,
|
|
)
|
|
conversation.partner_id = partner
|
|
|
|
# Send message
|
|
self.env['whatsapp.message'].send_message(
|
|
conversation_id=conversation.id,
|
|
content=content,
|
|
)
|
|
sent_count += 1
|
|
|
|
except Exception:
|
|
failed_count += 1
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'message': f'Enviados: {sent_count}, Fallidos: {failed_count}',
|
|
'type': 'success' if failed_count == 0 else 'warning',
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/wizards/
|
|
git commit -m "feat(odoo): add send WhatsApp wizards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Webhook Controller
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/controllers/webhook.py`
|
|
|
|
**Step 1: Create webhook.py**
|
|
|
|
```python
|
|
from odoo import http
|
|
from odoo.http import request
|
|
import json
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppWebhookController(http.Controller):
|
|
|
|
@http.route('/whatsapp/webhook', type='json', auth='public', methods=['POST'], csrf=False)
|
|
def webhook(self, **kwargs):
|
|
"""
|
|
Receive webhooks from WhatsApp Central.
|
|
Events: message, status_update, conversation_update
|
|
"""
|
|
try:
|
|
data = request.jsonrequest
|
|
event_type = data.get('type')
|
|
|
|
_logger.info(f'WhatsApp webhook received: {event_type}')
|
|
|
|
handlers = {
|
|
'message': self._handle_message,
|
|
'status_update': self._handle_status_update,
|
|
'conversation_update': self._handle_conversation_update,
|
|
'account_status': self._handle_account_status,
|
|
}
|
|
|
|
handler = handlers.get(event_type)
|
|
if handler:
|
|
return handler(data)
|
|
|
|
return {'status': 'ignored', 'reason': f'Unknown event type: {event_type}'}
|
|
|
|
except Exception as e:
|
|
_logger.error(f'WhatsApp webhook error: {e}')
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def _handle_message(self, data):
|
|
"""Handle incoming message"""
|
|
msg_data = data.get('data', {})
|
|
account_external_id = data.get('account_id')
|
|
conversation_external_id = msg_data.get('conversation_id')
|
|
|
|
# Find account
|
|
account = request.env['whatsapp.account'].sudo().search([
|
|
('external_id', '=', account_external_id)
|
|
], limit=1)
|
|
|
|
if not account:
|
|
return {'status': 'ignored', 'reason': 'Account not found'}
|
|
|
|
# Find or create conversation
|
|
conversation = request.env['whatsapp.conversation'].sudo().search([
|
|
('external_id', '=', conversation_external_id)
|
|
], limit=1)
|
|
|
|
if not conversation:
|
|
# Create new conversation
|
|
phone = msg_data.get('from', '').split('@')[0]
|
|
conversation = request.env['whatsapp.conversation'].sudo().create({
|
|
'external_id': conversation_external_id,
|
|
'account_id': account.id,
|
|
'phone_number': phone,
|
|
'contact_name': msg_data.get('contact_name'),
|
|
'status': 'bot',
|
|
})
|
|
|
|
# Try to link to existing partner
|
|
partner = request.env['res.partner'].sudo().search([
|
|
'|',
|
|
('phone', 'ilike', phone[-10:]),
|
|
('mobile', 'ilike', phone[-10:]),
|
|
], limit=1)
|
|
|
|
if partner:
|
|
conversation.partner_id = partner
|
|
|
|
# Create message
|
|
request.env['whatsapp.message'].sudo().create({
|
|
'external_id': msg_data.get('id'),
|
|
'conversation_id': conversation.id,
|
|
'direction': 'inbound',
|
|
'message_type': msg_data.get('type', 'text'),
|
|
'content': msg_data.get('content'),
|
|
'media_url': msg_data.get('media_url'),
|
|
'status': 'delivered',
|
|
})
|
|
|
|
return {'status': 'ok'}
|
|
|
|
def _handle_status_update(self, data):
|
|
"""Handle message status update"""
|
|
msg_data = data.get('data', {})
|
|
external_id = msg_data.get('message_id')
|
|
new_status = msg_data.get('status')
|
|
|
|
message = request.env['whatsapp.message'].sudo().search([
|
|
('external_id', '=', external_id)
|
|
], limit=1)
|
|
|
|
if message:
|
|
message.write({'status': new_status})
|
|
|
|
return {'status': 'ok'}
|
|
|
|
def _handle_conversation_update(self, data):
|
|
"""Handle conversation status update"""
|
|
conv_data = data.get('data', {})
|
|
external_id = conv_data.get('conversation_id')
|
|
new_status = conv_data.get('status')
|
|
|
|
conversation = request.env['whatsapp.conversation'].sudo().search([
|
|
('external_id', '=', external_id)
|
|
], limit=1)
|
|
|
|
if conversation:
|
|
conversation.write({'status': new_status})
|
|
|
|
return {'status': 'ok'}
|
|
|
|
def _handle_account_status(self, data):
|
|
"""Handle account status change"""
|
|
acc_data = data.get('data', {})
|
|
external_id = data.get('account_id')
|
|
new_status = acc_data.get('status')
|
|
|
|
account = request.env['whatsapp.account'].sudo().search([
|
|
('external_id', '=', external_id)
|
|
], limit=1)
|
|
|
|
if account:
|
|
account.write({
|
|
'status': new_status,
|
|
'phone_number': acc_data.get('phone_number'),
|
|
'qr_code': acc_data.get('qr_code'),
|
|
})
|
|
|
|
return {'status': 'ok'}
|
|
|
|
@http.route('/whatsapp/webhook/test', type='http', auth='public', methods=['GET'])
|
|
def webhook_test(self):
|
|
"""Test endpoint to verify webhook connectivity"""
|
|
return json.dumps({'status': 'ok', 'message': 'WhatsApp webhook is active'})
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/controllers/
|
|
git commit -m "feat(odoo): add WhatsApp webhook controller"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: XML Views - Menu and Account
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/views/whatsapp_menu.xml`
|
|
- Create: `odoo_whatsapp_hub/views/whatsapp_account_views.xml`
|
|
|
|
**Step 1: Create whatsapp_menu.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<!-- Main Menu -->
|
|
<menuitem
|
|
id="menu_whatsapp_root"
|
|
name="WhatsApp"
|
|
web_icon="odoo_whatsapp_hub,static/description/icon.png"
|
|
sequence="50"
|
|
/>
|
|
|
|
<!-- Conversations Menu -->
|
|
<menuitem
|
|
id="menu_whatsapp_conversations"
|
|
name="Conversaciones"
|
|
parent="menu_whatsapp_root"
|
|
action="action_whatsapp_conversation"
|
|
sequence="10"
|
|
/>
|
|
|
|
<!-- Accounts Menu -->
|
|
<menuitem
|
|
id="menu_whatsapp_accounts"
|
|
name="Cuentas"
|
|
parent="menu_whatsapp_root"
|
|
action="action_whatsapp_account"
|
|
sequence="20"
|
|
/>
|
|
|
|
<!-- Configuration Menu -->
|
|
<menuitem
|
|
id="menu_whatsapp_config"
|
|
name="Configuración"
|
|
parent="menu_whatsapp_root"
|
|
sequence="100"
|
|
/>
|
|
|
|
<menuitem
|
|
id="menu_whatsapp_accounts_config"
|
|
name="Cuentas WhatsApp"
|
|
parent="menu_whatsapp_config"
|
|
action="action_whatsapp_account"
|
|
sequence="10"
|
|
/>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 2: Create whatsapp_account_views.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<!-- Tree View -->
|
|
<record id="view_whatsapp_account_tree" model="ir.ui.view">
|
|
<field name="name">whatsapp.account.tree</field>
|
|
<field name="model">whatsapp.account</field>
|
|
<field name="arch" type="xml">
|
|
<tree>
|
|
<field name="name"/>
|
|
<field name="phone_number"/>
|
|
<field name="status" widget="badge" decoration-success="status == 'connected'" decoration-warning="status == 'connecting'" decoration-danger="status == 'disconnected'"/>
|
|
<field name="is_default"/>
|
|
<field name="conversation_count"/>
|
|
</tree>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Form View -->
|
|
<record id="view_whatsapp_account_form" model="ir.ui.view">
|
|
<field name="name">whatsapp.account.form</field>
|
|
<field name="model">whatsapp.account</field>
|
|
<field name="arch" type="xml">
|
|
<form>
|
|
<header>
|
|
<button name="action_sync_status" string="Actualizar Estado" type="object" class="btn-primary"/>
|
|
<field name="status" widget="statusbar" statusbar_visible="disconnected,connecting,connected"/>
|
|
</header>
|
|
<sheet>
|
|
<div class="oe_button_box" name="button_box">
|
|
<button name="action_view_conversations" type="object" class="oe_stat_button" icon="fa-comments">
|
|
<field name="conversation_count" widget="statinfo" string="Conversaciones"/>
|
|
</button>
|
|
</div>
|
|
<group>
|
|
<group>
|
|
<field name="name"/>
|
|
<field name="phone_number"/>
|
|
<field name="external_id"/>
|
|
<field name="is_default"/>
|
|
</group>
|
|
<group>
|
|
<field name="api_url"/>
|
|
<field name="api_key" password="True"/>
|
|
<field name="company_id" groups="base.group_multi_company"/>
|
|
</group>
|
|
</group>
|
|
<notebook>
|
|
<page string="Código QR" name="qr_code" invisible="status != 'connecting'">
|
|
<field name="qr_code" widget="image" class="oe_avatar"/>
|
|
</page>
|
|
</notebook>
|
|
</sheet>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Action -->
|
|
<record id="action_whatsapp_account" model="ir.actions.act_window">
|
|
<field name="name">Cuentas WhatsApp</field>
|
|
<field name="res_model">whatsapp.account</field>
|
|
<field name="view_mode">tree,form</field>
|
|
</record>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/views/whatsapp_menu.xml odoo_whatsapp_hub/views/whatsapp_account_views.xml
|
|
git commit -m "feat(odoo): add menu and account views"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: XML Views - Conversation and Partner
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/views/whatsapp_conversation_views.xml`
|
|
- Create: `odoo_whatsapp_hub/views/res_partner_views.xml`
|
|
|
|
**Step 1: Create whatsapp_conversation_views.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<!-- Tree View -->
|
|
<record id="view_whatsapp_conversation_tree" model="ir.ui.view">
|
|
<field name="name">whatsapp.conversation.tree</field>
|
|
<field name="model">whatsapp.conversation</field>
|
|
<field name="arch" type="xml">
|
|
<tree decoration-bf="unread_count > 0">
|
|
<field name="display_name"/>
|
|
<field name="phone_number"/>
|
|
<field name="status" widget="badge" decoration-info="status == 'bot'" decoration-warning="status == 'waiting'" decoration-success="status == 'active'" decoration-muted="status == 'resolved'"/>
|
|
<field name="assigned_user_id" widget="many2one_avatar_user"/>
|
|
<field name="last_message_at"/>
|
|
<field name="last_message_preview"/>
|
|
<field name="unread_count" widget="badge" decoration-danger="unread_count > 0"/>
|
|
</tree>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Form View -->
|
|
<record id="view_whatsapp_conversation_form" model="ir.ui.view">
|
|
<field name="name">whatsapp.conversation.form</field>
|
|
<field name="model">whatsapp.conversation</field>
|
|
<field name="arch" type="xml">
|
|
<form>
|
|
<header>
|
|
<button name="action_assign_to_me" string="Asignarme" type="object" class="btn-primary" invisible="assigned_user_id"/>
|
|
<button name="action_mark_resolved" string="Resolver" type="object" class="btn-secondary" invisible="status == 'resolved'"/>
|
|
<field name="status" widget="statusbar" statusbar_visible="bot,waiting,active,resolved"/>
|
|
</header>
|
|
<sheet>
|
|
<group>
|
|
<group>
|
|
<field name="display_name" readonly="1"/>
|
|
<field name="phone_number"/>
|
|
<field name="partner_id"/>
|
|
</group>
|
|
<group>
|
|
<field name="account_id"/>
|
|
<field name="assigned_user_id"/>
|
|
<field name="last_message_at"/>
|
|
</group>
|
|
</group>
|
|
<notebook>
|
|
<page string="Mensajes" name="messages">
|
|
<field name="message_ids" mode="tree" readonly="1">
|
|
<tree>
|
|
<field name="create_date"/>
|
|
<field name="direction" widget="badge"/>
|
|
<field name="content"/>
|
|
<field name="status" widget="badge"/>
|
|
<field name="sent_by_id"/>
|
|
</tree>
|
|
</field>
|
|
</page>
|
|
</notebook>
|
|
</sheet>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Kanban View -->
|
|
<record id="view_whatsapp_conversation_kanban" model="ir.ui.view">
|
|
<field name="name">whatsapp.conversation.kanban</field>
|
|
<field name="model">whatsapp.conversation</field>
|
|
<field name="arch" type="xml">
|
|
<kanban default_group_by="status">
|
|
<field name="display_name"/>
|
|
<field name="phone_number"/>
|
|
<field name="status"/>
|
|
<field name="unread_count"/>
|
|
<field name="last_message_preview"/>
|
|
<templates>
|
|
<t t-name="kanban-box">
|
|
<div class="oe_kanban_global_click">
|
|
<div class="oe_kanban_content">
|
|
<div class="o_kanban_record_title">
|
|
<strong><field name="display_name"/></strong>
|
|
<span t-if="record.unread_count.raw_value > 0" class="badge bg-danger ms-2">
|
|
<t t-esc="record.unread_count.value"/>
|
|
</span>
|
|
</div>
|
|
<div class="text-muted">
|
|
<field name="phone_number"/>
|
|
</div>
|
|
<div class="text-truncate">
|
|
<field name="last_message_preview"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</t>
|
|
</templates>
|
|
</kanban>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Search View -->
|
|
<record id="view_whatsapp_conversation_search" model="ir.ui.view">
|
|
<field name="name">whatsapp.conversation.search</field>
|
|
<field name="model">whatsapp.conversation</field>
|
|
<field name="arch" type="xml">
|
|
<search>
|
|
<field name="display_name"/>
|
|
<field name="phone_number"/>
|
|
<field name="partner_id"/>
|
|
<filter name="filter_unread" string="No Leídos" domain="[('unread_count', '>', 0)]"/>
|
|
<filter name="filter_active" string="Activas" domain="[('status', 'in', ['bot', 'waiting', 'active'])]"/>
|
|
<filter name="filter_mine" string="Mis Conversaciones" domain="[('assigned_user_id', '=', uid)]"/>
|
|
<group expand="0" string="Agrupar por">
|
|
<filter name="group_status" string="Estado" context="{'group_by': 'status'}"/>
|
|
<filter name="group_account" string="Cuenta" context="{'group_by': 'account_id'}"/>
|
|
<filter name="group_agent" string="Agente" context="{'group_by': 'assigned_user_id'}"/>
|
|
</group>
|
|
</search>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Action -->
|
|
<record id="action_whatsapp_conversation" model="ir.actions.act_window">
|
|
<field name="name">Conversaciones</field>
|
|
<field name="res_model">whatsapp.conversation</field>
|
|
<field name="view_mode">kanban,tree,form</field>
|
|
<field name="context">{'search_default_filter_active': 1}</field>
|
|
</record>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 2: Create res_partner_views.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<!-- Inherit Partner Form -->
|
|
<record id="view_partner_form_whatsapp" model="ir.ui.view">
|
|
<field name="name">res.partner.form.whatsapp</field>
|
|
<field name="model">res.partner</field>
|
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
|
<field name="arch" type="xml">
|
|
<!-- Add WhatsApp button box -->
|
|
<xpath expr="//div[@name='button_box']" position="inside">
|
|
<button name="action_open_whatsapp_conversations" type="object" class="oe_stat_button" icon="fa-whatsapp">
|
|
<div class="o_field_widget o_stat_info">
|
|
<span class="o_stat_value">
|
|
<field name="whatsapp_conversation_count"/>
|
|
</span>
|
|
<span class="o_stat_text">WhatsApp</span>
|
|
</div>
|
|
</button>
|
|
</xpath>
|
|
|
|
<!-- Add WhatsApp action buttons -->
|
|
<xpath expr="//div[hasclass('oe_title')]" position="after">
|
|
<div class="mb-2" invisible="not mobile and not phone">
|
|
<button name="action_send_whatsapp" string="Enviar WhatsApp" type="object" class="btn btn-success" icon="fa-whatsapp"/>
|
|
<button name="action_open_whatsapp_chat" string="Abrir Chat" type="object" class="btn btn-outline-success ms-1" icon="fa-comments"/>
|
|
</div>
|
|
</xpath>
|
|
|
|
<!-- Add WhatsApp notebook page -->
|
|
<xpath expr="//notebook" position="inside">
|
|
<page string="WhatsApp" name="whatsapp" invisible="whatsapp_conversation_count == 0">
|
|
<field name="whatsapp_conversation_ids" mode="tree" readonly="1">
|
|
<tree>
|
|
<field name="account_id"/>
|
|
<field name="status" widget="badge"/>
|
|
<field name="last_message_at"/>
|
|
<field name="last_message_preview"/>
|
|
<field name="unread_count" widget="badge" decoration-danger="unread_count > 0"/>
|
|
</tree>
|
|
</field>
|
|
</page>
|
|
</xpath>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Partner Tree - Add WhatsApp indicator -->
|
|
<record id="view_partner_tree_whatsapp" model="ir.ui.view">
|
|
<field name="name">res.partner.tree.whatsapp</field>
|
|
<field name="model">res.partner</field>
|
|
<field name="inherit_id" ref="base.view_partner_tree"/>
|
|
<field name="arch" type="xml">
|
|
<xpath expr="//field[@name='phone']" position="after">
|
|
<field name="whatsapp_unread_count" widget="badge" decoration-danger="whatsapp_unread_count > 0" optional="show"/>
|
|
</xpath>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Server Action: Send WhatsApp -->
|
|
<record id="action_partner_send_whatsapp" model="ir.actions.server">
|
|
<field name="name">Enviar WhatsApp</field>
|
|
<field name="model_id" ref="base.model_res_partner"/>
|
|
<field name="binding_model_id" ref="base.model_res_partner"/>
|
|
<field name="binding_view_types">list,form</field>
|
|
<field name="state">code</field>
|
|
<field name="code">
|
|
if records:
|
|
if len(records) == 1:
|
|
action = records.action_send_whatsapp()
|
|
else:
|
|
action = {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Envío Masivo WhatsApp',
|
|
'res_model': 'whatsapp.mass.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'default_partner_ids': records.ids},
|
|
}
|
|
</field>
|
|
</record>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/views/whatsapp_conversation_views.xml odoo_whatsapp_hub/views/res_partner_views.xml
|
|
git commit -m "feat(odoo): add conversation and partner views"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Wizard Views
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml`
|
|
|
|
**Step 1: Create send_whatsapp_wizard.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<!-- Send WhatsApp Wizard Form -->
|
|
<record id="view_whatsapp_send_wizard_form" model="ir.ui.view">
|
|
<field name="name">whatsapp.send.wizard.form</field>
|
|
<field name="model">whatsapp.send.wizard</field>
|
|
<field name="arch" type="xml">
|
|
<form string="Enviar WhatsApp">
|
|
<group>
|
|
<group>
|
|
<field name="partner_id" readonly="partner_id"/>
|
|
<field name="phone"/>
|
|
<field name="account_id"/>
|
|
</group>
|
|
<group>
|
|
<field name="message_type"/>
|
|
<field name="attachment_id" invisible="message_type == 'text'"/>
|
|
<field name="media_url" invisible="message_type == 'text'"/>
|
|
</group>
|
|
</group>
|
|
<group>
|
|
<field name="content" placeholder="Escribe tu mensaje aquí..."/>
|
|
</group>
|
|
<footer>
|
|
<button name="action_send" string="Enviar" type="object" class="btn-primary"/>
|
|
<button name="action_send_and_open" string="Enviar y Abrir Chat" type="object" class="btn-secondary"/>
|
|
<button string="Cancelar" class="btn-secondary" special="cancel"/>
|
|
</footer>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Mass WhatsApp Wizard Form -->
|
|
<record id="view_whatsapp_mass_wizard_form" model="ir.ui.view">
|
|
<field name="name">whatsapp.mass.wizard.form</field>
|
|
<field name="model">whatsapp.mass.wizard</field>
|
|
<field name="arch" type="xml">
|
|
<form string="Envío Masivo WhatsApp">
|
|
<group>
|
|
<group>
|
|
<field name="account_id"/>
|
|
<field name="use_template"/>
|
|
</group>
|
|
<group>
|
|
<field name="total_count" readonly="1"/>
|
|
<field name="valid_count" readonly="1"/>
|
|
</group>
|
|
</group>
|
|
<group string="Contactos">
|
|
<field name="partner_ids" widget="many2many_tags"/>
|
|
</group>
|
|
<group string="Mensaje">
|
|
<field name="content" placeholder="Escribe tu mensaje aquí... Variables disponibles: {{name}}, {{email}}"/>
|
|
</group>
|
|
<div class="alert alert-info" role="alert" invisible="not use_template">
|
|
<strong>Variables disponibles:</strong>
|
|
<ul>
|
|
<li><code>{{name}}</code> - Nombre del contacto</li>
|
|
<li><code>{{email}}</code> - Email del contacto</li>
|
|
</ul>
|
|
</div>
|
|
<footer>
|
|
<button name="action_send" string="Enviar a Todos" type="object" class="btn-primary" confirm="¿Está seguro de enviar el mensaje a todos los contactos seleccionados?"/>
|
|
<button string="Cancelar" class="btn-secondary" special="cancel"/>
|
|
</footer>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Action for Mass Wizard -->
|
|
<record id="action_whatsapp_mass_wizard" model="ir.actions.act_window">
|
|
<field name="name">Envío Masivo WhatsApp</field>
|
|
<field name="res_model">whatsapp.mass.wizard</field>
|
|
<field name="view_mode">form</field>
|
|
<field name="target">new</field>
|
|
</record>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml
|
|
git commit -m "feat(odoo): add wizard views"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Security and Data Files
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/security/ir.model.access.csv`
|
|
- Create: `odoo_whatsapp_hub/data/whatsapp_data.xml`
|
|
|
|
**Step 1: Create ir.model.access.csv**
|
|
|
|
```csv
|
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
|
access_whatsapp_account_user,whatsapp.account.user,model_whatsapp_account,base.group_user,1,0,0,0
|
|
access_whatsapp_account_manager,whatsapp.account.manager,model_whatsapp_account,base.group_system,1,1,1,1
|
|
access_whatsapp_conversation_user,whatsapp.conversation.user,model_whatsapp_conversation,base.group_user,1,1,1,0
|
|
access_whatsapp_conversation_manager,whatsapp.conversation.manager,model_whatsapp_conversation,base.group_system,1,1,1,1
|
|
access_whatsapp_message_user,whatsapp.message.user,model_whatsapp_message,base.group_user,1,1,1,0
|
|
access_whatsapp_message_manager,whatsapp.message.manager,model_whatsapp_message,base.group_system,1,1,1,1
|
|
access_whatsapp_send_wizard,whatsapp.send.wizard,model_whatsapp_send_wizard,base.group_user,1,1,1,1
|
|
access_whatsapp_mass_wizard,whatsapp.mass.wizard,model_whatsapp_mass_wizard,base.group_user,1,1,1,1
|
|
```
|
|
|
|
**Step 2: Create whatsapp_data.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<odoo>
|
|
<data noupdate="1">
|
|
<!-- Default WhatsApp Account (to be configured) -->
|
|
<record id="default_whatsapp_account" model="whatsapp.account">
|
|
<field name="name">WhatsApp Principal</field>
|
|
<field name="api_url">http://localhost:8000</field>
|
|
<field name="is_default">True</field>
|
|
</record>
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/security/ odoo_whatsapp_hub/data/
|
|
git commit -m "feat(odoo): add security and data files"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Static Assets - CSS
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/static/src/css/whatsapp.css`
|
|
|
|
**Step 1: Create whatsapp.css**
|
|
|
|
```css
|
|
/* WhatsApp Hub Styles */
|
|
|
|
/* WhatsApp Green */
|
|
:root {
|
|
--whatsapp-green: #25D366;
|
|
--whatsapp-dark-green: #128C7E;
|
|
--whatsapp-light-green: #DCF8C6;
|
|
--whatsapp-bg: #E5DDD5;
|
|
}
|
|
|
|
/* Chat container */
|
|
.o_whatsapp_chat_container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: var(--whatsapp-bg);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Chat header */
|
|
.o_whatsapp_chat_header {
|
|
background: var(--whatsapp-dark-green);
|
|
color: white;
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.o_whatsapp_chat_header .avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
color: var(--whatsapp-dark-green);
|
|
}
|
|
|
|
.o_whatsapp_chat_header .contact-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.o_whatsapp_chat_header .contact-name {
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.o_whatsapp_chat_header .contact-status {
|
|
font-size: 12px;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* Messages container */
|
|
.o_whatsapp_messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Message bubble */
|
|
.o_whatsapp_message {
|
|
max-width: 65%;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
position: relative;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.o_whatsapp_message.inbound {
|
|
align-self: flex-start;
|
|
background: white;
|
|
border-top-left-radius: 0;
|
|
}
|
|
|
|
.o_whatsapp_message.outbound {
|
|
align-self: flex-end;
|
|
background: var(--whatsapp-light-green);
|
|
border-top-right-radius: 0;
|
|
}
|
|
|
|
.o_whatsapp_message .message-content {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.o_whatsapp_message .message-meta {
|
|
font-size: 11px;
|
|
color: #667781;
|
|
text-align: right;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 4px;
|
|
}
|
|
|
|
.o_whatsapp_message .message-status {
|
|
color: #53bdeb;
|
|
}
|
|
|
|
/* Input area */
|
|
.o_whatsapp_input_area {
|
|
background: #F0F2F5;
|
|
padding: 12px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.o_whatsapp_input_area input {
|
|
flex: 1;
|
|
border: none;
|
|
border-radius: 20px;
|
|
padding: 10px 16px;
|
|
outline: none;
|
|
}
|
|
|
|
.o_whatsapp_input_area button {
|
|
background: var(--whatsapp-green);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.o_whatsapp_input_area button:hover {
|
|
background: var(--whatsapp-dark-green);
|
|
}
|
|
|
|
/* Status badges */
|
|
.o_whatsapp_status_connected {
|
|
color: var(--whatsapp-green);
|
|
}
|
|
|
|
.o_whatsapp_status_disconnected {
|
|
color: #dc3545;
|
|
}
|
|
|
|
/* Partner WhatsApp button */
|
|
.btn-whatsapp {
|
|
background-color: var(--whatsapp-green);
|
|
border-color: var(--whatsapp-green);
|
|
color: white;
|
|
}
|
|
|
|
.btn-whatsapp:hover {
|
|
background-color: var(--whatsapp-dark-green);
|
|
border-color: var(--whatsapp-dark-green);
|
|
color: white;
|
|
}
|
|
|
|
/* Unread badge */
|
|
.o_whatsapp_unread_badge {
|
|
background: #dc3545;
|
|
color: white;
|
|
border-radius: 10px;
|
|
padding: 2px 8px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* QR Code display */
|
|
.o_whatsapp_qr_code {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.o_whatsapp_qr_code img {
|
|
max-width: 300px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/static/src/css/
|
|
git commit -m "feat(odoo): add WhatsApp CSS styles"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Static Assets - OWL Chat Widget
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/static/src/js/chat_widget.js`
|
|
- Create: `odoo_whatsapp_hub/static/src/xml/chat_widget.xml`
|
|
|
|
**Step 1: Create chat_widget.js**
|
|
|
|
```javascript
|
|
/** @odoo-module **/
|
|
|
|
import { registry } from "@web/core/registry";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
import { Component, useState, onWillStart, onMounted } from "@odoo/owl";
|
|
|
|
export class WhatsAppChatWidget extends Component {
|
|
static template = "odoo_whatsapp_hub.ChatWidget";
|
|
static props = {
|
|
conversationId: { type: Number, optional: true },
|
|
partnerId: { type: Number, optional: true },
|
|
};
|
|
|
|
setup() {
|
|
this.orm = useService("orm");
|
|
this.state = useState({
|
|
conversation: null,
|
|
messages: [],
|
|
newMessage: "",
|
|
loading: true,
|
|
});
|
|
|
|
onWillStart(async () => {
|
|
await this.loadConversation();
|
|
});
|
|
|
|
onMounted(() => {
|
|
this.scrollToBottom();
|
|
});
|
|
}
|
|
|
|
async loadConversation() {
|
|
this.state.loading = true;
|
|
|
|
try {
|
|
if (this.props.conversationId) {
|
|
const conversations = await this.orm.searchRead(
|
|
"whatsapp.conversation",
|
|
[["id", "=", this.props.conversationId]],
|
|
["id", "display_name", "phone_number", "status"]
|
|
);
|
|
if (conversations.length) {
|
|
this.state.conversation = conversations[0];
|
|
await this.loadMessages();
|
|
}
|
|
}
|
|
} finally {
|
|
this.state.loading = false;
|
|
}
|
|
}
|
|
|
|
async loadMessages() {
|
|
if (!this.state.conversation) return;
|
|
|
|
const messages = await this.orm.searchRead(
|
|
"whatsapp.message",
|
|
[["conversation_id", "=", this.state.conversation.id]],
|
|
["id", "direction", "content", "message_type", "status", "create_date"],
|
|
{ order: "create_date asc" }
|
|
);
|
|
this.state.messages = messages;
|
|
|
|
// Mark as read
|
|
const unreadIds = messages
|
|
.filter(m => m.direction === "inbound" && !m.is_read)
|
|
.map(m => m.id);
|
|
if (unreadIds.length) {
|
|
await this.orm.write("whatsapp.message", unreadIds, { is_read: true });
|
|
}
|
|
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
async sendMessage() {
|
|
if (!this.state.newMessage.trim() || !this.state.conversation) return;
|
|
|
|
const content = this.state.newMessage;
|
|
this.state.newMessage = "";
|
|
|
|
await this.orm.call(
|
|
"whatsapp.message",
|
|
"send_message",
|
|
[this.state.conversation.id, content]
|
|
);
|
|
|
|
await this.loadMessages();
|
|
}
|
|
|
|
scrollToBottom() {
|
|
const container = document.querySelector(".o_whatsapp_messages");
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
|
|
formatTime(dateStr) {
|
|
if (!dateStr) return "";
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
getStatusIcon(status) {
|
|
const icons = {
|
|
pending: "fa-clock-o",
|
|
sent: "fa-check",
|
|
delivered: "fa-check-double text-muted",
|
|
read: "fa-check-double text-primary",
|
|
failed: "fa-exclamation-circle text-danger",
|
|
};
|
|
return icons[status] || "";
|
|
}
|
|
|
|
onKeyPress(ev) {
|
|
if (ev.key === "Enter" && !ev.shiftKey) {
|
|
ev.preventDefault();
|
|
this.sendMessage();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register the component
|
|
registry.category("public_components").add("WhatsAppChatWidget", WhatsAppChatWidget);
|
|
```
|
|
|
|
**Step 2: Create chat_widget.xml**
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<templates xml:space="preserve">
|
|
<t t-name="odoo_whatsapp_hub.ChatWidget">
|
|
<div class="o_whatsapp_chat_container">
|
|
<t t-if="state.loading">
|
|
<div class="d-flex justify-content-center align-items-center h-100">
|
|
<i class="fa fa-spinner fa-spin fa-2x"/>
|
|
</div>
|
|
</t>
|
|
<t t-elif="state.conversation">
|
|
<!-- Header -->
|
|
<div class="o_whatsapp_chat_header">
|
|
<div class="avatar">
|
|
<t t-esc="state.conversation.display_name[0]"/>
|
|
</div>
|
|
<div class="contact-info">
|
|
<div class="contact-name" t-esc="state.conversation.display_name"/>
|
|
<div class="contact-status" t-esc="state.conversation.phone_number"/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<div class="o_whatsapp_messages">
|
|
<t t-foreach="state.messages" t-as="message" t-key="message.id">
|
|
<div t-attf-class="o_whatsapp_message #{message.direction}">
|
|
<div class="message-content" t-esc="message.content"/>
|
|
<div class="message-meta">
|
|
<span t-esc="formatTime(message.create_date)"/>
|
|
<t t-if="message.direction === 'outbound'">
|
|
<i t-attf-class="fa #{getStatusIcon(message.status)} message-status"/>
|
|
</t>
|
|
</div>
|
|
</div>
|
|
</t>
|
|
</div>
|
|
|
|
<!-- Input -->
|
|
<div class="o_whatsapp_input_area">
|
|
<input
|
|
type="text"
|
|
placeholder="Escribe un mensaje..."
|
|
t-model="state.newMessage"
|
|
t-on-keypress="onKeyPress"
|
|
/>
|
|
<button t-on-click="sendMessage">
|
|
<i class="fa fa-paper-plane"/>
|
|
</button>
|
|
</div>
|
|
</t>
|
|
<t t-else="">
|
|
<div class="d-flex justify-content-center align-items-center h-100 text-muted">
|
|
<span>Selecciona una conversación</span>
|
|
</div>
|
|
</t>
|
|
</div>
|
|
</t>
|
|
</templates>
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/static/src/js/ odoo_whatsapp_hub/static/src/xml/
|
|
git commit -m "feat(odoo): add OWL chat widget"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Module Icon and Final Commit
|
|
|
|
**Files:**
|
|
- Create: `odoo_whatsapp_hub/static/description/icon.png`
|
|
|
|
**Step 1: Create a placeholder for the icon**
|
|
|
|
The icon should be a 128x128 PNG with WhatsApp-style branding. For now, create the directory structure.
|
|
|
|
```bash
|
|
mkdir -p odoo_whatsapp_hub/static/description
|
|
# Add a placeholder icon (in production, use a proper WhatsApp-themed icon)
|
|
```
|
|
|
|
**Step 2: Final Commit**
|
|
|
|
```bash
|
|
git add odoo_whatsapp_hub/
|
|
git commit -m "feat(odoo): complete odoo_whatsapp_hub module
|
|
|
|
- WhatsApp account management
|
|
- Conversation tracking and management
|
|
- Message send/receive with status tracking
|
|
- Partner integration with WhatsApp tab
|
|
- Send message wizard (single and mass)
|
|
- Webhook controller for real-time updates
|
|
- OWL chat widget
|
|
- CSS styling with WhatsApp theme"
|
|
```
|
|
|
|
**Step 3: Push to remote**
|
|
|
|
```bash
|
|
git push origin main
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 6 creates a complete Odoo module with:
|
|
|
|
1. **Models**
|
|
- `whatsapp.account` - Account management
|
|
- `whatsapp.conversation` - Conversation tracking
|
|
- `whatsapp.message` - Message storage
|
|
- `res.partner` extension - WhatsApp integration
|
|
|
|
2. **Wizards**
|
|
- Send single WhatsApp message
|
|
- Mass WhatsApp sending
|
|
|
|
3. **Controllers**
|
|
- Webhook endpoint for receiving events from WhatsApp Central
|
|
|
|
4. **Views**
|
|
- Full CRUD views for accounts and conversations
|
|
- Partner form extension with WhatsApp tab
|
|
- Kanban view for conversations
|
|
|
|
5. **Assets**
|
|
- OWL chat widget for real-time messaging
|
|
- WhatsApp-themed CSS styling
|
|
|
|
6. **Security**
|
|
- Access rights for users and managers
|