## Frontend - Add media display (images, audio, video, docs) in Inbox - Add pause/resume functionality for WhatsApp accounts - Fix media URLs to use nginx proxy (relative URLs) ## API Gateway - Add /accounts/:id/pause and /accounts/:id/resume endpoints - Fix media URL handling for browser access ## WhatsApp Core - Add pauseSession() - disconnect without logout - Add resumeSession() - reconnect using saved credentials - Add media download and storage for incoming messages - Serve media files via /media/ static route ## Odoo Module (odoo_whatsapp_hub) - Add Chat Hub interface with DOLLARS theme (dark, 3-column layout) - Add WhatsApp/DRRR theme switcher for chat view - Add "ABRIR CHAT" button in conversation form - Add send_message_from_chat() method - Add security/ir.model.access.csv - Fix CSS scoping to avoid breaking Odoo UI - Update webhook to handle message events properly ## Documentation - Add docs/CONTEXTO_DESARROLLO.md with complete project context ## Infrastructure - Add whatsapp_media Docker volume - Configure nginx proxy for /media/ route - Update .gitignore to track src/sessions/ source files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
6.5 KiB
Python
215 lines
6.5 KiB
Python
from odoo import models, fields, api
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppConversation(models.Model):
|
|
_name = 'whatsapp.conversation'
|
|
_description = 'WhatsApp Conversation'
|
|
_order = 'last_message_at desc'
|
|
_rec_name = 'display_name'
|
|
|
|
external_id = fields.Char(string='ID Externo', index=True)
|
|
account_id = fields.Many2one(
|
|
'whatsapp.account',
|
|
string='Cuenta WhatsApp',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Contacto',
|
|
ondelete='set null',
|
|
)
|
|
phone_number = fields.Char(string='Teléfono', required=True, index=True)
|
|
contact_name = fields.Char(string='Nombre del Contacto')
|
|
status = fields.Selection([
|
|
('bot', 'Bot'),
|
|
('waiting', 'En Espera'),
|
|
('active', 'Activa'),
|
|
('resolved', 'Resuelta'),
|
|
], string='Estado', default='bot')
|
|
assigned_user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Agente Asignado',
|
|
)
|
|
last_message_at = fields.Datetime(string='Último Mensaje')
|
|
last_message_preview = fields.Char(
|
|
string='Último Mensaje',
|
|
compute='_compute_last_message',
|
|
store=True,
|
|
)
|
|
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',
|
|
store=True,
|
|
)
|
|
|
|
@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',
|
|
})
|
|
|
|
def action_open_send_wizard(self):
|
|
"""Open wizard to send WhatsApp message"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': 'Enviar WhatsApp',
|
|
'res_model': 'whatsapp.send.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_phone': self.phone_number,
|
|
'default_account_id': self.account_id.id,
|
|
'default_partner_id': self.partner_id.id if self.partner_id else False,
|
|
},
|
|
}
|
|
|
|
def action_open_chat(self):
|
|
"""Open fullscreen chat interface"""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'whatsapp_chat',
|
|
'name': self.display_name,
|
|
'context': {
|
|
'active_id': self.id,
|
|
},
|
|
}
|
|
|
|
def send_message_from_chat(self, message):
|
|
"""Send a message from the chat interface"""
|
|
self.ensure_one()
|
|
import requests
|
|
|
|
account = self.account_id
|
|
if not account or not account.api_url:
|
|
raise Exception("Cuenta WhatsApp no configurada")
|
|
|
|
# Send via API
|
|
api_url = account.api_url.rstrip('/')
|
|
response = requests.post(
|
|
f"{api_url}/whatsapp/send",
|
|
json={
|
|
'account_id': account.external_id,
|
|
'phone': self.phone_number,
|
|
'message': message,
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise Exception(f"Error al enviar: {response.text}")
|
|
|
|
result = response.json()
|
|
|
|
# Create local message record
|
|
self.env['whatsapp.message'].create({
|
|
'conversation_id': self.id,
|
|
'direction': 'outbound',
|
|
'message_type': 'text',
|
|
'content': message,
|
|
'status': 'sent',
|
|
'sent_by_id': self.env.user.id,
|
|
'external_id': result.get('message_id'),
|
|
})
|
|
|
|
# Update conversation
|
|
self.write({
|
|
'last_message_at': fields.Datetime.now(),
|
|
'status': 'active' if self.status == 'bot' else self.status,
|
|
})
|
|
|
|
return True
|
|
|
|
@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
|