>
Article 2026-04-18

Synchronisation des contacts téléphone dans FiveM

TDYSKY

TDYSKY

Fondateur et développeur principal chez Agency Scripts

Le défi des données téléphoniques dans FiveM

Construire un système téléphonique pour FiveM implique bien plus que le rendu d'une jolie interface utilisateur sur l'écran du joueur. Le véritable défi technique consiste à gérer les données persistantes entre les sessions : les contacts, les fils de messages, les journaux d'appels, les photos et les paramètres d'application doivent tous survivre aux redémarrages du serveur, aux changements de caractères et aux environnements multi-caractères. Le Agency Phone a été conçu dès le départ avec l'intégrité des données comme principe fondamental. Chaque élément de données circule via un pipeline faisant autorité sur le serveur où le client demande des actions et le serveur les valide, les traite et les conserve avant de les confirmer au client. Cet article lève le rideau sur la façon dont Agency Phone gère la synchronisation des contacts, le stockage des messages, le partage de photos, la journalisation des appels et la confidentialité dès la conception afin que les développeurs et les propriétaires de serveurs comprennent l'architecture derrière le produit.

Architecture de stockage

Les contacts dans Agency Phone sont stockés par personnage et non par joueur. Cette distinction est importante car un joueur peut avoir trois personnages sur le même serveur, chacun avec des cercles sociaux complètement différents. La table des contacts utilise une clé composite du numéro de téléphone du propriétaire et du numéro de téléphone du contact, avec des champs supplémentaires pour le nom d'affichage, l'URL de l'avatar et un indicateur de favori. Lorsqu'un joueur ouvre son application de contacts, le client envoie une seule requête au serveur, qui interroge la base de données et renvoie la liste complète des contacts en un seul lot. Cela évite le modèle en cascade où chaque contact déclenche sa propre requête de base de données, ce qui serait dévastateur sur un serveur avec des joueurs ayant des centaines de contacts.

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

Synchronisation des contacts en temps réel

Lorsqu'un joueur ajoute, modifie ou supprime un contact, la modification doit être immédiatement reflétée sur son appareil et conservée dans la base de données. Agency Phone utilise un modèle de mise à jour optimiste : le client met immédiatement à jour son état local pour donner un retour instantané, tout en envoyant simultanément la mutation au serveur. Si le serveur rejette la modification en raison d'un échec de validation, le client revient à l'état précédent et affiche une erreur. Cela crée une expérience utilisateur réactive qui semble native tout en conservant l'autorité du serveur sur les données. Le serveur diffuse également les modifications pertinentes aux autres clients connectés lorsque cela est nécessaire, par exemple lorsqu'un joueur met à jour son propre nom de profil qui apparaît dans les listes de contacts des autres joueurs.

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

Stockage et fil de discussion des messages

Les messages constituent la fonctionnalité la plus gourmande en données de tout système téléphonique. Agency Phone organise les messages en fils de conversation identifiés par une paire de numéros de téléphone triés. Cela signifie que la conversation entre le numéro A et le numéro B correspond toujours au même fil, quel que soit celui qui l'a initiée. Les messages d'un fil de discussion sont stockés chronologiquement avec l'identification de l'expéditeur, l'état de lecture et les pièces jointes facultatives. Le modèle de thread prend également en charge les messages de groupe dans lesquels trois numéros ou plus participent à une conversation partagée. Les fils de discussion de groupe utilisent un identifiant distinct généré lors de la création du groupe, et chaque membre conserve son propre pointeur de lecture afin que le nombre de non lus soit précis par participant.

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

Partage de photos et gestion des médias

Le partage de photos sur un téléphone FiveM nécessite une approche différente de celle des applications Web traditionnelles car tu ne pouvez pas accéder directement au système de fichiers du lecteur. Agency Phone gère les photos via deux mécanismes : des captures d'écran dans le jeu capturées à l'aide de la fonctionnalité de capture d'écran de GTA et des images basées sur des URL que les joueurs collent à partir de services d'hébergement d'images externes. Les captures d'écran dans le jeu sont prises à l'aide de la capture d'écran native API, converties en URL de données et téléchargées sur un backend de stockage configurable. Le serveur valide les limites de taille de fichier et le type de contenu avant de conserver l'URL. Lorsque des photos sont partagées dans des messages, seule la référence URL est stockée dans l'enregistrement du message, ce qui permet de simplifier le tableau des messages. Les données d'image réelles se trouvent dans le backend de stockage multimédia, qui peut être configuré pour utiliser un disque local, un stockage compatible S3 ou un CDN d'image externe en fonction de l'infrastructure du propriétaire du serveur.

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

Journaux et historique des appels

Les journaux d'appels enregistrent chaque appel entrant, sortant et manqué avec horodatage et durée. Lorsqu'un joueur lance un appel, un enregistrement d'appel est créé avec le statut « numérotation ». Si le destinataire répond, le statut passe à « actif » et un horodatage de début est enregistré. A la fin de l'appel, la durée est calculée et l'enregistrement est finalisé. Les appels manqués se produisent lorsque le destinataire ne répond pas dans le délai imparti ou refuse explicitement. Le journal des appels est affiché dans l'onglet Récents du téléphone avec des indicateurs visuels pour la direction et l'état des appels. Les joueurs peuvent appuyer sur une entrée d’appel manqué pour rappeler immédiatement, ou appuyer longuement pour ajouter le numéro aux contacts. Le serveur supprime les journaux d'appels antérieurs à une période de conservation configurable, par défaut de 30 jours, pour empêcher la table de croître indéfiniment sur les serveurs à exécution longue.

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

Confidentialité dès la conception

Agency Phone suit les principes de confidentialité dès la conception dans toute son architecture. Les numéros de téléphone sont générés de manière aléatoire et ne sont liés à aucun identifiant réel. Le contenu du message est stocké dans la base de données mais n'est accessible à l'expéditeur et au destinataire que via des rappels validés du serveur. Il n'existe pas de recherche globale de messages qu'un administrateur pourrait utiliser pour lire des conversations privées sans accès explicite à la base de données. Les listes de contacts sont strictement par caractère, sans fuite de données entre caractères. Lorsqu'un personnage est supprimé, toutes les données téléphoniques associées, y compris les contacts, les messages, les journaux d'appels et les photos, sont supprimées en cascade de la base de données, garantissant ainsi qu'aucune donnée personnelle orpheline ne reste. Le système de téléchargement de photos supprime les métadonnées EXIF ​​avant le stockage pour éviter les fuites involontaires d'informations sur l'emplacement ou l'appareil, bien qu'il s'agisse davantage d'une bonne pratique que d'une préoccupation pratique dans un environnement de jeu.

Performances à grande échelle

Agency Phone est testé et optimisé pour les serveurs avec 200 joueurs simultanés ou plus. Les stratégies de performances clés incluent le chargement paresseux des fils de discussion, de sorte que seules les conversations les plus récentes soient récupérées sur le téléphone ouvert, les fils de discussion plus anciens étant chargés lors du défilement. Les listes de contacts sont mises en cache côté client après la récupération initiale et actualisées uniquement lorsqu'une mutation se produit. Les requêtes de base de données utilisent des index appropriés sur les numéros de téléphone et les horodatages pour garantir des recherches en moins d'une milliseconde, même sur des tables comportant des millions de lignes. Le serveur conserve une carte en mémoire des numéros de téléphone des joueurs en ligne pour des recherches instantanées de destinataires sans accès à la base de données. Toutes les communications NUI sont groupées lorsque cela est possible, donc l'ouverture de l'application de messages déclenche une requête de serveur qui renvoie les threads avec leur dernier aperçu du message plutôt que de faire des requêtes distinctes pour chaque thread. Ces optimisations garantissent que le téléphone reste réactif même pendant les heures de pointe du serveur, lorsque des dizaines de joueurs envoient simultanément des messages et passent des appels.

Partager cet article

Prêt à améliorer votre serveur ?

Découvrez nos scripts FiveM premium dans la boutique Agency Scripts ou rejoignez notre communauté Discord pour le support et les mises à jour.