Volver al blog
Article10 min de lectura

Sincronización de contactos en teléfonos FiveM: entre dispositivos

Sincroniza contactos entre teléfonos, tablets y personajes de FiveM. Bases de datos, scripts puente y cómo mantener los números coherentes.

Agency Scripts

Fundador y desarrollador principal en Agency Scripts

El desafío de los datos del teléfono en FiveM

Crear un sistema de teléfono para FiveM implica mucho más que mostrar una interfaz atractiva en la pantalla del jugador. El verdadero desafío de ingeniería consiste en gestionar datos persistentes entre sesiones: los contactos, las conversaciones, el registro de llamadas, las fotos y los ajustes de las aplicaciones deben conservarse tras reiniciar el servidor, cambiar de personaje y en entornos con varios personajes. Agency Phone se diseñó desde cero con la integridad de los datos como principio fundamental. Todos los datos pasan por un flujo de trabajo controlado por el servidor, donde el cliente solicita acciones y el servidor las valida, procesa y guarda antes de confirmarlas al cliente. En este artículo te mostramos cómo Agency Phone gestiona la sincronización de contactos, el almacenamiento de mensajes, el envío de fotos, el registro de llamadas y la privacidad desde el diseño, para que los desarrolladores y propietarios de servidores conozcan la arquitectura del producto.

Arquitectura de almacenamiento de contactos

Los contactos de Agency Phone se almacenan por personaje, no por jugador. Esta diferencia es importante porque un jugador puede tener tres personajes en el mismo servidor, cada uno con círculos sociales completamente distintos. La tabla de contactos usa una clave compuesta por el número de teléfono del propietario y el número de teléfono del contacto, además de campos para el nombre mostrado, la URL del avatar y una marca de favorito. Cuando un jugador abre la aplicación de contactos, el cliente envía una sola solicitud al servidor, que consulta la base de datos y devuelve la lista completa de contactos en un único lote. Así se evita el patrón de consultas en cascada, donde cada contacto provoca su propia consulta a la base de datos, algo que tendría un impacto grave en un servidor con jugadores que tienen cientos de contactos.

-- How contacts are structured internally
-- Each contact belongs to a specific phone number (character)
local contactSchema = {
    owner_number = 'string',    -- the character's phone number
    contact_number = 'string',  -- the saved contact's number
    display_name = 'string',    -- custom name set by player
    avatar = 'string|nil',      -- optional avatar URL
    is_favorite = 'boolean',    -- pinned to top of list
    created_at = 'timestamp',   -- when contact was added
}

-- Server: fetch all contacts for a character
lib.callback.register('phone:contacts:getAll', function(source)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return {} end

    local contacts = MySQL.query.await([[
        SELECT contact_number, display_name, avatar, is_favorite
        FROM phone_contacts
        WHERE owner_number = ?
        ORDER BY is_favorite DESC, display_name ASC
    ]], { phoneNumber })

    return contacts or {}
end)

Sincronización de contactos en tiempo real

Cuando un jugador añade, edita o elimina un contacto, el cambio debe reflejarse de inmediato en su dispositivo y guardarse en la base de datos. Agency Phone usa un patrón de actualización optimista: el cliente actualiza de inmediato su estado local para ofrecer una respuesta instantánea y, al mismo tiempo, envía el cambio al servidor. Si el servidor rechaza el cambio porque no supera la validación, el cliente vuelve al estado anterior y muestra un error. Esto crea una experiencia de usuario ágil y natural, a la vez que mantiene la autoridad del servidor sobre los datos. Cuando es necesario, el servidor también difunde los cambios relevantes a otros clientes conectados, por ejemplo, cuando un jugador actualiza su nombre de perfil y este aparece en las listas de contactos de otros jugadores.

-- Server: add a new contact with validation
lib.callback.register('phone:contacts:add', function(source, data)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return { success = false, error = 'NO_PHONE' } end

    -- Validate the contact number exists in the system
    local numberExists = MySQL.scalar.await(
        'SELECT COUNT(*) FROM phone_numbers WHERE number = ?',
        { data.contact_number }
    )
    if numberExists == 0 then
        return { success = false, error = 'NUMBER_NOT_FOUND' }
    end

    -- Prevent duplicate contacts
    local existing = MySQL.scalar.await(
        'SELECT COUNT(*) FROM phone_contacts WHERE owner_number = ? AND contact_number = ?',
        { phoneNumber, data.contact_number }
    )
    if existing > 0 then
        return { success = false, error = 'ALREADY_EXISTS' }
    end

    -- Insert the contact
    MySQL.insert.await([[
        INSERT INTO phone_contacts (owner_number, contact_number, display_name, avatar)
        VALUES (?, ?, ?, ?)
    ]], { phoneNumber, data.contact_number, data.display_name, data.avatar })

    return { success = true }
end)

Almacenamiento y organización de mensajes

Los mensajes son la función que más datos consume en cualquier sistema de teléfono. Agency Phone organiza los mensajes en conversaciones identificadas por un par ordenado de números de teléfono. Así, la conversación entre los números A y B siempre corresponde al mismo hilo, sin importar quién la inició. Los mensajes de cada hilo se almacenan en orden cronológico e incluyen la identificación del remitente, el estado de lectura y archivos adjuntos opcionales. Este modelo también admite mensajes grupales en los que participan tres o más números. Los hilos grupales usan un identificador independiente que se genera al crear el grupo, y cada integrante mantiene su propio marcador de lectura para que el recuento de mensajes sin leer sea preciso para cada participante.

-- Message thread resolution
-- Ensures A->B and B->A map to the same conversation
local function GetThreadId(number1, number2)
    -- Sort numbers to create a deterministic thread ID
    local sorted = { number1, number2 }
    table.sort(sorted)
    return sorted[1] .. ':' .. sorted[2]
end

-- Server: send a message
lib.callback.register('phone:messages:send', function(source, data)
    local senderNumber = GetPlayerPhoneNumber(source)
    if not senderNumber then return { success = false } end

    local threadId = GetThreadId(senderNumber, data.to)

    local messageId = MySQL.insert.await([[
        INSERT INTO phone_messages (thread_id, sender_number, recipient_number, content, attachment, sent_at)
        VALUES (?, ?, ?, ?, ?, NOW())
    ]], { threadId, senderNumber, data.to, data.content, data.attachment })

    -- Notify recipient if online
    local recipientSource = GetPlayerByPhoneNumber(data.to)
    if recipientSource then
        TriggerClientEvent('phone:messages:receive', recipientSource, {
            id = messageId,
            thread_id = threadId,
            sender = senderNumber,
            sender_name = GetContactName(data.to, senderNumber),
            content = data.content,
            attachment = data.attachment,
            sent_at = os.time()
        })
    end

    return { success = true, id = messageId }
end)

Envío de fotos y gestión de archivos multimedia

El envío de fotos en un teléfono de FiveM requiere un enfoque distinto al de las aplicaciones web tradicionales, porque no se puede acceder directamente al sistema de archivos del jugador. Agency Phone gestiona las fotos de dos maneras: mediante capturas dentro del juego, hechas con la funcionalidad de capturas de GTA, y mediante imágenes con URL que los jugadores pegan desde servicios externos de alojamiento de imágenes. Las capturas dentro del juego se hacen con la API nativa de capturas de pantalla, se convierten en una URL de datos y se suben a un sistema de almacenamiento configurable. Antes de guardar la URL, el servidor valida el tamaño máximo del archivo y el tipo de contenido. Cuando se comparten fotos en los mensajes, en el registro del mensaje solo se guarda la referencia a la URL, para mantener ligera la tabla de mensajes. Los datos de imagen se guardan en el sistema de almacenamiento multimedia, que se puede configurar para usar un disco local, almacenamiento compatible con S3 o una CDN externa de imágenes, según la infraestructura del propietario del servidor.

-- Server: handle photo upload from in-game camera
lib.callback.register('phone:photos:upload', function(source, imageData)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return { success = false } end

    -- Validate size (max 2MB base64)
    if #imageData > 2 * 1024 * 1024 * 1.37 then
        return { success = false, error = 'FILE_TOO_LARGE' }
    end

    -- Generate unique filename
    local filename = ('%s_%s.jpg'):format(phoneNumber, os.time())

    -- Store via configured backend (webhook, local, S3)
    local url = StorageBackend:upload(filename, imageData)
    if not url then
        return { success = false, error = 'UPLOAD_FAILED' }
    end

    -- Save photo reference in gallery
    MySQL.insert.await([[
        INSERT INTO phone_photos (owner_number, url, created_at)
        VALUES (?, ?, NOW())
    ]], { phoneNumber, url })

    return { success = true, url = url }
end)

Registro e historial de llamadas

El registro de llamadas guarda cada llamada entrante, saliente y perdida, con la fecha, la hora y la duración. Cuando un jugador inicia una llamada, se crea un registro con el estado "dialing". Si el destinatario responde, el estado cambia a "active" y se registra la hora de inicio. Cuando termina la llamada, se calcula su duración y se finaliza el registro. Se considera que una llamada se ha perdido cuando el destinatario no responde dentro del tiempo de espera o la rechaza explícitamente. El registro de llamadas aparece en la pestaña de llamadas recientes del teléfono, con indicadores visuales de la dirección y el estado de cada llamada. Los jugadores pueden tocar una llamada perdida para devolverla de inmediato o mantenerla pulsada para añadir el número a sus contactos. El servidor elimina los registros de llamadas que superan el período de conservación configurable, cuyo valor predeterminado es de 30 days, para evitar que la tabla crezca indefinidamente en servidores activos durante mucho tiempo.

-- Server: create and manage call records
local activeCalls = {}

function StartCallRecord(callerNumber, receiverNumber)
    local callId = MySQL.insert.await([[
        INSERT INTO phone_calls (caller_number, receiver_number, status, started_at)
        VALUES (?, ?, 'dialing', NOW())
    ]], { callerNumber, receiverNumber })

    activeCalls[callId] = {
        caller = callerNumber,
        receiver = receiverNumber,
        answeredAt = nil
    }
    return callId
end

function AnswerCall(callId)
    if not activeCalls[callId] then return end
    activeCalls[callId].answeredAt = os.time()
    MySQL.update.await(
        'UPDATE phone_calls SET status = ?, answered_at = NOW() WHERE id = ?',
        { 'active', callId }
    )
end

function EndCall(callId)
    local call = activeCalls[callId]
    if not call then return end

    local duration = call.answeredAt and (os.time() - call.answeredAt) or 0
    local status = call.answeredAt and 'completed' or 'missed'

    MySQL.update.await(
        'UPDATE phone_calls SET status = ?, duration = ?, ended_at = NOW() WHERE id = ?',
        { status, duration, callId }
    )
    activeCalls[callId] = nil
end

Privacidad desde el diseño

Agency Phone aplica principios de privacidad desde el diseño en toda su arquitectura. Los números de teléfono se generan aleatoriamente y no están vinculados a ningún identificador del mundo real. El contenido de los mensajes se almacena en la base de datos, pero solo pueden acceder a él el remitente y el destinatario mediante callbacks del servidor validados. No existe una búsqueda global de mensajes que un administrador pueda usar para leer conversaciones privadas sin acceso explícito a la base de datos. Las listas de contactos son estrictamente individuales para cada personaje, sin filtraciones de datos entre personajes. Cuando se elimina un personaje, todos los datos telefónicos asociados, incluidos los contactos, los mensajes, el registro de llamadas y las fotos, se eliminan en cascada de la base de datos para que no queden datos personales huérfanos. El sistema de carga de fotos elimina los metadatos EXIF antes de guardarlas para evitar filtraciones accidentales de información sobre la ubicación o el dispositivo, aunque esto es más una buena práctica que una preocupación real en un entorno de juego.

Rendimiento a escala

Agency Phone se prueba y optimiza para servidores con 200 o más jugadores simultáneos. Entre las principales estrategias de rendimiento se incluyen la carga diferida de los hilos de mensajes, de modo que al abrir el teléfono solo se obtienen las conversaciones más recientes y las anteriores se cargan al desplazarse. Las listas de contactos se guardan en la caché del cliente después de la carga inicial y solo se actualizan cuando se produce un cambio. Las consultas a la base de datos usan índices adecuados en los números de teléfono y las marcas de tiempo para garantizar búsquedas en menos de un milisegundo, incluso en tablas con millones de filas. El servidor mantiene un mapa en memoria de los números de teléfono de los jugadores conectados para encontrar destinatarios al instante sin consultar la base de datos. La comunicación NUI se agrupa siempre que es posible, así que al abrir la aplicación de mensajes se envía una sola solicitud al servidor, que devuelve los hilos con una vista previa del último mensaje, en lugar de enviar solicitudes separadas para cada hilo. Estas optimizaciones mantienen el teléfono ágil incluso durante las horas de mayor actividad, cuando decenas de jugadores envían mensajes y hacen llamadas al mismo tiempo.

El script adecuado

Listo para tu servidor, con actualizaciones incluidas.

¿Listo para empezar?

Consigue los scripts en nuestra tienda, o únete al Discord para soporte, novedades y un adelanto de lo que viene.