العودة إلى المدونة
Article8 دقيقة قراءة

مزامنة جهات الاتصال في هواتف FiveM: أنظمة جهات اتصال بين الأجهزة

زامن جهات الاتصال بين هواتف FiveM وأجهزتها اللوحية وخانات الشخصيات المتعددة. تعرّف على أنماط قواعد البيانات وسكربتات الربط وأفضل طريقة لتوحيد الأرقام.

Agency Scripts

المؤسس والمطور الرئيسي في Agency Scripts

تحدي بيانات الهاتف في FiveM

بناء نظام هاتف لـ FiveM يتطلب أكثر بكثير من عرض واجهة مستخدم جميلة على شاشة اللاعب. التحدي الهندسي الحقيقي هو إدارة البيانات المستمرة عبر الجلسات: جهات الاتصال، محادثات الرسائل، سجلات المكالمات، الصور، وإعدادات التطبيقات كلها تحتاج إلى البقاء بعد إعادة تشغيل الخادم، تبديل الشخصيات، وبيئات الشخصيات المتعددة. تم تصميم Agency Phone من الألف إلى الياء مع سلامة البيانات كمبدأ أساسي. كل قطعة بيانات تمر عبر خط أنابيب موثوق من الخادم حيث يطلب العميل الإجراءات ويقوم الخادم بالتحقق، المعالجة، والحفظ قبل التأكيد للعميل. تكشف هذه المقالة كيف يتعامل Agency Phone مع مزامنة جهات الاتصال، تخزين الرسائل، مشاركة الصور، تسجيل المكالمات، والخصوصية حسب التصميم حتى يفهم المطورون ومالكو الخوادم البنية وراء المنتج.

هيكل تخزين جهات الاتصال

يتم تخزين جهات الاتصال في Agency Phone لكل شخصية، وليس لكل لاعب. هذا التمييز مهم لأن اللاعب قد يكون لديه ثلاث شخصيات على نفس الخادم، كل منها بدوائر اجتماعية مختلفة تمامًا. يستخدم جدول جهات الاتصال مفتاحًا مركبًا من رقم هاتف المالك ورقم هاتف جهة الاتصال، مع حقول إضافية لاسم العرض، رابط الصورة الرمزية، وعلم المفضلة. عندما يفتح اللاعب تطبيق جهات الاتصال، يرسل العميل طلبًا واحدًا إلى الخادم، الذي يستعلم قاعدة البيانات ويعيد قائمة جهات الاتصال كاملة دفعة واحدة. هذا يتجنب نمط الشلال حيث كل جهة اتصال تطلق استعلام قاعدة بيانات خاص بها، مما سيكون مدمرًا على خادم به لاعبين لديهم مئات جهات الاتصال.

-- 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 يلصقها اللاعبون من خدمات استضافة الصور الخارجية. تُلتقط لقطات الشاشة داخل اللعبة باستخدام واجهة برمجة التطبيقات الأصلية للقطات الشاشة، تُحول إلى عنوان 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 مبادئ الخصوصية منذ التصميم في بنيته بالكامل. تُنشأ أرقام الهواتف عشوائيًا ولا ترتبط بأي معرّف من العالم الحقيقي. يُخزّن محتوى الرسائل في قاعدة البيانات، لكن لا يمكن الوصول إليه إلا للمرسل والمستلم عبر استدعاءات خادم تم التحقق منها. لا يوجد بحث شامل عن الرسائل يمكن للمشرف استخدامه لقراءة المحادثات الخاصة دون الوصول الصريح إلى قاعدة البيانات. قوائم جهات الاتصال خاصة بكل شخصية، ولا يحدث أي تسرب للبيانات بين الشخصيات. عند حذف شخصية، تُحذف جميع بيانات الهاتف المرتبطة بها، بما فيها جهات الاتصال والرسائل وسجلات المكالمات والصور، بالتتابع من قاعدة البيانات، لضمان عدم بقاء أي بيانات شخصية يتيمة. يزيل نظام رفع الصور بيانات EXIF الوصفية قبل التخزين لمنع تسرب معلومات الموقع أو الجهاز دون قصد، رغم أن هذا يُعد إجراءً وقائيًا أفضل منه مصدر قلق عملي في بيئة الألعاب.

الأداء على نطاق واسع

اختُبر Agency Phone وحُسّن للخوادم التي تضم 200 لاعب متزامن أو أكثر. تشمل استراتيجيات الأداء الأساسية التحميل الكسول لسلاسل الرسائل، بحيث لا تُجلب إلا أحدث المحادثات عند فتح الهاتف، بينما تُحمّل السلاسل الأقدم عند التمرير. تُخزّن قوائم جهات الاتصال مؤقتًا على جانب العميل بعد جلبها أول مرة، ولا تُحدّث إلا عند إجراء تغيير. تستخدم استعلامات قاعدة البيانات فهارس مناسبة على أرقام الهواتف والطوابع الزمنية لضمان عمليات بحث تستغرق أقل من مللي ثانية، حتى في الجداول التي تضم ملايين الصفوف. يحتفظ الخادم بخريطة في الذاكرة لأرقام هواتف اللاعبين المتصلين، للعثور على المستلمين فورًا دون الرجوع إلى قاعدة البيانات. تُجمّع اتصالات NUI متى أمكن، لذا يرسل فتح تطبيق الرسائل طلبًا واحدًا إلى الخادم يعيد سلاسل المحادثات مع معاينة أحدث رسالة فيها، بدلًا من إرسال طلب منفصل لكل سلسلة. تضمن هذه التحسينات استجابة الهاتف حتى في أوقات الذروة على الخادم، عندما يرسل عشرات اللاعبين الرسائل ويجرون المكالمات في الوقت نفسه.

السكربت المناسب

جاهز لسيرفرك مع التحديثات المضمّنة.

جاهز للبدء؟

احصل على السكربتات من متجرنا، أو انضم إلى Discord للدعم والتحديثات ونظرة على ما هو قادم.