Compare commits

..

13 Commits

Author SHA1 Message Date
Claude AI
0c2e2f1b7a feat(odoo): add static description directory for module icon
- Add placeholder .gitkeep for icon.png location
- Required for Odoo Apps store compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:46:45 +00:00
Claude AI
48db1a94f7 feat(odoo): add conversation and partner views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:44 +00:00
Claude AI
f1933cf0d0 feat(odoo): add security and data files
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:40 +00:00
Claude AI
ad218ecccf feat(odoo): add menu and account views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:33 +00:00
Claude AI
5f61a815e5 feat(odoo): add wizard views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:45:29 +00:00
Claude AI
13dedaf48d feat(odoo): add WhatsApp webhook controller
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:19 +00:00
Claude AI
cf424b1f37 feat(odoo): add OWL chat widget
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:14 +00:00
Claude AI
c8c6deb4de feat(odoo): add WhatsApp account model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:08 +00:00
Claude AI
e85c9c10b5 feat(odoo): add WhatsApp conversation model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:06 +00:00
Claude AI
218c137564 feat(odoo): add WhatsApp CSS styles
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:05 +00:00
Claude AI
1074bf6739 feat(odoo): extend res.partner with WhatsApp fields
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:03 +00:00
Claude AI
7551a3d8b7 feat(odoo): add WhatsApp message model
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:43:02 +00:00
Claude AI
87d59ca433 feat(odoo): create module structure and manifest
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:40:07 +00:00
23 changed files with 1607 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import wizards

View File

@@ -0,0 +1,37 @@
{
'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,
}

View File

@@ -0,0 +1 @@
from . import webhook

View File

@@ -0,0 +1,140 @@
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')
account = request.env['whatsapp.account'].sudo().search([
('external_id', '=', account_external_id)
], limit=1)
if not account:
return {'status': 'ignored', 'reason': 'Account not found'}
conversation = request.env['whatsapp.conversation'].sudo().search([
('external_id', '=', conversation_external_id)
], limit=1)
if not 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',
})
partner = request.env['res.partner'].sudo().search([
'|',
('phone', 'ilike', phone[-10:]),
('mobile', 'ilike', phone[-10:]),
], limit=1)
if partner:
conversation.partner_id = partner
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'})

View File

@@ -0,0 +1,11 @@
<?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>

View File

@@ -0,0 +1,4 @@
from . import res_partner
from . import whatsapp_account
from . import whatsapp_conversation
from . import whatsapp_message

View File

@@ -0,0 +1,116 @@
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',
}
}
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',
}
}
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()

View File

@@ -0,0 +1,106 @@
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)

View File

@@ -0,0 +1,139 @@
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:
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

View File

@@ -0,0 +1,116 @@
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)
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

View File

@@ -0,0 +1,9 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_whatsapp_account_user whatsapp.account.user model_whatsapp_account base.group_user 1 0 0 0
3 access_whatsapp_account_manager whatsapp.account.manager model_whatsapp_account base.group_system 1 1 1 1
4 access_whatsapp_conversation_user whatsapp.conversation.user model_whatsapp_conversation base.group_user 1 1 1 0
5 access_whatsapp_conversation_manager whatsapp.conversation.manager model_whatsapp_conversation base.group_system 1 1 1 1
6 access_whatsapp_message_user whatsapp.message.user model_whatsapp_message base.group_user 1 1 1 0
7 access_whatsapp_message_manager whatsapp.message.manager model_whatsapp_message base.group_system 1 1 1 1
8 access_whatsapp_send_wizard whatsapp.send.wizard model_whatsapp_send_wizard base.group_user 1 1 1 1
9 access_whatsapp_mass_wizard whatsapp.mass.wizard model_whatsapp_mass_wizard base.group_user 1 1 1 1

View File

@@ -0,0 +1,184 @@
/* 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;
}

View File

@@ -0,0 +1,120 @@
/** @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;
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();
}
}
}
registry.category("public_components").add("WhatsAppChatWidget", WhatsAppChatWidget);

View File

@@ -0,0 +1,57 @@
<?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>

View File

@@ -0,0 +1,80 @@
<?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>

View File

@@ -0,0 +1,63 @@
<?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>

View File

@@ -0,0 +1,125 @@
<?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>

View File

@@ -0,0 +1,44 @@
<?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>

View File

@@ -0,0 +1,2 @@
from . import send_whatsapp
from . import mass_whatsapp

View File

@@ -0,0 +1,95 @@
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')
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)
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:
content = self.content
if self.use_template:
content = content.replace('{{name}}', partner.name or '')
content = content.replace('{{email}}', partner.email or '')
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
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',
}
}

View File

@@ -0,0 +1,78 @@
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')
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')
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
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'}

View File

@@ -0,0 +1,77 @@
<?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>