mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 10:01:38 +00:00
25c8adb737
Company service lines were validated as full-length SIM numbers, which rejected short configured numbers such as 911 and stopped dependent server modules from initializing. Accept non-empty service numbers up to the configured SIM length, resolve configured service lines before direct SIM dialing for phone and payphone calls, and load Companies and SIM before Phone. Add focused normalization boundary tests.
1455 lines
55 KiB
Lua
1455 lines
55 KiB
Lua
Bridge.Database.AfterMigration("sky_phone", function()
|
|
|
|
SkyPhoneCalls = {}
|
|
|
|
local calls = {}
|
|
local active_by_source = {}
|
|
local active_by_sim = {}
|
|
local dial_locks = {}
|
|
local dialing_by_sim = {}
|
|
local next_voice_channel = 10000
|
|
local reroute_company_call
|
|
|
|
local function uuid()
|
|
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
|
if not rows[1] or type(rows[1].id) ~= "string" then
|
|
error("[sky_phone] Database did not generate a call UUID.")
|
|
end
|
|
return rows[1].id
|
|
end
|
|
|
|
local function trim(value)
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
return value:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function scope_for_device(device)
|
|
if device.account_id then
|
|
return tonumber(device.account_id), nil
|
|
end
|
|
return nil, device.imei
|
|
end
|
|
|
|
local function current_scope(source)
|
|
local session, error_response = SkyPhone.RequireSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
local device = SkyPhone.LoadDevice(session.imei)
|
|
if not device then
|
|
return nil, { success = false, error = "device_not_found" }
|
|
end
|
|
local account_id, device_imei = scope_for_device(device)
|
|
return {
|
|
account_id = account_id,
|
|
device = device,
|
|
device_imei = device_imei,
|
|
session = session,
|
|
}
|
|
end
|
|
|
|
local function scope_condition(scope, alias)
|
|
local prefix = alias and (alias .. ".") or ""
|
|
if scope.account_id then
|
|
return prefix .. "`account_id` = ?", { scope.account_id }
|
|
end
|
|
return prefix .. "`device_imei` = ?", { scope.device_imei }
|
|
end
|
|
|
|
local function find_device_holder(imei)
|
|
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
|
|
local source = tonumber(player_source) or player_source
|
|
if SkyPhone.FindDeviceSlots(source, imei)[1] then
|
|
return source
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function airplane_mode(imei)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `payload` FROM `sky_phone_device_data`
|
|
WHERE `device_imei` = ? AND `namespace` = 'settings' LIMIT 1
|
|
]], { imei })
|
|
if not rows[1] then
|
|
return false
|
|
end
|
|
local payload = json.decode(rows[1].payload)
|
|
return payload and payload.settings and payload.settings.airplaneMode == true
|
|
end
|
|
|
|
local function add_call_entry(call_id, device, direction, status, other_number)
|
|
local account_id, device_imei = scope_for_device(device)
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_call_entries`
|
|
(`call_id`, `account_id`, `device_imei`, `direction`, `status`, `other_number`)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
]], { call_id, account_id, device_imei, direction, status, other_number })
|
|
end
|
|
|
|
local function send_state(call, source, state, channel)
|
|
local outgoing = source == call.caller_source
|
|
local speaker_supported = Bridge.Speaker.IsEnabled() and (
|
|
call.voice_provider == "saltychat"
|
|
or (not call.voice_provider and Bridge.Calls.SupportsSpeaker())
|
|
)
|
|
local payload = {
|
|
id = call.id,
|
|
state = state,
|
|
direction = outgoing and "outgoing" or "incoming",
|
|
otherNumber = outgoing and call.callee_number or call.caller_number,
|
|
startedAt = call.started_at,
|
|
answeredAt = call.answered_at,
|
|
channel = channel,
|
|
speakerEnabled = call.speakers and call.speakers[source] == true or false,
|
|
speakerSupported = speaker_supported,
|
|
}
|
|
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)
|
|
if device.account_id then
|
|
SkyPhone.NotifyAccount(device.account_id, "sky_phone:calls:changed", {})
|
|
elseif source then
|
|
TriggerClientEvent("sky_phone:calls:changed", 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
|
|
end
|
|
call.ended = true
|
|
if call.voice_started then
|
|
local player_handles = { call.caller_source, call.callee_source }
|
|
for _, player_source in ipairs(player_handles) do
|
|
if call.speakers and call.speakers[player_source] then
|
|
Bridge.Calls.SetSpeaker(player_source, false, call.voice_provider)
|
|
end
|
|
end
|
|
Bridge.Calls.Stop(call.id, player_handles, call.voice_provider)
|
|
call.speakers = {}
|
|
call.voice_started = false
|
|
end
|
|
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
|
|
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' AND `status` IN ('ringing', 'connected')
|
|
]],
|
|
params = { status, call.id },
|
|
},
|
|
{
|
|
query = [[
|
|
UPDATE `sky_phone_call_entries` SET `status` = ?
|
|
WHERE `call_id` = ? AND `direction` = 'incoming' AND `status` IN ('ringing', 'connected')
|
|
]],
|
|
params = { callee_status, call.id },
|
|
},
|
|
})
|
|
end
|
|
active_by_source[call.caller_source] = 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
|
|
if call.callee_sim_id then
|
|
active_by_sim[call.callee_sim_id] = nil
|
|
end
|
|
send_state(call, call.caller_source, status)
|
|
if call.callee_source then
|
|
send_state(call, call.callee_source, callee_status)
|
|
end
|
|
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
|
|
|
|
function SkyPhoneCalls.EndForSim(sim_id, reason)
|
|
local call_id = active_by_sim[sim_id]
|
|
local call = call_id and calls[call_id] or nil
|
|
if not call then
|
|
return
|
|
end
|
|
if call.callee_sim_id == sim_id and call.company_service_call and not call.answered_at then
|
|
if not reroute_company_call(call, "missed") then
|
|
finish_call(call, "unavailable")
|
|
end
|
|
return
|
|
end
|
|
finish_call(call, reason or "ended")
|
|
end
|
|
|
|
function SkyPhoneCalls.LinkAccountData(account_id, imei)
|
|
local counts = Bridge.Database.Query([[
|
|
SELECT
|
|
(SELECT COUNT(*) FROM `sky_phone_contacts` WHERE `account_id` = ?) AS `contacts`,
|
|
(SELECT COUNT(*) FROM `sky_phone_call_entries` WHERE `account_id` = ?) AS `recents`
|
|
]], { account_id, account_id })
|
|
local has_cloud_data = counts[1] and ((tonumber(counts[1].contacts) or 0) > 0 or (tonumber(counts[1].recents) or 0) > 0)
|
|
if has_cloud_data then
|
|
return Bridge.Database.Transaction({
|
|
{ query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
|
|
{ query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
|
|
})
|
|
end
|
|
return Bridge.Database.Transaction({
|
|
{
|
|
query = "UPDATE `sky_phone_contacts` SET `account_id` = ?, `device_imei` = NULL WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
params = { account_id, imei },
|
|
},
|
|
{
|
|
query = "UPDATE `sky_phone_call_entries` SET `account_id` = ?, `device_imei` = NULL WHERE `device_imei` = ? AND `account_id` IS NULL",
|
|
params = { account_id, imei },
|
|
},
|
|
})
|
|
end
|
|
|
|
function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
|
|
return Bridge.Database.Transaction({
|
|
{ query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
|
|
{ query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
|
|
{
|
|
query = [[
|
|
INSERT INTO `sky_phone_contacts`
|
|
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
|
|
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
|
|
FROM `sky_phone_contacts` WHERE `account_id` = ?
|
|
]],
|
|
params = { imei, account_id },
|
|
},
|
|
{
|
|
query = [[
|
|
INSERT INTO `sky_phone_call_entries`
|
|
(`call_id`, `device_imei`, `direction`, `status`, `other_number`, `created_at`)
|
|
SELECT `call_id`, ?, `direction`, `status`, `other_number`, `created_at`
|
|
FROM `sky_phone_call_entries` WHERE `account_id` = ?
|
|
]],
|
|
params = { imei, account_id },
|
|
},
|
|
})
|
|
end
|
|
|
|
Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
local condition, params = scope_condition(scope)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`,
|
|
(SELECT media.`url` FROM `sky_phone_media` media WHERE media.`id` = `avatar_media_id`) AS `avatar_url`,
|
|
`created_at`, `updated_at`
|
|
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
|
|
]]):format(condition), params)
|
|
local system_contacts = SkyPhoneCompanies.GetSystemContacts()
|
|
local system_ids = {}
|
|
local contacts = {}
|
|
for _, contact in ipairs(system_contacts) do
|
|
system_ids[contact.id] = true
|
|
contacts[#contacts + 1] = contact
|
|
end
|
|
for _, contact in ipairs(rows) do
|
|
if not system_ids[contact.id] and not SkyPhoneCompanies.IsSystemContactNumber(contact.phone_number) then
|
|
contacts[#contacts + 1] = contact
|
|
end
|
|
end
|
|
table.sort(contacts, function(left, right)
|
|
local left_name = tostring(left.name):lower()
|
|
local right_name = tostring(right.name):lower()
|
|
if left_name == right_name then
|
|
return tostring(left.phone_number) < tostring(right.phone_number)
|
|
end
|
|
return left_name < right_name
|
|
end)
|
|
return { success = true, data = contacts }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "contact_save", 30, 60) or type(data) ~= "table" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
local name = trim(data.name)
|
|
local notes = trim(data.notes) or ""
|
|
local organization = trim(data.organization) or ""
|
|
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
|
local avatar_media_id = tonumber(data.avatarMediaId) or 0
|
|
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then
|
|
return { success = false, error = "invalid_contact" }
|
|
end
|
|
if (type(data.id) == "string" and data.id:sub(1, 8) == "company:")
|
|
or SkyPhoneCompanies.IsSystemContactNumber(number)
|
|
then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Source %s attempted to modify a read-only company contact.",
|
|
tostring(source)
|
|
)
|
|
return { success = false, error = "readonly_contact" }
|
|
end
|
|
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
|
|
return { success = false, error = "invalid_contact" }
|
|
end
|
|
local avatar_url
|
|
if avatar_media_id > 0 then
|
|
local media_error
|
|
avatar_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
|
|
if not avatar_url then
|
|
return { success = false, error = media_error }
|
|
end
|
|
end
|
|
local condition, condition_params = scope_condition(scope)
|
|
local id = type(data.id) == "string" and data.id or uuid()
|
|
if data.id then
|
|
local owned_params = { id }
|
|
for _, value in ipairs(condition_params) do
|
|
owned_params[#owned_params + 1] = value
|
|
end
|
|
local owned = Bridge.Database.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
|
|
if not owned[1] then
|
|
return { success = false, error = "contact_not_found" }
|
|
end
|
|
local params = { name, notes, organization, number, avatar_media_id, id }
|
|
for _, value in ipairs(condition_params) do
|
|
params[#params + 1] = value
|
|
end
|
|
Bridge.Database.Query(([[
|
|
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
|
|
WHERE `contact_id` = ? AND %s
|
|
]]):format(condition), params)
|
|
else
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`)
|
|
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
|
|
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, number, avatar_media_id })
|
|
end
|
|
if scope.account_id then
|
|
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
|
end
|
|
return {
|
|
success = true,
|
|
data = {
|
|
id = id,
|
|
name = name,
|
|
notes = notes ~= "" and notes or nil,
|
|
organization = organization ~= "" and organization or nil,
|
|
phone_number = number,
|
|
avatar_media_id = avatar_media_id > 0 and avatar_media_id or nil,
|
|
avatar_url = avatar_url,
|
|
},
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:contacts:favorite", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "contact_favorite", 60, 60) or type(data) ~= "table" or type(data.id) ~= "string" or type(data.favorite) ~= "boolean" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
local condition, condition_params = scope_condition(scope)
|
|
local owned_params = { data.id }
|
|
for _, value in ipairs(condition_params) do
|
|
owned_params[#owned_params + 1] = value
|
|
end
|
|
local owned = Bridge.Database.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
|
|
if not owned[1] then
|
|
return { success = false, error = "contact_not_found" }
|
|
end
|
|
local params = { data.favorite and 1 or 0, data.id }
|
|
for _, value in ipairs(condition_params) do
|
|
params[#params + 1] = value
|
|
end
|
|
Bridge.Database.Query(("UPDATE `sky_phone_contacts` SET `favorite` = ? WHERE `contact_id` = ? AND %s"):format(condition), params)
|
|
if scope.account_id then
|
|
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
|
end
|
|
return { success = true, data = { id = data.id, favorite = data.favorite } }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data)
|
|
if type(data) ~= "table" or type(data.id) ~= "string" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
if data.id:sub(1, 8) == "company:" then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Source %s attempted to delete a read-only company contact.",
|
|
tostring(source)
|
|
)
|
|
return { success = false, error = "readonly_contact" }
|
|
end
|
|
local condition, values = scope_condition(scope)
|
|
local params = { data.id }
|
|
for _, value in ipairs(values) do
|
|
params[#params + 1] = value
|
|
end
|
|
Bridge.Database.Query(("DELETE FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s"):format(condition), params)
|
|
if scope.account_id then
|
|
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
|
|
end
|
|
return { success = true }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:calls:recents", function(source)
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
local condition, params = scope_condition(scope, "e")
|
|
params[#params + 1] = Config.Calls.RecentPageSize
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT e.`id`, e.`call_id`, e.`direction`, e.`status`, e.`other_number`, e.`created_at`,
|
|
c.`duration_seconds`
|
|
FROM `sky_phone_call_entries` e
|
|
JOIN `sky_phone_calls` c ON c.`id` = e.`call_id`
|
|
WHERE %s ORDER BY e.`created_at` DESC, e.`id` DESC LIMIT ?
|
|
]]):format(condition), params)
|
|
return { success = true, data = rows }
|
|
end)
|
|
|
|
local function create_terminal_call(scope, number, target_sim, status, caller_number)
|
|
local id = uuid()
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_calls`
|
|
(`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, `ended_at`)
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
]], {
|
|
id,
|
|
scope.device.sim_id,
|
|
target_sim and target_sim.id or nil,
|
|
caller_number or scope.device.phone_number,
|
|
number,
|
|
status,
|
|
})
|
|
add_call_entry(id, scope.device, "outgoing", status, number)
|
|
notify_recents(scope.device, nil)
|
|
return {
|
|
id = id,
|
|
state = status,
|
|
direction = "outgoing",
|
|
otherNumber = number,
|
|
startedAt = os.time(),
|
|
}
|
|
end
|
|
|
|
local function company_call_target(company_id, caller_source, caller_sim_id, excluded_sim_ids)
|
|
local has_busy_target = false
|
|
for _, candidate in ipairs(SkyPhoneCompanies.GetCallTargets(company_id)) do
|
|
local candidate_source = tonumber(candidate.source)
|
|
local candidate_sim_id = candidate.simId
|
|
local candidate_imei = candidate.imei
|
|
if candidate_source and type(candidate_sim_id) == "string" and type(candidate_imei) == "string"
|
|
and candidate_source ~= caller_source and candidate_sim_id ~= caller_sim_id
|
|
and (not excluded_sim_ids or not excluded_sim_ids[candidate_sim_id])
|
|
then
|
|
if active_by_source[candidate_source] or active_by_sim[candidate_sim_id]
|
|
or dialing_by_sim[candidate_sim_id]
|
|
then
|
|
has_busy_target = true
|
|
else
|
|
dialing_by_sim[candidate_sim_id] = true
|
|
local device = SkyPhone.LoadDevice(candidate_imei)
|
|
local holder = device and find_device_holder(candidate_imei) or nil
|
|
if not device or device.sim_id ~= candidate_sim_id or holder ~= candidate_source then
|
|
dialing_by_sim[candidate_sim_id] = nil
|
|
elseif SkyPhoneCompanies.IsServiceNumber(device.phone_number) then
|
|
dialing_by_sim[candidate_sim_id] = nil
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Company call target %s uses reserved service number %s.",
|
|
tostring(candidate_source),
|
|
tostring(device.phone_number)
|
|
)
|
|
elseif airplane_mode(candidate_imei) then
|
|
dialing_by_sim[candidate_sim_id] = nil
|
|
else
|
|
return {
|
|
device = device,
|
|
sim_id = candidate_sim_id,
|
|
source = candidate_source,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil, has_busy_target and "busy" or "unavailable"
|
|
end
|
|
|
|
local handle_no_answer
|
|
|
|
local function ring_callee(call)
|
|
SkyPhone.OpenDeviceForCall(call.callee_source, call.callee_device.imei)
|
|
TriggerClientEvent("sky_phone:call:incoming", call.callee_source, {
|
|
id = call.id,
|
|
state = "ringing",
|
|
direction = "incoming",
|
|
otherNumber = call.caller_number,
|
|
startedAt = call.started_at,
|
|
device = {
|
|
imei = call.callee_device.imei,
|
|
name = call.callee_device.device_name,
|
|
},
|
|
})
|
|
end
|
|
|
|
local function schedule_no_answer(call)
|
|
call.ring_attempt = (call.ring_attempt or 0) + 1
|
|
local ring_attempt = call.ring_attempt
|
|
SetTimeout(call.ring_seconds * 1000, function()
|
|
local active_call = calls[call.id]
|
|
if active_call and not active_call.answered_at and active_call.ring_attempt == ring_attempt then
|
|
handle_no_answer(active_call)
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function start_ringing_call(call, ring_seconds)
|
|
if not call.payphone then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_calls`
|
|
(`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`)
|
|
VALUES (?, ?, ?, ?, ?, 'ringing')
|
|
]], { call.id, call.caller_sim_id, call.callee_sim_id, call.caller_number, call.callee_number })
|
|
add_call_entry(call.id, call.caller_device, "outgoing", "ringing", call.callee_number)
|
|
add_call_entry(call.id, call.callee_device, "incoming", "ringing", call.caller_number)
|
|
end
|
|
|
|
calls[call.id] = call
|
|
active_by_source[call.caller_source] = call.id
|
|
active_by_source[call.callee_source] = call.id
|
|
if call.caller_sim_id then
|
|
active_by_sim[call.caller_sim_id] = call.id
|
|
dialing_by_sim[call.caller_sim_id] = nil
|
|
end
|
|
active_by_sim[call.callee_sim_id] = call.id
|
|
dialing_by_sim[call.callee_sim_id] = nil
|
|
dial_locks[call.caller_source] = nil
|
|
|
|
send_state(call, call.caller_source, "ringing")
|
|
if call.payphone then
|
|
send_payphone_visual(call, "start")
|
|
end
|
|
call.ring_seconds = math.max(1, math.floor(tonumber(ring_seconds) or Config.Calls.RingSeconds))
|
|
ring_callee(call)
|
|
schedule_no_answer(call)
|
|
|
|
local result = {
|
|
id = call.id,
|
|
state = "ringing",
|
|
direction = "outgoing",
|
|
otherNumber = call.callee_number,
|
|
startedAt = call.started_at,
|
|
}
|
|
if call.payphone then
|
|
result.elapsedSeconds = 0
|
|
result.totalCost = 0
|
|
end
|
|
return result
|
|
end
|
|
|
|
reroute_company_call = function(call, previous_status)
|
|
if not call.company_service_call or call.company_attempts_remaining <= 0 then
|
|
return false
|
|
end
|
|
if call.rerouting then
|
|
return true
|
|
end
|
|
call.rerouting = true
|
|
local next_target = company_call_target(
|
|
call.company_id,
|
|
call.caller_source,
|
|
call.caller_sim_id,
|
|
call.company_attempted_sims
|
|
)
|
|
if not next_target then
|
|
call.rerouting = nil
|
|
return false
|
|
end
|
|
if call.ended or calls[call.id] ~= call then
|
|
dialing_by_sim[next_target.sim_id] = nil
|
|
call.rerouting = nil
|
|
return true
|
|
end
|
|
|
|
local previous_source = call.callee_source
|
|
local previous_sim_id = call.callee_sim_id
|
|
local previous_device = call.callee_device
|
|
if not call.payphone then
|
|
local next_account_id, next_device_imei = scope_for_device(next_target.device)
|
|
local updated = Bridge.Database.Transaction({
|
|
{
|
|
query = "UPDATE `sky_phone_calls` SET `callee_sim_id` = ? WHERE `id` = ? AND `status` = 'ringing'",
|
|
params = { next_target.sim_id, call.id },
|
|
},
|
|
{
|
|
query = [[
|
|
UPDATE `sky_phone_call_entries` SET `status` = ?
|
|
WHERE `call_id` = ? AND `direction` = 'incoming' AND `status` = 'ringing'
|
|
]],
|
|
params = { previous_status, call.id },
|
|
},
|
|
{
|
|
query = [[
|
|
INSERT INTO `sky_phone_call_entries`
|
|
(`call_id`, `account_id`, `device_imei`, `direction`, `status`, `other_number`)
|
|
SELECT `id`, ?, ?, 'incoming', 'ringing', ?
|
|
FROM `sky_phone_calls`
|
|
WHERE `id` = ? AND `status` = 'ringing'
|
|
]],
|
|
params = { next_account_id, next_device_imei, call.caller_number, call.id },
|
|
},
|
|
})
|
|
if not updated then
|
|
dialing_by_sim[next_target.sim_id] = nil
|
|
call.rerouting = nil
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Could not reroute company call %s to the next target.",
|
|
tostring(call.id)
|
|
)
|
|
return false
|
|
end
|
|
if call.ended or calls[call.id] ~= call then
|
|
dialing_by_sim[next_target.sim_id] = nil
|
|
call.rerouting = nil
|
|
return true
|
|
end
|
|
end
|
|
|
|
send_state(call, previous_source, previous_status)
|
|
if active_by_source[previous_source] == call.id then
|
|
active_by_source[previous_source] = nil
|
|
end
|
|
if active_by_sim[previous_sim_id] == call.id then
|
|
active_by_sim[previous_sim_id] = nil
|
|
end
|
|
if not call.payphone then
|
|
notify_recents(previous_device, previous_source)
|
|
end
|
|
if call.ended or calls[call.id] ~= call then
|
|
dialing_by_sim[next_target.sim_id] = nil
|
|
call.rerouting = nil
|
|
return true
|
|
end
|
|
|
|
call.callee_source = next_target.source
|
|
call.callee_sim_id = next_target.sim_id
|
|
call.callee_device = next_target.device
|
|
call.company_attempted_sims[next_target.sim_id] = true
|
|
call.company_attempts_remaining = call.company_attempts_remaining - 1
|
|
active_by_source[call.callee_source] = call.id
|
|
active_by_sim[call.callee_sim_id] = call.id
|
|
dialing_by_sim[call.callee_sim_id] = nil
|
|
call.rerouting = nil
|
|
ring_callee(call)
|
|
schedule_no_answer(call)
|
|
return true
|
|
end
|
|
|
|
handle_no_answer = function(call)
|
|
if not reroute_company_call(call, "missed") then
|
|
finish_call(call, "no_answer")
|
|
end
|
|
end
|
|
|
|
local function lock_direct_target(caller_source, target, caller_sim_id)
|
|
if not target or not target.imei then
|
|
return nil, "unavailable"
|
|
end
|
|
if caller_sim_id then
|
|
local blocks = Bridge.Database.Query([[
|
|
SELECT 1 FROM `sky_phone_call_blocks`
|
|
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1
|
|
]], { target.id, caller_sim_id })
|
|
if blocks[1] then
|
|
return nil, "declined"
|
|
end
|
|
end
|
|
local callee_source = find_device_holder(target.imei)
|
|
if not callee_source or callee_source == caller_source then
|
|
return nil, "unavailable"
|
|
end
|
|
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
|
|
return nil, "busy"
|
|
end
|
|
dialing_by_sim[target.id] = true
|
|
if airplane_mode(target.imei) then
|
|
dialing_by_sim[target.id] = nil
|
|
return nil, "unavailable"
|
|
end
|
|
return callee_source
|
|
end
|
|
|
|
function SkyPhoneCalls.StartCompanyCall(source, company_id, customer_number)
|
|
source = tonumber(source)
|
|
if not source or type(company_id) ~= "string" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if not SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id) then
|
|
return { success = false, error = "not_authorized" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "company_call_customer", 15, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
if dial_locks[source] then
|
|
return { success = false, error = "busy" }
|
|
end
|
|
|
|
local service_line = SkyPhoneCompanies.GetServiceLineForCompany(company_id)
|
|
if not service_line or not service_line.canCall then
|
|
return { success = false, error = "company_unavailable" }
|
|
end
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
if not scope.device.sim_id then
|
|
return { success = false, error = "no_sim" }
|
|
end
|
|
if SkyPhoneCompanies.IsServiceNumber(scope.device.phone_number) then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Source %s attempted to call from a SIM using reserved service number %s.",
|
|
tostring(source),
|
|
tostring(scope.device.phone_number)
|
|
)
|
|
return { success = false, error = "invalid_sim" }
|
|
end
|
|
if airplane_mode(scope.device.imei) then
|
|
return { success = false, error = "airplane_mode" }
|
|
end
|
|
|
|
local number = SkyPhoneSimNumber.Normalize(customer_number, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
|
if not number or SkyPhoneCompanies.IsServiceNumber(number) then
|
|
return { success = false, error = "invalid_number" }
|
|
end
|
|
if number == scope.device.phone_number then
|
|
return { success = false, error = "self_call" }
|
|
end
|
|
if active_by_source[source] or active_by_sim[scope.device.sim_id] or dialing_by_sim[scope.device.sim_id] then
|
|
return { success = false, error = "busy" }
|
|
end
|
|
|
|
dial_locks[source] = true
|
|
dialing_by_sim[scope.device.sim_id] = 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]
|
|
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
|
|
if not callee_source then
|
|
local terminal = create_terminal_call(scope, number, target, target_status, service_line.number)
|
|
dialing_by_sim[scope.device.sim_id] = nil
|
|
dial_locks[source] = nil
|
|
return { success = true, data = terminal }
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
data = start_ringing_call({
|
|
id = uuid(),
|
|
caller_source = source,
|
|
caller_sim_id = scope.device.sim_id,
|
|
caller_number = service_line.number,
|
|
caller_device = scope.device,
|
|
callee_source = callee_source,
|
|
callee_sim_id = target.id,
|
|
callee_number = number,
|
|
callee_device = target,
|
|
company_id = company_id,
|
|
started_at = os.time(),
|
|
}, Config.Calls.RingSeconds),
|
|
}
|
|
end
|
|
|
|
Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "call_dial", 15, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
if type(data) ~= "table" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if dial_locks[source] then
|
|
return { success = false, error = "busy" }
|
|
end
|
|
dial_locks[source] = true
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
dial_locks[source] = nil
|
|
return error_response
|
|
end
|
|
if not scope.device.sim_id then
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "no_sim" }
|
|
end
|
|
if SkyPhoneCompanies.IsServiceNumber(scope.device.phone_number) then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Source %s attempted to call from a SIM using reserved service number %s.",
|
|
tostring(source),
|
|
tostring(scope.device.phone_number)
|
|
)
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "invalid_sim" }
|
|
end
|
|
if airplane_mode(scope.device.imei) then
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "airplane_mode" }
|
|
end
|
|
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
|
|
local number = service_line and service_line.number
|
|
or SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
|
|
if not number then
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "invalid_number" }
|
|
end
|
|
if number == scope.device.phone_number then
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "self_call" }
|
|
end
|
|
if active_by_source[source] or active_by_sim[scope.device.sim_id] or dialing_by_sim[scope.device.sim_id] then
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "busy" }
|
|
end
|
|
dialing_by_sim[scope.device.sim_id] = true
|
|
if service_line then
|
|
if not service_line.canCall then
|
|
local terminal = create_terminal_call(scope, number, nil, "unavailable")
|
|
dialing_by_sim[scope.device.sim_id] = nil
|
|
dial_locks[source] = nil
|
|
return { success = true, data = terminal }
|
|
end
|
|
local company_target, target_status = company_call_target(
|
|
service_line.companyId,
|
|
source,
|
|
scope.device.sim_id
|
|
)
|
|
if not company_target then
|
|
local terminal = create_terminal_call(scope, number, nil, target_status)
|
|
dialing_by_sim[scope.device.sim_id] = nil
|
|
dial_locks[source] = nil
|
|
return { success = true, data = terminal }
|
|
end
|
|
return {
|
|
success = true,
|
|
data = start_ringing_call({
|
|
id = uuid(),
|
|
caller_source = source,
|
|
caller_sim_id = scope.device.sim_id,
|
|
caller_number = scope.device.phone_number,
|
|
caller_device = scope.device,
|
|
callee_source = company_target.source,
|
|
callee_sim_id = company_target.sim_id,
|
|
callee_number = service_line.number,
|
|
callee_device = company_target.device,
|
|
company_id = service_line.companyId,
|
|
company_service_call = service_line.routing == "round_robin",
|
|
company_attempted_sims = { [company_target.sim_id] = true },
|
|
company_attempts_remaining = math.max(
|
|
0,
|
|
math.floor(tonumber(Config.Companies.CallRouting.MaxAttempts) or 1) - 1
|
|
),
|
|
started_at = os.time(),
|
|
}, Config.Companies.CallRouting.RingSeconds),
|
|
}
|
|
end
|
|
|
|
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 then
|
|
dialing_by_sim[scope.device.sim_id] = nil
|
|
dial_locks[source] = nil
|
|
return { success = false, error = "recipient_not_found" }
|
|
end
|
|
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
|
|
if not callee_source then
|
|
local terminal = create_terminal_call(scope, number, target, target_status)
|
|
dialing_by_sim[scope.device.sim_id] = nil
|
|
dial_locks[source] = nil
|
|
return { success = true, data = terminal }
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
data = start_ringing_call({
|
|
id = uuid(),
|
|
caller_source = source,
|
|
caller_sim_id = scope.device.sim_id,
|
|
caller_number = scope.device.phone_number,
|
|
caller_device = scope.device,
|
|
callee_source = callee_source,
|
|
callee_sim_id = target.id,
|
|
callee_number = number,
|
|
callee_device = target,
|
|
started_at = os.time(),
|
|
}, Config.Calls.RingSeconds),
|
|
}
|
|
end)
|
|
|
|
local payphone_models = {}
|
|
for _, model_name in ipairs(Config.Payphones.Props or {}) do
|
|
if type(model_name) == "string" then
|
|
payphone_models[model_name] = true
|
|
end
|
|
end
|
|
|
|
local payphone_locations, rejected_payphone_locations = SkyPhonePayphones.ValidateLocations(
|
|
Config.Payphones.Locations,
|
|
payphone_models
|
|
)
|
|
if Config.Payphones.Enabled and #payphone_locations == 0 then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Payphones are enabled, but no valid server-owned locations are configured; payphone calls will be rejected.",
|
|
{ always = true }
|
|
)
|
|
elseif Config.Payphones.Enabled and rejected_payphone_locations > 0 then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Ignored %s invalid server-owned payphone location(s).",
|
|
rejected_payphone_locations,
|
|
{ always = true }
|
|
)
|
|
end
|
|
|
|
local function valid_payphone_position(source)
|
|
local ped = GetPlayerPed(source)
|
|
if not ped or ped == 0 then
|
|
return nil
|
|
end
|
|
local player_coords = GetEntityCoords(ped)
|
|
local location = SkyPhonePayphones.FindNearest(
|
|
payphone_locations,
|
|
player_coords,
|
|
Config.Payphones.ServerValidationDistance
|
|
)
|
|
if not location then
|
|
return nil
|
|
end
|
|
return vector3(location.coords.x, location.coords.y, location.coords.z), location.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 type(data) ~= "table" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
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)
|
|
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 service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
|
|
local number = service_line and service_line.number
|
|
or 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
|
|
if service_line then
|
|
if not service_line.canCall then
|
|
dial_locks[source] = nil
|
|
return { success = true, data = payphone_terminal(number, "unavailable") }
|
|
end
|
|
local company_target, target_status = company_call_target(service_line.companyId, source, nil)
|
|
if not company_target then
|
|
dial_locks[source] = nil
|
|
return { success = true, data = payphone_terminal(number, target_status) }
|
|
end
|
|
return {
|
|
success = true,
|
|
data = start_ringing_call({
|
|
id = uuid(),
|
|
caller_source = source,
|
|
caller_number = Config.Payphones.CallerNumber,
|
|
callee_source = company_target.source,
|
|
callee_sim_id = company_target.sim_id,
|
|
callee_number = service_line.number,
|
|
callee_device = company_target.device,
|
|
company_id = service_line.companyId,
|
|
company_service_call = service_line.routing == "round_robin",
|
|
company_attempted_sims = { [company_target.sim_id] = true },
|
|
company_attempts_remaining = math.max(
|
|
0,
|
|
math.floor(tonumber(Config.Companies.CallRouting.MaxAttempts) or 1) - 1
|
|
),
|
|
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),
|
|
},
|
|
}, Config.Companies.CallRouting.RingSeconds),
|
|
}
|
|
end
|
|
|
|
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]
|
|
local callee_source, target_status = lock_direct_target(source, target)
|
|
if not callee_source then
|
|
dial_locks[source] = nil
|
|
return { success = true, data = payphone_terminal(number, target_status) }
|
|
end
|
|
|
|
return {
|
|
success = true,
|
|
data = start_ringing_call({
|
|
id = uuid(),
|
|
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),
|
|
},
|
|
}, Config.Payphones.NoAnswerTimeoutSeconds),
|
|
}
|
|
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 or call.rerouting then
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
if call.company_service_call and not SkyPhoneCompanies.CanAnswerCompanyCall(
|
|
source,
|
|
call.company_id,
|
|
call.callee_device.imei,
|
|
call.callee_sim_id
|
|
) then
|
|
if not reroute_company_call(call, "missed") then
|
|
finish_call(call, "unavailable")
|
|
end
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
if not SkyPhone.FindDeviceSlots(source, call.callee_device.imei)[1] then
|
|
finish_call(call, "unavailable")
|
|
return { success = false, error = "phone_not_owned" }
|
|
end
|
|
if not Bridge.Calls.IsAvailable() then
|
|
return { success = false, error = "voice_unavailable" }
|
|
end
|
|
local voice_started, voice_provider = Bridge.Calls.Start(call.id, {
|
|
call.caller_source,
|
|
call.callee_source,
|
|
})
|
|
if not voice_started then
|
|
return { success = false, error = "voice_unavailable" }
|
|
end
|
|
call.voice_provider = voice_provider
|
|
call.voice_started = true
|
|
call.speakers = {}
|
|
call.answered_at = os.time()
|
|
call.channel = next_voice_channel
|
|
next_voice_channel = next_voice_channel + 1
|
|
if not call.payphone then
|
|
Bridge.Database.Query([[
|
|
UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP
|
|
WHERE `id` = ? AND `status` = 'ringing'
|
|
]], { call.id })
|
|
Bridge.Database.Query([[
|
|
UPDATE `sky_phone_call_entries` SET `status` = 'connected'
|
|
WHERE `call_id` = ? AND `status` = 'ringing'
|
|
]], { call.id })
|
|
end
|
|
if call.ended or calls[call.id] ~= call then
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
send_state(call, call.caller_source, "connected", call.channel)
|
|
send_state(call, call.callee_source, "connected", call.channel)
|
|
return { success = true }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data)
|
|
if type(data) ~= "table" or type(data.id) ~= "string" or type(data.enabled) ~= "boolean" then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if not SkyPhone.AllowOperation(source, "call_speaker", 30, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
|
|
local call_id = active_by_source[source]
|
|
local call = call_id and calls[call_id] or nil
|
|
if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
if call.voice_provider ~= "saltychat" then
|
|
return { success = false, error = "speaker_unsupported" }
|
|
end
|
|
if not Bridge.Speaker.IsEnabled() then
|
|
return { success = false, error = "speaker_unsupported" }
|
|
end
|
|
if not Bridge.Calls.SetSpeaker(source, data.enabled, call.voice_provider) then
|
|
return { success = false, error = "voice_unavailable" }
|
|
end
|
|
|
|
call.speakers[source] = data.enabled
|
|
send_state(call, source, "connected", call.channel)
|
|
return {
|
|
success = true,
|
|
data = {
|
|
speakerEnabled = data.enabled,
|
|
speakerSupported = true,
|
|
},
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:calls:decline", 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 or call.rerouting then
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
if not reroute_company_call(call, "declined") then
|
|
finish_call(call, "declined")
|
|
end
|
|
return { success = true }
|
|
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 (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
|
|
if call.company_service_call and not call.answered_at and call.callee_source == source then
|
|
if call.rerouting then
|
|
return { success = false, error = "call_not_found" }
|
|
end
|
|
if not reroute_company_call(call, "declined") then
|
|
finish_call(call, "declined")
|
|
end
|
|
return { success = true }
|
|
end
|
|
finish_call(call, call.answered_at and "completed" or "cancelled")
|
|
return { success = true }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:calls:block", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "call_block", 10, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local scope, error_response = current_scope(source)
|
|
if not scope then
|
|
return error_response
|
|
end
|
|
if not scope.device.sim_id then
|
|
return { success = false, error = "no_sim" }
|
|
end
|
|
local number = type(data) == "table" and SkyPhoneSimNumber.Normalize(
|
|
data.phoneNumber,
|
|
Config.Sim.NumberLength,
|
|
Config.Sim.NumberPrefix
|
|
) or nil
|
|
if not number then
|
|
return { success = false, error = "invalid_number" }
|
|
end
|
|
if number == scope.device.phone_number then
|
|
return { success = false, error = "self_call" }
|
|
end
|
|
local rows = Bridge.Database.Query(
|
|
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
|
|
{ number }
|
|
)
|
|
if not rows[1] then
|
|
return { success = false, error = "recipient_not_found" }
|
|
end
|
|
Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_call_blocks` (`blocker_sim_id`, `blocked_sim_id`)
|
|
VALUES (?, ?)
|
|
]], { scope.device.sim_id, rows[1].id })
|
|
|
|
local call_id = active_by_source[source]
|
|
local call = call_id and calls[call_id] or nil
|
|
if call then
|
|
local other_number = source == call.caller_source and call.callee_number or call.caller_number
|
|
if other_number == number then
|
|
finish_call(call, call.answered_at and "completed" or "declined")
|
|
end
|
|
end
|
|
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")
|
|
return { success = true }
|
|
end)
|
|
|
|
CreateThread(function()
|
|
while true do
|
|
Wait(1000)
|
|
local calls_to_finish = {}
|
|
for call_id, call in pairs(calls) do
|
|
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 then
|
|
calls_to_finish[call_id] = "disconnected"
|
|
elseif not callee_valid then
|
|
if call.company_service_call and not call.answered_at then
|
|
if not reroute_company_call(call, "missed") then
|
|
calls_to_finish[call_id] = "unavailable"
|
|
end
|
|
else
|
|
calls_to_finish[call_id] = "disconnected"
|
|
end
|
|
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, status in pairs(calls_to_finish) do
|
|
finish_call(calls[call_id], status)
|
|
end
|
|
end
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
local call_id = active_by_source[source]
|
|
local call = call_id and calls[call_id] or nil
|
|
if not call then
|
|
return
|
|
end
|
|
if call.callee_source == source and call.company_service_call and not call.answered_at then
|
|
if not reroute_company_call(call, "missed") then
|
|
finish_call(call, "unavailable")
|
|
end
|
|
return
|
|
end
|
|
finish_call(call, "disconnected")
|
|
end)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name ~= GetCurrentResourceName() then
|
|
return
|
|
end
|
|
local call_ids = {}
|
|
for call_id in pairs(calls) do
|
|
call_ids[#call_ids + 1] = call_id
|
|
end
|
|
for _, call_id in ipairs(call_ids) do
|
|
finish_call(calls[call_id], "disconnected")
|
|
end
|
|
end)
|
|
end)
|