# -*- 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. El frontend consulta varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y todos llaman aquí. Por eso se corta antes de tocar la fila cuando el registro es reciente (TOUCH_INTERVAL): el UPDATE se ejecutaba aunque no aplicara ningún cambio y abortaba la transacción de otra petición concurrente ("could not serialize access due to concurrent update", 211 veces en un barrido de 260 visitas). El savepoint sigue ahí por si dos peticiones pasan el corte en el mismo instante; su fallo se descarta y la petición continúa. """ try: ahora = fields.Datetime.now() limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL) if me.last_seen and me.last_seen >= limite: return 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)