mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 11:01:34 +00:00
1098 lines
36 KiB
Lua
1098 lines
36 KiB
Lua
local phone_open_handler
|
|
local pending_phone_opens = {}
|
|
local server_started = false
|
|
|
|
local function flush_pending_phone_opens()
|
|
if not server_started or not phone_open_handler then
|
|
return
|
|
end
|
|
|
|
for player_source in pairs(pending_phone_opens) do
|
|
pending_phone_opens[player_source] = nil
|
|
phone_open_handler(player_source, nil)
|
|
end
|
|
end
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
|
|
if not server_started or not phone_open_handler then
|
|
pending_phone_opens[source] = true
|
|
return { success = true, data = { queued = true } }
|
|
end
|
|
|
|
local success, error_code = phone_open_handler(source, nil)
|
|
return { success = success, error = error_code }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
|
if not Config.Phone.DevelopmentCommand then
|
|
return { success = false, error = "disabled" }
|
|
end
|
|
if not server_started or not phone_open_handler then
|
|
pending_phone_opens[source] = true
|
|
return { success = true, data = { queued = true } }
|
|
end
|
|
|
|
local success, error_code = phone_open_handler(source, nil)
|
|
return { success = success, error = error_code }
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
pending_phone_opens[source] = nil
|
|
end)
|
|
|
|
AddEventHandler("onServerResourceStart", function(resource_name)
|
|
if resource_name ~= GetCurrentResourceName() then
|
|
return
|
|
end
|
|
|
|
server_started = true
|
|
flush_pending_phone_opens()
|
|
end)
|
|
|
|
Bridge.Database.AfterMigration("sky_phone", function()
|
|
Bridge.Debug("debug", "[sky_phone] Server initialization started after database migration.", { always = true })
|
|
|
|
SkyPhone = {}
|
|
|
|
function SkyPhone.IsAppEnabled(app_id)
|
|
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
|
|
end
|
|
|
|
function SkyPhone.GetDisabledApps()
|
|
local disabled = {}
|
|
for app_id, enabled in pairs(Config.Apps or {}) do
|
|
if type(app_id) == "string" and enabled == false then
|
|
disabled[#disabled + 1] = app_id
|
|
end
|
|
end
|
|
table.sort(disabled)
|
|
return disabled
|
|
end
|
|
|
|
local sessions = {}
|
|
local preferred_device_imeis = {}
|
|
local equipped_phone_numbers = {}
|
|
local equipped_phone_identifiers = {}
|
|
local equipped_phone_sources = {}
|
|
local operation_attempts = {}
|
|
local phone_open_in_progress = {}
|
|
local character_device_cache = {}
|
|
|
|
local function trim(value)
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
|
|
return value:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function affected_rows(result)
|
|
if type(result) == "number" then
|
|
return result
|
|
end
|
|
|
|
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
|
end
|
|
|
|
local function reserve_imei()
|
|
local imei = SkyPhoneImei.Reserve(function()
|
|
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
|
local uuid = rows[1] and rows[1].id
|
|
if type(uuid) ~= "string" then
|
|
error("[sky_phone] Database did not generate entropy for an IMEI.")
|
|
end
|
|
return uuid
|
|
end, function(candidate)
|
|
local result = Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
|
VALUES (?, ?)
|
|
]], { candidate, Config.Phone.DeviceName })
|
|
return affected_rows(result) > 0
|
|
end)
|
|
|
|
if not imei then
|
|
error("[sky_phone] Could not generate a unique IMEI after 20 attempts.")
|
|
end
|
|
return imei
|
|
end
|
|
|
|
local function character_identifier(source)
|
|
local identifier = Bridge.Framework.GetIdentifier(source)
|
|
if identifier == nil then
|
|
return nil
|
|
end
|
|
|
|
identifier = tostring(identifier)
|
|
if identifier == "" or #identifier > 80 then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Could not resolve a valid character identifier for source %s.",
|
|
tostring(source)
|
|
)
|
|
return nil
|
|
end
|
|
return identifier
|
|
end
|
|
|
|
local function discard_equipped_phone_number(source)
|
|
local phone_number = equipped_phone_numbers[source]
|
|
local identifier = equipped_phone_identifiers[source]
|
|
equipped_phone_numbers[source] = nil
|
|
equipped_phone_identifiers[source] = nil
|
|
if identifier then
|
|
equipped_phone_numbers[identifier] = nil
|
|
end
|
|
if phone_number and equipped_phone_sources[phone_number] == source then
|
|
equipped_phone_sources[phone_number] = nil
|
|
end
|
|
return phone_number
|
|
end
|
|
|
|
local function cache_equipped_phone_number(source, identifier, phone_number)
|
|
local previous_number = discard_equipped_phone_number(source)
|
|
equipped_phone_identifiers[source] = identifier
|
|
equipped_phone_numbers[source] = phone_number
|
|
if identifier then
|
|
equipped_phone_numbers[identifier] = phone_number
|
|
end
|
|
if phone_number then
|
|
equipped_phone_sources[phone_number] = source
|
|
end
|
|
if previous_number ~= phone_number then
|
|
TriggerEvent("sky_phone:server:phoneNumberChanged", source, phone_number)
|
|
end
|
|
end
|
|
|
|
local function remove_equipped_phone_number(source)
|
|
local previous_number = discard_equipped_phone_number(source)
|
|
if previous_number then
|
|
TriggerEvent("sky_phone:server:phoneNumberChanged", source, nil)
|
|
end
|
|
end
|
|
|
|
local function migrated_device_for_character(source)
|
|
local identifier = character_identifier(source)
|
|
if not identifier then
|
|
return nil
|
|
end
|
|
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT mapping.`device_imei`
|
|
FROM `sky_phone_migration_owners` mapping
|
|
JOIN `sky_phone_character_devices` character_device
|
|
ON character_device.`owner_identifier` = mapping.`owner_identifier`
|
|
AND character_device.`device_imei` = mapping.`device_imei`
|
|
JOIN `sky_phone_devices` device ON device.`imei` = mapping.`device_imei`
|
|
WHERE mapping.`owner_identifier` = ?
|
|
ORDER BY mapping.`created_at` DESC
|
|
LIMIT 1
|
|
]], { identifier })
|
|
local imei = rows[1] and rows[1].device_imei or nil
|
|
if imei and not SkyPhoneImei.IsValid(imei) then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Phone migration mapping contains invalid IMEI '%s' for '%s'.",
|
|
tostring(imei),
|
|
identifier
|
|
)
|
|
return nil
|
|
end
|
|
return imei
|
|
end
|
|
|
|
local function load_character_device(source, identifier)
|
|
identifier = identifier or character_identifier(source)
|
|
if not identifier then
|
|
return nil, "player_unavailable"
|
|
end
|
|
|
|
local cached = character_device_cache[source]
|
|
if cached and cached.identifier == identifier then
|
|
return cached.imei, nil, identifier
|
|
end
|
|
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `device_imei`
|
|
FROM `sky_phone_character_devices`
|
|
WHERE `owner_identifier` = ?
|
|
LIMIT 1
|
|
]], { identifier })
|
|
local imei = rows[1] and rows[1].device_imei or nil
|
|
if imei then
|
|
character_device_cache[source] = { identifier = identifier, imei = imei }
|
|
else
|
|
character_device_cache[source] = nil
|
|
end
|
|
return imei, nil, identifier
|
|
end
|
|
|
|
local function cache_character_device(source, identifier, imei)
|
|
character_device_cache[source] = { identifier = identifier, imei = imei }
|
|
return imei
|
|
end
|
|
|
|
local function map_character_device(source, slot)
|
|
local mapped_imei, error_code, identifier = load_character_device(source)
|
|
if error_code or mapped_imei then
|
|
return mapped_imei, error_code
|
|
end
|
|
|
|
-- On the first switch from unique to shared phones, adopt the currently used
|
|
-- legacy device when it has not already been claimed by another character.
|
|
local legacy_imei = slot.metadata and slot.metadata.imei or nil
|
|
if SkyPhoneImei.IsValid(legacy_imei) then
|
|
Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
|
VALUES (?, ?)
|
|
]], { legacy_imei, Config.Phone.DeviceName })
|
|
local result = Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
|
|
VALUES (?, ?)
|
|
]], { identifier, legacy_imei })
|
|
if affected_rows(result) == 1 then
|
|
return cache_character_device(source, identifier, legacy_imei)
|
|
end
|
|
|
|
mapped_imei = load_character_device(source, identifier)
|
|
if mapped_imei then
|
|
return mapped_imei
|
|
end
|
|
end
|
|
|
|
local imei = reserve_imei()
|
|
local result = Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
|
|
VALUES (?, ?)
|
|
]], { identifier, imei })
|
|
if affected_rows(result) == 1 then
|
|
return cache_character_device(source, identifier, imei)
|
|
end
|
|
|
|
-- Another simultaneous open may have created the mapping first. Keep its
|
|
-- winner and remove only the fresh, unmapped device reserved by this call.
|
|
mapped_imei = load_character_device(source, identifier)
|
|
Bridge.Database.Query([[
|
|
DELETE d FROM `sky_phone_devices` d
|
|
LEFT JOIN `sky_phone_character_devices` c ON c.`device_imei` = d.`imei`
|
|
WHERE d.`imei` = ? AND c.`device_imei` IS NULL
|
|
]], { imei })
|
|
if mapped_imei then
|
|
return mapped_imei
|
|
end
|
|
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Could not create a shared device mapping for source %s.",
|
|
tostring(source)
|
|
)
|
|
return nil, "request_failed"
|
|
end
|
|
|
|
local function find_device_slots(source, imei)
|
|
local matches = {}
|
|
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
|
if Config.Phone.Unique == false then
|
|
if not slots[1] then
|
|
return matches
|
|
end
|
|
local mapped_imei = load_character_device(source)
|
|
if mapped_imei ~= imei then
|
|
return matches
|
|
end
|
|
for _, item in ipairs(slots) do
|
|
matches[#matches + 1] = item
|
|
end
|
|
return matches
|
|
end
|
|
|
|
for _, item in ipairs(slots) do
|
|
if item.metadata and item.metadata.imei == imei then
|
|
matches[#matches + 1] = item
|
|
end
|
|
end
|
|
|
|
if #matches > 1 then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Source %s has %s items with duplicated IMEI %s.",
|
|
tostring(source),
|
|
tostring(#matches),
|
|
imei
|
|
)
|
|
end
|
|
|
|
return matches
|
|
end
|
|
|
|
local function resolve_used_slot(source, used_item)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Resolving used item for source %s: type=%s slot=%s id=%s name=%s.",
|
|
tostring(source),
|
|
type(used_item),
|
|
tostring(used_item and used_item.slot),
|
|
tostring(used_item and used_item.id),
|
|
tostring(used_item and used_item.name),
|
|
{ always = true }
|
|
)
|
|
|
|
local slot_id = used_item and (used_item.slot or used_item.id)
|
|
if slot_id then
|
|
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Inventory slot lookup for source %s slot %s returned name=%s amount=%s metadata_imei=%s.",
|
|
tostring(source),
|
|
tostring(slot_id),
|
|
tostring(slot and slot.name),
|
|
tostring(slot and (slot.amount or slot.count)),
|
|
tostring(slot and slot.metadata and slot.metadata.imei),
|
|
{ always = true }
|
|
)
|
|
if slot and slot.name == Config.Phone.Item then
|
|
return slot
|
|
end
|
|
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Usable item callback reported invalid phone slot %s for source %s.",
|
|
tostring(slot_id),
|
|
tostring(source)
|
|
)
|
|
return nil, "phone_slot_missing"
|
|
end
|
|
|
|
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Inventory fallback found %s phone slot candidates for source %s.",
|
|
tostring(#slots),
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
for _, candidate in ipairs(slots) do
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Candidate slot=%s name=%s amount=%s metadata_imei=%s.",
|
|
tostring(candidate.slot),
|
|
tostring(candidate.name),
|
|
tostring(candidate.amount or candidate.count),
|
|
tostring(candidate.metadata and candidate.metadata.imei),
|
|
{ always = true }
|
|
)
|
|
end
|
|
|
|
if Config.Phone.Unique == false and #slots > 0 then
|
|
return slots[1]
|
|
end
|
|
|
|
local preferred_imei = preferred_device_imeis[source]
|
|
if preferred_imei then
|
|
for _, candidate in ipairs(slots) do
|
|
if candidate.metadata and candidate.metadata.imei == preferred_imei then
|
|
return candidate
|
|
end
|
|
end
|
|
end
|
|
|
|
table.sort(slots, function(left, right)
|
|
local left_slot = tonumber(left.slot)
|
|
local right_slot = tonumber(right.slot)
|
|
if left_slot and right_slot and left_slot ~= right_slot then
|
|
return left_slot < right_slot
|
|
end
|
|
return tostring(left.slot) < tostring(right.slot)
|
|
end)
|
|
|
|
if slots[1] then
|
|
return slots[1]
|
|
end
|
|
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Inventory '%s' returned no '%s' phone item for source %s. This is an item/configuration problem, not a missing SIM card.",
|
|
tostring(Bridge.Inventory.GetResourceName()),
|
|
tostring(Config.Phone.Item),
|
|
tostring(source)
|
|
)
|
|
return nil, "phone_required"
|
|
end
|
|
|
|
local function ensure_device(source, slot)
|
|
local amount = tonumber(slot.amount or slot.count) or 0
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Ensuring device for source %s slot=%s amount=%s existing_imei=%s inventory=%s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
tostring(amount),
|
|
tostring(slot.metadata and slot.metadata.imei),
|
|
tostring(Bridge.Inventory.GetResourceName()),
|
|
{ always = true }
|
|
)
|
|
if Config.Phone.Unique == false then
|
|
if amount < 1 then
|
|
return nil, "phone_slot_missing"
|
|
end
|
|
return map_character_device(source, slot)
|
|
end
|
|
|
|
if amount ~= 1 then
|
|
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s is stacked for source %s.", tostring(slot.slot), tostring(source))
|
|
return nil, "phone_stacked"
|
|
end
|
|
|
|
local metadata = slot.metadata or {}
|
|
local imei = metadata.imei
|
|
if imei and not SkyPhoneImei.IsValid(imei) then
|
|
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s has invalid IMEI metadata.", tostring(slot.slot))
|
|
return nil, "invalid_imei"
|
|
end
|
|
|
|
if not imei then
|
|
imei = migrated_device_for_character(source)
|
|
if imei then
|
|
metadata.imei = imei
|
|
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
|
|
if not metadata_written then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Inventory '%s' could not adopt migrated phone metadata for source %s slot %s.",
|
|
Bridge.Inventory.GetResourceName(),
|
|
tostring(source),
|
|
tostring(slot.slot)
|
|
)
|
|
return nil, "metadata_unsupported"
|
|
end
|
|
Bridge.Debug(
|
|
"info",
|
|
"[sky_phone] Adopted LB Phone migrated device %s for source %s.",
|
|
imei,
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
return imei
|
|
end
|
|
|
|
imei = reserve_imei()
|
|
metadata.imei = imei
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Reserved IMEI %s; writing metadata to source %s slot %s.",
|
|
imei,
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
{ always = true }
|
|
)
|
|
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Metadata write result for source %s slot %s IMEI %s: %s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
imei,
|
|
tostring(metadata_written),
|
|
{ always = true }
|
|
)
|
|
if not metadata_written then
|
|
Bridge.Database.Query("DELETE FROM `sky_phone_devices` WHERE `imei` = ?", { imei })
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Inventory '%s' could not write phone metadata for source %s slot %s.",
|
|
Bridge.Inventory.GetResourceName(),
|
|
tostring(source),
|
|
tostring(slot.slot)
|
|
)
|
|
return nil, "metadata_unsupported"
|
|
end
|
|
return imei
|
|
end
|
|
|
|
Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
|
VALUES (?, ?)
|
|
]], { imei, Config.Phone.DeviceName })
|
|
|
|
return imei
|
|
end
|
|
|
|
local function load_device(imei)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
|
|
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`, s.`is_virtual` AS `sim_is_virtual`
|
|
FROM `sky_phone_devices` d
|
|
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
|
|
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
|
|
WHERE d.`imei` = ?
|
|
LIMIT 1
|
|
]], { imei })
|
|
return rows[1]
|
|
end
|
|
|
|
local function load_device_data(imei)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `namespace`, `payload`, `revision`
|
|
FROM `sky_phone_device_data`
|
|
WHERE `device_imei` = ?
|
|
]], { imei })
|
|
local data = {}
|
|
for _, row in ipairs(rows) do
|
|
data[row.namespace] = {
|
|
payload = json.decode(row.payload),
|
|
revision = tonumber(row.revision) or 0,
|
|
}
|
|
end
|
|
return data
|
|
end
|
|
|
|
local function bootstrap(source, security, security_loaded)
|
|
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
|
|
local device = load_device(session.imei)
|
|
if not device then
|
|
error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei))
|
|
end
|
|
|
|
session.account_id = device.account_id and tonumber(device.account_id) or nil
|
|
session.account_email = session.account_id and device.email or nil
|
|
|
|
local identifier = character_identifier(source)
|
|
cache_equipped_phone_number(source, identifier, device.phone_number)
|
|
|
|
return {
|
|
token = session.token,
|
|
disabledApps = SkyPhone.GetDisabledApps(),
|
|
phoneNumberFormat = {
|
|
length = Config.Sim.NumberLength,
|
|
groups = Config.Sim.NumberGroups,
|
|
},
|
|
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
|
|
device = {
|
|
imei = device.imei,
|
|
name = device.device_name,
|
|
sim = device.sim_id and {
|
|
id = device.sim_id,
|
|
number = device.phone_number,
|
|
removable = Config.Sim.Enabled ~= false and tonumber(device.sim_is_virtual) ~= 1,
|
|
type = device.sim_type,
|
|
registered = device.registered_at ~= nil,
|
|
} or nil,
|
|
data = load_device_data(device.imei),
|
|
},
|
|
account = device.account_id and {
|
|
id = tonumber(device.account_id),
|
|
email = device.email,
|
|
devices = SkyPhoneAccounts.List(device.account_id, device.imei),
|
|
} or nil,
|
|
notes = SkyPhoneNotes.List(device.account_id, device.imei),
|
|
memos = SkyPhoneMemos.List(device.account_id, device.imei),
|
|
player = {
|
|
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
|
|
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
|
|
},
|
|
}
|
|
end
|
|
|
|
local function refresh_source(source)
|
|
local payload = bootstrap(source)
|
|
if payload then
|
|
TriggerClientEvent("sky_phone:device:updated", source, payload)
|
|
end
|
|
end
|
|
|
|
SkyPhone.EnsureDevice = ensure_device
|
|
SkyPhone.FindDeviceSlots = find_device_slots
|
|
SkyPhone.LoadDevice = load_device
|
|
SkyPhone.RefreshSource = refresh_source
|
|
|
|
local function online_source_for_identifier(identifier)
|
|
for _, player in ipairs(GetPlayers()) do
|
|
local player_source = tonumber(player)
|
|
if player_source and character_identifier(player_source) == identifier then
|
|
return player_source
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function resolve_equipped_phone_number(source)
|
|
local identifier = character_identifier(source)
|
|
if not identifier then
|
|
return nil
|
|
end
|
|
|
|
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
|
if not slots[1] then
|
|
remove_equipped_phone_number(source)
|
|
return nil
|
|
end
|
|
|
|
local imei
|
|
if Config.Phone.Unique ~= false then
|
|
table.sort(slots, function(left, right)
|
|
local left_slot = tonumber(left.slot)
|
|
local right_slot = tonumber(right.slot)
|
|
if left_slot and right_slot and left_slot ~= right_slot then
|
|
return left_slot < right_slot
|
|
end
|
|
return tostring(left.slot) < tostring(right.slot)
|
|
end)
|
|
|
|
local preferred_imei = preferred_device_imeis[source]
|
|
for _, slot in ipairs(slots) do
|
|
local amount = tonumber(slot.amount or slot.count) or 0
|
|
local slot_imei = slot.metadata and slot.metadata.imei or nil
|
|
if amount == 1 and SkyPhoneImei.IsValid(slot_imei) then
|
|
if slot_imei == preferred_imei then
|
|
imei = slot_imei
|
|
break
|
|
end
|
|
imei = imei or slot_imei
|
|
end
|
|
end
|
|
else
|
|
imei = load_character_device(source, identifier)
|
|
end
|
|
|
|
if not imei then
|
|
remove_equipped_phone_number(source)
|
|
return nil
|
|
end
|
|
|
|
local device = load_device(imei)
|
|
local phone_number = device and device.phone_number or nil
|
|
cache_equipped_phone_number(source, identifier, phone_number)
|
|
return phone_number
|
|
end
|
|
|
|
function SkyPhone.GetEquippedPhoneNumber(player)
|
|
if type(player) == "number" then
|
|
if player <= 0 or player ~= math.floor(player) then
|
|
return nil
|
|
end
|
|
return resolve_equipped_phone_number(player)
|
|
end
|
|
if type(player) ~= "string" or player == "" then
|
|
return nil
|
|
end
|
|
|
|
local player_source = online_source_for_identifier(player)
|
|
return player_source and resolve_equipped_phone_number(player_source) or nil
|
|
end
|
|
|
|
function SkyPhone.GetSourceFromNumber(phone_number)
|
|
local normalized = SkyPhoneSimNumber.Normalize(
|
|
phone_number,
|
|
Config.Sim.NumberLength,
|
|
Config.Sim.NumberPrefix
|
|
)
|
|
if not normalized then
|
|
return nil
|
|
end
|
|
|
|
local player_source = equipped_phone_sources[normalized]
|
|
if player_source and resolve_equipped_phone_number(player_source) == normalized then
|
|
return player_source
|
|
end
|
|
for _, player in ipairs(GetPlayers()) do
|
|
player_source = tonumber(player)
|
|
if player_source and resolve_equipped_phone_number(player_source) == normalized then
|
|
return player_source
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function SkyPhone.FormatNumber(phone_number)
|
|
return SkyPhoneSimNumber.Format(
|
|
phone_number,
|
|
Config.Sim.NumberGroups,
|
|
Config.Sim.NumberLength,
|
|
Config.Sim.NumberPrefix
|
|
)
|
|
end
|
|
|
|
function SkyPhone.RequireDeviceSession(source)
|
|
local session = sessions[source]
|
|
if not session then
|
|
return nil, { success = false, error = "device_not_open" }
|
|
end
|
|
|
|
local matches = find_device_slots(source, session.imei)
|
|
if not matches[1] then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
sessions[source] = nil
|
|
remove_equipped_phone_number(source)
|
|
TriggerClientEvent("sky_phone:device:invalidated", source)
|
|
return nil, { success = false, error = "device_not_owned" }
|
|
end
|
|
session.slot = matches[1].slot
|
|
return session
|
|
end
|
|
|
|
function SkyPhone.RequireSession(source)
|
|
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
if not session.unlocked then
|
|
return nil, { success = false, error = "device_locked" }
|
|
end
|
|
return session
|
|
end
|
|
|
|
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
|
|
local now = os.time()
|
|
operation_attempts[source] = operation_attempts[source] or {}
|
|
local attempts = operation_attempts[source][operation]
|
|
if not attempts or now - attempts.started_at >= window_seconds then
|
|
operation_attempts[source][operation] = { count = 1, started_at = now }
|
|
return true
|
|
end
|
|
if attempts.count >= maximum then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Rate limit '%s' exceeded by source %s.",
|
|
operation,
|
|
tostring(source)
|
|
)
|
|
return false
|
|
end
|
|
attempts.count = attempts.count + 1
|
|
return true
|
|
end
|
|
|
|
function SkyPhone.RequireAccount(source)
|
|
local session, error_response = SkyPhone.RequireSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
if not session.account_id then
|
|
return nil, { success = false, error = "not_authenticated" }
|
|
end
|
|
return {
|
|
id = session.account_id,
|
|
email = session.account_email,
|
|
imei = session.imei,
|
|
}
|
|
end
|
|
|
|
function SkyPhone.NotifyAccount(account_id, event_name, data)
|
|
for source in pairs(sessions) do
|
|
local account = SkyPhone.RequireAccount(source)
|
|
if account and account.id == tonumber(account_id) then
|
|
TriggerClientEvent(event_name, source, data)
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
|
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.`account_id` = ?
|
|
]], { account_id })
|
|
local devices = {}
|
|
for _, row in ipairs(rows) do
|
|
devices[row.imei] = row
|
|
end
|
|
|
|
local function notify_device(source, device)
|
|
local payload = {}
|
|
for key, value in pairs(data) do
|
|
payload[key] = value
|
|
end
|
|
payload.device = {
|
|
imei = device.imei,
|
|
name = device.device_name,
|
|
settings = device.settings,
|
|
}
|
|
TriggerClientEvent(event_name, source, payload)
|
|
end
|
|
|
|
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
|
local source = tonumber(player_source) or player_source
|
|
if Config.Phone.Unique == false then
|
|
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
|
|
local imei = load_character_device(source)
|
|
local device = imei and devices[imei] or nil
|
|
if device then
|
|
notify_device(source, device)
|
|
end
|
|
end
|
|
else
|
|
local notified_devices = {}
|
|
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
|
|
local imei = item.metadata and item.metadata.imei
|
|
local device = imei and devices[imei]
|
|
if device and not notified_devices[imei] then
|
|
notified_devices[imei] = true
|
|
notify_device(source, device)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.RefreshAccount(account_id)
|
|
for source in pairs(sessions) do
|
|
local account = SkyPhone.RequireAccount(source)
|
|
if account and account.id == tonumber(account_id) then
|
|
refresh_source(source)
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.RefreshDevice(imei)
|
|
for source, session in pairs(sessions) do
|
|
if session.imei == imei then
|
|
refresh_source(source)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function perform_phone_open(source, used_item)
|
|
local opened_at = GetGameTimer()
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Usable phone callback invoked for source %s.",
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
local slot, slot_error = resolve_used_slot(source, used_item)
|
|
if not slot then
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Phone open rejected for source %s: %s.",
|
|
tostring(source),
|
|
tostring(slot_error),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
|
return false, slot_error
|
|
end
|
|
|
|
local imei, error_code = ensure_device(source, slot)
|
|
if not imei then
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Phone open rejected for source %s slot %s: %s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
tostring(error_code),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
|
return false, error_code
|
|
end
|
|
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
|
|
if not prepared then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Phone preparation failed for source %s IMEI %s: %s.",
|
|
tostring(source),
|
|
imei,
|
|
tostring(prepare_error)
|
|
)
|
|
local error_code = prepare_error or "request_failed"
|
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
|
return false, error_code
|
|
end
|
|
|
|
local security = SkyPhoneSecurity.Load(imei)
|
|
if sessions[source] and sessions[source].imei ~= imei then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
end
|
|
sessions[source] = {
|
|
imei = imei,
|
|
slot = slot.slot,
|
|
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
|
unlocked = security == nil,
|
|
}
|
|
preferred_device_imeis[source] = imei
|
|
local payload, bootstrap_error = bootstrap(source, security, true)
|
|
if not payload then
|
|
local error_code = type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed"
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Phone bootstrap failed for source %s slot %s IMEI %s: %s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
imei,
|
|
tostring(error_code)
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
|
return false, error_code
|
|
end
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
imei,
|
|
tostring(payload.account ~= nil),
|
|
tostring(GetGameTimer() - opened_at),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:open", source, payload)
|
|
return true
|
|
end
|
|
|
|
local function open_phone(source, used_item)
|
|
if phone_open_in_progress[source] then
|
|
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
|
|
return false, "operation_in_progress"
|
|
end
|
|
|
|
local request_limit = math.max(
|
|
1,
|
|
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
|
|
)
|
|
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
|
|
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
|
|
return false, "rate_limited"
|
|
end
|
|
|
|
phone_open_in_progress[source] = true
|
|
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
|
|
phone_open_in_progress[source] = nil
|
|
if not completed then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
|
|
tostring(source),
|
|
tostring(success),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
|
|
return false, "request_failed"
|
|
end
|
|
return success, error_code
|
|
end
|
|
|
|
phone_open_handler = open_phone
|
|
flush_pending_phone_opens()
|
|
|
|
function SkyPhone.OpenDeviceForCall(source, imei)
|
|
local matches = find_device_slots(source, imei)
|
|
if not matches[1] then
|
|
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
|
|
return false
|
|
end
|
|
local security = SkyPhoneSecurity.Load(imei)
|
|
local existing_session = sessions[source]
|
|
if existing_session and existing_session.imei ~= imei then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
end
|
|
if existing_session and existing_session.imei == imei then
|
|
existing_session.slot = matches[1].slot
|
|
else
|
|
sessions[source] = {
|
|
imei = imei,
|
|
slot = matches[1].slot,
|
|
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
|
unlocked = security == nil,
|
|
}
|
|
end
|
|
preferred_device_imeis[source] = imei
|
|
local payload, bootstrap_error = bootstrap(source)
|
|
if not payload then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Call notification bootstrap failed for source %s IMEI %s: %s.",
|
|
tostring(source),
|
|
tostring(imei),
|
|
tostring(type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed")
|
|
)
|
|
return false
|
|
end
|
|
TriggerClientEvent("sky_phone:device:open", source, payload)
|
|
return true
|
|
end
|
|
|
|
local registered_usable_items = {}
|
|
|
|
local function register_configured_phone_item()
|
|
local item_name = Config.Phone.Item
|
|
if registered_usable_items[item_name] then
|
|
return
|
|
end
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
|
|
item_name,
|
|
tostring(Bridge.Inventory.GetResourceName()),
|
|
{ always = true }
|
|
)
|
|
if not Bridge.Inventory.RegisterUsableItem(item_name, function(...)
|
|
if Config.Phone.Item == item_name then
|
|
open_phone(...)
|
|
end
|
|
end) then
|
|
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
|
|
tostring(Bridge.Inventory.GetResourceName()),
|
|
tostring(item_name)
|
|
))
|
|
end
|
|
registered_usable_items[item_name] = true
|
|
end
|
|
|
|
register_configured_phone_item()
|
|
|
|
AddEventHandler("sky_phone:configurator:serverUpdated", function()
|
|
register_configured_phone_item()
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
|
sessions[source] = nil
|
|
return { success = true }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:equipped-number", function(source)
|
|
return {
|
|
success = true,
|
|
data = { phoneNumber = SkyPhone.GetEquippedPhoneNumber(source) },
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:notification-open", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "notification_open", 10, 60)
|
|
or type(data) ~= "table" or type(data.imei) ~= "string"
|
|
then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if not SkyPhone.OpenDeviceForCall(source, data.imei) then
|
|
return { success = false, error = "device_not_owned" }
|
|
end
|
|
return { success = true }
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
sessions[source] = nil
|
|
discard_equipped_phone_number(source)
|
|
operation_attempts[source] = nil
|
|
phone_open_in_progress[source] = nil
|
|
character_device_cache[source] = nil
|
|
preferred_device_imeis[source] = nil
|
|
end)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name == GetCurrentResourceName() then
|
|
sessions = {}
|
|
operation_attempts = {}
|
|
phone_open_in_progress = {}
|
|
character_device_cache = {}
|
|
preferred_device_imeis = {}
|
|
equipped_phone_numbers = {}
|
|
equipped_phone_identifiers = {}
|
|
equipped_phone_sources = {}
|
|
end
|
|
end)
|
|
end)
|