ENH - merge dev into Companies feature

This commit is contained in:
Leon.Schmidt
2026-08-11 16:31:27 +02:00
24 changed files with 5113 additions and 438 deletions
+3
View File
@@ -15,6 +15,7 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -44,6 +45,7 @@ Config.Security = {
}
Config.Sim = {
Enabled = true, -- false: devices without a SIM receive a persistent random number automatically
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10,
@@ -55,6 +57,7 @@ Config.Calls = {
VoiceProvider = "pma",
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
RecentPageSize = 100,
}
+13 -2
View File
@@ -21,6 +21,7 @@ Locales["en"] = {
phone_stacked = "Phones cannot be stacked.",
invalid_imei = "This phone has invalid IMEI metadata.",
metadata_unsupported = "The configured inventory cannot store unique phone metadata.",
player_unavailable = "Your character identity is not available.",
sim_slot_missing = "The used SIM card could not be identified.",
sim_stacked = "SIM cards cannot be stacked.",
invalid_sim = "This SIM card has invalid metadata.",
@@ -685,9 +686,17 @@ Locales["en"] = {
phone = {
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts",
addContact = "New Contact", editContact = "Edit Contact", contactName = "Name", phoneNumber = "Phone Number",
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number",
officialContact = "Official company contact",
choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo",
message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private",
contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", mobile = "Mobile", messagesProfile = "Messages", notes = "Notes",
sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites",
addEmergency = "Add to Emergency Contacts", blockContact = "Block Contact",
block = "Block", blockCaller = "Block Caller", blockCallerTitle = "Block this caller?",
blockCallerBody = "{number} will no longer be able to call this SIM.",
faceTime = "FaceTime", hideKeypad = "Hide Keypad", more = "More", mute = "Mute", speaker = "Speaker", viewContact = "View Contact",
call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
@@ -702,6 +711,8 @@ Locales["en"] = {
self_call = "You cannot call your own number.", busy = "The line is busy.",
company_unavailable = "This company service line is unavailable.",
readonly_contact = "Official company contacts cannot be changed.",
message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.",
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
@@ -9,11 +9,26 @@ end
local ESX = exports["es_extended"]:getSharedObject()
local metadata_key = "sky_phone_inventory"
ESX.AddItems({
local inventory_items = {
{ name = Config.Phone.Item, label = Config.Phone.DeviceName, weight = 100, rare = false, canRemove = true },
{ name = Config.Sim.RegisteredItem, label = "Registered SIM", weight = 5, rare = false, canRemove = true },
{ name = Config.Sim.AnonymousItem, label = "Anonymous SIM", weight = 5, rare = false, canRemove = true },
})
}
if Config.Sim.Enabled ~= false then
inventory_items[#inventory_items + 1] = {
name = Config.Sim.RegisteredItem,
label = "Registered SIM",
weight = 5,
rare = false,
canRemove = true,
}
inventory_items[#inventory_items + 1] = {
name = Config.Sim.AnonymousItem,
label = "Anonymous SIM",
weight = 5,
rare = false,
canRemove = true,
}
end
ESX.AddItems(inventory_items)
local function get_player(source)
return ESX.GetPlayerFromId(source)
+2
View File
@@ -194,11 +194,13 @@ local server_callbacks = {
"contacts:list",
"contacts:save",
"contacts:delete",
"contacts:favorite",
"calls:recents",
"calls:dial",
"calls:answer",
"calls:decline",
"calls:hangup",
"calls:block",
"banking:overview",
"banking:transfer",
"billing:overview",
+127 -13
View File
@@ -333,8 +333,8 @@ function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
{
query = [[
INSERT INTO `sky_phone_contacts`
(`id`, `contact_id`, `device_imei`, `name`, `phone_number`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `phone_number`, `created_at`, `updated_at`
(`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 },
@@ -358,7 +358,9 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
end
local condition, params = scope_condition(scope)
local rows = Bridge.Database.Query(([[
SELECT `contact_id` AS `id`, `name`, `phone_number`, `created_at`, `updated_at`
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()
@@ -393,8 +395,11 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
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)
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or not number then
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:")
@@ -407,6 +412,17 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
)
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
@@ -418,24 +434,63 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
if not owned[1] then
return { success = false, error = "contact_not_found" }
end
local params = { name, number, id }
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` = ?, `phone_number` = ?
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`, `phone_number`)
VALUES (?, ?, ?, ?, ?, ?)
]], { uuid(), id, scope.account_id, scope.device_imei, name, number })
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, phone_number = number } }
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)
@@ -728,10 +783,19 @@ handle_no_answer = function(call)
end
end
local function lock_direct_target(caller_source, target)
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"
@@ -805,7 +869,7 @@ function SkyPhoneCalls.StartCompanyCall(source, company_id, customer_number)
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
local callee_source, target_status = lock_direct_target(source, target)
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
@@ -928,7 +992,12 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
local callee_source, target_status = lock_direct_target(source, target)
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
@@ -1169,6 +1238,51 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
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
+40
View File
@@ -122,6 +122,7 @@ local schema = {
{ name = "contact_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sim_type", type = "ENUM('registered', 'anonymous') NOT NULL" },
{ name = "is_virtual", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "owner_identifier", type = "VARCHAR(80) NULL" },
{ name = "owner_firstname", type = "VARCHAR(80) NULL" },
{ name = "owner_lastname", type = "VARCHAR(80) NULL" },
@@ -173,6 +174,39 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_character_devices",
columns = {
{
name = "owner_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "owner_identifier",
uniqueKeys = {
{ name = "uniq_sky_phone_character_devices_device", columns = "(`device_imei`)" },
},
foreignKeys = {
{
column = "device_imei",
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_device_data",
columns = {
@@ -370,7 +404,11 @@ local schema = {
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "name", type = "VARCHAR(80) NOT NULL" },
{ name = "notes", type = "VARCHAR(500) NULL" },
{ name = "organization", type = "VARCHAR(80) NULL" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
@@ -378,10 +416,12 @@ local schema = {
indexes = {
{ name = "idx_sky_phone_contacts_account", columns = "(`account_id`, `name`)" },
{ name = "idx_sky_phone_contacts_device", columns = "(`device_imei`, `name`)" },
{ name = "idx_sky_phone_contacts_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+8 -4
View File
@@ -433,10 +433,14 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
local recipients = Bridge.Database.Query(
"SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
{ number }
)
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ?
AND (s.`is_virtual` = 0 OR d.`imei` IS NOT NULL)
LIMIT 1
]], { number })
local recipient = recipients[1]
if not recipient then
return { success = false, error = "recipient_not_found" }
+178 -18
View File
@@ -3,9 +3,12 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
SkyPhone = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local sessions = {}
local auth_attempts = {}
local operation_attempts = {}
local character_device_cache = {}
local max_device_data_bytes = 100000
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
if passcode_pepper == "" then
@@ -64,9 +67,130 @@ local function reserve_imei()
return imei
end
local function character_identifier(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if identifier == nil then
return nil
end
identifier = tostring(identifier)
if identifier == "" or #identifier > 80 then
Bridge.Debug(
"warn",
"[sky_phone] Could not resolve a valid character identifier for source %s.",
tostring(source)
)
return nil
end
return identifier
end
local function load_character_device(source, identifier)
identifier = identifier or character_identifier(source)
if not identifier then
return nil, "player_unavailable"
end
local cached = character_device_cache[source]
if cached and cached.identifier == identifier then
return cached.imei, nil, identifier
end
local rows = Bridge.Database.Query([[
SELECT `device_imei`
FROM `sky_phone_character_devices`
WHERE `owner_identifier` = ?
LIMIT 1
]], { identifier })
local imei = rows[1] and rows[1].device_imei or nil
if imei then
character_device_cache[source] = { identifier = identifier, imei = imei }
else
character_device_cache[source] = nil
end
return imei, nil, identifier
end
local function cache_character_device(source, identifier, imei)
character_device_cache[source] = { identifier = identifier, imei = imei }
return imei
end
local function map_character_device(source, slot)
local mapped_imei, error_code, identifier = load_character_device(source)
if error_code or mapped_imei then
return mapped_imei, error_code
end
-- On the first switch from unique to shared phones, adopt the currently used
-- legacy device when it has not already been claimed by another character.
local legacy_imei = slot.metadata and slot.metadata.imei or nil
if SkyPhoneImei.IsValid(legacy_imei) then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
VALUES (?, ?)
]], { legacy_imei, Config.Phone.DeviceName })
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, legacy_imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, legacy_imei)
end
mapped_imei = load_character_device(source, identifier)
if mapped_imei then
return mapped_imei
end
end
local imei = reserve_imei()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, imei)
end
-- Another simultaneous open may have created the mapping first. Keep its
-- winner and remove only the fresh, unmapped device reserved by this call.
mapped_imei = load_character_device(source, identifier)
Bridge.Database.Query([[
DELETE d FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_character_devices` c ON c.`device_imei` = d.`imei`
WHERE d.`imei` = ? AND c.`device_imei` IS NULL
]], { imei })
if mapped_imei then
return mapped_imei
end
Bridge.Debug(
"error",
"[sky_phone] Could not create a shared device mapping for source %s.",
tostring(source)
)
return nil, "request_failed"
end
local function find_device_slots(source, imei)
local matches = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
if not unique_phones then
if not slots[1] then
return matches
end
local mapped_imei = load_character_device(source)
if mapped_imei ~= imei then
return matches
end
for _, item in ipairs(slots) do
matches[#matches + 1] = item
end
return matches
end
for _, item in ipairs(slots) do
if item.metadata and item.metadata.imei == imei then
matches[#matches + 1] = item
end
@@ -134,7 +258,7 @@ local function resolve_used_slot(source, used_item)
{ always = true }
)
end
if #slots == 1 then
if #slots == 1 or (not unique_phones and #slots > 0) then
return slots[1]
end
@@ -159,6 +283,13 @@ local function ensure_device(source, slot)
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
if not unique_phones then
if amount < 1 then
return nil, "phone_slot_missing"
end
return map_character_device(source, slot)
end
if amount ~= 1 then
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s is stacked for source %s.", tostring(slot.slot), tostring(source))
return nil, "phone_stacked"
@@ -217,7 +348,7 @@ end
local function load_device(imei)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`, s.`is_virtual` AS `sim_is_virtual`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
@@ -370,6 +501,7 @@ local function bootstrap(source, security, security_loaded)
sim = device.sim_id and {
id = device.sim_id,
number = device.phone_number,
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
type = device.sim_type,
registered = device.registered_at ~= nil,
} or nil,
@@ -648,24 +780,38 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
devices[row.imei] = row
end
local function notify_device(source, device)
local payload = {}
for key, value in pairs(data) do
payload[key] = value
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, source, payload)
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
local notified_devices = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = item.metadata and item.metadata.imei
local device = imei and devices[imei]
if device and not notified_devices[imei] then
local payload = {}
for key, value in pairs(data) do
payload[key] = value
if not unique_phones then
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
local imei = load_character_device(source)
local device = imei and devices[imei] or nil
if device then
notify_device(source, device)
end
end
else
local notified_devices = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = item.metadata and item.metadata.imei
local device = imei and devices[imei]
if device and not notified_devices[imei] then
notified_devices[imei] = true
notify_device(source, device)
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
notified_devices[imei] = true
TriggerClientEvent(event_name, source, payload)
end
end
end
@@ -721,6 +867,18 @@ local function open_phone(source, used_item)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
if not prepared then
Bridge.Debug(
"error",
"[sky_phone] Phone preparation failed for source %s IMEI %s: %s.",
tostring(source),
imei,
tostring(prepare_error)
)
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
return false
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
@@ -1157,6 +1315,7 @@ AddEventHandler("playerDropped", function()
sessions[source] = nil
auth_attempts[source] = nil
operation_attempts[source] = nil
character_device_cache[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
@@ -1164,6 +1323,7 @@ AddEventHandler("onResourceStop", function(resource_name)
sessions = {}
auth_attempts = {}
operation_attempts = {}
character_device_cache = {}
end
end)
end)
+160 -29
View File
@@ -2,6 +2,8 @@ Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneSim = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local pending_insertions = {}
local operation_locks = {}
local sim_types = {
@@ -24,7 +26,7 @@ local function uuid()
return rows[1].id
end
local function reserve_sim(sim_type)
local function reserve_sim(sim_type, is_virtual)
local sim_id
local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate)
if SkyPhoneCompanies.IsServiceNumber(candidate) then
@@ -32,9 +34,9 @@ local function reserve_sim(sim_type)
end
sim_id = uuid()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`)
VALUES (?, ?, ?)
]], { sim_id, candidate, sim_type })
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`)
VALUES (?, ?, ?, ?)
]], { sim_id, candidate, sim_type, is_virtual and 1 or 0 })
return affected_rows(result) == 1
end, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
@@ -65,6 +67,91 @@ local function sim_metadata(sim)
return metadata
end
local function set_phone_sim_metadata(source, phone_slot, sim)
if not unique_phones then
return true
end
if not phone_slot then
return false
end
local metadata = phone_slot.metadata or {}
metadata.sim_id = sim and sim.id or nil
metadata.phone_number = sim and sim.phone_number or nil
metadata.formatted_number = sim and SkyPhoneSimNumber.Format(
sim.phone_number,
Config.Sim.NumberGroups,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
) or nil
return Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
end
local function prepare_device(source, phone_slot, imei)
local device = SkyPhone.LoadDevice(imei)
if not device then
return false, "request_failed"
end
local sim = device.sim_id and load_sim(device.sim_id) or nil
if sim_cards_enabled then
if sim and tonumber(sim.is_virtual) == 1 then
local result = Bridge.Database.Query([[
UPDATE `sky_phone_devices`
SET `sim_id` = NULL
WHERE `imei` = ? AND `sim_id` = ?
]], { imei, sim.id })
if affected_rows(result) ~= 1 then
return false, "request_failed"
end
sim = nil
end
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
if sim then
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
local automatic_sim = reserve_sim("anonymous", true)
local result = Bridge.Database.Query([[
UPDATE `sky_phone_devices`
SET `sim_id` = ?
WHERE `imei` = ? AND `sim_id` IS NULL
]], { automatic_sim.id, imei })
if affected_rows(result) ~= 1 then
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
device = SkyPhone.LoadDevice(imei)
sim = device and device.sim_id and load_sim(device.sim_id) or nil
if not sim then
return false, "request_failed"
end
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
if not set_phone_sim_metadata(source, phone_slot, automatic_sim) then
Bridge.Database.Query(
"UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?",
{ imei, automatic_sim.id }
)
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
return false, "metadata_unsupported"
end
return true
end
SkyPhoneSim.PrepareDevice = prepare_device
local function resolve_used_sim(source, used_item, item_name)
local slot_id = used_item and (used_item.slot or used_item.id)
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
@@ -85,7 +172,12 @@ local function ensure_sim(source, slot, sim_type)
end
local metadata = slot.metadata or {}
local sim = metadata.sim_id and load_sim(metadata.sim_id) or nil
if metadata.sim_id and (not sim or sim.sim_type ~= sim_type or sim.phone_number ~= metadata.phone_number) then
if metadata.sim_id and (
not sim
or tonumber(sim.is_virtual) == 1
or sim.sim_type ~= sim_type
or sim.phone_number ~= metadata.phone_number
) then
return nil, "invalid_sim"
end
if sim and SkyPhoneCompanies.IsServiceNumber(sim.phone_number) then
@@ -109,30 +201,46 @@ end
local function list_phone_choices(source)
local choices = {}
local seen = {}
local choice_error
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = SkyPhone.EnsureDevice(source, slot)
if imei then
local device = SkyPhone.LoadDevice(imei)
choices[#choices + 1] = {
imei = imei,
name = device.device_name,
occupied = device.sim_id ~= nil,
number = device.phone_number,
}
local imei, error_code = SkyPhone.EnsureDevice(source, slot)
if imei and not seen[imei] then
local prepared, prepare_error = prepare_device(source, slot, imei)
if prepared then
local device = SkyPhone.LoadDevice(imei)
choices[#choices + 1] = {
imei = imei,
name = device.device_name,
occupied = device.sim_id ~= nil,
number = device.phone_number,
}
seen[imei] = true
else
Bridge.Debug(
"error",
"[sky_phone] Could not prepare SIM target %s for source %s: %s.",
imei,
tostring(source),
tostring(prepare_error)
)
choice_error = choice_error or prepare_error
end
elseif not imei then
choice_error = choice_error or error_code
end
end
return choices
return choices, choice_error
end
local function rollback_phone_metadata(source, phone_slot, old_sim)
local metadata = phone_slot.metadata or {}
metadata.sim_id = old_sim and old_sim.id or nil
metadata.phone_number = old_sim and old_sim.phone_number or nil
metadata.formatted_number = old_sim and SkyPhoneSimNumber.Format(old_sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix) or nil
Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
set_phone_sim_metadata(source, phone_slot, old_sim)
end
local function insert_sim(source, phone_imei, confirmed)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
@@ -161,6 +269,10 @@ local function insert_sim(source, phone_imei, confirmed)
return { success = false, error = "phone_not_owned" }
end
local phone_slot = phone_matches[1]
local prepared, prepare_error = prepare_device(source, phone_slot, phone_imei)
if not prepared then
return { success = false, error = prepare_error }
end
local device = SkyPhone.LoadDevice(phone_imei)
local old_sim = device.sim_id and load_sim(device.sim_id) or nil
if old_sim and not confirmed then
@@ -168,11 +280,7 @@ local function insert_sim(source, phone_imei, confirmed)
end
operation_locks[source] = true
local phone_metadata = phone_slot.metadata or {}
phone_metadata.sim_id = pending.sim.id
phone_metadata.phone_number = pending.sim.phone_number
phone_metadata.formatted_number = SkyPhoneSimNumber.Format(pending.sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, phone_metadata) then
if not set_phone_sim_metadata(source, phone_slot, pending.sim) then
operation_locks[source] = nil
return { success = false, error = "metadata_unsupported" }
end
@@ -234,6 +342,9 @@ local function insert_sim(source, phone_imei, confirmed)
end
local function use_sim(source, used_item)
if not sim_cards_enabled then
return false
end
local item_name = used_item and used_item.name
local sim_type = item_name and sim_types[item_name]
if not sim_type then
@@ -257,10 +368,10 @@ local function use_sim(source, used_item)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local choices = list_phone_choices(source)
local choices, choice_error = list_phone_choices(source)
if #choices == 0 then
operation_locks[source] = nil
TriggerClientEvent("sky_phone:device:error", source, "phone_required")
TriggerClientEvent("sky_phone:device:error", source, choice_error or "phone_required")
return false
end
pending_insertions[source] = {
@@ -280,10 +391,15 @@ local function use_sim(source, used_item)
return true
end
-- Register the guarded callbacks in both modes so a resource-only restart can
-- replace registrations left behind in framework-owned usable-item tables.
Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim)
Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim)
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
@@ -291,6 +407,9 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
end)
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
@@ -300,7 +419,7 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
end
local device = SkyPhone.LoadDevice(session.imei)
local sim = device and device.sim_id and load_sim(device.sim_id) or nil
if not sim then
if not sim or tonumber(sim.is_virtual) == 1 then
return { success = false, error = "no_sim" }
end
local phone_slot = Bridge.Inventory.GetSlot(source, session.slot)
@@ -311,7 +430,10 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
end
operation_locks[source] = true
rollback_phone_metadata(source, phone_slot, nil)
if not set_phone_sim_metadata(source, phone_slot, nil) then
operation_locks[source] = nil
return { success = false, error = "metadata_unsupported" }
end
if not Bridge.Inventory.AddItem(source, item_name, 1, nil, metadata) then
rollback_phone_metadata(source, phone_slot, sim)
operation_locks[source] = nil
@@ -337,4 +459,13 @@ AddEventHandler("playerDropped", function()
pending_insertions[source] = nil
operation_locks[source] = nil
end)
if sim_cards_enabled then
Bridge.Database.Query([[
UPDATE `sky_phone_devices` d
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
SET d.`sim_id` = NULL
WHERE s.`is_virtual` = 1
]], {})
end
end)
+28 -1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sims` (
`contact_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sim_type` ENUM('registered', 'anonymous') NOT NULL,
`is_virtual` TINYINT(1) NOT NULL DEFAULT 0,
`owner_identifier` VARCHAR(80) NULL,
`owner_firstname` VARCHAR(80) NULL,
`owner_lastname` VARCHAR(80) NULL,
@@ -80,6 +81,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_devices` (
FOREIGN KEY (`sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_character_devices` (
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`owner_identifier`),
UNIQUE KEY `uniq_sky_phone_character_devices_device` (`device_imei`),
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
@@ -156,7 +167,11 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
`account_id` BIGINT UNSIGNED NULL,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
`name` VARCHAR(80) NOT NULL,
`notes` VARCHAR(500) NULL,
`organization` VARCHAR(80) NULL,
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`avatar_media_id` BIGINT UNSIGNED NULL,
`favorite` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -164,8 +179,10 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
UNIQUE KEY `uniq_sky_phone_contacts_device_contact` (`device_imei`, `contact_id`),
KEY `idx_sky_phone_contacts_account` (`account_id`, `name`),
KEY `idx_sky_phone_contacts_device` (`device_imei`, `name`),
KEY `idx_sky_phone_contacts_avatar` (`avatar_media_id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE,
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
@@ -186,6 +203,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
FOREIGN KEY (`callee_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_call_blocks` (
`blocker_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`blocked_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`blocker_sim_id`, `blocked_sim_id`),
KEY `idx_sky_phone_call_blocks_blocked` (`blocked_sim_id`),
FOREIGN KEY (`blocker_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`blocked_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`call_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,