mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
MERGE - integrate latest dev changes
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
local provider
|
||||
local provider_resources = {
|
||||
yaca = "yaca-voice",
|
||||
pma = "pma-voice",
|
||||
saltychat = "saltychat",
|
||||
}
|
||||
local provider_aliases = {
|
||||
["yaca-voice"] = "yaca",
|
||||
["pma-voice"] = "pma",
|
||||
salty = "saltychat",
|
||||
}
|
||||
|
||||
local function resolve_provider()
|
||||
if provider and GetResourceState(provider_resources[provider]) == "started" then
|
||||
return provider
|
||||
end
|
||||
provider = nil
|
||||
|
||||
local configured = Config.Radio.VoiceProvider
|
||||
if configured ~= "auto" then
|
||||
local selected = provider_aliases[configured] or configured
|
||||
if provider_resources[selected] and GetResourceState(provider_resources[selected]) == "started" then
|
||||
provider = selected
|
||||
return provider
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local providers = {
|
||||
{ name = "yaca", resource = "yaca-voice" },
|
||||
{ name = "pma", resource = "pma-voice" },
|
||||
{ name = "saltychat", resource = "saltychat" },
|
||||
}
|
||||
for _, candidate in ipairs(providers) do
|
||||
if GetResourceState(candidate.resource) == "started" then
|
||||
provider = candidate.name
|
||||
return provider
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function Bridge.Radio.GetProvider()
|
||||
return resolve_provider()
|
||||
end
|
||||
|
||||
function Bridge.Radio.SupportsSecondary()
|
||||
local selected = resolve_provider()
|
||||
return Config.Radio.AllowSecondary and (selected == "yaca" or selected == "saltychat")
|
||||
end
|
||||
|
||||
function Bridge.Radio.Join(primary, secondary)
|
||||
local selected = resolve_provider()
|
||||
if selected == "yaca" then
|
||||
local voice = exports["yaca-voice"]
|
||||
if not voice:isRadioEnabled() then
|
||||
voice:enableRadio(true)
|
||||
Wait(100)
|
||||
end
|
||||
voice:setActiveRadioChannel(1)
|
||||
voice:changeRadioFrequency(tostring(primary))
|
||||
if secondary > 0 and Bridge.Radio.SupportsSecondary() then
|
||||
voice:setSecondaryRadioChannel(2)
|
||||
voice:changeRadioFrequencyRaw(2, tostring(secondary))
|
||||
voice:muteRadioChannelRaw(2, false)
|
||||
else
|
||||
voice:changeRadioFrequencyRaw(2, "0")
|
||||
voice:muteRadioChannelRaw(2, true)
|
||||
end
|
||||
voice:setActiveRadioChannel(1)
|
||||
return true
|
||||
end
|
||||
|
||||
if selected == "pma" then
|
||||
exports["pma-voice"]:setRadioChannel(primary)
|
||||
return true
|
||||
end
|
||||
|
||||
if selected == "saltychat" then
|
||||
exports.saltychat:SetRadioChannel(tostring(primary), true)
|
||||
exports.saltychat:SetRadioChannel(secondary > 0 and tostring(secondary) or "", false)
|
||||
return true
|
||||
end
|
||||
|
||||
Bridge.Debug("error", "[sky_phone] No supported radio voice provider is running.")
|
||||
return false
|
||||
end
|
||||
|
||||
function Bridge.Radio.Leave()
|
||||
local selected = resolve_provider()
|
||||
if selected == "yaca" then
|
||||
exports["yaca-voice"]:enableRadio(false)
|
||||
elseif selected == "pma" then
|
||||
exports["pma-voice"]:setRadioChannel(0)
|
||||
elseif selected == "saltychat" then
|
||||
exports.saltychat:SetRadioChannel("", true)
|
||||
exports.saltychat:SetRadioChannel("", false)
|
||||
end
|
||||
end
|
||||
|
||||
function Bridge.Radio.SetVolume(volume)
|
||||
local selected = resolve_provider()
|
||||
if selected == "yaca" then
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(1, volume / 100)
|
||||
if Bridge.Radio.SupportsSecondary() then
|
||||
exports["yaca-voice"]:changeRadioChannelVolumeRaw(2, volume / 100)
|
||||
end
|
||||
elseif selected == "pma" then
|
||||
exports["pma-voice"]:setRadioVolume(volume)
|
||||
elseif selected == "saltychat" then
|
||||
exports.saltychat:SetRadioVolume(volume / 100)
|
||||
end
|
||||
end
|
||||
|
||||
AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
Bridge.Radio.Leave()
|
||||
end
|
||||
end)
|
||||
@@ -90,6 +90,20 @@ function Bridge.Framework.GetBirthdate(source)
|
||||
return player and (player.get("dateofbirth") or player.get("dob")) or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetJob(source)
|
||||
local player = get_player(source)
|
||||
local job = player and player.getJob()
|
||||
if not job then
|
||||
return { name = "", label = "", grade = 0, gradeLabel = "" }
|
||||
end
|
||||
return {
|
||||
name = job.name or "",
|
||||
label = job.label or "",
|
||||
grade = tonumber(job.grade) or 0,
|
||||
gradeLabel = job.grade_label or job.label or "",
|
||||
}
|
||||
end
|
||||
|
||||
function Bridge.Framework.RegisterUsableItem(item_name, callback)
|
||||
ESX.RegisterUsableItem(item_name, callback)
|
||||
return true
|
||||
|
||||
@@ -70,6 +70,18 @@ function Bridge.Framework.GetBirthdate(source)
|
||||
return character and character.birthdate or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetJob(source)
|
||||
local player = get_player(source)
|
||||
local job = player and player.PlayerData and player.PlayerData.job
|
||||
local grade = job and job.grade
|
||||
return {
|
||||
name = job and job.name or "",
|
||||
label = job and job.label or "",
|
||||
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
|
||||
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
|
||||
}
|
||||
end
|
||||
|
||||
function Bridge.Framework.RegisterUsableItem(item_name, callback)
|
||||
QBCore.Functions.CreateUseableItem(item_name, callback)
|
||||
return true
|
||||
|
||||
@@ -59,6 +59,18 @@ function Bridge.Framework.GetBirthdate(source)
|
||||
return character and character.birthdate or nil
|
||||
end
|
||||
|
||||
function Bridge.Framework.GetJob(source)
|
||||
local player = get_player(source)
|
||||
local job = player and player.PlayerData and player.PlayerData.job
|
||||
local grade = job and job.grade
|
||||
return {
|
||||
name = job and job.name or "",
|
||||
label = job and job.label or "",
|
||||
grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0,
|
||||
gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""),
|
||||
}
|
||||
end
|
||||
|
||||
function Bridge.Framework.RegisterUsableItem(item_name, callback)
|
||||
exports.qbx_core:CreateUseableItem(item_name, callback)
|
||||
return true
|
||||
|
||||
@@ -3,6 +3,7 @@ Bridge.Callbacks = Bridge.Callbacks or {}
|
||||
Bridge.Database = Bridge.Database or {}
|
||||
Bridge.Framework = Bridge.Framework or {}
|
||||
Bridge.Inventory = Bridge.Inventory or {}
|
||||
Bridge.Radio = Bridge.Radio or {}
|
||||
|
||||
local level_colours = {
|
||||
debug = "^5",
|
||||
|
||||
@@ -209,6 +209,7 @@ end
|
||||
|
||||
RegisterNUICallback("ui:ready", function(_, cb)
|
||||
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
|
||||
TriggerEvent("sky_phone:client:nuiReady")
|
||||
if open_requested and device_payload then
|
||||
send_open_message()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
local current_volume = math.max(0, math.min(100, tonumber(Config.Radio.DefaultVolume) or 50))
|
||||
local current_primary = 0
|
||||
local current_secondary = 0
|
||||
local radio_settings = {
|
||||
autoRejoin = Config.Radio.AutoRejoin,
|
||||
notifications = Config.Radio.Notifications,
|
||||
}
|
||||
local auto_rejoin_pending = false
|
||||
local hud_members = { [1] = {}, [2] = {} }
|
||||
local hud_talking = {}
|
||||
|
||||
local function get_hud_config()
|
||||
local hud = type(Config.Radio.Hud) == "table" and Config.Radio.Hud or {}
|
||||
local position = type(hud.Position) == "table" and hud.Position or {}
|
||||
return {
|
||||
enabled = hud.Enabled == true,
|
||||
horizontal = position.Horizontal == "left" and "left" or "right",
|
||||
vertical = position.Vertical == "bottom" and "bottom" or "top",
|
||||
horizontalOffset = math.max(0.0, math.min(100.0, tonumber(position.HorizontalOffset) or 2.0)),
|
||||
verticalOffset = math.max(0.0, math.min(100.0, tonumber(position.VerticalOffset) or 30.0)),
|
||||
speakerPersistMilliseconds = math.max(
|
||||
0,
|
||||
math.min(10000, math.floor(tonumber(hud.SpeakerPersistMilliseconds) or 3000))
|
||||
),
|
||||
}
|
||||
end
|
||||
|
||||
local function send_hud_config()
|
||||
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
|
||||
end
|
||||
|
||||
local function send_hud_members()
|
||||
local combined = {}
|
||||
for channel_id = 1, 2 do
|
||||
for player_id, member in pairs(hud_members[channel_id]) do
|
||||
if not combined[player_id] then
|
||||
local talking = hud_talking[player_id]
|
||||
combined[player_id] = {
|
||||
id = player_id,
|
||||
name = member.name,
|
||||
badge = member.badge,
|
||||
talking = talking and talking.state or false,
|
||||
channel = talking and talking.channel or channel_id,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local members = {}
|
||||
for _, member in pairs(combined) do
|
||||
members[#members + 1] = member
|
||||
end
|
||||
table.sort(members, function(left, right)
|
||||
return left.name:lower() < right.name:lower()
|
||||
end)
|
||||
SendNUIMessage({ type = "radio:hud-update", data = { members = members } })
|
||||
end
|
||||
|
||||
local function clear_hud_members()
|
||||
hud_members = { [1] = {}, [2] = {} }
|
||||
hud_talking = {}
|
||||
send_hud_members()
|
||||
end
|
||||
|
||||
local function set_hud_members(channel_id, members)
|
||||
local channel_members = {}
|
||||
if type(members) == "table" then
|
||||
for _, member in ipairs(members) do
|
||||
local player_id = tonumber(member.id)
|
||||
if player_id then
|
||||
channel_members[player_id] = {
|
||||
name = tostring(member.name or ("ID " .. player_id)),
|
||||
badge = tostring(member.badge or ""),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
hud_members[channel_id] = channel_members
|
||||
send_hud_members()
|
||||
end
|
||||
|
||||
local function set_hud_talking(player_id, state, channel_id)
|
||||
player_id = tonumber(player_id)
|
||||
if not player_id then
|
||||
return
|
||||
end
|
||||
hud_talking[player_id] = state and { state = true, channel = channel_id == 2 and 2 or 1 } or nil
|
||||
send_hud_members()
|
||||
end
|
||||
|
||||
local function request(name, data)
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:radio:" .. name, data or {})
|
||||
if type(result) ~= "table" then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function apply_server_state(data)
|
||||
if type(data) ~= "table" then
|
||||
return
|
||||
end
|
||||
current_primary = tonumber(data.frequency) or current_primary
|
||||
current_secondary = tonumber(data.secondaryFrequency) or current_secondary
|
||||
if type(data.settings) == "table" then
|
||||
radio_settings.autoRejoin = data.settings.autoRejoin == true
|
||||
radio_settings.notifications = data.settings.notifications == true
|
||||
end
|
||||
end
|
||||
|
||||
local function join_radio(primary, secondary)
|
||||
if not Bridge.Radio.SupportsSecondary() then
|
||||
secondary = 0
|
||||
end
|
||||
local approved = request("connect", {
|
||||
frequency = primary,
|
||||
secondaryFrequency = secondary,
|
||||
})
|
||||
if not approved.success then
|
||||
return approved
|
||||
end
|
||||
|
||||
local data = approved.data or {}
|
||||
local approved_primary = tonumber(data.frequency) or 0
|
||||
local approved_secondary = tonumber(data.secondaryFrequency) or 0
|
||||
if not Bridge.Radio.Join(approved_primary, approved_secondary) then
|
||||
request("disconnect")
|
||||
return { success = false, error = "voice_unavailable" }
|
||||
end
|
||||
|
||||
if current_primary ~= approved_primary or current_secondary ~= approved_secondary then
|
||||
clear_hud_members()
|
||||
end
|
||||
current_primary = approved_primary
|
||||
current_secondary = approved_secondary
|
||||
Bridge.Radio.SetVolume(current_volume)
|
||||
data.secondaryFrequency = approved_secondary
|
||||
data.provider = Bridge.Radio.GetProvider()
|
||||
data.secondarySupported = Bridge.Radio.SupportsSecondary()
|
||||
return { success = true, data = data }
|
||||
end
|
||||
|
||||
local function leave_radio()
|
||||
Bridge.Radio.Leave()
|
||||
current_primary = 0
|
||||
current_secondary = 0
|
||||
clear_hud_members()
|
||||
return request("disconnect")
|
||||
end
|
||||
|
||||
RegisterNUICallback("radio:get", function(_, cb)
|
||||
local result = request("get")
|
||||
if result.success then
|
||||
apply_server_state(result.data)
|
||||
result.data.volume = current_volume
|
||||
result.data.provider = Bridge.Radio.GetProvider()
|
||||
result.data.secondarySupported = Bridge.Radio.SupportsSecondary()
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:connect", function(data, cb)
|
||||
cb(join_radio(data.frequency, data.secondaryFrequency))
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:disconnect", function(_, cb)
|
||||
cb(leave_radio())
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:set-volume", function(data, cb)
|
||||
local volume = tonumber(data.volume)
|
||||
if not volume then
|
||||
cb({ success = false, error = "invalid_volume" })
|
||||
return
|
||||
end
|
||||
current_volume = math.max(0, math.min(100, math.floor(volume + 0.5)))
|
||||
Bridge.Radio.SetVolume(current_volume)
|
||||
cb({ success = true, data = { volume = current_volume } })
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:save-settings", function(data, cb)
|
||||
local result = request("save-settings", data)
|
||||
if result.success then
|
||||
apply_server_state({ settings = result.data })
|
||||
end
|
||||
cb(result)
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:save-badge", function(data, cb)
|
||||
cb(request("save-badge", data))
|
||||
end)
|
||||
|
||||
RegisterNUICallback("radio:save-display-name", function(data, cb)
|
||||
cb(request("save-display-name", data))
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:radio:members", function(data)
|
||||
local frequency = tonumber(data.frequency)
|
||||
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
|
||||
if not channel_id then
|
||||
return
|
||||
end
|
||||
set_hud_members(channel_id, data.members)
|
||||
if channel_id == 1 then
|
||||
SendNUIMessage({ type = "radio:updated", data = data })
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNetEvent("yaca:external:isRadioReceiving", function(state, channel, player_id)
|
||||
if Bridge.Radio.GetProvider() ~= "yaca" then
|
||||
return
|
||||
end
|
||||
set_hud_talking(player_id, state == true, tonumber(channel) or 1)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("yaca:external:isRadioTalking", function(state, channel)
|
||||
if Bridge.Radio.GetProvider() ~= "yaca" then
|
||||
return
|
||||
end
|
||||
set_hud_talking(GetPlayerServerId(PlayerId()), state == true, tonumber(channel) or 1)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("yaca:external:isRadioEnabled", function(state)
|
||||
if Bridge.Radio.GetProvider() == "yaca" and not state then
|
||||
clear_hud_members()
|
||||
end
|
||||
end)
|
||||
|
||||
AddEventHandler("sky_phone:client:nuiReady", function()
|
||||
send_hud_config()
|
||||
send_hud_members()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:radio:notification", function(data)
|
||||
if not radio_settings.notifications or current_primary <= 0 then
|
||||
return
|
||||
end
|
||||
local locale = (Locales[Config.Bridge.Locale] or Locales.en).Nui.Apps.radio
|
||||
local template = data.joined and locale.memberJoined or locale.memberLeft
|
||||
SendNUIMessage({
|
||||
type = "notification:show",
|
||||
data = {
|
||||
appId = "radio",
|
||||
title = locale.name,
|
||||
text = template:gsub("{name}", tostring(data.playerName or locale.unknownMember)),
|
||||
},
|
||||
})
|
||||
end)
|
||||
|
||||
local function try_auto_rejoin()
|
||||
if auto_rejoin_pending or current_primary > 0 then
|
||||
return
|
||||
end
|
||||
auto_rejoin_pending = true
|
||||
local result = request("get")
|
||||
if result.success then
|
||||
apply_server_state(result.data)
|
||||
local data = result.data or {}
|
||||
if radio_settings.autoRejoin and tonumber(data.savedFrequency) and tonumber(data.savedFrequency) > 0 then
|
||||
local joined = join_radio(data.savedFrequency, data.savedSecondaryFrequency)
|
||||
if not joined.success then
|
||||
Bridge.Debug("warn", "[sky_phone] Radio auto-rejoin failed: %s", tostring(joined.error))
|
||||
end
|
||||
end
|
||||
end
|
||||
auto_rejoin_pending = false
|
||||
end
|
||||
|
||||
AddEventHandler("playerSpawned", function()
|
||||
SetTimeout(2000, try_auto_rejoin)
|
||||
end)
|
||||
|
||||
AddEventHandler("onResourceStart", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
SetTimeout(5000, try_auto_rejoin)
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNetEvent("yaca:external:setRadioFrequency", function(channel, frequency)
|
||||
if Bridge.Radio.GetProvider() ~= "yaca" then
|
||||
return
|
||||
end
|
||||
local channel_id = tonumber(channel)
|
||||
local value = math.max(0, tonumber(frequency) or 0)
|
||||
if channel_id == 1 then
|
||||
hud_members[1] = {}
|
||||
current_primary = value
|
||||
if value == 0 then
|
||||
current_secondary = 0
|
||||
hud_members[2] = {}
|
||||
hud_talking = {}
|
||||
request("disconnect")
|
||||
else
|
||||
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
|
||||
end
|
||||
elseif channel_id == 2 then
|
||||
hud_members[2] = {}
|
||||
current_secondary = value == current_primary and 0 or value
|
||||
if current_primary > 0 then
|
||||
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
|
||||
end
|
||||
end
|
||||
send_hud_members()
|
||||
end)
|
||||
@@ -735,6 +735,24 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_radio_profiles",
|
||||
columns = {
|
||||
{ name = "identifier", type = "VARCHAR(80) NOT NULL" },
|
||||
{ name = "history", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "settings", type = "LONGTEXT NOT NULL" },
|
||||
{ name = "primary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" },
|
||||
{ name = "secondary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" },
|
||||
{ name = "badge", type = "VARCHAR(32) NOT NULL DEFAULT ''" },
|
||||
{ name = "display_name", type = "VARCHAR(64) NOT NULL DEFAULT ''" },
|
||||
{
|
||||
name = "updated_at",
|
||||
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
|
||||
},
|
||||
},
|
||||
primaryKey = "identifier",
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_darkchat_profiles",
|
||||
columns = {
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
local profiles = {}
|
||||
local channels = {}
|
||||
local joined_at = {}
|
||||
local last_requests = {}
|
||||
|
||||
local function supports_secondary()
|
||||
if not Config.Radio.AllowSecondary then
|
||||
return false
|
||||
end
|
||||
local configured = Config.Radio.VoiceProvider
|
||||
if configured == "pma" or configured == "pma-voice" then
|
||||
return false
|
||||
end
|
||||
if configured ~= "auto" then
|
||||
return true
|
||||
end
|
||||
if GetResourceState("yaca-voice") == "started" then
|
||||
return true
|
||||
end
|
||||
if GetResourceState("pma-voice") == "started" then
|
||||
return false
|
||||
end
|
||||
return GetResourceState("saltychat") == "started"
|
||||
end
|
||||
|
||||
local function default_profile()
|
||||
return {
|
||||
history = {},
|
||||
settings = {
|
||||
autoRejoin = Config.Radio.AutoRejoin,
|
||||
notifications = Config.Radio.Notifications,
|
||||
},
|
||||
primaryFrequency = 0,
|
||||
secondaryFrequency = 0,
|
||||
badge = "",
|
||||
displayName = "",
|
||||
}
|
||||
end
|
||||
|
||||
local function decode_table(value, fallback)
|
||||
if type(value) ~= "string" or value == "" then
|
||||
return fallback
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or fallback
|
||||
end
|
||||
|
||||
local function normalize_frequency(value, allow_zero)
|
||||
local frequency = tonumber(value)
|
||||
if not frequency or frequency ~= frequency then
|
||||
return nil
|
||||
end
|
||||
if allow_zero and frequency == 0 then
|
||||
return 0
|
||||
end
|
||||
if frequency < Config.Radio.FrequencyMin or frequency > Config.Radio.FrequencyMax then
|
||||
return nil
|
||||
end
|
||||
local factor = 10 ^ Config.Radio.FrequencyDecimals
|
||||
return math.floor(frequency * factor + 0.5) / factor
|
||||
end
|
||||
|
||||
local function can_set_display_name(source)
|
||||
local config = Config.Radio.DisplayName
|
||||
if type(config) ~= "table" or not config.Enabled then
|
||||
return false
|
||||
end
|
||||
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
|
||||
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
|
||||
end
|
||||
|
||||
local function normalize_display_name(value)
|
||||
local name = tostring(value or ""):gsub("%c", ""):gsub("%s+", " ")
|
||||
name = name:match("^%s*(.-)%s*$") or ""
|
||||
local length = utf8.len(name)
|
||||
if not length then
|
||||
return nil
|
||||
end
|
||||
|
||||
local maximum = math.max(1, math.min(tonumber(Config.Radio.DisplayName.MaxLength) or 32, 64))
|
||||
if length > maximum then
|
||||
local next_character = utf8.offset(name, maximum + 1)
|
||||
name = next_character and name:sub(1, next_character - 1) or name
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
local function has_channel_access(source, frequency)
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
for _, locked in ipairs(Config.Radio.LockedChannels or {}) do
|
||||
local minimum = tonumber(locked.range and locked.range[1])
|
||||
local maximum = tonumber(locked.range and locked.range[2])
|
||||
if minimum and maximum and frequency >= minimum and frequency <= maximum then
|
||||
return locked.jobs and locked.jobs[job.name] == true
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function sanitize_history(source, history)
|
||||
local result = {}
|
||||
local seen = {}
|
||||
if type(history) ~= "table" then
|
||||
return result
|
||||
end
|
||||
for index = 1, #history do
|
||||
local entry = history[index]
|
||||
if type(entry) == "table" then
|
||||
local primary = normalize_frequency(entry.primary or entry.frequency, false)
|
||||
local secondary = normalize_frequency(entry.secondary or entry.secondaryFrequency or 0, true)
|
||||
if primary and secondary and secondary == primary then
|
||||
secondary = 0
|
||||
end
|
||||
if primary and secondary and has_channel_access(source, primary)
|
||||
and (secondary == 0 or has_channel_access(source, secondary)) then
|
||||
local key = ("%.3f|%.3f"):format(primary, secondary)
|
||||
if not seen[key] then
|
||||
result[#result + 1] = { primary = primary, secondary = secondary }
|
||||
seen[key] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
if #result >= Config.Radio.HistoryLimit then
|
||||
break
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function load_profile(source)
|
||||
local identifier = Bridge.Framework.GetIdentifier(source)
|
||||
if not identifier then
|
||||
return nil, nil
|
||||
end
|
||||
if profiles[identifier] then
|
||||
return identifier, profiles[identifier]
|
||||
end
|
||||
|
||||
local profile = default_profile()
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`
|
||||
FROM `sky_phone_radio_profiles` WHERE `identifier` = ? LIMIT 1
|
||||
]], { identifier })
|
||||
local row = rows[1]
|
||||
if row then
|
||||
profile.history = sanitize_history(source, decode_table(row.history, {}))
|
||||
local settings = decode_table(row.settings, {})
|
||||
profile.settings.autoRejoin = settings.autoRejoin == true
|
||||
profile.settings.notifications = settings.notifications == true
|
||||
profile.primaryFrequency = normalize_frequency(row.primary_frequency, true) or 0
|
||||
profile.secondaryFrequency = normalize_frequency(row.secondary_frequency, true) or 0
|
||||
profile.badge = tostring(row.badge or ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32))
|
||||
profile.displayName = normalize_display_name(row.display_name) or ""
|
||||
end
|
||||
profiles[identifier] = profile
|
||||
return identifier, profile
|
||||
end
|
||||
|
||||
local function save_profile(identifier, profile)
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_radio_profiles`
|
||||
(`identifier`, `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `history` = VALUES(`history`), `settings` = VALUES(`settings`),
|
||||
`primary_frequency` = VALUES(`primary_frequency`),
|
||||
`secondary_frequency` = VALUES(`secondary_frequency`), `badge` = VALUES(`badge`),
|
||||
`display_name` = VALUES(`display_name`)
|
||||
]], {
|
||||
identifier,
|
||||
json.encode(profile.history),
|
||||
json.encode(profile.settings),
|
||||
profile.primaryFrequency,
|
||||
profile.secondaryFrequency,
|
||||
profile.badge,
|
||||
profile.displayName,
|
||||
})
|
||||
end
|
||||
|
||||
local function get_effective_display_name(source, profile)
|
||||
if not can_set_display_name(source) then
|
||||
return ""
|
||||
end
|
||||
if not profile then
|
||||
local _, loaded_profile = load_profile(source)
|
||||
profile = loaded_profile
|
||||
end
|
||||
return profile and profile.displayName or ""
|
||||
end
|
||||
|
||||
local function get_radio_member_name(source)
|
||||
local display_name = get_effective_display_name(source)
|
||||
return display_name ~= "" and display_name or GetPlayerName(source) or "Unknown"
|
||||
end
|
||||
|
||||
local function frequency_set(channel)
|
||||
local result = {}
|
||||
if channel and channel.primary and channel.primary > 0 then
|
||||
result[channel.primary] = true
|
||||
end
|
||||
if channel and channel.secondary and channel.secondary > 0 then
|
||||
result[channel.secondary] = true
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function get_members(frequency)
|
||||
local members = {}
|
||||
for player_source, channel in pairs(channels) do
|
||||
if channel.primary == frequency or channel.secondary == frequency then
|
||||
local job = Bridge.Framework.GetJob(player_source)
|
||||
local _, profile = load_profile(player_source)
|
||||
members[#members + 1] = {
|
||||
id = player_source,
|
||||
name = get_radio_member_name(player_source),
|
||||
badge = Config.Radio.Badge.Enabled and profile and profile.badge or "",
|
||||
joinTime = os.time() - (joined_at[player_source] or os.time()),
|
||||
rank = job.gradeLabel,
|
||||
rankNumber = job.grade,
|
||||
}
|
||||
end
|
||||
end
|
||||
table.sort(members, function(left, right)
|
||||
return left.name:lower() < right.name:lower()
|
||||
end)
|
||||
return members
|
||||
end
|
||||
|
||||
local function broadcast_frequency(frequency)
|
||||
if not frequency or frequency <= 0 then
|
||||
return
|
||||
end
|
||||
local members = get_members(frequency)
|
||||
for _, member in ipairs(members) do
|
||||
TriggerClientEvent("sky_phone:radio:members", member.id, {
|
||||
frequency = frequency,
|
||||
members = members,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function notify_frequency(frequency, excluded_source, player_name, joined)
|
||||
if not frequency or frequency <= 0 then
|
||||
return
|
||||
end
|
||||
for player_source, channel in pairs(channels) do
|
||||
if player_source ~= excluded_source
|
||||
and (channel.primary == frequency or channel.secondary == frequency) then
|
||||
TriggerClientEvent("sky_phone:radio:notification", player_source, {
|
||||
joined = joined,
|
||||
playerName = player_name,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function remove_from_channels(source)
|
||||
local previous = channels[source]
|
||||
if not previous then
|
||||
return
|
||||
end
|
||||
channels[source] = nil
|
||||
joined_at[source] = nil
|
||||
local name = get_radio_member_name(source)
|
||||
for frequency in pairs(frequency_set(previous)) do
|
||||
notify_frequency(frequency, source, name, false)
|
||||
broadcast_frequency(frequency)
|
||||
end
|
||||
end
|
||||
|
||||
local function rate_limited(source, action, milliseconds)
|
||||
local now = GetGameTimer()
|
||||
local key = ("%s:%s"):format(source, action)
|
||||
if last_requests[key] and now - last_requests[key] < milliseconds then
|
||||
return true
|
||||
end
|
||||
last_requests[key] = now
|
||||
return false
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:get", function(source)
|
||||
local _, profile = load_profile(source)
|
||||
if not profile then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local channel = channels[source]
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
connected = channel ~= nil,
|
||||
frequency = channel and channel.primary or 0,
|
||||
secondaryFrequency = channel and channel.secondary or 0,
|
||||
members = channel and get_members(channel.primary) or {},
|
||||
history = profile.history,
|
||||
settings = profile.settings,
|
||||
badge = profile.badge,
|
||||
badgeEnabled = Config.Radio.Badge.Enabled,
|
||||
badgeMaxLength = math.min(Config.Radio.Badge.MaxLength, 32),
|
||||
displayName = profile.displayName,
|
||||
displayNameAllowed = can_set_display_name(source),
|
||||
displayNameEnabled = Config.Radio.DisplayName.Enabled,
|
||||
displayNameMaxLength = math.min(Config.Radio.DisplayName.MaxLength, 64),
|
||||
frequencyMin = Config.Radio.FrequencyMin,
|
||||
frequencyMax = Config.Radio.FrequencyMax,
|
||||
frequencyStep = 1 / (10 ^ Config.Radio.FrequencyDecimals),
|
||||
savedFrequency = profile.primaryFrequency,
|
||||
savedSecondaryFrequency = profile.secondaryFrequency,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:connect", function(source, data)
|
||||
if rate_limited(source, "connect", 500) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local primary = normalize_frequency(data.frequency, false)
|
||||
local secondary = normalize_frequency(data.secondaryFrequency or 0, true)
|
||||
if not primary or not secondary then
|
||||
return { success = false, error = "invalid_frequency" }
|
||||
end
|
||||
if secondary == primary then
|
||||
secondary = 0
|
||||
end
|
||||
if not supports_secondary() then
|
||||
secondary = 0
|
||||
end
|
||||
if not has_channel_access(source, primary) then
|
||||
return { success = false, error = "channel_locked" }
|
||||
end
|
||||
if secondary > 0 and not has_channel_access(source, secondary) then
|
||||
return { success = false, error = "secondary_locked" }
|
||||
end
|
||||
|
||||
local identifier, profile = load_profile(source)
|
||||
if not profile then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
local previous = channels[source]
|
||||
local previous_set = frequency_set(previous)
|
||||
channels[source] = { primary = primary, secondary = secondary }
|
||||
joined_at[source] = os.time()
|
||||
|
||||
profile.primaryFrequency = primary
|
||||
profile.secondaryFrequency = secondary
|
||||
profile.history = sanitize_history(source, (function()
|
||||
local history = { { primary = primary, secondary = secondary } }
|
||||
for _, entry in ipairs(profile.history) do
|
||||
history[#history + 1] = entry
|
||||
end
|
||||
return history
|
||||
end)())
|
||||
save_profile(identifier, profile)
|
||||
|
||||
local current_set = frequency_set(channels[source])
|
||||
local name = get_radio_member_name(source)
|
||||
for frequency in pairs(previous_set) do
|
||||
if not current_set[frequency] then
|
||||
notify_frequency(frequency, source, name, false)
|
||||
broadcast_frequency(frequency)
|
||||
end
|
||||
end
|
||||
for frequency in pairs(current_set) do
|
||||
if not previous_set[frequency] then
|
||||
notify_frequency(frequency, source, name, true)
|
||||
end
|
||||
broadcast_frequency(frequency)
|
||||
end
|
||||
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
connected = true,
|
||||
frequency = primary,
|
||||
secondaryFrequency = secondary,
|
||||
members = get_members(primary),
|
||||
history = profile.history,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source)
|
||||
remove_from_channels(source)
|
||||
local identifier, profile = load_profile(source)
|
||||
if profile then
|
||||
profile.primaryFrequency = 0
|
||||
profile.secondaryFrequency = 0
|
||||
save_profile(identifier, profile)
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:save-settings", function(source, data)
|
||||
local key = tostring(data.key or "")
|
||||
if key ~= "autoRejoin" and key ~= "notifications" then
|
||||
return { success = false, error = "invalid_setting" }
|
||||
end
|
||||
local identifier, profile = load_profile(source)
|
||||
if not profile then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
profile.settings[key] = data.value == true
|
||||
save_profile(identifier, profile)
|
||||
return { success = true, data = profile.settings }
|
||||
end)
|
||||
|
||||
local function badge_forbidden(badge)
|
||||
local digits = badge:gsub("%D", "")
|
||||
for _, pattern in ipairs(Config.Radio.Badge.ForbiddenPatterns or {}) do
|
||||
if digits:find(tostring(pattern), 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:save-badge", function(source, data)
|
||||
if not Config.Radio.Badge.Enabled then
|
||||
return { success = false, error = "badge_disabled" }
|
||||
end
|
||||
local badge = tostring(data.badge or ""):gsub("[^%w_%-]", ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32))
|
||||
if badge_forbidden(badge) then
|
||||
return { success = false, error = "badge_forbidden" }
|
||||
end
|
||||
local identifier, profile = load_profile(source)
|
||||
if not profile then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
profile.badge = badge
|
||||
save_profile(identifier, profile)
|
||||
for frequency in pairs(frequency_set(channels[source])) do
|
||||
broadcast_frequency(frequency)
|
||||
end
|
||||
return { success = true, data = { badge = badge } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:radio:save-display-name", function(source, data)
|
||||
if not Config.Radio.DisplayName.Enabled then
|
||||
return { success = false, error = "display_name_disabled" }
|
||||
end
|
||||
if rate_limited(source, "save-display-name", 750) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if not can_set_display_name(source) then
|
||||
return { success = false, error = "display_name_forbidden" }
|
||||
end
|
||||
|
||||
local display_name = normalize_display_name(data.displayName)
|
||||
if display_name == nil then
|
||||
return { success = false, error = "invalid_display_name" }
|
||||
end
|
||||
local identifier, profile = load_profile(source)
|
||||
if not profile then
|
||||
return { success = false, error = "player_unavailable" }
|
||||
end
|
||||
|
||||
profile.displayName = display_name
|
||||
save_profile(identifier, profile)
|
||||
for frequency in pairs(frequency_set(channels[source])) do
|
||||
broadcast_frequency(frequency)
|
||||
end
|
||||
return { success = true, data = { displayName = display_name } }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
local player_source = source
|
||||
local identifier = Bridge.Framework.GetIdentifier(player_source)
|
||||
remove_from_channels(player_source)
|
||||
if identifier then
|
||||
profiles[identifier] = nil
|
||||
end
|
||||
for key in pairs(last_requests) do
|
||||
if key:sub(1, #tostring(player_source) + 1) == tostring(player_source) .. ":" then
|
||||
last_requests[key] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
Reference in New Issue
Block a user