mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 11:01:34 +00:00
519 lines
18 KiB
Lua
519 lines
18 KiB
Lua
local profiles = {}
|
|
local channels = {}
|
|
local joined_at = {}
|
|
local last_requests = {}
|
|
local speaker_states = {}
|
|
|
|
local function supports_secondary()
|
|
return Bridge.Radio.SupportsSecondary()
|
|
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)
|
|
if display_name ~= "" then
|
|
return display_name
|
|
end
|
|
|
|
local first_name, last_name = Bridge.Framework.GetCharacterName(source)
|
|
local character_name = ("%s %s"):format(first_name or "", last_name or "")
|
|
character_name = character_name:gsub("%c", ""):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
|
return character_name ~= "" and character_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,
|
|
speakerEnabled = speaker_states[source] == true,
|
|
speakerSupported = Bridge.Radio.SupportsSpeaker(),
|
|
},
|
|
}
|
|
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,
|
|
speakerEnabled = speaker_states[source] == true,
|
|
speakerSupported = Bridge.Radio.SupportsSpeaker(),
|
|
},
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source)
|
|
if speaker_states[source] then
|
|
Bridge.Radio.SetPlayerSpeaker(source, false)
|
|
end
|
|
speaker_states[source] = nil
|
|
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:set-speaker", function(source, data)
|
|
if type(data) ~= "table" or type(data.enabled) ~= "boolean" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if rate_limited(source, "set-speaker", 250) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
if not channels[source] then
|
|
return { success = false, error = "radio_not_connected" }
|
|
end
|
|
if not Bridge.Radio.SupportsSpeaker() then
|
|
return { success = false, error = "speaker_unsupported" }
|
|
end
|
|
if not Bridge.Radio.SetPlayerSpeaker(source, data.enabled) then
|
|
return { success = false, error = "voice_unavailable" }
|
|
end
|
|
speaker_states[source] = data.enabled
|
|
return {
|
|
success = true,
|
|
data = {
|
|
speakerEnabled = data.enabled,
|
|
speakerSupported = 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
|
|
if speaker_states[player_source] then
|
|
Bridge.Radio.SetPlayerSpeaker(player_source, false)
|
|
end
|
|
speaker_states[player_source] = nil
|
|
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)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name ~= GetCurrentResourceName() then
|
|
return
|
|
end
|
|
for player_source, enabled in pairs(speaker_states) do
|
|
if enabled then
|
|
Bridge.Radio.SetPlayerSpeaker(player_source, false)
|
|
end
|
|
end
|
|
end)
|