-- ===================================================================== -- Aether Phone — Serverlogik -- Telefonnummer, Kontakte, Nachrichten, Anrufe und Sicherheit. -- Die PIN verlaesst den Server nicht. -- ===================================================================== local Core = exports['aether-core'] local Inventory = exports['aether-inventory'] -- Laufende Anrufe: [anruferQuelle] = { ziel = quelle, nummer = ..., seit = ... } local aktiveAnrufe = {} -- Wer hat sein Handy gerade entsperrt? (fuer Aktionen, die das voraussetzen) local entsperrt = {} -- --------------------------------------------------------------------- -- Hilfsfunktionen -- --------------------------------------------------------------------- local function CharakterVon(quelle) return Core:HoleCharakterId(quelle) end --- Erzeugt eine freie Telefonnummer im Format 555-XXXX --- @return string local function ErzeugeNummer() for _ = 1, 50 do local nummer = ('555-%04d'):format(math.random(0, 9999)) local belegt = MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer }) if not belegt then return nummer end end -- Notfalls eine Nummer mit anderer Vorwahl return ('%03d-%04d'):format(math.random(200, 999), math.random(0, 9999)) end --- Laedt (oder erzeugt) den Handy-Datensatz eines Charakters --- @param charakterId number Charakter-ID --- @return table local function HoleGeraet(charakterId) local zeilen = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId }) if zeilen and zeilen[1] then return zeilen[1] end local nummer = ErzeugeNummer() MySQL.insert.await('INSERT INTO aether_phone (`character`, number) VALUES (?, ?)', { charakterId, nummer }) Aether.Info(('Telefonnummer %s fuer Charakter #%d vergeben.'):format(nummer, charakterId)) local neue = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId }) return neue[1] end --- Sucht den Charakter zu einer Telefonnummer --- @param nummer string Telefonnummer --- @return number|nil Charakter-ID local function CharakterZuNummer(nummer) return MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer }) end --- Sucht die Server-ID zu einer Telefonnummer (nur wenn online) --- @param nummer string Telefonnummer --- @return number|nil local function QuelleZuNummer(nummer) local charakterId = CharakterZuNummer(nummer) if not charakterId then return nil end return Core:HoleQuelleNachCharakter(charakterId) end --- Prueft die Hardware: Handy, SIM und Akku muessen vorhanden sein --- @param quelle number Server-ID --- @return boolean hatHandy, boolean hatSim local function PruefeHardware(quelle) local handy = (Inventory:ZaehleItem(quelle, Handy.ITEM_HANDY) or 0) > 0 local sim = (Inventory:ZaehleItem(quelle, Handy.ITEM_SIM) or 0) > 0 return handy, sim end --- Laedt die Kontakte eines Charakters local function HoleKontakte(charakterId) local zeilen = MySQL.query.await( 'SELECT id, name, number FROM aether_phone_contacts WHERE `character` = ? ORDER BY name ASC', { charakterId }) or {} local liste = {} -- Notrufe stehen immer oben for nummer, eintrag in pairs(Handy.NOTRUFE) do liste[#liste + 1] = { id = 0, name = eintrag.name, nummer = nummer, notruf = true } end for _, zeile in ipairs(zeilen) do liste[#liste + 1] = { id = zeile.id, name = zeile.name, nummer = zeile.number } end return liste end --- Laedt die Unterhaltungen (letzte Nachricht je Gegenstelle) local function HoleChats(nummer) local zeilen = MySQL.query.await([[ SELECT sender, receiver, text, gelesen, created_at FROM aether_phone_messages WHERE sender = ? OR receiver = ? ORDER BY id DESC LIMIT 300 ]], { nummer, nummer }) or {} local chats = {} for _, zeile in ipairs(zeilen) do local gegenstelle = (zeile.sender == nummer) and zeile.receiver or zeile.sender chats[gegenstelle] = chats[gegenstelle] or {} table.insert(chats[gegenstelle], 1, { von = (zeile.sender == nummer) and 'me' or 'them', text = zeile.text, zeit = tostring(zeile.created_at):sub(12, 16), }) end return chats end --- Laedt den Anrufverlauf local function HoleAnrufe(nummer) local zeilen = MySQL.query.await([[ SELECT caller, callee, verpasst, created_at FROM aether_phone_calls WHERE caller = ? OR callee = ? ORDER BY id DESC LIMIT 30 ]], { nummer, nummer }) or {} local liste = {} for _, zeile in ipairs(zeilen) do local eingehend = zeile.callee == nummer liste[#liste + 1] = { nummer = eingehend and zeile.caller or zeile.callee, art = eingehend and 'eingehend' or 'ausgehend', verpasst = zeile.verpasst == 1, zeit = tostring(zeile.created_at):sub(1, 16), } end return liste end --- Baut den kompletten Zustand fuer die Oberflaeche (ohne PIN!) --- @param quelle number Server-ID --- @return table|nil local function BaueZustand(quelle) local charakterId = CharakterVon(quelle) if not charakterId then return nil end local person = Core:HoleSpielerInfo(quelle) local geraet = HoleGeraet(charakterId) local hatHandy, hatSim = PruefeHardware(quelle) local dock = { 'phone', 'messages', 'bank', 'camera' } local erfolg, entschluesselt = pcall(json.decode, geraet.dock or '') if erfolg and type(entschluesselt) == 'table' then dock = entschluesselt end local freigeschaltet = {} local ok2, entsch2 = pcall(json.decode, geraet.unlocked or '{}') if ok2 and type(entsch2) == 'table' then freigeschaltet = entsch2 end return { name = ((person and person.vorname or '') .. ' ' .. (person and person.nachname or '')), nummer = geraet.number, kontostand = person and person.konto or 0, kontonummer = person and person.iban or '—', theme = geraet.theme, wallpaper = geraet.wallpaper, dockApps = dock, freigeschaltet = freigeschaltet, handy = hatHandy, sim = hatSim, akku = geraet.battery, -- Die PIN selbst wird NIE geschickt, nur ob eine eingerichtet ist pinGesetzt = geraet.pin ~= nil, faceId = geraet.faceid == 1, kontakte = HoleKontakte(charakterId), chats = HoleChats(geraet.number), anrufe = HoleAnrufe(geraet.number), } end --- Schickt den Zustand an einen Spieler local function SendeZustand(quelle, hinweis) local zustand = BaueZustand(quelle) if not zustand then return end zustand.hinweis = hinweis TriggerClientEvent('aether:phone:zustand', quelle, zustand) end local function Ablehnen(quelle, grund) TriggerClientEvent('aether:phone:fehler', quelle, grund) end -- --------------------------------------------------------------------- -- Handy oeffnen -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:oeffnen', function() local quelle = source local charakterId = CharakterVon(quelle) if not charakterId then return end local hatHandy = PruefeHardware(quelle) if not hatHandy then Ablehnen(quelle, 'Du hast kein Handy dabei.') return end local geraet = HoleGeraet(charakterId) if geraet.battery <= 0 then Ablehnen(quelle, 'Der Akku ist leer.') return end local zustand = BaueZustand(quelle) if zustand then TriggerClientEvent('aether:phone:zeige', quelle, zustand) end end) -- --------------------------------------------------------------------- -- Sicherheit: Ersteinrichtung und Entsperren -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:pinEinrichten', function(pin, faceId) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId then return end if type(pin) ~= 'string' or not pin:match('^%d%d%d%d$') then Ablehnen(quelle, 'Der Code muss aus vier Ziffern bestehen.') return end MySQL.update.await('UPDATE aether_phone SET pin = ?, faceid = ? WHERE `character` = ?', { pin, faceId and 1 or 0, charakterId }) entsperrt[quelle] = true TriggerClientEvent('aether:phone:entsperrt', quelle, true) end) RegisterNetEvent('aether:phone:entsperren', function(pin) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId then return end local gespeichert = MySQL.scalar.await( 'SELECT pin FROM aether_phone WHERE `character` = ?', { charakterId }) if gespeichert and gespeichert == pin then entsperrt[quelle] = true TriggerClientEvent('aether:phone:entsperrt', quelle, true) else entsperrt[quelle] = false TriggerClientEvent('aether:phone:entsperrt', quelle, false) end end) --- Entsperren per Face-ID (nur wenn dafuer eingerichtet) RegisterNetEvent('aether:phone:faceId', function() local quelle = source local charakterId = CharakterVon(quelle) if not charakterId then return end local aktiv = MySQL.scalar.await( 'SELECT faceid FROM aether_phone WHERE `character` = ?', { charakterId }) if aktiv == 1 then entsperrt[quelle] = true TriggerClientEvent('aether:phone:entsperrt', quelle, true) else TriggerClientEvent('aether:phone:entsperrt', quelle, false) end end) -- --------------------------------------------------------------------- -- Kontakte -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:kontaktSpeichern', function(name, nummer, kontaktId) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId or not entsperrt[quelle] then return end if type(name) ~= 'string' or #name < 1 or #name > 48 then Ablehnen(quelle, 'Bitte einen Namen angeben.') return end if not Handy.NummerGueltig(nummer) then Ablehnen(quelle, 'Ungültige Telefonnummer.') return end if type(kontaktId) == 'number' and kontaktId > 0 then MySQL.update.await( 'UPDATE aether_phone_contacts SET name = ?, number = ? WHERE id = ? AND `character` = ?', { name, nummer, kontaktId, charakterId }) else MySQL.insert.await( 'INSERT INTO aether_phone_contacts (`character`, name, number) VALUES (?, ?, ?)', { charakterId, name, nummer }) end SendeZustand(quelle, 'Kontakt gespeichert.') end) RegisterNetEvent('aether:phone:kontaktLoeschen', function(kontaktId) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId or not entsperrt[quelle] then return end if type(kontaktId) ~= 'number' then return end MySQL.update.await('DELETE FROM aether_phone_contacts WHERE id = ? AND `character` = ?', { kontaktId, charakterId }) SendeZustand(quelle, 'Kontakt gelöscht.') end) -- --------------------------------------------------------------------- -- Nachrichten -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:senden', function(anNummer, text) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId or not entsperrt[quelle] then return end local hatHandy, hatSim = PruefeHardware(quelle) if not hatHandy or not hatSim then Ablehnen(quelle, 'Ohne SIM-Karte kannst du keine Nachrichten senden.') return end if not Handy.NummerGueltig(anNummer) then Ablehnen(quelle, 'Ungültige Telefonnummer.') return end if type(text) ~= 'string' then return end text = text:gsub('^%s+', ''):gsub('%s+$', '') if #text < 1 or #text > 512 then Ablehnen(quelle, 'Die Nachricht ist leer oder zu lang.') return end local geraet = HoleGeraet(charakterId) MySQL.insert.await( 'INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)', { geraet.number, anNummer, text }) -- Empfaenger benachrichtigen, falls online local zielQuelle = QuelleZuNummer(anNummer) if zielQuelle then TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, { von = geraet.number, text = text, zeit = os.date('%H:%M'), }) SendeZustand(zielQuelle) end SendeZustand(quelle) end) -- --------------------------------------------------------------------- -- Anrufe -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:anrufen', function(nummer) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId or not entsperrt[quelle] then return end local hatHandy, hatSim = PruefeHardware(quelle) if not hatHandy or not hatSim then Ablehnen(quelle, 'Ohne SIM-Karte kannst du nicht telefonieren.') return end if not Handy.NummerGueltig(nummer) then Ablehnen(quelle, 'Ungültige Telefonnummer.') return end local geraet = HoleGeraet(charakterId) if nummer == geraet.number then Ablehnen(quelle, 'Du kannst dich nicht selbst anrufen.') return end -- Notrufe gehen an die Leitstelle der jeweiligen Fraktion if Handy.NOTRUFE[nummer] then TriggerEvent('aether:phone:notruf', quelle, Handy.NOTRUFE[nummer].fraktion, geraet.number) MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)', { geraet.number, nummer }) TriggerClientEvent('aether:phone:anrufStatus', quelle, 'notruf', Handy.NOTRUFE[nummer].name) return end local zielQuelle = QuelleZuNummer(nummer) if not zielQuelle then MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)', { geraet.number, nummer }) TriggerClientEvent('aether:phone:anrufStatus', quelle, 'nichtErreichbar') return end -- Ist die Gegenstelle schon im Gespraech? for _, anruf in pairs(aktiveAnrufe) do if anruf.ziel == zielQuelle or anruf.anrufer == zielQuelle then TriggerClientEvent('aether:phone:anrufStatus', quelle, 'besetzt') return end end aktiveAnrufe[quelle] = { anrufer = quelle, ziel = zielQuelle, nummer = geraet.number, zielNummer = nummer, angenommen = false, } TriggerClientEvent('aether:phone:eingehenderAnruf', zielQuelle, geraet.number) TriggerClientEvent('aether:phone:anrufStatus', quelle, 'klingelt', nummer) end) RegisterNetEvent('aether:phone:annehmen', function() local quelle = source for anruferQuelle, anruf in pairs(aktiveAnrufe) do if anruf.ziel == quelle then anruf.angenommen = true MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)', { anruf.nummer, anruf.zielNummer }) TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'verbunden') TriggerClientEvent('aether:phone:anrufStatus', quelle, 'verbunden') return end end end) RegisterNetEvent('aether:phone:auflegen', function() local quelle = source for anruferQuelle, anruf in pairs(aktiveAnrufe) do if anruferQuelle == quelle or anruf.ziel == quelle then -- Nicht angenommene Anrufe als verpasst protokollieren if not anruf.angenommen then MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)', { anruf.nummer, anruf.zielNummer }) end TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'beendet') TriggerClientEvent('aether:phone:anrufStatus', anruf.ziel, 'beendet') aktiveAnrufe[anruferQuelle] = nil return end end end) -- --------------------------------------------------------------------- -- Einstellungen speichern -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:einstellungen', function(einstellungen) local quelle = source local charakterId = CharakterVon(quelle) if not charakterId or not entsperrt[quelle] then return end if type(einstellungen) ~= 'table' then return end local theme = (einstellungen.theme == 'light') and 'light' or 'dark' local wallpaper = tostring(einstellungen.wallpaper or 'wp-aurora'):sub(1, 32) local dock = type(einstellungen.dockApps) == 'table' and einstellungen.dockApps or nil MySQL.update.await([[ UPDATE aether_phone SET theme = ?, wallpaper = ?, dock = COALESCE(?, dock) WHERE `character` = ? ]], { theme, wallpaper, dock and json.encode(dock) or nil, charakterId }) end) -- --------------------------------------------------------------------- -- Akkuverbrauch -- --------------------------------------------------------------------- CreateThread(function() while true do Wait(Handy.AKKU_INTERVALL) for _, quelle in ipairs(GetPlayers()) do quelle = tonumber(quelle) local charakterId = CharakterVon(quelle) if charakterId then local hatHandy = PruefeHardware(quelle) if hatHandy then local geraet = HoleGeraet(charakterId) if geraet.battery > 0 then local neu = math.max(0, geraet.battery - 1) MySQL.update('UPDATE aether_phone SET battery = ? WHERE `character` = ?', { neu, charakterId }) TriggerClientEvent('aether:phone:akku', quelle, neu) end end end end end end) --- Akku austauschen: verbraucht ein Akku-Item und laedt auf 100 % RegisterNetEvent('aether:phone:akkuTauschen', function() local quelle = source local charakterId = CharakterVon(quelle) if not charakterId then return end if not Inventory:EntferneItem(quelle, Handy.ITEM_AKKU, 1) then Ablehnen(quelle, 'Du hast keinen Ersatz-Akku dabei.') return end MySQL.update.await('UPDATE aether_phone SET battery = 100 WHERE `character` = ?', { charakterId }) TriggerClientEvent('aether:phone:akku', quelle, 100) SendeZustand(quelle, 'Akku getauscht.') end) -- --------------------------------------------------------------------- -- Aufraeumen -- --------------------------------------------------------------------- RegisterNetEvent('aether:phone:schliessen', function() entsperrt[source] = false end) AddEventHandler('playerDropped', function() local quelle = source entsperrt[quelle] = nil -- Laufende Anrufe beenden for anruferQuelle, anruf in pairs(aktiveAnrufe) do if anruferQuelle == quelle or anruf.ziel == quelle then local anderer = (anruferQuelle == quelle) and anruf.ziel or anruferQuelle TriggerClientEvent('aether:phone:anrufStatus', anderer, 'beendet') aktiveAnrufe[anruferQuelle] = nil end end end) -- ===================================================================== -- EXPORTS -- ===================================================================== --- Liefert die Telefonnummer eines Spielers exports('HoleNummer', function(quelle) local charakterId = CharakterVon(quelle) if not charakterId then return nil end return HoleGeraet(charakterId).number end) --- Schickt eine Nachricht von einer Dienstnummer an einen Spieler --- (z. B. Rechnung vom EMS, Bestaetigung eines Jobs) exports('SendeNachricht', function(anNummer, vonNummer, text) if not Handy.NummerGueltig(anNummer) then return false end MySQL.insert('INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)', { vonNummer or 'System', anNummer, tostring(text):sub(1, 512) }) local zielQuelle = QuelleZuNummer(anNummer) if zielQuelle then TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, { von = vonNummer or 'System', text = text, zeit = os.date('%H:%M'), }) end return true end)