Compare commits
13 Commits
1c01acd168
...
0c2e2f1b7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2e2f1b7a | ||
|
|
48db1a94f7 | ||
|
|
f1933cf0d0 | ||
|
|
ad218ecccf | ||
|
|
5f61a815e5 | ||
|
|
13dedaf48d | ||
|
|
cf424b1f37 | ||
|
|
c8c6deb4de | ||
|
|
e85c9c10b5 | ||
|
|
218c137564 | ||
|
|
1074bf6739 | ||
|
|
7551a3d8b7 | ||
|
|
87d59ca433 |
3
odoo_whatsapp_hub/__init__.py
Normal file
3
odoo_whatsapp_hub/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
37
odoo_whatsapp_hub/__manifest__.py
Normal file
37
odoo_whatsapp_hub/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
odoo_whatsapp_hub/controllers/__init__.py
Normal file
1
odoo_whatsapp_hub/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import webhook
|
||||
140
odoo_whatsapp_hub/controllers/webhook.py
Normal file
140
odoo_whatsapp_hub/controllers/webhook.py
Normal 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'})
|
||||
11
odoo_whatsapp_hub/data/whatsapp_data.xml
Normal file
11
odoo_whatsapp_hub/data/whatsapp_data.xml
Normal 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>
|
||||
4
odoo_whatsapp_hub/models/__init__.py
Normal file
4
odoo_whatsapp_hub/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import res_partner
|
||||
from . import whatsapp_account
|
||||
from . import whatsapp_conversation
|
||||
from . import whatsapp_message
|
||||
116
odoo_whatsapp_hub/models/res_partner.py
Normal file
116
odoo_whatsapp_hub/models/res_partner.py
Normal 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()
|
||||
106
odoo_whatsapp_hub/models/whatsapp_account.py
Normal file
106
odoo_whatsapp_hub/models/whatsapp_account.py
Normal 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)
|
||||
139
odoo_whatsapp_hub/models/whatsapp_conversation.py
Normal file
139
odoo_whatsapp_hub/models/whatsapp_conversation.py
Normal 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
|
||||
116
odoo_whatsapp_hub/models/whatsapp_message.py
Normal file
116
odoo_whatsapp_hub/models/whatsapp_message.py
Normal 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
|
||||
9
odoo_whatsapp_hub/security/ir.model.access.csv
Normal file
9
odoo_whatsapp_hub/security/ir.model.access.csv
Normal 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
|
||||
|
0
odoo_whatsapp_hub/static/description/.gitkeep
Normal file
0
odoo_whatsapp_hub/static/description/.gitkeep
Normal file
184
odoo_whatsapp_hub/static/src/css/whatsapp.css
Normal file
184
odoo_whatsapp_hub/static/src/css/whatsapp.css
Normal 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;
|
||||
}
|
||||
120
odoo_whatsapp_hub/static/src/js/chat_widget.js
Normal file
120
odoo_whatsapp_hub/static/src/js/chat_widget.js
Normal 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);
|
||||
57
odoo_whatsapp_hub/static/src/xml/chat_widget.xml
Normal file
57
odoo_whatsapp_hub/static/src/xml/chat_widget.xml
Normal 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>
|
||||
80
odoo_whatsapp_hub/views/res_partner_views.xml
Normal file
80
odoo_whatsapp_hub/views/res_partner_views.xml
Normal 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>
|
||||
63
odoo_whatsapp_hub/views/whatsapp_account_views.xml
Normal file
63
odoo_whatsapp_hub/views/whatsapp_account_views.xml
Normal 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>
|
||||
125
odoo_whatsapp_hub/views/whatsapp_conversation_views.xml
Normal file
125
odoo_whatsapp_hub/views/whatsapp_conversation_views.xml
Normal 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>
|
||||
44
odoo_whatsapp_hub/views/whatsapp_menu.xml
Normal file
44
odoo_whatsapp_hub/views/whatsapp_menu.xml
Normal 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>
|
||||
2
odoo_whatsapp_hub/wizards/__init__.py
Normal file
2
odoo_whatsapp_hub/wizards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import send_whatsapp
|
||||
from . import mass_whatsapp
|
||||
95
odoo_whatsapp_hub/wizards/mass_whatsapp.py
Normal file
95
odoo_whatsapp_hub/wizards/mass_whatsapp.py
Normal 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',
|
||||
}
|
||||
}
|
||||
78
odoo_whatsapp_hub/wizards/send_whatsapp.py
Normal file
78
odoo_whatsapp_hub/wizards/send_whatsapp.py
Normal 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'}
|
||||
77
odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml
Normal file
77
odoo_whatsapp_hub/wizards/send_whatsapp_wizard.xml
Normal 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í... 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>
|
||||
Reference in New Issue
Block a user