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