Назад к блогу
Article8 мин чтения

FiveM Phone Contacts Sync: Кросс-устройственные системы контактов

Синхронизируйте контакты между FiveM телефонами, планшетами и слотами мультиперсонажей. Паттерны базы данных, мостовые скрипты и самый чистый способ поддерживать номера в актуальном состоянии.

Agency Scripts

Основатель и ведущий разработчик Agency Scripts

Проблема данных телефона в FiveM

Создание телефонной системы для FiveM включает гораздо больше, чем просто отображение красивого UI на экране игрока. Главная инженерная задача , управление постоянными данными между сессиями: контакты, переписки, журналы звонков, фотографии и настройки приложений должны сохраняться при перезапусках сервера, смене персонажей и мультиперсонажных средах. Agency Phone разработан с нуля с принципом целостности данных. Каждый фрагмент данных проходит через серверно-авторитетный канал, где клиент запрашивает действия, а сервер проверяет, обрабатывает и сохраняет их, прежде чем подтвердить клиенту. В этой статье раскрывается, как Agency Phone обрабатывает синхронизацию контактов, хранение сообщений, обмен фотографиями, ведение журналов звонков и приватность по дизайну, чтобы разработчики и владельцы серверов понимали архитектуру продукта.

Архитектура хранения контактов

Контакты в Agency Phone хранятся по персонажам, а не по игрокам. Это важно, так как у игрока может быть три персонажа на одном сервере, каждый с совершенно разными социальными кругами. Таблица контактов использует составной ключ из номера телефона владельца и номера телефона контакта, с дополнительными полями для отображаемого имени, URL аватара и флага избранного. Когда игрок открывает приложение контактов, клиент отправляет один запрос на сервер, который обращается к базе данных и возвращает полный список контактов за один раз. Это предотвращает каскадный паттерн, когда каждый контакт вызывает отдельный запрос к базе, что было бы катастрофично на сервере с игроками, у которых сотни контактов.

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

Синхронизация контактов в реальном времени

Когда игрок добавляет, редактирует или удаляет контакт, изменения должны сразу отображаться на устройстве и сохраняться в базе данных. Agency Phone использует паттерн оптимистического обновления: клиент сразу обновляет локальное состояние для мгновенной обратной связи и одновременно отправляет изменение на сервер. Если сервер отклоняет изменение из-за ошибки валидации, клиент откатывается к предыдущему состоянию и показывает ошибку. Это создает отзывчивый пользовательский опыт, который ощущается нативным, при этом сервер сохраняет контроль над данными. Сервер также рассылает соответствующие изменения другим подключенным клиентам, когда это необходимо, например, когда игрок обновляет свое имя профиля, которое отображается в списках контактов других игроков.

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

Хранение сообщений и ветвление

Сообщения , самая ресурсоемкая функция любой телефонной системы. Agency Phone организует сообщения в цепочки разговоров, идентифицируемые отсортированной парой номеров телефонов. Это значит, что разговор между номером A и номером B всегда относится к одной и той же цепочке независимо от того, кто начал разговор. Сообщения внутри цепочки хранятся в хронологическом порядке с указанием отправителя, статуса прочтения и опциональных вложений. Модель ветвления также поддерживает групповые сообщения, где три и более номера участвуют в общем разговоре. Групповые цепочки используют отдельный идентификатор, создаваемый при формировании группы, и каждый участник ведет свой собственный указатель прочтения, чтобы количество непрочитанных сообщений было точным для каждого.

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

Обмен фотографиями и работа с медиа

Обмен фотографиями в телефоне FiveM требует другого подхода, чем традиционные веб-приложения, так как прямой доступ к файловой системе игрока невозможен. Agency Phone обрабатывает фотографии двумя способами: внутриигровые скриншоты, сделанные с помощью функционала GTA, и изображения по URL, которые игроки вставляют из внешних сервисов хостинга изображений. Внутриигровые скриншоты делаются через нативный API скриншотов, конвертируются в data URL и загружаются на настраиваемое хранилище. Сервер проверяет ограничения по размеру файла и типу контента перед сохранением URL. При обмене фотографиями в сообщениях сохраняется только ссылка на URL, что облегчает таблицу сообщений. Фактические данные изображений хранятся в медиа-хранилище, которое можно настроить на локальный диск, совместимое с S3 хранилище или внешний CDN в зависимости от инфраструктуры владельца сервера.

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

Журналы и история звонков

Журналы звонков записывают каждый входящий, исходящий и пропущенный звонок с отметками времени и длительностью. Когда игрок инициирует звонок, создаётся запись со статусом "набора". Если получатель отвечает, статус обновляется на "активный" и фиксируется время начала. По окончании звонка вычисляется длительность и запись завершается. Пропущенные звонки происходят, когда получатель не отвечает в течение тайм-аута или явно отклоняет вызов. Журнал звонков отображается во вкладке последних звонков телефона с визуальными индикаторами направления и статуса звонка. Игроки могут нажать на запись пропущенного звонка, чтобы сразу перезвонить, или удерживать для добавления номера в контакты. Сервер очищает журналы звонков старше настраиваемого периода хранения, по умолчанию 30 дней, чтобы таблица не росла бесконечно на долго работающих серверах.

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

Конфиденциальность по дизайну

Agency Phone follows privacy-by-design principles throughout its architecture. Phone numbers are generated randomly and are not tied to any real-world identifier. Message content is stored in the database but is only accessible to the sender and recipient through validated server callbacks. There is no global message search that an admin could use to read private conversations without explicit database access. Contact lists are strictly per-character with no cross-character data leakage. When a character is deleted, all associated phone data including contacts, messages, call logs, and photos are cascade-deleted from the database, ensuring no orphaned personal data remains. The photo upload system strips EXIF metadata before storage to prevent unintentional location or device information leaks, though this is more of a best practice than a practical concern in a game environment.

Производительность в масштабе

Agency Phone is tested and optimized for servers with 200 or more concurrent players. The key performance strategies include lazy loading message threads so only the most recent conversations are fetched on phone open, with older threads loaded on scroll. Contact lists are cached client-side after the initial fetch and only refreshed when a mutation occurs. Database queries use proper indexes on phone numbers and timestamps to ensure sub-millisecond lookups even on tables with millions of rows. The server maintains an in-memory map of online player phone numbers for instant recipient lookups without database hits. All NUI communication is batched where possible, so opening the messages app triggers one server request that returns threads with their latest message preview rather than making separate requests for each thread. These optimizations ensure the phone remains responsive even during peak server hours when dozens of players are simultaneously sending messages and making calls.

Готовы начать?

Возьмите скрипты в нашем магазине или заходите в Discord за поддержкой, обновлениями и анонсами.