>
Artigo 2026-04-18

Como o Agency Phone Gere a Sincronização e os Dados de Contactos

TDYSKY

TDYSKY

Fundador & Lead Developer na Agency Scripts

O desafio dos dados telefônicos no FiveM

Construir um sistema telefônico para FiveM envolve muito mais do que renderizar uma interface de usuário bonita na tela do jogador. O verdadeiro desafio de engenharia é gerenciar dados persistentes entre sessões: contatos, threads de mensagens, registros de chamadas, fotos e configurações de aplicativos, todos precisam sobreviver a reinicializações de servidores, trocas de caracteres e ambientes com vários caracteres. O Agency Phone foi projetado desde o início tendo a integridade dos dados como princípio fundamental. Cada dado flui através de um pipeline autoritativo do servidor, onde o cliente solicita ações e o servidor as valida, processa e persiste antes de confirmar de volta ao cliente. Este artigo revela como o Agency Phone lida com a sincronização de contatos, armazenamento de mensagens, compartilhamento de fotos, registro de chamadas e privacidade por design para que os desenvolvedores e proprietários de servidores entendam a arquitetura por trás do produto.

Arquitetura de armazenamento de contato

Os contatos no Agency Phone são armazenados por personagem, não por jogador. Esta distinção é importante porque um jogador pode ter três personagens no mesmo servidor, cada um com círculos sociais completamente diferentes. A tabela de contato usa uma chave composta do número de telefone do proprietário e do número de telefone do contato, com campos adicionais para o nome de exibição, URL do avatar e um sinalizador favorito. Quando um jogador abre seu aplicativo de contatos, o cliente envia uma única solicitação ao servidor, que consulta o banco de dados e retorna a lista completa de contatos em um lote. Isso evita o padrão cascata em que cada contato aciona sua própria consulta ao banco de dados, o que seria devastador em um servidor com jogadores que possuem centenas de contatos.

-- 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)

Sincronização de contatos em tempo real

Quando um jogador adiciona, edita ou exclui um contato, a alteração precisa ser refletida imediatamente no dispositivo e persistida no banco de dados. O Agency Phone usa um padrão de atualização otimista: o cliente atualiza imediatamente seu estado local para fornecer feedback instantâneo, enquanto envia simultaneamente a mutação ao servidor. Se o servidor rejeitar a alteração devido a uma falha na validação, o cliente retornará ao estado anterior e exibirá um erro. Isso cria uma experiência de usuário responsiva que parece nativa, ao mesmo tempo que mantém a autoridade do servidor sobre os dados. O servidor também transmite alterações relevantes para outros clientes conectados quando necessário, como quando um jogador atualiza seu próprio nome de perfil que aparece nas listas de contatos de outros jogadores.

-- 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)

Armazenamento e segmentação de mensagens

As mensagens são o recurso que mais consome dados de qualquer sistema telefônico. O Agency Phone organiza as mensagens em conversas identificadas por um par classificado de números de telefone. Isso significa que a conversa entre o número A e o número B sempre é mapeada para o mesmo thread, independentemente de quem a iniciou. As mensagens dentro de um thread são armazenadas cronologicamente com identificação do remetente, status de leitura e anexos opcionais. O modelo de threading também suporta mensagens em grupo onde três ou mais números participam de uma conversa compartilhada. Os threads de grupo usam um identificador separado gerado quando o grupo é criado, e cada membro mantém seu próprio ponteiro de leitura para que as contagens de não lidas sejam precisas por 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)

Compartilhamento de fotos e manuseio de mídia

O compartilhamento de fotos em um telefone FiveM requer uma abordagem diferente dos aplicativos web tradicionais porque você não pode acessar diretamente o sistema de arquivos do player. O Agency Phone lida com fotos por meio de dois mecanismos: capturas de tela do jogo capturadas usando a funcionalidade de captura de tela do GTA e imagens baseadas em URL que os jogadores colam de serviços externos de hospedagem de imagens. As capturas de tela do jogo são tiradas usando a API de captura de tela nativa, convertidas em um URL de dados e carregadas em um back-end de armazenamento configurável. O servidor valida os limites de tamanho do arquivo e o tipo de conteúdo antes de persistir o URL. Quando as fotos são compartilhadas em mensagens, apenas a referência da URL é armazenada no registro da mensagem, mantendo a tabela de mensagens enxuta. Os dados reais da imagem residem no back-end de armazenamento de mídia, que pode ser configurado para usar disco local, armazenamento compatível com S3 ou um CDN de imagem externo, dependendo da infraestrutura do proprietário do 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)

Registros e histórico de chamadas

Os registros de chamadas registram todas as chamadas recebidas, efetuadas e perdidas com carimbos de data e hora e duração. Quando um jogador inicia uma chamada, um registro de chamada é criado com o status "discando". Se o destinatário atender, o status será atualizado para "ativo" e um carimbo de data/hora inicial será registrado. Quando a chamada termina, a duração é calculada e o registro é finalizado. As chamadas perdidas ocorrem quando o destinatário não atende dentro do período de tempo limite ou recusa explicitamente. O registro de chamadas é exibido na guia Recentes do telefone com indicadores visuais para direção e status da chamada. Os jogadores podem tocar em uma entrada de chamada perdida para ligar de volta imediatamente ou manter pressionado para adicionar o número aos contatos. O servidor remove logs de chamadas anteriores a um período de retenção configurável, com o padrão de 30 dias, para evitar que a tabela cresça indefinidamente em servidores de longa execução.

-- 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

Privacidade desde o projeto

O Agency Phone segue princípios de privacidade desde o design em toda a sua arquitetura. Os números de telefone são gerados aleatoriamente e não estão vinculados a nenhum identificador do mundo real. O conteúdo da mensagem é armazenado no banco de dados, mas só pode ser acessado pelo remetente e pelo destinatário por meio de retornos de chamada do servidor validados. Não há pesquisa global de mensagens que um administrador possa usar para ler conversas privadas sem acesso explícito ao banco de dados. As listas de contatos são estritamente por caractere, sem vazamento de dados entre caracteres. Quando um personagem é excluído, todos os dados telefônicos associados, incluindo contatos, mensagens, registros de chamadas e fotos, são excluídos em cascata do banco de dados, garantindo que nenhum dado pessoal órfão permaneça. O sistema de upload de fotos remove metadados EXIF ​​​​antes do armazenamento para evitar vazamentos não intencionais de localização ou informações do dispositivo, embora isso seja mais uma prática recomendada do que uma preocupação prática em um ambiente de jogo.

Desempenho em escala

O Agency Phone é testado e otimizado para servidores com 200 ou mais jogadores simultâneos. As principais estratégias de desempenho incluem threads de mensagens de carregamento lento, de modo que apenas as conversas mais recentes sejam obtidas no telefone aberto, com threads mais antigos carregados na rolagem. As listas de contatos são armazenadas em cache no lado do cliente após a busca inicial e atualizadas somente quando ocorre uma mutação. As consultas ao banco de dados usam índices adequados em números de telefone e carimbos de data/hora para garantir pesquisas abaixo de milissegundos, mesmo em tabelas com milhões de linhas. O servidor mantém um mapa na memória dos números de telefone dos jogadores online para pesquisas instantâneas de destinatários, sem ocorrências no banco de dados. Toda a comunicação NUI é agrupada sempre que possível, portanto, abrir o aplicativo de mensagens aciona uma solicitação do servidor que retorna threads com a visualização da mensagem mais recente, em vez de fazer solicitações separadas para cada thread. Essas otimizações garantem que o telefone permaneça responsivo mesmo durante os horários de pico do servidor, quando dezenas de jogadores enviam mensagens e fazem chamadas simultaneamente.

Partilhar este artigo

Pronto para melhorar o teu servidor?

Explora os nossos scripts FiveM premium na loja Agency Scripts ou junta-te à nossa comunidade no Discord para suporte e atualizações.