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.