Zurück zum Blog
Article8 Min. Lesezeit

FiveM-Telefonkontakte synchronisieren: Geräteübergreifende Kontaktsysteme

Synchronisiere Kontakte zwischen FiveM-Telefonen, Tablets und Mehrcharakter-Slots. Datenbankmuster, Brückenskripte und ein zuverlässiger Abgleich von Nummern.

Agency Scripts

Gründer und leitender Entwickler bei Agency Scripts

Die Herausforderung mit Telefondaten in FiveM

Ein Telefonsystem für FiveM zu entwickeln, umfasst weit mehr, als eine ansprechende Benutzeroberfläche auf dem Bildschirm des Spielers darzustellen. Die eigentliche technische Herausforderung ist die Verwaltung persistenter Daten über mehrere Sitzungen hinweg: Kontakte, Nachrichtenverläufe, Anruflisten, Fotos und App-Einstellungen müssen Serverneustarts, Charakterwechsel und Umgebungen mit mehreren Charakteren überstehen. Agency Phone wurde von Grund auf mit Datenintegrität als zentralem Prinzip entwickelt. Jedes Datenelement durchläuft eine serverseitig maßgebliche Pipeline: Der Client fordert Aktionen an, und der Server validiert, verarbeitet und speichert sie dauerhaft, bevor er dem Client eine Bestätigung sendet. Dieser Artikel gibt Einblick darin, wie Agency Phone die Kontaktsynchronisierung, Nachrichtenspeicherung, das Teilen von Fotos, die Anrufprotokollierung und Datenschutz von Anfang an umsetzt, damit Entwickler und Serverbetreiber die Architektur hinter dem Produkt verstehen.

Architektur der Kontaktspeicherung

Kontakte in Agency Phone werden pro Charakter und nicht pro Spieler gespeichert. Dieser Unterschied ist wichtig, denn ein Spieler kann auf demselben Server drei Charaktere mit jeweils völlig unterschiedlichen sozialen Kreisen haben. Die Kontakttabelle verwendet einen zusammengesetzten Schlüssel aus der Telefonnummer des Besitzers und der Telefonnummer des Kontakts. Dazu kommen Felder für den Anzeigenamen, die Avatar-URL und einen Favoritenstatus. Wenn ein Spieler seine Kontakte-App öffnet, sendet der Client eine einzelne Anfrage an den Server, der die Datenbank abfragt und die vollständige Kontaktliste in einem Durchgang zurückgibt. So wird das Kaskadenmuster vermieden, bei dem jeder Kontakt eine eigene Datenbankabfrage auslöst. Das wäre auf einem Server mit Spielern, die Hunderte Kontakte haben, verheerend für die Performance.

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

Kontakte in Echtzeit synchronisieren

Wenn ein Spieler einen Kontakt hinzufügt, bearbeitet oder löscht, muss die Änderung sofort auf seinem Gerät angezeigt und in der Datenbank gespeichert werden. Agency Phone verwendet ein Optimistic-Update-Muster: Der Client aktualisiert sofort seinen lokalen Status und gibt so direkt Rückmeldung, während er gleichzeitig die Änderung an den Server sendet. Lehnt der Server die Änderung aufgrund eines Validierungsfehlers ab, setzt der Client den Status auf den vorherigen Wert zurück und zeigt eine Fehlermeldung an. So entsteht eine reaktionsschnelle, native Benutzererfahrung, während die Datenhoheit beim Server bleibt. Bei Bedarf überträgt der Server relevante Änderungen auch an andere verbundene Clients, etwa wenn ein Spieler seinen Profilnamen aktualisiert, der in den Kontaktlisten anderer Spieler erscheint.

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

Nachrichtenspeicherung und Unterhaltungen

Nachrichten sind die datenintensivste Funktion eines Telefonsystems. Agency Phone gruppiert Nachrichten in Unterhaltungen, die durch ein sortiertes Paar von Telefonnummern identifiziert werden. Dadurch verweist die Unterhaltung zwischen Nummer A und Nummer B immer auf denselben Thread, unabhängig davon, wer sie begonnen hat. Nachrichten innerhalb eines Threads werden chronologisch mit Absender, Lesestatus und optionalen Anhängen gespeichert. Das Threading-Modell unterstützt auch Gruppennachrichten mit drei oder mehr Nummern in einer gemeinsamen Unterhaltung. Gruppen-Threads verwenden eine separate ID, die beim Erstellen der Gruppe generiert wird. Jedes Mitglied verwaltet seinen eigenen Lesestand, damit die Zahl ungelesener Nachrichten pro Teilnehmer korrekt ist.

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

Fotos teilen und Medien verwalten

Das Teilen von Fotos in einem FiveM-Telefon erfordert einen anderen Ansatz als bei herkömmlichen Webanwendungen, da du nicht direkt auf das Dateisystem des Spielers zugreifen kannst. Agency Phone verarbeitet Fotos auf zwei Arten: durch Ingame-Screenshots, die mit der Screenshot-Funktion von GTA aufgenommen werden, und durch URL-basierte Bilder, die Spieler von externen Bildhosting-Diensten einfügen. Ingame-Screenshots werden mit der nativen Screenshot-API aufgenommen, in eine Data-URL umgewandelt und auf einen konfigurierbaren Speicher-Backend hochgeladen. Der Server prüft Dateigrößenbeschränkungen und Inhaltstyp, bevor er die URL dauerhaft speichert. Werden Fotos in Nachrichten geteilt, wird nur die URL-Referenz im Nachrichtendatensatz gespeichert. So bleibt die Nachrichtentabelle schlank. Die Bilddaten selbst liegen im Medien-Backend, das je nach Infrastruktur des Serverbetreibers auf lokale Festplatte, S3-kompatiblen Speicher oder ein externes Image-CDN eingestellt werden kann.

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

Anruflisten und Verlauf

Anruflisten erfassen jeden eingehenden, ausgehenden und verpassten Anruf mit Zeitstempel und Dauer. Wenn ein Spieler einen Anruf startet, wird ein Anrufdatensatz mit dem Status "dialing" erstellt. Nimmt der Empfänger ab, wird der Status auf "active" gesetzt und ein Startzeitstempel gespeichert. Wenn der Anruf endet, wird die Dauer berechnet und der Datensatz abgeschlossen. Ein Anruf gilt als verpasst, wenn der Empfänger nicht innerhalb des Zeitlimits abnimmt oder ihn ausdrücklich ablehnt. Die Anrufliste wird im Tab „Letzte“ des Telefons angezeigt, mit visuellen Kennzeichnungen für Anrufrichtung und Status. Spieler können auf einen verpassten Anruf tippen, um sofort zurückzurufen, oder lange darauf drücken, um die Nummer zu den Kontakten hinzuzufügen. Der Server löscht Anruflisten, die älter als ein konfigurierbarer Aufbewahrungszeitraum sind. Standardmäßig sind das 30 days. So wird verhindert, dass die Tabelle auf Servern mit langer Laufzeit unbegrenzt anwächst.

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

Datenschutz von Anfang an

Agency Phone folgt in seiner gesamten Architektur den Grundsätzen des Privacy by Design. Telefonnummern werden zufällig generiert und sind nicht mit realen Identifikationsmerkmalen verknüpft. Nachrichteninhalte werden in der Datenbank gespeichert, sind aber über validierte Server-Callbacks nur für Absender und Empfänger zugänglich. Es gibt keine globale Nachrichtensuche, mit der ein Admin private Unterhaltungen ohne direkten Datenbankzugriff lesen könnte. Kontaktlisten sind strikt pro Charakter getrennt, es gibt keinen Datenabfluss zwischen Charakteren. Wird ein Charakter gelöscht, werden alle zugehörigen Telefondaten, einschließlich Kontakte, Nachrichten, Anruflisten und Fotos, per Cascade Delete aus der Datenbank entfernt, sodass keine verwaisten persönlichen Daten zurückbleiben. Das Foto-Uploadsystеm entfernt EXIF-Metadaten vor dem Speichern, um ein versehentliches Offenlegen von Standort- oder Geräteinformationen zu verhindern. In einer Spielumgebung ist das zwar eher eine bewährte Vorgehensweise als ein praktisches Risiko.

Leistung bei hoher Auslastung

Agency Phone wurde für Server mit 200 oder mehr gleichzeitig aktiven Spielern getestet und optimiert. Zu den wichtigsten Leistungsoptimierungen zählt das verzögerte Laden von Nachrichtenverläufen: Beim Öffnen des Telefons werden nur die neuesten Unterhaltungen abgerufen, ältere Verläufe werden beim Scrollen geladen. Kontaktlisten werden nach dem ersten Abruf clientseitig zwischengespeichert und nur bei Änderungen aktualisiert. Datenbankabfragen verwenden passende Indizes für Telefonnummern und Zeitstempel, damit auch bei Tabellen mit Millionen von Zeilen Suchvorgänge in unter einer Millisekunde abgeschlossen werden. Der Server hält eine In-Memory-Zuordnung der Telefonnummern aktiver Spieler bereit, um Empfänger sofort und ohne Datenbankzugriff zu finden. Die gesamte NUI-Kommunikation wird nach Möglichkeit gebündelt. Beim Öffnen der Nachrichten-App wird also eine einzige Serveranfrage gesendet, die Nachrichtenverläufe samt Vorschau der neuesten Nachricht zurückgibt, statt für jeden Verlauf eine eigene Anfrage zu stellen. Diese Optimierungen sorgen dafür, dass das Telefon auch in den Spitzenzeiten reaktionsschnell bleibt, wenn Dutzende Spieler gleichzeitig Nachrichten senden und telefonieren.

Das passende Script

Fertig für deinen Server, Updates inklusive.

Bereit loszulegen?

Hol dir die Scripts in unserem Shop, oder komm auf den Discord für Support, Updates und einen Blick auf das, was kommt.