SkyPhoneConfigurator = SkyPhoneConfigurator or {} local TABLE_NAME = "sky_phone_configurator" local CONFIG_ROW_ID = 1 local MAX_CHANGES = 1000 local MAX_PAYLOAD_BYTES = 8 * 1024 * 1024 local MAX_STRUCTURED_DEPTH = 20 local MAX_STRUCTURED_ENTRIES = 20000 local REDACTED_VALUE = "***REDACTED***" local configurator_enabled = Config.PhoneConfigurator.Enabled == true local default_config local default_media local stored_config local stored_media local revision = 1 local updated_at local updated_by_name local is_sequence local FIXED_CONFIG_PATHS = { CommandPermissions = true, ["AdminPanel.AdminGroups"] = true, CustomTones = true, ["TestData.AdminGroups"] = true, ["FlipTok.AdminGroups"] = true, ["Picstagram.AdminGroups"] = true, } if configurator_enabled then local border = "======================================================================" print(([[ ^1%s^0 ^1 SKY PHONE CONFIGURATION FILES ARE DISABLED ^0 ^1%s^0 ^1 The Phone Configurator is ENABLED.^0 ^1 Runtime settings from config.lua and media.lua are DISABLED.^0 ^1 Configure all phone and media settings IN GAME through /phonepanel.^0 ^1 Config.PhoneConfigurator, Config.CommandPermissions and Config.CustomTones remain file-based.^0 ^1%s^0]]):format(border, border, border)) end local CLIENT_CONFIG_KEYS = { AdminPanel = true, Animations = true, Apps = true, Banking = true, Billing = true, Bridge = true, Calendar = true, Calls = true, Command = true, CrewLink = true, Crypto = true, CustomApps = true, DarkChat = true, EasyShare = true, Feather = true, FlipTok = true, Garage = true, Health = true, Housing = true, LocalPages = true, Mail = true, MapMarkers = true, Marketplace = true, Memos = true, Messages = true, Payphones = true, Phone = true, Picstagram = true, Radio = true, Security = true, Sim = true, SkyRide = true, Speaker = true, TestData = true, } local function copy_value(value, active) local value_type = type(value) if value_type ~= "table" then return value end active = active or {} if active[value] then error("[sky_phone] Phone configurator cannot copy a cyclic table.") end active[value] = true local copy = {} for key, child in pairs(value) do copy[key] = copy_value(child, active) end active[value] = nil return copy end local function serialize_value(value, active) local value_type = type(value) if value_type == "nil" or value_type == "boolean" or value_type == "string" then return value end if value_type == "number" then if value ~= value or value == math.huge or value == -math.huge then error("[sky_phone] Phone configurator cannot serialize a non-finite number.") end return value end if value_type == "vector2" then return { __skyType = "vector2", x = value.x, y = value.y } end if value_type == "vector3" then return { __skyType = "vector3", x = value.x, y = value.y, z = value.z } end if value_type == "vector4" then return { __skyType = "vector4", x = value.x, y = value.y, z = value.z, w = value.w } end if value_type ~= "table" then error(("[sky_phone] Phone configurator cannot serialize value type '%s'."):format(value_type)) end active = active or {} if active[value] then error("[sky_phone] Phone configurator cannot serialize a cyclic table.") end active[value] = true local has_numeric_map_key = false if not is_sequence(value) then for key in pairs(value) do if type(key) == "number" then has_numeric_map_key = true break end end end if has_numeric_map_key then local entries = {} for key, child in pairs(value) do if type(key) ~= "string" and type(key) ~= "number" then error("[sky_phone] Phone configurator only supports string and numeric table keys.") end entries[#entries + 1] = { key = key, keyType = type(key), value = serialize_value(child, active), } end table.sort(entries, function(left, right) if left.keyType ~= right.keyType then return left.keyType < right.keyType end if left.keyType == "number" then return left.key < right.key end return tostring(left.key) < tostring(right.key) end) active[value] = nil return { __skyType = "map", entries = entries } end local serialized = {} for key, child in pairs(value) do if type(key) ~= "string" and type(key) ~= "number" then error("[sky_phone] Phone configurator only supports string and numeric table keys.") end serialized[key] = serialize_value(child, active) end active[value] = nil return serialized end local function deserialize_value(value) if type(value) ~= "table" then return value end if value.__skyType == "vector2" then return vector2(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0) end if value.__skyType == "vector3" then return vector3(tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0) end if value.__skyType == "vector4" then return vector4( tonumber(value.x) or 0.0, tonumber(value.y) or 0.0, tonumber(value.z) or 0.0, tonumber(value.w) or 0.0 ) end if value.__skyType == "map" then local decoded = {} for _, entry in ipairs(value.entries or {}) do local key = entry.keyType == "number" and tonumber(entry.key) or entry.key decoded[key] = deserialize_value(entry.value) end return decoded end local decoded = {} for key, child in pairs(value) do decoded[key] = deserialize_value(child) end return decoded end local function apply_runtime_table(current, replacement) for key in pairs(current) do if replacement[key] == nil then current[key] = nil end end for key, value in pairs(replacement) do local current_value = current[key] if type(current_value) == "table" and type(value) == "table" then apply_runtime_table(current_value, value) else current[key] = value end end end is_sequence = function(value) if type(value) ~= "table" then return false end local count = 0 local maximum = 0 for key in pairs(value) do if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then return false end count = count + 1 maximum = math.max(maximum, key) end return count > 0 and count == maximum end local function upgrade_legacy_map(defaults, saved) local default_types = {} local numeric_only = true for _, entry in ipairs(defaults.entries or {}) do default_types[tostring(entry.key)] = entry.keyType if entry.keyType ~= "number" then numeric_only = false end end local entries = {} for key, child in pairs(saved) do local key_type = default_types[tostring(key)] or (numeric_only and "number" or type(key)) local normalized_key = key_type == "number" and tonumber(key) or tostring(key) if normalized_key == nil then error("[sky_phone] Phone configurator could not migrate a numeric configuration key.") end entries[#entries + 1] = { key = normalized_key, keyType = key_type, value = copy_value(child), } end table.sort(entries, function(left, right) if left.keyType ~= right.keyType then return left.keyType < right.keyType end if left.keyType == "number" then return left.key < right.key end return tostring(left.key) < tostring(right.key) end) return { __skyType = "map", entries = entries } end local function radio_job_entry_default(path) if path == "Radio.DisplayName.AllowedJobs" then return 0 end if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then return true end return nil end local function merge_values(defaults, saved, path, excluded_paths) path = path or "" if type(defaults) ~= "table" or type(saved) ~= "table" then return copy_value(saved) end if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then return copy_value(saved) end if defaults.__skyType == "map" and not saved.__skyType then saved = upgrade_legacy_map(defaults, saved) end if defaults.__skyType == "map" and saved.__skyType == "map" then local saved_entries = {} for _, entry in ipairs(saved.entries or {}) do saved_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry end local entries = {} local included = {} for _, entry in ipairs(defaults.entries or {}) do local identity = entry.keyType .. ":" .. tostring(entry.key) local saved_entry = saved_entries[identity] entries[#entries + 1] = { key = entry.key, keyType = entry.keyType, value = saved_entry and merge_values( entry.value, saved_entry.value, path .. "." .. tostring(entry.key), excluded_paths ) or copy_value(entry.value), } included[identity] = true end for _, entry in ipairs(saved.entries or {}) do local identity = entry.keyType .. ":" .. tostring(entry.key) if not included[identity] then entries[#entries + 1] = copy_value(entry) end end return { __skyType = "map", entries = entries } end if defaults.__skyType or saved.__skyType then return copy_value(saved) end if is_sequence(defaults) then local merged = copy_value(saved) for index, child in ipairs(defaults) do merged[index] = saved[index] ~= nil and merge_values(child, saved[index], path .. "." .. tostring(index), excluded_paths) or copy_value(child) end return merged end if is_sequence(saved) then return copy_value(saved) end local merged = copy_value(defaults) for key, child in pairs(saved) do local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) if not excluded_paths or not excluded_paths[child_path] then if merged[key] ~= nil then merged[key] = merge_values(merged[key], child, child_path, excluded_paths) else merged[key] = copy_value(child) end end end return merged end local function decode_payload(encoded, scope) if type(encoded) ~= "string" or encoded == "" then error(("[sky_phone] Phone configurator has an empty %s SQL payload."):format(scope)) end local success, decoded = pcall(json.decode, encoded) if not success or type(decoded) ~= "table" then error(("[sky_phone] Phone configurator failed to decode the %s SQL payload."):format(scope)) end return decoded end local function encode_payload(payload, scope) local success, encoded = pcall(json.encode, payload) if not success or type(encoded) ~= "string" then error(("[sky_phone] Phone configurator failed to encode the %s SQL payload."):format(scope)) end if #encoded > MAX_PAYLOAD_BYTES then error(("[sky_phone] Phone configurator %s payload exceeds %s bytes."):format(scope, MAX_PAYLOAD_BYTES)) end return encoded 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 apply_runtime_configuration() if not configurator_enabled then return end local runtime_config = deserialize_value(stored_config) for key, value in pairs(runtime_config) do if key ~= "CommandPermissions" and key ~= "CustomTones" then if type(Config[key]) == "table" and type(value) == "table" then apply_runtime_table(Config[key], value) else Config[key] = value end end end local runtime_media = deserialize_value(stored_media) if type(Config.Media) == "table" and type(runtime_media) == "table" then apply_runtime_table(Config.Media, runtime_media) else Config.Media = runtime_media end end local function humanize(value) local text = tostring(value or "") :gsub("[_%-]+", " ") :gsub("(%l)(%u)", "%1 %2") :gsub("(%a)(%d)", "%1 %2") return text:gsub("^%l", string.upper) end local function sensitive_path(path) local leaf = tostring(path):match("([^.]+)$") or path local normalized = leaf:lower():gsub("[^%w]", "") if normalized:find("apikey", 1, true) or normalized:find("secret", 1, true) or normalized:find("pepper", 1, true) or normalized == "password" or normalized == "token" or normalized == "authorization" or normalized == "credential" or normalized == "connectionstring" then return true end return false end local function mask_admin_value(value, path) if type(value) ~= "table" then return type(value) == "string" and sensitive_path(path) and REDACTED_VALUE or value end if value.__skyType == "map" then local entries = {} for index, entry in ipairs(value.entries or {}) do entries[index] = { key = entry.key, keyType = entry.keyType, value = mask_admin_value(entry.value, path .. "." .. tostring(entry.key)), } end return { __skyType = "map", entries = entries } end local masked = {} for key, child in pairs(value) do masked[key] = mask_admin_value(child, path .. "." .. tostring(key)) end return masked end local function restore_redacted_values(value, current, path) if type(value) ~= "table" then if value == REDACTED_VALUE and sensitive_path(path) then return current end return value end if value.__skyType == "map" then local current_entries = {} if type(current) == "table" and current.__skyType == "map" then for _, entry in ipairs(current.entries or {}) do current_entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value end end local entries = {} for index, entry in ipairs(value.entries or {}) do local identity = entry.keyType .. ":" .. tostring(entry.key) entries[index] = { key = entry.key, keyType = entry.keyType, value = restore_redacted_values( entry.value, current_entries[identity], path .. "." .. tostring(entry.key) ), } end return { __skyType = "map", entries = entries } end local restored = {} for key, child in pairs(value) do local current_child = type(current) == "table" and current[key] or nil restored[key] = restore_redacted_values(child, current_child, path .. "." .. tostring(key)) end return restored end local function empty_structure(scope, path) if scope ~= "config" then return nil end local radio_job_default = radio_job_entry_default(path) if radio_job_default ~= nil then return { entryDefault = radio_job_default, fields = {}, kind = "table", mutableKeys = true, template = { kind = "value", valueType = type(radio_job_default) }, } end if path == "Garage.VehicleImages.ModelNames" then return { entries = {}, keyType = "number", kind = "map", template = { kind = "value", valueType = "string" }, } end if path == "CrewLink.ExternalPingResources" or path == "CustomApps.TrustedAdapters" then return { fields = {}, kind = "table", mutableKeys = true, template = { kind = "value", valueType = "boolean" }, } end if path == "FlipTok.MusicTracks" then return { items = {}, kind = "list", template = { fields = { Artist = { kind = "value", valueType = "string" }, Id = { kind = "value", valueType = "string" }, Title = { kind = "value", valueType = "string" }, Url = { kind = "value", valueType = "string" }, }, kind = "table", }, } end if path == "Music.Tracks" then return { items = {}, kind = "list", template = { fields = { Artist = { kind = "value", valueType = "string" }, Id = { kind = "value", valueType = "string" }, Title = { kind = "value", valueType = "string" }, }, kind = "table", }, } end if path == "Payphones.CustomLocations" then return { items = {}, kind = "list", template = { kind = "vector", vectorType = "vector4" }, } end if path:match("^Companies%.Definitions%.[^.]+%.Services$") then return { items = {}, kind = "list", template = { fields = { Description = { kind = "value", valueType = "string" }, Id = { kind = "value", valueType = "string" }, Price = { kind = "value", valueType = "string" }, RequestsEnabled = { kind = "value", valueType = "boolean" }, Title = { kind = "value", valueType = "string" }, }, kind = "table", }, } end return nil end local function next_available_company_service_number(configuration) configuration = configuration or stored_config local maximum_length = configuration.Sim.NumberLength local service_length = math.max(1, math.min(3, maximum_length - 1)) local first_candidate = service_length == 1 and 5 or 5 * (10 ^ (service_length - 1)) local last_candidate = (10 ^ service_length) - 1 local used = {} for _, definition in pairs(configuration.Companies.Definitions) do used[tostring(definition.ServiceLine.Number):gsub("%D", "")] = true end for offset = 0, last_candidate - 1 do local candidate = ((first_candidate - 1 + offset) % last_candidate) + 1 local number = ("%0" .. tostring(service_length) .. "d"):format(candidate) if not used[number] then return number end end return "" end local function company_definition_entry_default(company_id, configuration) configuration = configuration or stored_config local name = company_id and humanize(company_id) or "" local logo_seed = company_id or "new" return { AcceptsRequests = true, Address = "", Category = configuration.Companies.Categories[1] or default_config.Companies.Categories[1], DefaultAvailability = "closed", Description = "", District = "", Emergency = false, Icon = "building", Job = company_id or "", Location = { __skyType = "vector3", x = 0.0, y = 0.0, z = 0.0 }, LocationLabel = "", LogoUrl = ("https://picsum.photos/seed/companies-%s-logo/180/180"):format(logo_seed), Name = name, Permissions = { Announcement = 0, Assign = 0, Availability = 0, Hours = 0, Profile = 0, Services = 0, WorkQueue = 0, }, Public = true, ServiceLine = { AutoContact = true, CanCall = true, CanMessage = false, MinimumGrade = 0, Number = next_available_company_service_number(configuration), Routing = "round_robin", }, Services = { { Description = "", Id = company_id or "", Price = "", RequestsEnabled = true, Title = name, }, }, Verified = false, } end local function build_structure(value, scope, path) local value_type = type(value) if scope == "config" and path == "Phone.Keybind" then return { kind = "optionalString" } end if scope == "config" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") and value_type == "table" then local fields = {} for key, child in pairs(value) do fields[key] = build_structure(child, scope, path .. "." .. tostring(key)) end return { fields = fields, kind = "table", mutableKeys = true, template = { kind = "value", valueType = "boolean" }, } end if scope == "config" and path == "Companies.Definitions" and value_type == "table" then local keys = {} for key in pairs(value) do keys[#keys + 1] = key end table.sort(keys, function(left, right) return tostring(left) < tostring(right) end) local fields = {} for _, key in ipairs(keys) do fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key)) end return { entryDefault = company_definition_entry_default(), fields = fields, kind = "table", mutableKeys = true, template = keys[1] and fields[keys[1]] or nil, } end if value_type ~= "table" then return { kind = "value", valueType = value_type, } end if value.__skyType == "vector2" or value.__skyType == "vector3" or value.__skyType == "vector4" then return { kind = "vector", vectorType = value.__skyType, } end if value.__skyType == "map" then local entries = {} local key_type for index, entry in ipairs(value.entries or {}) do entries[index] = { key = entry.key, keyType = entry.keyType, structure = build_structure(entry.value, scope, path .. "." .. tostring(entry.key)), } if index == 1 then key_type = entry.keyType elseif key_type ~= entry.keyType then key_type = nil end end return { entries = entries, keyType = key_type, kind = "map", template = entries[1] and entries[1].structure or nil, } end local configured_empty_structure = next(value) == nil and empty_structure(scope, path) or nil if configured_empty_structure then return configured_empty_structure end if is_sequence(value) then local items = {} for index, child in ipairs(value) do items[index] = build_structure(child, scope, path .. "." .. tostring(index)) end return { items = items, kind = "list", template = items[1], } end local fields = {} for key, child in pairs(value) do fields[key] = build_structure(child, scope, path .. "." .. tostring(key)) end local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil if radio_job_default ~= nil then return { entryDefault = radio_job_default, fields = fields, kind = "table", mutableKeys = true, template = { kind = "value", valueType = type(radio_job_default) }, } end return { fields = fields, kind = "table", } end local function add_field(fields, field_index, scope, path, value, default_value) local value_type = type(value) local field_type = value_type if value_type == "table" then field_type = "json" elseif value_type ~= "boolean" and value_type ~= "number" and value_type ~= "string" then return end if scope == "config" and path == "Phone.Keybind" then field_type = "stringOrFalse" end local sensitive = type(value) == "string" and sensitive_path(path) local field = { configured = sensitive and value ~= "" or nil, label = scope == "config" and path == "Companies.Definitions" and "Jobs" or humanize(path:match("([^.]+)$") or path), path = path, scope = scope, sensitive = sensitive, structure = field_type == "json" and build_structure(default_value, scope, path) or nil, type = field_type, value = sensitive and "" or mask_admin_value(value, path), } fields[#fields + 1] = field field_index[scope .. ":" .. path] = field end local function flatten_company_fields(fields, field_index, path, value, default_value) if path == "Companies.Definitions" or type(value) ~= "table" or value.__skyType or is_sequence(value) or next(value) == nil then add_field(fields, field_index, "config", path, value, default_value) return end local keys = {} for key in pairs(value) do keys[#keys + 1] = key end table.sort(keys, function(left, right) return tostring(left) < tostring(right) end) for _, key in ipairs(keys) do flatten_company_fields( fields, field_index, path .. "." .. tostring(key), value[key], type(default_value) == "table" and default_value[key] or nil ) end end local function build_sections(scope, payload, defaults, sections, field_index) local general_fields = {} local keys = {} for key in pairs(payload) do keys[#keys + 1] = key end table.sort(keys, function(left, right) return tostring(left) < tostring(right) end) for _, key in ipairs(keys) do local value = payload[key] if scope == "config" and key == "Companies" then local fields = {} flatten_company_fields(fields, field_index, "Companies", value, defaults[key]) table.sort(fields, function(left, right) if left.path == "Companies.Definitions" then return false end if right.path == "Companies.Definitions" then return true end return left.path < right.path end) sections[#sections + 1] = { fields = fields, id = "config:Companies", label = humanize(key), scope = scope, } elseif type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then local fields = {} add_field(fields, field_index, scope, tostring(key), value, defaults[key]) sections[#sections + 1] = { fields = fields, id = scope .. ":" .. tostring(key), label = humanize(key), scope = scope, } else add_field(general_fields, field_index, scope, tostring(key), value, defaults[key]) end end if #general_fields > 0 then table.insert(sections, scope == "config" and 1 or (#sections + 1), { fields = general_fields, id = scope .. ":general", label = "General", scope = scope, }) end end local function build_admin_data() local sections = {} local field_index = {} build_sections("config", stored_config, default_config, sections, field_index) build_sections("media", stored_media, default_media, sections, field_index) return { enabled = configurator_enabled, revision = revision, sections = sections, updatedAt = updated_at, updatedBy = updated_by_name, }, field_index end local function split_path(path) local parts = {} for part in path:gmatch("[^.]+") do parts[#parts + 1] = part end return parts end local function set_path(root, path, value) local parts = split_path(path) if #parts == 0 then return false end local parent = root for index = 1, #parts - 1 do parent = parent[parts[index]] if type(parent) ~= "table" then return false end end parent[parts[#parts]] = value return true end local function get_path(root, path) local value = root for _, part in ipairs(split_path(path)) do if type(value) ~= "table" then return nil end value = value[part] end return value end local function validate_structured_value(value, depth, state) local value_type = type(value) if value_type == "string" then return #value <= 65535 end if value_type == "number" then return value == value and value ~= math.huge and value ~= -math.huge end if value_type == "boolean" or value_type == "nil" then return true end if value_type ~= "table" or depth > MAX_STRUCTURED_DEPTH then return false end state.entries = state.entries + 1 if state.entries > MAX_STRUCTURED_ENTRIES then return false end local sky_type = value.__skyType if sky_type then if sky_type == "vector2" or sky_type == "vector3" or sky_type == "vector4" then local axes = sky_type == "vector2" and { "x", "y" } or sky_type == "vector3" and { "x", "y", "z" } or { "x", "y", "z", "w" } for _, axis in ipairs(axes) do local coordinate = value[axis] if type(coordinate) ~= "number" or coordinate ~= coordinate or coordinate == math.huge or coordinate == -math.huge then return false end end return true end if sky_type ~= "map" or type(value.entries) ~= "table" then return false end if next(value.entries) and not is_sequence(value.entries) then return false end local seen = {} for _, entry in ipairs(value.entries) do if type(entry) ~= "table" or (entry.keyType ~= "string" and entry.keyType ~= "number") then return false end local key = entry.key if entry.keyType == "string" then if type(key) ~= "string" or key == "" or #key > 255 then return false end elseif type(key) ~= "number" or key ~= key or key == math.huge or key == -math.huge then return false end local identity = entry.keyType .. ":" .. tostring(key) if seen[identity] then return false end seen[identity] = true if not validate_structured_value(entry.value, depth + 1, state) then return false end end return true end for key, child in pairs(value) do if type(key) ~= "string" and type(key) ~= "number" then return false end if not validate_structured_value(child, depth + 1, state) then return false end end return true end local function validate_locked_structure(structure, value) if type(structure) ~= "table" or type(structure.kind) ~= "string" then return false end if structure.kind == "value" then return type(value) == structure.valueType end if structure.kind == "optionalString" then return value == false or type(value) == "string" end if structure.kind == "vector" then return type(value) == "table" and value.__skyType == structure.vectorType end if structure.kind == "list" then if type(value) ~= "table" or value.__skyType or (next(value) and not is_sequence(value)) then return false end local fixed_items = structure.items or {} for index, item_structure in ipairs(fixed_items) do if value[index] == nil or not validate_locked_structure(item_structure, value[index]) then return false end end if structure.template then for index = #fixed_items + 1, #value do if not validate_locked_structure(structure.template, value[index]) then return false end end end return true end if structure.kind == "table" then if type(value) ~= "table" or value.__skyType or is_sequence(value) then return false end if not structure.mutableKeys then for key in pairs(value) do if not structure.fields[key] then return false end end for key, field_structure in pairs(structure.fields or {}) do if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then return false end end end if structure.mutableKeys and structure.template then for key, child in pairs(value) do if type(key) ~= "string" or #key > 64 or not key:match("^[a-z0-9_-]+$") or not validate_locked_structure(structure.template, child) then return false end end end return true end if structure.kind ~= "map" or type(value) ~= "table" then return false end if value.__skyType ~= "map" then return next(value) == nil and #(structure.entries or {}) == 0 end local entries = {} for _, entry in ipairs(value.entries or {}) do entries[entry.keyType .. ":" .. tostring(entry.key)] = entry.value end for _, entry in ipairs(structure.entries or {}) do local child = entries[entry.keyType .. ":" .. tostring(entry.key)] if child == nil or not validate_locked_structure(entry.structure, child) then return false end end if structure.template or structure.keyType then local fixed_entries = {} for _, entry in ipairs(structure.entries or {}) do fixed_entries[entry.keyType .. ":" .. tostring(entry.key)] = true end for _, entry in ipairs(value.entries or {}) do local identity = entry.keyType .. ":" .. tostring(entry.key) local invalid_key_type = structure.keyType and entry.keyType ~= structure.keyType local invalid_value = structure.template and not validate_locked_structure(structure.template, entry.value) if not fixed_entries[identity] and (invalid_key_type or invalid_value) then return false end end end return true end local function normalize_change_value(field, value) if field.type == "boolean" then return type(value) == "boolean" and value or nil end if field.type == "number" then if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then return nil end return value end if field.type == "string" then if type(value) ~= "string" or #value > 65535 or value == REDACTED_VALUE then return nil end return value end if field.type == "stringOrFalse" then if value == false then return false end if type(value) ~= "string" or #value > 65535 then return nil end return value end if field.type == "json" and type(value) == "table" then if not validate_structured_value(value, 0, { entries = 0 }) then return nil end local serialized = serialize_value(value) if not validate_locked_structure(field.structure, serialized) then return nil end return serialized end return nil end local function client_payload() local payload = {} for key in pairs(CLIENT_CONFIG_KEYS) do if stored_config[key] ~= nil then payload[key] = copy_value(stored_config[key]) end end return payload end function SkyPhoneConfigurator.Broadcast(target) TriggerClientEvent("sky_phone:configurator:sync", target or -1, { config = client_payload(), enabled = configurator_enabled, revision = revision, }) end local function read_stored_row() local rows = Bridge.Database.Query(([[ SELECT `config_payload`, `media_payload`, `revision`, `updated_at`, `updated_by_name` FROM `%s` WHERE `id` = ? LIMIT 1 ]]):format(TABLE_NAME), { CONFIG_ROW_ID }) local row = rows[1] if not row then error("[sky_phone] Phone configurator could not load its SQL row.") end return row end local function apply_stored_row(row) stored_config = merge_values( default_config, decode_payload(row.config_payload, "config"), "", FIXED_CONFIG_PATHS ) stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") revision = tonumber(row.revision) or 1 updated_at = row.updated_at updated_by_name = row.updated_by_name end local function migrate_blank_company_definitions() local migration_name = "sky-phone:configurator:company-definition-defaults:v1" local completed = Bridge.Database.Query( "SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1", { migration_name } ) if completed[1] then return end local row = read_stored_row() local config_payload = decode_payload(row.config_payload, "config") local next_config = merge_values(default_config, config_payload, "", FIXED_CONFIG_PATHS) local migrated_companies = {} for company_id, definition in pairs(next_config.Companies.Definitions) do local line = type(definition) == "table" and definition.ServiceLine or nil if type(company_id) == "string" and company_id:match("^[a-z0-9_-]+$") and type(definition) == "table" and (definition.Job == "" or definition.Job == company_id) and definition.Name == "" and definition.Category == "" and definition.Icon == "" and definition.LogoUrl == "" and definition.DefaultAvailability == "" and type(line) == "table" and line.Number == "" and line.Routing == "" and type(definition.Services) == "table" and next(definition.Services) == nil then next_config.Companies.Definitions[company_id] = company_definition_entry_default( company_id, next_config ) migrated_companies[#migrated_companies + 1] = company_id end end table.sort(migrated_companies) local statements = {} if #migrated_companies > 0 then statements[#statements + 1] = { query = ([[ UPDATE `%s` SET `config_payload` = ?, `revision` = `revision` + 1 WHERE `id` = ? ]]):format(TABLE_NAME), params = { encode_payload(next_config, "config"), CONFIG_ROW_ID }, } end statements[#statements + 1] = { query = [[ INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) VALUES (?, ?, ?) ]], params = { migration_name, "sky-phone", json.encode({ companies = migrated_companies }), }, } if not Bridge.Database.Transaction(statements) then error("[sky_phone] Could not migrate blank Phone Configurator company definitions.") end if #migrated_companies == 0 then return end apply_stored_row(read_stored_row()) apply_runtime_configuration() TriggerEvent("sky_phone:configurator:serverUpdated", revision) SkyPhoneConfigurator.Broadcast(-1) Bridge.Debug( "info", "[sky_phone] Migrated blank Phone Configurator company definitions: %s", table.concat(migrated_companies, ", "), { always = true } ) end local function migrate_police_request_defaults() local migration_name = "sky-phone:configurator:police-requests:v1" local completed = Bridge.Database.Query( "SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1", { migration_name } ) if completed[1] then return end local row = read_stored_row() local config_payload = decode_payload(row.config_payload, "config") local police = config_payload.Companies and config_payload.Companies.Definitions and config_payload.Companies.Definitions.police local migrated = type(police) == "table" and police.Emergency == true and police.AcceptsRequests == false and type(police.Services) == "table" and next(police.Services) == nil local statements = {} if migrated then local defaults = default_config.Companies.Definitions.police police.AcceptsRequests = defaults.AcceptsRequests police.Services = copy_value(defaults.Services) statements[#statements + 1] = { query = ([[ UPDATE `%s` SET `config_payload` = ?, `revision` = `revision` + 1 WHERE `id` = ? ]]):format(TABLE_NAME), params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID }, } end statements[#statements + 1] = { query = [[ INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) VALUES (?, ?, ?) ]], params = { migration_name, "sky-phone", json.encode({ migrated = migrated }), }, } if not Bridge.Database.Transaction(statements) then error("[sky_phone] Could not migrate Phone Configurator police request defaults.") end if not migrated then return end apply_stored_row(read_stored_row()) apply_runtime_configuration() TriggerEvent("sky_phone:configurator:serverUpdated", revision) SkyPhoneConfigurator.Broadcast(-1) Bridge.Debug( "info", "[sky_phone] Migrated Phone Configurator police request defaults.", { always = true } ) end local function migrate_police_service_line_messaging() local migration_name = "sky-phone:configurator:service-line-messaging:v2" local completed = Bridge.Database.Query( "SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1", { migration_name } ) if completed[1] then return end local row = read_stored_row() local config_payload = decode_payload(row.config_payload, "config") local police = config_payload.Companies and config_payload.Companies.Definitions and config_payload.Companies.Definitions.police local line = type(police) == "table" and police.ServiceLine or nil local migrated = type(line) == "table" and line.CanMessage ~= true if migrated then line.CanMessage = true end local statements = {} if migrated then statements[#statements + 1] = { query = ([[ UPDATE `%s` SET `config_payload` = ?, `revision` = `revision` + 1 WHERE `id` = ? ]]):format(TABLE_NAME), params = { encode_payload(config_payload, "config"), CONFIG_ROW_ID }, } end statements[#statements + 1] = { query = [[ INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`) VALUES (?, ?, ?) ]], params = { migration_name, "sky-phone", json.encode({ police = migrated }), }, } if not Bridge.Database.Transaction(statements) then error("[sky_phone] Could not enable Phone Configurator police service-line messaging.") end if not migrated then return end apply_stored_row(read_stored_row()) apply_runtime_configuration() TriggerEvent("sky_phone:configurator:serverUpdated", revision) SkyPhoneConfigurator.Broadcast(-1) Bridge.Debug( "info", "[sky_phone] Enabled Phone Configurator police service-line messaging.", { always = true } ) end default_config = {} for key, value in pairs(ConfigDefaults) do if key ~= "Media" and key ~= "PhoneConfigurator" and key ~= "CommandPermissions" and key ~= "CustomTones" then default_config[key] = serialize_value(value) end end default_media = serialize_value(ConfigDefaults.Media) Bridge.Database.Migrate("sky_phone_configurator", { SkyPhoneConfiguratorSchema }) Bridge.Database.Query(([[ INSERT IGNORE INTO `%s` (`id`, `config_payload`, `media_payload`, `revision`) VALUES (?, ?, ?, 1) ]]):format(TABLE_NAME), { CONFIG_ROW_ID, encode_payload(default_config, "config"), encode_payload(default_media, "media"), }) apply_stored_row(read_stored_row()) apply_runtime_configuration() Bridge.Database.AfterMigration("sky_phone", migrate_blank_company_definitions) Bridge.Database.AfterMigration("sky_phone", migrate_police_request_defaults) Bridge.Database.AfterMigration("sky_phone", migrate_police_service_line_messaging) function SkyPhoneConfigurator.GetAdminData() local data = build_admin_data() return data end function SkyPhoneConfigurator.Save(expected_revision, changes, actor_identifier, actor_name) if not configurator_enabled then return { success = false, error = "configurator_disabled" } end if tonumber(expected_revision) ~= revision then return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() } end if type(changes) ~= "table" or #changes < 1 or #changes > MAX_CHANGES then return { success = false, error = "invalid_request" } end local _, field_index = build_admin_data() local next_config = copy_value(stored_config) local next_media = copy_value(stored_media) local seen = {} for _, change in ipairs(changes) do if type(change) ~= "table" or type(change.scope) ~= "string" or type(change.path) ~= "string" then return { success = false, error = "invalid_request" } end local field_key = change.scope .. ":" .. change.path local field = field_index[field_key] if not field or seen[field_key] then return { success = false, error = "invalid_field" } end seen[field_key] = true local normalized = normalize_change_value(field, change.value) if normalized == nil then return { success = false, error = "invalid_value" } end local target = change.scope == "config" and next_config or next_media if change.scope ~= "config" and change.scope ~= "media" then return { success = false, error = "invalid_field" } end if field.type == "json" then normalized = restore_redacted_values(normalized, get_path(target, change.path), change.path) end if not set_path(target, change.path, normalized) then return { success = false, error = "invalid_field" } end end local candidate_config = deserialize_value(next_config) local companies_valid, validation_error = SkyPhoneCompanies.ValidateConfiguration(candidate_config) if not companies_valid then Bridge.Debug( "warn", "[sky_phone] Rejected invalid Phone Configurator Companies configuration: %s", validation_error, { always = true } ) return { success = false, error = "invalid_company_configuration" } end local config_encoded = encode_payload(next_config, "config") local media_encoded = encode_payload(next_media, "media") local result = Bridge.Database.Query(([[ UPDATE `%s` SET `config_payload` = ?, `media_payload` = ?, `revision` = `revision` + 1, `updated_by_identifier` = ?, `updated_by_name` = ? WHERE `id` = ? AND `revision` = ? ]]):format(TABLE_NAME), { config_encoded, media_encoded, tostring(actor_identifier or ""):sub(1, 80), tostring(actor_name or ""):sub(1, 120), CONFIG_ROW_ID, revision, }) if affected_rows(result) ~= 1 then local latest = Bridge.Database.Query(("SELECT `revision` FROM `%s` WHERE `id` = ? LIMIT 1"):format(TABLE_NAME), { CONFIG_ROW_ID, }) revision = latest[1] and tonumber(latest[1].revision) or revision return { success = false, error = "revision_conflict", data = SkyPhoneConfigurator.GetAdminData() } end local expected_revision = revision + 1 local persisted_row = read_stored_row() if persisted_row.config_payload ~= config_encoded or persisted_row.media_payload ~= media_encoded or tonumber(persisted_row.revision) ~= expected_revision then error("[sky_phone] Phone configurator SQL verification failed after saving config and media payloads.") end apply_stored_row(persisted_row) apply_runtime_configuration() TriggerEvent("sky_phone:configurator:serverUpdated", revision) SkyPhoneConfigurator.Broadcast(-1) Bridge.Debug( "info", "[sky_phone] Applied Phone Configurator revision %s through the internal runtime refresh.", tostring(revision), { always = true } ) return { success = true, data = SkyPhoneConfigurator.GetAdminData() } end Bridge.Callbacks.Register("sky_phone:configurator:runtime", function() return { success = true, data = { config = configurator_enabled and client_payload() or {}, enabled = configurator_enabled, revision = revision, }, } end) Bridge.Debug( "info", "[sky_phone] Phone configurator initialized enabled=%s revision=%s.", tostring(configurator_enabled), tostring(revision) )