# -*- coding: utf-8 -*- """Chat interno 1-a-1 entre usuarios del frontend React (skeen.frontend.user). Mismo esquema de auth HMAC que el resto de la API. Todos los endpoints están restringidos al usuario autenticado: solo puede listar/leer conversaciones donde él es sender o recipient, y solo puede enviar como él mismo. """ from odoo import http, fields from odoo.http import request from odoo.addons.skeen_whatsapp.controllers.auth import require_role from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _parse_json_body MAX_BODY = 2000 HISTORY_LIMIT = 50 LIST_SCAN_LIMIT = 500 # Presencia: actividad en los últimos 2 minutos = "en línea" ONLINE_SECONDS = 120 # No reescribir last_seen más seguido que esto (varias pestañas del mismo # usuario laten a la vez) TOUCH_INTERVAL = 20 class SkeenChatController(http.Controller): def _touch(self, me): """Marca actividad del usuario (presencia). Nunca debe romper la petición. Se hace con SQL directo dentro de un savepoint: el frontend consulta varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos UPDATE concurrentes sobre la misma fila abortan la transacción en REPEATABLE READ ("could not serialize access due to concurrent update"). Con el savepoint ese fallo se descarta y la petición sigue; además sólo se escribe si el último registro tiene más de TOUCH_INTERVAL segundos, que es lo que evita la mayor parte de los choques. """ try: ahora = fields.Datetime.now() limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL) cr = request.env.cr with cr.savepoint(): cr.execute( "UPDATE skeen_frontend_user SET last_seen = %s, write_date = %s " "WHERE id = %s AND (last_seen IS NULL OR last_seen < %s)", (ahora, ahora, me.id, limite)) except Exception: pass def _online(self, user): if not user.last_seen: return False delta = (fields.Datetime.now() - user.last_seen).total_seconds() return delta <= ONLINE_SECONDS def _message_to_dict(self, msg, me_id): return { 'id': msg.id, 'sender_id': msg.sender_id.id, 'recipient_id': msg.recipient_id.id, 'body': msg.body, 'is_read': msg.is_read, 'mine': msg.sender_id.id == me_id, 'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None, } def _pair_domain(self, me_id, other_id): return [ '|', '&', ('sender_id', '=', me_id), ('recipient_id', '=', other_id), '&', ('sender_id', '=', other_id), ('recipient_id', '=', me_id), ] @http.route('/skeen/frontend/v1/chat/heartbeat', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_heartbeat(self, **kw): """Latido de presencia: el cliente lo manda cada ~30 s con la app abierta. Es la ÚNICA señal que escribe last_seen: así no compite con las demás llamadas de chat que el frontend lanza en paralelo. """ try: me = request.frontend_user self._touch(me) online = request.env['skeen.frontend.user'].sudo().search_count([ ('active', '=', True), ('last_seen', '>=', fields.Datetime.subtract(fields.Datetime.now(), seconds=ONLINE_SECONDS)), ]) return json_response({ 'status': 'success', 'server_time': fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'online_count': online, 'online_window_seconds': ONLINE_SECONDS, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_users(self, **kw): """Usuarios activos (menos yo) con conteo de no leídos y último mensaje. Orden: primero conversaciones con actividad (más reciente primero), luego el resto alfabéticamente. """ try: me = request.frontend_user User = request.env['skeen.frontend.user'].sudo() Msg = request.env['skeen.chat.message'].sudo() users = User.search([('active', '=', True), ('id', '!=', me.id)], order='name') unread = {} for g in Msg.read_group( [('recipient_id', '=', me.id), ('is_read', '=', False)], ['sender_id'], ['sender_id']): unread[g['sender_id'][0]] = g['sender_id_count'] last = {} recent = Msg.search([ '|', ('sender_id', '=', me.id), ('recipient_id', '=', me.id), ], order='id desc', limit=LIST_SCAN_LIMIT) for m in recent: other_id = m.recipient_id.id if m.sender_id.id == me.id else m.sender_id.id if other_id not in last: last[other_id] = { 'body': m.body or '', 'date': m.create_date.strftime('%Y-%m-%d %H:%M:%S') if m.create_date else None, 'mine': m.sender_id.id == me.id, } # Conversaciones con actividad primero (más reciente arriba) conversations = sorted( (u for u in users if u.id in last), key=lambda u: last[u.id]['date'] or '', reverse=True) rest = [u for u in users if u.id not in last] return json_response({ 'status': 'success', 'users': [{ 'id': u.id, 'name': u.name, 'role': u.role, 'unread': unread.get(u.id, 0), 'last_message': last.get(u.id, {}).get('body'), 'last_date': last.get(u.id, {}).get('date'), 'last_mine': last.get(u.id, {}).get('mine'), 'online': self._online(u), 'last_seen': u.last_seen.strftime('%Y-%m-%d %H:%M:%S') if u.last_seen else None, } for u in conversations + rest], }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_messages(self, **kw): """Historial con otro usuario (últimos 50; since_id para incremental, around_id para abrir la ventana que termina en un mensaje concreto). Marca como leídos los mensajes recibidos de ese usuario. """ try: me = request.frontend_user data = _parse_json_body() other_id = int(data.get('user_id') or 0) if not other_id or other_id == me.id: return json_response({'status': 'error', 'message': 'user_id inválido'}, 400) User = request.env['skeen.frontend.user'].sudo() if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) domain = self._pair_domain(me.id, other_id) since_id = int(data.get('since_id') or 0) around_id = int(data.get('around_id') or 0) Msg = request.env['skeen.chat.message'].sudo() if since_id: domain.append(('id', '>', since_id)) msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id') elif around_id: # Ventana de historial que TERMINA en ese mensaje (contexto hacia atrás) msgs = Msg.search(domain + [('id', '<=', around_id)], order='id desc', limit=HISTORY_LIMIT).sorted('id') else: msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id') # Marcar leídos los recibidos en esta conversación incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read) if incoming: incoming.write({'is_read': True}) return json_response({ 'status': 'success', 'messages': [self._message_to_dict(m, me.id) for m in msgs], 'around_id': around_id or None, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/chat/search', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_search(self, **kw): """Busca texto en el historial con otro usuario. Devuelve los mensajes que coinciden (más recientes primero) con el texto y el fragmento resaltado del lado del cliente. """ try: me = request.frontend_user data = _parse_json_body() other_id = int(data.get('user_id') or 0) q = (data.get('q') or '').strip() limit = min(max(int(data.get('limit', 50) or 50), 1), 100) if not other_id or other_id == me.id: return json_response({'status': 'error', 'message': 'user_id inválido'}, 400) if len(q) < 2: return json_response({'status': 'error', 'message': 'Escribe al menos 2 caracteres'}, 400) User = request.env['skeen.frontend.user'].sudo() if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) Msg = request.env['skeen.chat.message'].sudo() msgs = Msg.search(self._pair_domain(me.id, other_id) + [('body', 'ilike', q)], order='id desc', limit=limit) results = [self._message_to_dict(m, me.id) for m in msgs] return json_response({ 'status': 'success', 'query': q, 'count': len(results), 'results': results, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/chat/send', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_send(self, **kw): """Enviar mensaje directo a otro usuario.""" try: me = request.frontend_user data = _parse_json_body() other_id = int(data.get('user_id') or 0) body = (data.get('message') or '').strip() if not other_id or other_id == me.id: return json_response({'status': 'error', 'message': 'user_id inválido'}, 400) if not body: return json_response({'status': 'error', 'message': 'El mensaje no puede estar vacío'}, 400) if len(body) > MAX_BODY: return json_response( {'status': 'error', 'message': f'El mensaje excede {MAX_BODY} caracteres'}, 400) User = request.env['skeen.frontend.user'].sudo() if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1): return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404) msg = request.env['skeen.chat.message'].sudo().create({ 'sender_id': me.id, 'recipient_id': other_id, 'body': body, }) return json_response({ 'status': 'success', 'message': self._message_to_dict(msg, me.id), }, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') def chat_unread_count(self, **kw): """Total global de mensajes no leídos del usuario (badge del menú). Mismo universo que /chat/users: sólo remitentes ACTIVOS, para que el badge cuadre con la suma de los contadores de la bandeja. """ try: me = request.frontend_user activos = request.env['skeen.frontend.user'].sudo().search([('active', '=', True)]).ids total = request.env['skeen.chat.message'].sudo().search_count([ ('recipient_id', '=', me.id), ('is_read', '=', False), ('sender_id', 'in', activos), ]) return json_response({'status': 'success', 'unread': total}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500)