diff --git a/docs/plans/2026-01-29-fase-6-modulo-odoo.md b/docs/plans/2026-01-29-fase-6-modulo-odoo.md new file mode 100644 index 0000000..b7e413a --- /dev/null +++ b/docs/plans/2026-01-29-fase-6-modulo-odoo.md @@ -0,0 +1,2052 @@ +# 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 + + + + + + + + + + + + + + + + +``` + +**Step 2: Create whatsapp_account_views.xml** + +```xml + + + + + whatsapp.account.tree + whatsapp.account + + + + + + + + + + + + + + whatsapp.account.form + whatsapp.account + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cuentas WhatsApp + whatsapp.account + tree,form + + +``` + +**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 + + + + + whatsapp.conversation.tree + whatsapp.conversation + + + + + + + + + + + + + + + + whatsapp.conversation.form + whatsapp.conversation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + whatsapp.conversation.kanban + whatsapp.conversation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + whatsapp.conversation.search + whatsapp.conversation + + + + + + + + + + + + + + + + + + + + Conversaciones + whatsapp.conversation + kanban,tree,form + {'search_default_filter_active': 1} + + +``` + +**Step 2: Create res_partner_views.xml** + +```xml + + + + + res.partner.form.whatsapp + res.partner + + + + + + + + + + WhatsApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + res.partner.tree.whatsapp + res.partner + + + + + + + + + + + Enviar WhatsApp + + + list,form + 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}, + } + + + +``` + +**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 + + + + + whatsapp.send.wizard.form + whatsapp.send.wizard + + + + + + + + + + + + + + + + + + + + + + + + + whatsapp.mass.wizard.form + whatsapp.mass.wizard + + + + + + + + + + + + + + + + + + + + Variables disponibles: + + {{name}} - Nombre del contacto + {{email}} - Email del contacto + + + + + + + + + + Envío Masivo WhatsApp + whatsapp.mass.wizard + form + new + + +``` + +**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 + + + + + + WhatsApp Principal + http://localhost:8000 + True + + + +``` + +**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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Selecciona una conversación + + + + + +``` + +**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
{{name}}
{{email}}