aether-framework/resources/[aether]/aether-phone/server/main.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

555 lines
20 KiB
Lua

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