Files
WhatsAppCentralizado/docs/plans/2026-01-29-fase-6-modulo-odoo.md
2026-01-29 22:36:17 +00:00

64 KiB

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

{
    '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:

from . import models
from . import controllers
from . import wizards

odoo_whatsapp_hub/models/__init__.py:

from . import res_partner
from . import whatsapp_account
from . import whatsapp_conversation
from . import whatsapp_message

odoo_whatsapp_hub/controllers/__init__.py:

from . import webhook

odoo_whatsapp_hub/wizards/__init__.py:

from . import send_whatsapp
from . import mass_whatsapp

Step 3: Commit

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 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í...&#10;&#10;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

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

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

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

/* 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

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

/** @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 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

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.

mkdir -p odoo_whatsapp_hub/static/description
# Add a placeholder icon (in production, use a proper WhatsApp-themed icon)

Step 2: Final Commit

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

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