Files
sky_phone-sky-systems/sky_phone/source/server/notifications.lua
T
Leon.Schmidt 30559048fd FIX - complete LB custom app compatibility (#11)
* FIX - preserve LB custom app lifecycle

* FIX - reset LB export aliases before startup

* FIX - report competing custom app providers

* FIX - identify LB app frames as live NUI

* FIX - complete LB custom app compatibility

* FIX - provide LB PicChat runtime exports

* FIX - route LB PicChat through Sky Phone

* FIX - return cached LB equipped phone number

* FIX - harden PicChat compatibility migration

* ENH - complete modular phone provider and Creator APIs

* DOC - document the Sky Phone Creator API

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-20 18:19:16 +02:00

272 lines
8.7 KiB
Lua

local NOTIFICATION_TEXT_MAX_LENGTH = 2000
local NOTIFICATION_TITLE_MAX_LENGTH = 160
local function trim(value)
return value:match("^%s*(.-)%s*$")
end
local function normalize_text(value, error_code, maximum_length)
if type(value) ~= "string" then
return nil, error_code
end
local normalized = trim(value)
if normalized == "" or #normalized > maximum_length then
return nil, error_code
end
return normalized
end
local function resolve_alias(data, primary_key, alternate_key, error_code)
local primary = data[primary_key]
local alternate = data[alternate_key]
if primary ~= nil and alternate ~= nil and primary ~= alternate then
return nil, error_code
end
return primary ~= nil and primary or alternate
end
local function normalize_notification(notification)
if type(notification) ~= "table" then
return nil, "invalid_notification"
end
local app_id = resolve_alias(notification, "appId", "app_id", "invalid_app_id")
local valid_app_id = SkyPhoneApps.ValidateAppId(app_id)
if not valid_app_id then
return nil, "invalid_app_id"
end
local title, title_error = normalize_text(
notification.title,
"invalid_title",
NOTIFICATION_TITLE_MAX_LENGTH
)
if not title then
return nil, title_error
end
local raw_text = resolve_alias(notification, "text", "body", "invalid_text")
local notification_text, text_error = normalize_text(
raw_text,
"invalid_text",
NOTIFICATION_TEXT_MAX_LENGTH
)
if not notification_text then
return nil, text_error
end
return {
appId = app_id,
text = notification_text,
title = title,
}
end
local function normalize_source(value)
if type(value) ~= "number"
or value ~= value
or value <= 0
or value >= math.huge
or value ~= math.floor(value)
then
return nil
end
return value
end
local function normalize_list_source(value)
return normalize_source(tonumber(value))
end
local function normalize_target(target)
if type(target) ~= "table" or type(target.kind) ~= "string" then
return nil, "invalid_target"
end
if target.kind == "all" then
if target.value ~= nil then
return nil, "invalid_target"
end
return { kind = "all" }
end
if target.kind == "source" then
local player_source = normalize_source(target.value)
if not player_source then
return nil, "invalid_target"
end
return { kind = "source", value = player_source }
end
if target.kind == "number" then
local phone_number = SkyPhoneSimNumber.Normalize(
target.value,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not phone_number then
return nil, "invalid_target"
end
return { kind = "number", value = phone_number }
end
if target.kind == "device" then
if not SkyPhoneImei.IsValid(target.value) then
return nil, "invalid_target"
end
return { kind = "device", value = target.value }
end
return nil, "invalid_target"
end
Bridge.Database.AfterMigration("sky_phone", function()
if type(SkyPhoneDeviceDirectory) ~= "table"
or type(SkyPhoneDeviceDirectory.GetOnlineBySource) ~= "function"
or type(SkyPhoneDeviceDirectory.GetOnlineByPhoneNumber) ~= "function"
or type(SkyPhoneDeviceDirectory.GetOnlineByImei) ~= "function"
then
error("[sky_phone] Notification router initialized before the phone device directory.")
end
SkyPhoneNotifications = {}
local function add_candidate(candidates, identity, expected_phone_number)
if type(identity) ~= "table" then
return
end
local player_source = normalize_source(identity.source)
if not player_source or candidates[player_source] then
return
end
if not SkyPhoneImei.IsValid(identity.imei) then
return
end
candidates[player_source] = {
expectedImei = identity.imei,
expectedPhoneNumber = expected_phone_number,
source = player_source,
}
end
local function resolve_candidates(target)
local candidates = {}
if target.kind == "source" then
add_candidate(candidates, SkyPhoneDeviceDirectory.GetOnlineBySource(target.value))
return candidates
end
if target.kind == "number" then
local identity = SkyPhoneDeviceDirectory.GetOnlineByPhoneNumber(target.value)
add_candidate(candidates, identity, target.value)
return candidates
end
if target.kind == "device" then
add_candidate(candidates, SkyPhoneDeviceDirectory.GetOnlineByImei(target.value))
return candidates
end
for _, player in ipairs(Bridge.Framework.GetPlayers()) do
local player_source = normalize_list_source(player)
if player_source and not candidates[player_source] then
add_candidate(
candidates,
SkyPhoneDeviceDirectory.GetOnlineBySource(player_source)
)
end
end
return candidates
end
local function identity_still_matches(candidate, identity)
return type(identity) == "table"
and identity.source == candidate.source
and identity.imei == candidate.expectedImei
and (
candidate.expectedPhoneNumber == nil
or identity.phoneNumber == candidate.expectedPhoneNumber
)
end
local function load_device_context(imei)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei`
AND settings.`namespace` = 'settings'
WHERE d.`imei` = ?
LIMIT 1
]], { imei })
local row = rows[1]
if not row or row.imei ~= imei or type(row.device_name) ~= "string" then
Bridge.Debug(
"error",
"[sky_phone] Notification delivery could not load device %s.",
tostring(imei)
)
return nil
end
return {
imei = row.imei,
name = row.device_name,
settings = row.settings,
}
end
function SkyPhoneNotifications.Send(target, notification)
local normalized_target, target_error = normalize_target(target)
if not normalized_target then
return nil, target_error
end
local normalized_notification, notification_error = normalize_notification(notification)
if not normalized_notification then
return nil, notification_error
end
local delivered = 0
local candidates = resolve_candidates(normalized_target)
for _, candidate in pairs(candidates) do
local identity = SkyPhoneDeviceDirectory.GetOnlineBySource(candidate.source)
if identity_still_matches(candidate, identity) then
local device = load_device_context(candidate.expectedImei)
local current_identity = device
and SkyPhoneDeviceDirectory.GetOnlineBySource(candidate.source)
or nil
if device and identity_still_matches(candidate, current_identity) then
TriggerClientEvent(
"sky_phone:notifications:show",
candidate.source,
{
appId = normalized_notification.appId,
device = device,
text = normalized_notification.text,
title = normalized_notification.title,
}
)
delivered = delivered + 1
end
end
end
return { delivered = delivered }
end
Bridge.Callbacks.Register("sky_phone:notifications:self", function(source, notification)
local result, notification_error = SkyPhoneNotifications.Send(
{ kind = "source", value = source },
notification
)
if not result then
return { success = false, error = notification_error }
end
if result.delivered ~= 1 then
return { success = false, error = "device_not_equipped" }
end
return { success = true, data = result }
end)
end)