ENH - merge payphone calling

This commit is contained in:
Dominik
2026-08-10 20:53:25 +02:00
10 changed files with 1771 additions and 34 deletions
+28
View File
@@ -41,6 +41,34 @@ Config.Calls = {
RecentPageSize = 100,
}
Config.Payphones = {
Enabled = true,
Props = {
"prop_phonebox_01b",
"p_phonebox_01b_s",
"prop_phonebox_01a",
"prop_phonebox_04",
},
ReplacementProp = "sf_prop_sf_phonebox_01b_s",
PricePerSecond = 1,
PaymentAccount = "cash", -- cash or bank
Currency = "$",
NoAnswerTimeoutSeconds = 30,
CallerNumber = "PAYPHONE",
InteractionDistance = 1.8,
ServerValidationDistance = 3.0,
MaximumCallDistance = 4.0,
ScanDistance = 25.0,
ScanIntervalMs = 1000,
ModelLoadTimeoutMs = 5000,
Animation = {
Dictionary = "anim@scripted@payphone_hits@male@",
PedClip = "FXFR_PAV_1_INTRO_MALE",
PropClip = "FXFR_PAV_1_INTRO_PHONE",
HangupDurationMs = 2000,
},
}
Config.Radio = {
VoiceProvider = "auto", -- auto, yaca, pma, saltychat
DefaultVolume = 50,
+34
View File
@@ -29,7 +29,41 @@ Locales["en"] = {
voice_unavailable = "The configured phone voice service is unavailable.",
default = "The phone could not be opened.",
},
Payphone = {
Interact = "Use the payphone.",
RingingHelp = "Calling {number}... | Hang up",
ConnectedHelp = "Connected to {number} | {duration} | {currency}{cost} | Hang up",
},
Nui = {
Payphone = {
title = "LOS SANTOS PAYPHONE",
subtitle = "PUBLIC TELEPHONE",
numberLabel = "NUMBER TO CALL",
numberPlaceholder = "Enter a phone number",
rate = "{currency}{price} / SEC",
ready = "READY",
dialing = "DIALING",
ringing = "RINGING",
connected = "CONNECTED",
callEnded = "CALL ENDED",
unavailable = "NUMBER UNAVAILABLE",
busy = "LINE BUSY",
noAnswer = "NO ANSWER",
declined = "CALL DECLINED",
disconnected = "DISCONNECTED",
insufficientFunds = "OUT OF MONEY",
invalidNumber = "ENTER A VALID NUMBER",
requestFailed = "CALL COULD NOT BE STARTED",
voiceUnavailable = "VOICE SERVICE UNAVAILABLE",
call = "CALL",
hangup = "HANG UP",
close = "Close payphone",
delete = "Delete digit",
clear = "Clear number",
keypad = "Dial pad",
elapsed = "TIME",
cost = "COST",
},
Common = {
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
+1
View File
@@ -32,6 +32,7 @@ client_scripts {
'source/client/skyride.lua',
'source/client/housing.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/main.lua',
'source/client/radio.lua',
}
@@ -50,3 +50,10 @@ function Bridge.Framework.Notify(title, message, notification_type, duration)
Bridge.Debug("error", "[sky_phone] Notification requested for unsupported framework '%s'.", tostring(framework_name))
end
function Bridge.Framework.ShowHelpNotification(message, key)
local control = key == "E" and "~INPUT_CONTEXT~" or ("[%s]"):format(tostring(key or "E"))
BeginTextCommandDisplayHelp("STRING")
AddTextComponentSubstringPlayerName(("%s %s"):format(control, tostring(message or "")))
EndTextCommandDisplayHelp(0, false, false, -1)
end
+641
View File
@@ -0,0 +1,641 @@
local payphone_open = false
local nearest_payphone = nil
local active_booth = nil
local active_call_id = nil
local active_call_state = nil
local active_call_number = nil
local active_call_elapsed_seconds = 0
local active_call_elapsed_updated_at = 0
local call_channel = 0
local replacement_prop = nil
local hidden_prop = nil
local animation_scene = nil
local network_animation_scene = nil
local animation_ped = nil
local animation_floor_z = nil
local visuals_starting = false
local visuals_ending = false
local hangup_requested = false
local remote_visuals = {}
local configured_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
configured_models[joaat(model_name)] = model_name
end
local function valid_visual_number(value, maximum)
local number = tonumber(value)
if not number or number ~= number or math.abs(number) > maximum then
return nil
end
return number
end
local function restore_remote_visual(id)
local visual = remote_visuals[id]
if not visual then
return
end
remote_visuals[id] = nil
if not visual.hidden_entity or not DoesEntityExist(visual.hidden_entity) then
return
end
for _, other in pairs(remote_visuals) do
if other.model_hash == visual.model_hash and #(other.coords - visual.coords) < 0.5 then
other.hidden_entity = other.hidden_entity or visual.hidden_entity
return
end
end
SetEntityVisible(visual.hidden_entity, true, false)
end
RegisterNetEvent("sky_phone:payphone:visual:start", function(data)
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.model) ~= "string"
or tonumber(data.callerSource) == GetPlayerServerId(PlayerId())
then
return
end
local model_hash = joaat(data.model)
if not configured_models[model_hash] or type(data.coords) ~= "table" then
return
end
local x = valid_visual_number(data.coords.x, 10000.0)
local y = valid_visual_number(data.coords.y, 10000.0)
local z = valid_visual_number(data.coords.z, 2000.0)
if not x or not y or not z then
return
end
restore_remote_visual(data.id)
remote_visuals[data.id] = {
coords = vector3(x, y, z),
model_hash = model_hash,
hidden_entity = nil,
}
end)
RegisterNetEvent("sky_phone:payphone:visual:stop", function(data)
if type(data) ~= "table" or type(data.id) ~= "string" then
return
end
restore_remote_visual(data.id)
end)
local function get_locale()
return Locales[Config.Bridge.Locale] or Locales["en"]
end
local function load_model(model_hash)
if HasModelLoaded(model_hash) then
return true
end
RequestModel(model_hash)
local deadline = GetGameTimer() + Config.Payphones.ModelLoadTimeoutMs
while not HasModelLoaded(model_hash) and GetGameTimer() < deadline do
Wait(0)
end
return HasModelLoaded(model_hash)
end
local function load_animation(dictionary)
if HasAnimDictLoaded(dictionary) then
return true
end
RequestAnimDict(dictionary)
local deadline = GetGameTimer() + Config.Payphones.ModelLoadTimeoutMs
while not HasAnimDictLoaded(dictionary) and GetGameTimer() < deadline do
Wait(0)
end
return HasAnimDictLoaded(dictionary)
end
local function leave_call_voice()
if call_channel == 0 then
return
end
if Config.Calls.VoiceProvider == "pma" and GetResourceState("pma-voice") == "started" then
exports["pma-voice"]:setCallChannel(0)
end
call_channel = 0
end
local function join_call_voice(channel)
if Config.Calls.VoiceProvider ~= "pma" or GetResourceState("pma-voice") ~= "started" then
Bridge.Debug("error", "[sky_phone] The payphone call could not join the configured voice provider.")
return false
end
call_channel = tonumber(channel) or 0
exports["pma-voice"]:setCallChannel(call_channel)
return call_channel > 0
end
local function stop_call_visuals()
local animation = Config.Payphones.Animation
local ped = animation_ped or PlayerPedId()
local visuals_active = animation_scene ~= nil or replacement_prop ~= nil
visuals_starting = false
visuals_ending = false
if visuals_active and DoesEntityExist(ped) then
StopAnimTask(ped, animation.Dictionary, animation.PedClip, 0.0)
ClearPedTasksImmediately(ped)
end
if network_animation_scene then
NetworkStopSynchronisedScene(network_animation_scene)
network_animation_scene = nil
animation_scene = nil
elseif animation_scene then
SetSynchronizedSceneHoldLastFrame(animation_scene, false)
DisposeSynchronizedScene(animation_scene)
animation_scene = nil
end
animation_ped = nil
animation_floor_z = nil
if replacement_prop and DoesEntityExist(replacement_prop) then
StopEntityAnim(replacement_prop, animation.PropClip, animation.Dictionary, 0.0)
SetEntityVisible(replacement_prop, false, false)
SetEntityAsMissionEntity(replacement_prop, true, true)
DeleteEntity(replacement_prop)
end
replacement_prop = nil
if hidden_prop and DoesEntityExist(hidden_prop) then
SetEntityVisible(hidden_prop, true, false)
end
hidden_prop = nil
RemoveAnimDict(animation.Dictionary)
end
local function keep_animation_ped_grounded()
if not animation_ped or not animation_floor_z or not DoesEntityExist(animation_ped) then
return
end
local coords = GetEntityCoords(animation_ped)
if coords.z >= animation_floor_z - 0.15 then
return
end
SetEntityCoordsNoOffset(animation_ped, coords.x, coords.y, animation_floor_z, false, false, false)
SetEntityVelocity(animation_ped, 0.0, 0.0, 0.0)
end
local function play_hangup_visuals()
if visuals_ending then
return
end
if not animation_scene or not animation_ped or not DoesEntityExist(animation_ped) then
stop_call_visuals()
active_booth = nil
return
end
visuals_ending = true
local scene = animation_scene
local starting_phase = math.max(0.0, math.min(1.0, GetSynchronizedScenePhase(scene)))
local duration_ms = math.max(250, math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 2000))
local started_at = GetGameTimer()
SetSynchronizedSceneRate(scene, 0.0)
CreateThread(function()
while animation_scene == scene do
local progress = math.min(1.0, (GetGameTimer() - started_at) / duration_ms)
local phase = starting_phase * (1.0 - progress)
SetSynchronizedScenePhase(scene, phase)
keep_animation_ped_grounded()
if progress >= 1.0 then
break
end
Wait(0)
end
if animation_scene == scene then
stop_call_visuals()
active_booth = nil
end
end)
end
local function start_call_visuals()
if visuals_starting or replacement_prop or not active_call_id
or not active_booth or not DoesEntityExist(active_booth.entity)
then
return
end
visuals_starting = true
local expected_call_id = active_call_id
local booth = active_booth
local replacement_hash = joaat(Config.Payphones.ReplacementProp)
local animation = Config.Payphones.Animation
if not load_model(replacement_hash) or not load_animation(animation.Dictionary) then
Bridge.Debug("error", "[sky_phone] The payphone animation assets could not be loaded.")
visuals_starting = false
SetModelAsNoLongerNeeded(replacement_hash)
RemoveAnimDict(animation.Dictionary)
return
end
if active_call_id ~= expected_call_id or active_booth ~= booth or not DoesEntityExist(booth.entity) then
visuals_starting = false
SetModelAsNoLongerNeeded(replacement_hash)
RemoveAnimDict(animation.Dictionary)
return
end
local original = booth.entity
local coords = GetEntityCoords(original)
local rotation = GetEntityRotation(original, 2)
local replacement = CreateObjectNoOffset(
replacement_hash,
coords.x,
coords.y,
coords.z,
true,
true,
false
)
if replacement == 0 or not DoesEntityExist(replacement) then
Bridge.Debug("error", "[sky_phone] The animated payphone replacement prop could not be created.")
visuals_starting = false
SetModelAsNoLongerNeeded(replacement_hash)
RemoveAnimDict(animation.Dictionary)
return
end
SetEntityRotation(replacement, rotation.x, rotation.y, rotation.z, 2, false)
FreezeEntityPosition(replacement, true)
SetEntityCollision(replacement, false, false)
local replacement_network_id = NetworkGetNetworkIdFromEntity(replacement)
if replacement_network_id == 0 then
Bridge.Debug("error", "[sky_phone] The animated payphone replacement prop is not networked.")
SetEntityAsMissionEntity(replacement, true, true)
DeleteEntity(replacement)
visuals_starting = false
SetModelAsNoLongerNeeded(replacement_hash)
RemoveAnimDict(animation.Dictionary)
return
end
SetNetworkIdCanMigrate(replacement_network_id, false)
SetEntityVisible(original, false, false)
hidden_prop = original
replacement_prop = replacement
local ped = PlayerPedId()
network_animation_scene = NetworkCreateSynchronisedScene(
coords.x,
coords.y,
coords.z,
rotation.x,
rotation.y,
rotation.z,
2,
true,
false,
1.0,
0.0,
1.0
)
if not network_animation_scene or network_animation_scene == -1 then
Bridge.Debug("error", "[sky_phone] The payphone network synchronized scene could not be created.")
network_animation_scene = nil
stop_call_visuals()
return
end
animation_ped = ped
local ped_coords = GetEntityCoords(animation_ped)
local found_ground, ground_z = GetGroundZFor_3dCoord(
ped_coords.x,
ped_coords.y,
ped_coords.z + 1.0,
false
)
animation_floor_z = found_ground and ground_z or ped_coords.z
NetworkAddPedToSynchronisedScene(
ped,
network_animation_scene,
animation.Dictionary,
animation.PedClip,
8.0,
-8.0,
2,
0,
1000.0,
0
)
NetworkAddEntityToSynchronisedScene(
replacement,
network_animation_scene,
animation.Dictionary,
animation.PropClip,
8.0,
-8.0,
0
)
NetworkStartSynchronisedScene(network_animation_scene)
local scene_deadline = GetGameTimer() + 1000
repeat
animation_scene = NetworkGetLocalSceneFromNetworkId(network_animation_scene)
if animation_scene and animation_scene ~= -1 then
break
end
Wait(0)
until GetGameTimer() >= scene_deadline
if not animation_scene or animation_scene == -1 then
Bridge.Debug("error", "[sky_phone] The local handle for the payphone network scene was not created.")
animation_scene = nil
stop_call_visuals()
return
end
visuals_starting = false
SetModelAsNoLongerNeeded(replacement_hash)
end
local function booth_payload(booth)
local coords = booth.coords
return {
model = booth.model,
coords = { x = coords.x, y = coords.y, z = coords.z },
}
end
local function close_payphone()
local was_open = payphone_open
payphone_open = false
if was_open then
SetNuiFocus(false, false)
SendNUIMessage({ type = "payphone:close" })
end
if not active_call_id then
active_booth = nil
end
end
local function format_duration(seconds)
local duration = math.max(0, math.floor(tonumber(seconds) or 0))
return ("%02d:%02d"):format(math.floor(duration / 60), duration % 60)
end
local function replace_placeholder(value, placeholder, replacement)
return value:gsub("{" .. placeholder .. "}", tostring(replacement))
end
local function current_call_elapsed_seconds()
if active_call_state ~= "connected" then
return 0
end
return active_call_elapsed_seconds
+ math.max(0, math.floor((GetGameTimer() - active_call_elapsed_updated_at) / 1000))
end
local function call_help_message()
local locale = get_locale().Payphone
local message
if active_call_state == "connected" then
local elapsed_seconds = current_call_elapsed_seconds()
message = locale.ConnectedHelp
message = replace_placeholder(message, "duration", format_duration(elapsed_seconds))
message = replace_placeholder(message, "currency", Config.Payphones.Currency)
message = replace_placeholder(message, "cost", elapsed_seconds * (tonumber(Config.Payphones.PricePerSecond) or 0))
else
message = locale.RingingHelp
end
return replace_placeholder(message, "number", active_call_number or "")
end
local function apply_active_call_state(data)
if active_call_id ~= data.id then
hangup_requested = false
end
active_call_id = data.id
active_call_state = data.state
active_call_number = data.otherNumber or active_call_number
if data.state == "connected" then
active_call_elapsed_seconds = math.max(0, math.floor(tonumber(data.elapsedSeconds) or 0))
active_call_elapsed_updated_at = GetGameTimer()
else
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
end
end
local function clear_active_call_state()
active_call_id = nil
active_call_state = nil
active_call_number = nil
active_call_elapsed_seconds = 0
active_call_elapsed_updated_at = 0
hangup_requested = false
end
local function open_payphone(booth)
if payphone_open or active_call_id or IsNuiFocused() then
return
end
active_booth = booth
payphone_open = true
SetNuiFocus(true, true)
SendNUIMessage({
type = "payphone:open",
data = {
currency = Config.Payphones.Currency,
maxNumberLength = Config.Sim.NumberLength,
pricePerSecond = Config.Payphones.PricePerSecond,
locales = get_locale().Nui.Payphone,
},
})
end
RegisterNUICallback("payphone:dial", function(data, cb)
if not payphone_open or not active_booth or active_call_id then
cb({ success = false, error = "invalid_request" })
return
end
local payload = booth_payload(active_booth)
payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload)
local call_started = result and result.success and result.data
and (result.data.state == "ringing" or result.data.state == "connected")
if call_started then
apply_active_call_state(result.data)
end
cb(result or { success = false, error = "request_failed" })
if call_started then
close_payphone()
start_call_visuals()
end
end)
RegisterNUICallback("payphone:hangup", function(_, cb)
if not active_call_id then
cb({ success = false, error = "call_not_found" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("payphone:close", function(_, cb)
if active_call_id then
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
close_payphone()
cb({ success = true })
end)
RegisterNetEvent("sky_phone:payphone:state", function(data)
if type(data) ~= "table" then
return
end
if data.state == "ringing" or data.state == "connected" then
apply_active_call_state(data)
close_payphone()
start_call_visuals()
if data.state == "connected" and data.channel and call_channel ~= tonumber(data.channel)
and not join_call_voice(data.channel)
then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id })
end
else
clear_active_call_state()
leave_call_voice()
play_hangup_visuals()
end
SendNUIMessage({ type = "payphone:state", data = data })
end)
CreateThread(function()
while true do
if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then
nearest_payphone = nil
Wait(Config.Payphones.ScanIntervalMs)
else
local ped_coords = GetEntityCoords(PlayerPedId())
local closest = nil
local closest_distance = Config.Payphones.ScanDistance + 0.01
for model_hash, model_name in pairs(configured_models) do
local entity = GetClosestObjectOfType(
ped_coords.x,
ped_coords.y,
ped_coords.z,
Config.Payphones.ScanDistance,
model_hash,
false,
false,
false
)
if entity ~= 0 and DoesEntityExist(entity) then
local coords = GetEntityCoords(entity)
local distance = #(ped_coords - coords)
if distance < closest_distance then
closest_distance = distance
closest = { entity = entity, coords = coords, model = model_name, distance = distance }
end
end
end
nearest_payphone = closest
Wait(Config.Payphones.ScanIntervalMs)
end
end
end)
CreateThread(function()
while true do
if nearest_payphone and nearest_payphone.distance <= Config.Payphones.InteractionDistance and not IsNuiFocused() then
Bridge.Framework.ShowHelpNotification(get_locale().Payphone.Interact, "E")
if IsControlJustReleased(0, 38) then
open_payphone(nearest_payphone)
end
Wait(0)
else
Wait(250)
end
end
end)
CreateThread(function()
while true do
local has_visuals = next(remote_visuals) ~= nil
if has_visuals then
local player_coords = GetEntityCoords(PlayerPedId())
for _, visual in pairs(remote_visuals) do
if visual.hidden_entity and not DoesEntityExist(visual.hidden_entity) then
visual.hidden_entity = nil
end
if #(player_coords - visual.coords) <= Config.Payphones.ScanDistance then
if not visual.hidden_entity then
local entity = GetClosestObjectOfType(
visual.coords.x,
visual.coords.y,
visual.coords.z,
1.0,
visual.model_hash,
false,
false,
false
)
if entity ~= 0 and DoesEntityExist(entity) then
visual.hidden_entity = entity
end
end
if visual.hidden_entity then
SetEntityVisible(visual.hidden_entity, false, false)
end
end
end
Wait(250)
else
Wait(1000)
end
end
end)
CreateThread(function()
while true do
local call_id = active_call_id
local booth = active_booth
if call_id and booth then
keep_animation_ped_grounded()
Bridge.Framework.ShowHelpNotification(call_help_message(), "E")
if IsControlJustReleased(0, 38) and not hangup_requested then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
end
if active_call_id == call_id and active_booth == booth then
local distance = #(GetEntityCoords(PlayerPedId()) - booth.coords)
if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then
hangup_requested = true
Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id })
end
end
Wait(0)
elseif visuals_ending then
keep_animation_ped_grounded()
Wait(0)
else
Wait(250)
end
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
if payphone_open then
SetNuiFocus(false, false)
end
leave_call_voice()
stop_call_visuals()
clear_active_call_state()
local visual_ids = {}
for id in pairs(remote_visuals) do
visual_ids[#visual_ids + 1] = id
end
for _, id in ipairs(visual_ids) do
restore_remote_visual(id)
end
end)
+340 -34
View File
@@ -90,7 +90,7 @@ end
local function send_state(call, source, state, channel)
local outgoing = source == call.caller_source
TriggerClientEvent("sky_phone:call:state", source, {
local payload = {
id = call.id,
state = state,
direction = outgoing and "outgoing" or "incoming",
@@ -98,7 +98,14 @@ local function send_state(call, source, state, channel)
startedAt = call.started_at,
answeredAt = call.answered_at,
channel = channel,
})
}
if call.payphone and outgoing then
payload.elapsedSeconds = call.payphone.elapsed_seconds or 0
payload.totalCost = call.payphone.total_cost or 0
TriggerClientEvent("sky_phone:payphone:state", source, payload)
return
end
TriggerClientEvent("sky_phone:call:state", source, payload)
end
local function notify_recents(device, source)
@@ -109,6 +116,100 @@ local function notify_recents(device, source)
end
end
local function settle_payphone_call(call, duration)
local payphone = call.payphone
local elapsed_seconds = math.max(0, math.floor(tonumber(duration) or 0))
local price_per_second = math.max(0, math.floor(tonumber(payphone.price_per_second) or 0))
local billable_seconds = elapsed_seconds
local total_cost = billable_seconds * price_per_second
if total_cost > 0 then
local available_money = tonumber(Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount))
if not available_money then
Bridge.Debug(
"error",
"[sky_phone] Payphone settlement could not read the balance for source %s.",
tostring(call.caller_source)
)
billable_seconds = 0
total_cost = 0
elseif available_money < total_cost then
billable_seconds = math.min(elapsed_seconds, math.floor(math.max(0, available_money) / price_per_second))
total_cost = billable_seconds * price_per_second
end
end
local charged = total_cost == 0
if total_cost > 0 then
local success, result = pcall(
Bridge.Framework.RemoveMoney,
call.caller_source,
Config.Payphones.PaymentAccount,
total_cost
)
charged = success and result and true or false
if not success then
Bridge.Debug(
"error",
"[sky_phone] Payphone settlement failed for source %s: %s",
tostring(call.caller_source),
tostring(result)
)
elseif not result then
Bridge.Debug(
"warn",
"[sky_phone] Payphone settlement was rejected for source %s.",
tostring(call.caller_source)
)
end
end
payphone.elapsed_seconds = elapsed_seconds
payphone.total_cost = charged and total_cost or 0
return charged and billable_seconds == elapsed_seconds
end
local function send_payphone_visual(call, action, delay_ms)
if not call.payphone then
return
end
local coords = call.payphone.coords
local payload = {
id = call.id,
callerSource = call.caller_source,
model = call.payphone.model,
coords = { x = coords.x, y = coords.y, z = coords.z },
}
local event_name = ("sky_phone:payphone:visual:%s"):format(action)
local routing_bucket = call.payphone.routing_bucket
local targets = {}
if action == "stop" and call.payphone.visual_targets then
for _, target in ipairs(call.payphone.visual_targets) do
targets[#targets + 1] = target
end
else
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
if GetPlayerRoutingBucket(target) == routing_bucket then
targets[#targets + 1] = target
end
end
if action == "start" then
call.payphone.visual_targets = targets
end
end
local function dispatch()
for _, target in ipairs(targets) do
TriggerClientEvent(event_name, target, payload)
end
end
if delay_ms and delay_ms > 0 then
SetTimeout(delay_ms, dispatch)
else
dispatch()
end
end
local function finish_call(call, status)
if not call or call.ended then
return
@@ -116,30 +217,42 @@ local function finish_call(call, status)
call.ended = true
local ended_at = os.time()
local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0
if call.payphone and call.answered_at and not settle_payphone_call(call, duration)
and status ~= "disconnected"
then
status = "insufficient_funds"
end
local callee_status = status
if status == "no_answer" or status == "cancelled" then
callee_status = "missed"
end
Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_calls`
SET `status` = ?, `ended_at` = CURRENT_TIMESTAMP, `duration_seconds` = ?
WHERE `id` = ?
]],
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
params = { status, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'",
params = { callee_status, call.id },
},
})
if call.payphone and call.answered_at and status == "insufficient_funds" then
callee_status = "completed"
end
if not call.payphone then
Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_calls`
SET `status` = ?, `ended_at` = CURRENT_TIMESTAMP, `duration_seconds` = ?
WHERE `id` = ?
]],
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
params = { status, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'",
params = { callee_status, call.id },
},
})
end
active_by_source[call.caller_source] = nil
active_by_sim[call.caller_sim_id] = nil
if call.caller_sim_id then
active_by_sim[call.caller_sim_id] = nil
end
if call.callee_source then
active_by_source[call.callee_source] = nil
end
@@ -150,10 +263,19 @@ local function finish_call(call, status)
if call.callee_source then
send_state(call, call.callee_source, callee_status)
end
notify_recents(call.caller_device, call.caller_source)
if call.callee_device then
if call.caller_device then
notify_recents(call.caller_device, call.caller_source)
end
if call.callee_device and not call.payphone then
notify_recents(call.callee_device, call.callee_source)
end
if call.payphone then
local hangup_duration = math.max(
250,
math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 2000)
)
send_payphone_visual(call, "stop", hangup_duration)
end
calls[call.id] = nil
end
@@ -440,6 +562,150 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
return { success = true, data = { id = id, state = "ringing", direction = "outgoing", otherNumber = number, startedAt = call.started_at } }
end)
local payphone_models = {}
for _, model_name in ipairs(Config.Payphones.Props or {}) do
payphone_models[model_name] = true
end
local function valid_payphone_position(source, data)
if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then
return nil
end
local x = tonumber(data.coords.x)
local y = tonumber(data.coords.y)
local z = tonumber(data.coords.z)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0
then
return nil
end
local ped = GetPlayerPed(source)
if not ped or ped == 0 then
return nil
end
local player_coords = GetEntityCoords(ped)
local booth_coords = vector3(x, y, z)
if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then
return nil
end
return booth_coords, data.model
end
local function payphone_terminal(number, state)
return {
id = ("payphone-terminal-%s-%s"):format(os.time(), math.random(100000, 999999)),
state = state,
direction = "outgoing",
otherNumber = number,
startedAt = os.time(),
elapsedSeconds = 0,
totalCost = 0,
}
end
Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
local booth_coords, booth_model = valid_payphone_position(source, data)
if not booth_coords then
return { success = false, error = "invalid_payphone" }
end
if active_by_source[source] or dial_locks[source] then
return { success = false, error = "busy" }
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
local price_per_second = math.max(0, math.floor(tonumber(Config.Payphones.PricePerSecond) or 0))
local available_money = Bridge.Framework.GetMoney(source, Config.Payphones.PaymentAccount)
if price_per_second > 0 and (not available_money or available_money < price_per_second) then
return { success = false, error = "insufficient_funds" }
end
dial_locks[source] = true
local targets = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name`
FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
if not target or not target.imei then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "unavailable") }
end
local callee_source = find_device_holder(target.imei)
if not callee_source or callee_source == source or airplane_mode(target.imei) then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "unavailable") }
end
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "busy") }
end
dialing_by_sim[target.id] = true
local id = uuid()
local call = {
id = id,
caller_source = source,
caller_number = Config.Payphones.CallerNumber,
callee_source = callee_source,
callee_sim_id = target.id,
callee_number = number,
callee_device = target,
started_at = os.time(),
payphone = {
elapsed_seconds = 0,
total_cost = 0,
coords = booth_coords,
model = booth_model,
price_per_second = price_per_second,
routing_bucket = GetPlayerRoutingBucket(source),
},
}
calls[id] = call
active_by_source[source] = id
active_by_source[callee_source] = id
active_by_sim[target.id] = id
dialing_by_sim[target.id] = nil
dial_locks[source] = nil
send_state(call, source, "ringing")
send_payphone_visual(call, "start")
SkyPhone.OpenDeviceForCall(callee_source, target.imei)
TriggerClientEvent("sky_phone:call:incoming", callee_source, {
id = id,
state = "ringing",
direction = "incoming",
otherNumber = call.caller_number,
startedAt = call.started_at,
device = {
imei = target.imei,
name = target.device_name,
},
})
SetTimeout(math.max(1, math.floor(tonumber(Config.Payphones.NoAnswerTimeoutSeconds) or 30)) * 1000, function()
if calls[id] and not calls[id].answered_at then
finish_call(calls[id], "no_answer")
end
end)
return {
success = true,
data = {
id = id,
state = "ringing",
direction = "outgoing",
otherNumber = number,
startedAt = call.started_at,
elapsedSeconds = 0,
totalCost = 0,
},
}
end)
Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
local call = type(data) == "table" and calls[data.id] or nil
if not call or call.callee_source ~= source or call.answered_at then
@@ -455,8 +721,10 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
call.answered_at = os.time()
call.channel = next_voice_channel
next_voice_channel = next_voice_channel + 1
Bridge.Database.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id })
Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
if not call.payphone then
Bridge.Database.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id })
Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
end
send_state(call, call.caller_source, "connected", call.channel)
send_state(call, call.callee_source, "connected", call.channel)
return { success = true }
@@ -474,7 +742,21 @@ end)
Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or (type(data) == "table" and data.id and data.id ~= call.id) then
if not call or (call.payphone and call.caller_source == source)
or (type(data) == "table" and data.id and data.id ~= call.id)
then
return { success = false, error = "call_not_found" }
end
finish_call(call, call.answered_at and "completed" or "cancelled")
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:payphone:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or not call.payphone or call.caller_source ~= source
or (type(data) == "table" and data.id and data.id ~= call.id)
then
return { success = false, error = "call_not_found" }
end
finish_call(call, call.answered_at and "completed" or "cancelled")
@@ -483,17 +765,41 @@ end)
CreateThread(function()
while true do
Wait(2000)
local invalid_calls = {}
Wait(1000)
local calls_to_finish = {}
for call_id, call in pairs(calls) do
if not SkyPhone.FindDeviceSlots(call.caller_source, call.caller_device.imei)[1]
or (call.callee_source and not SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1])
then
invalid_calls[#invalid_calls + 1] = call_id
local caller_valid = true
if call.payphone then
local caller_ped = GetPlayerPed(call.caller_source)
caller_valid = caller_ped and caller_ped ~= 0
if caller_valid then
caller_valid = #(GetEntityCoords(caller_ped) - call.payphone.coords)
<= Config.Payphones.MaximumCallDistance
end
else
caller_valid = SkyPhone.FindDeviceSlots(call.caller_source, call.caller_device.imei)[1] ~= nil
end
local callee_valid = not call.callee_source
or SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1] ~= nil
if not caller_valid or not callee_valid then
calls_to_finish[call_id] = "disconnected"
elseif call.payphone and call.answered_at and not call.ended then
local elapsed_seconds = math.max(0, os.time() - call.answered_at)
local total_cost = elapsed_seconds * call.payphone.price_per_second
local available_money = tonumber(
Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount)
)
if total_cost > 0 and (not available_money or available_money < total_cost) then
calls_to_finish[call_id] = "insufficient_funds"
elseif call.payphone.elapsed_seconds ~= elapsed_seconds then
call.payphone.elapsed_seconds = elapsed_seconds
call.payphone.total_cost = total_cost
send_state(call, call.caller_source, "connected", call.channel)
end
end
end
for _, call_id in ipairs(invalid_calls) do
finish_call(calls[call_id], "disconnected")
for call_id, status in pairs(calls_to_finish) do
finish_call(calls[call_id], status)
end
end
end)