ENH - integrate latest dev into EasyShare

This commit is contained in:
smx.pusha
2026-08-12 06:43:14 +02:00
84 changed files with 18328 additions and 454 deletions
+248
View File
@@ -0,0 +1,248 @@
Config.Companies = {
Enabled = true,
PageSize = 20,
MaximumPageSize = 50,
MaximumOpenRequestsPerSim = 5,
MaximumServices = 25,
MaximumRequestMedia = 3,
SubjectMaxLength = 120,
RequestBodyMaxLength = 2000,
MessageMaxLength = 2000,
ProfileDescriptionMaxLength = 1000,
DistrictMaxLength = 80,
AddressMaxLength = 160,
ServiceTitleMaxLength = 80,
ServiceDescriptionMaxLength = 500,
ServicePriceMaxLength = 80,
AnnouncementTitleMaxLength = 120,
AnnouncementBodyMaxLength = 1000,
AvailabilityMaximumSeconds = 24 * 60 * 60,
AnnouncementMaximumSeconds = 30 * 24 * 60 * 60,
RetentionDays = 180,
RateLimits = {
Read = 120,
Search = 60,
CreateRequest = 5,
Message = 30,
RequestAction = 30,
Profile = 12,
CallAvailability = 30,
},
CallRouting = {
MaxAttempts = 3,
RingSeconds = 10,
},
Categories = {
"public_services",
"vehicles",
"transport",
},
Statuses = {
new = true,
assigned = true,
in_progress = true,
waiting_customer = true,
completed = true,
cancelled = true,
},
AvailabilityStatuses = {
available = true,
busy = true,
closed = true,
},
Definitions = {
police = {
Job = "police",
Name = "Los Santos Police Department",
Category = "public_services",
Public = true,
Emergency = true,
Verified = true,
Icon = "shield",
Description = "Public safety, emergency response, and police services.",
DefaultAvailability = "closed",
AcceptsRequests = false,
District = "Mission Row",
LocationLabel = "Mission Row Police Station",
Address = "Mission Row Police Station",
Location = vector3(425.1, -979.5, 30.7),
ServiceLine = {
Number = "9110000000",
AutoContact = true,
CanCall = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
Permissions = {
WorkQueue = 0,
Availability = 1,
Assign = 2,
Profile = 3,
Hours = 3,
Services = 3,
Announcement = 3,
},
Services = {},
},
ambulance = {
Job = "ambulance",
Name = "Emergency Medical Services",
Category = "public_services",
Public = true,
Emergency = true,
Verified = true,
Icon = "medical",
Description = "Emergency medical response and patient care.",
DefaultAvailability = "closed",
AcceptsRequests = false,
District = "Pillbox Hill",
LocationLabel = "Pillbox Hill Medical Center",
Address = "Pillbox Hill Medical Center",
Location = vector3(307.2, -595.3, 43.3),
ServiceLine = {
Number = "9120000000",
AutoContact = true,
CanCall = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
Permissions = {
WorkQueue = 0,
Availability = 1,
Assign = 2,
Profile = 3,
Hours = 3,
Services = 3,
Announcement = 3,
},
Services = {},
},
fire = {
Job = "fire",
Name = "Los Santos Fire Department",
Category = "public_services",
Public = true,
Emergency = true,
Verified = true,
Icon = "flame",
Description = "Fire response, rescue, and public safety services.",
DefaultAvailability = "closed",
AcceptsRequests = false,
District = "El Burro Heights",
LocationLabel = "Capital Boulevard Fire Station",
Address = "Capital Boulevard Fire Station",
Location = vector3(1200.6, -1473.6, 34.9),
ServiceLine = {
Number = "9130000000",
AutoContact = true,
CanCall = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
Permissions = {
WorkQueue = 0,
Availability = 1,
Assign = 2,
Profile = 3,
Hours = 3,
Services = 3,
Announcement = 3,
},
Services = {},
},
mechanic = {
Job = "mechanic",
Name = "Los Santos Customs",
Category = "vehicles",
Public = true,
Emergency = false,
Verified = true,
Icon = "wrench",
Description = "Vehicle diagnostics, repairs, and roadside assistance.",
DefaultAvailability = "closed",
AcceptsRequests = true,
District = "Burton",
LocationLabel = "Los Santos Customs",
Address = "Carcer Way",
Location = vector3(-337.3, -136.9, 39.0),
ServiceLine = {
Number = "5550101000",
AutoContact = true,
CanCall = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
Permissions = {
WorkQueue = 0,
Availability = 1,
Assign = 2,
Profile = 3,
Hours = 3,
Services = 3,
Announcement = 3,
},
Services = {
{
Id = "mechanic-repair",
Title = "Vehicle repair",
Description = "Diagnostics and repairs for road vehicles.",
Price = "Price after inspection",
RequestsEnabled = true,
},
{
Id = "mechanic-towing",
Title = "Roadside assistance",
Description = "Assistance for disabled vehicles.",
Price = "Price by distance",
RequestsEnabled = true,
},
},
},
taxi = {
Job = "taxi",
Name = "Downtown Cab Co.",
Category = "transport",
Public = true,
Emergency = false,
Verified = true,
Icon = "car",
Description = "Staffed taxi rides throughout Los Santos and Blaine County.",
DefaultAvailability = "closed",
AcceptsRequests = true,
District = "East Vinewood",
LocationLabel = "Downtown Cab Co.",
Address = "Tangerine Street",
Location = vector3(895.0, -179.2, 74.7),
ServiceLine = {
Number = "5550102000",
AutoContact = true,
CanCall = true,
CanMessage = false,
Routing = "round_robin",
MinimumGrade = 0,
},
Permissions = {
WorkQueue = 0,
Availability = 1,
Assign = 2,
Profile = 3,
Hours = 3,
Services = 3,
Announcement = 3,
},
Services = {
{
Id = "taxi-ride",
Title = "Taxi ride",
Description = "Request a staffed taxi service.",
Price = "Metered fare",
RequestsEnabled = true,
},
},
},
},
}
+17
View File
@@ -20,6 +20,23 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
Config.CustomApps = {
Enabled = true,
BundledApps = true,
ExternalApps = true,
ReadyTimeoutMs = 8000,
MaximumMessageBytes = 65536,
MaximumStorageBytesPerApp = 262144,
MaximumStorageValueBytes = 65536, -- Bridge v1 ceiling; lower values tighten the server policy.
MaximumStorageKeyLength = 64, -- Bridge v1 ceiling; lower values tighten the server policy.
MaximumStorageKeysPerApp = 128,
StorageRequestsPerMinute = 120,
AllowRemoteOrigins = {
-- ["https://apps.example.com"] = true,
},
TrustedAdapters = {},
}
Config.Security = {
PasscodePepperConvar = "sky_phone_passcode_pepper",
MaximumAttempts = 5,
+318 -5
View File
@@ -77,7 +77,7 @@ Locales["en"] = {
open = "Open Control Center", play = "Play", previous = "Previous track", quickActions = "Quick actions",
timer = "Timer", unmuteRingtone = "Unmute ringtone and notifications", volume = "Volume", wifi = "Wi-Fi",
},
Notifications = { clearAll = "Clear All", now = "now" },
Notifications = { clearAll = "Clear All", now = "now", open = "Open notification" },
LockScreen = {
label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open",
passcode = {
@@ -115,6 +115,12 @@ Locales["en"] = {
},
},
Apps = {
customApps = {
loading = "Opening app...",
unavailableTitle = "App unavailable",
unavailableBody = "This custom app could not be loaded. Check that its resource is started, then reopen the app.",
close = "Close app",
},
crewlink = {
name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network",
signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings",
@@ -344,6 +350,7 @@ Locales["en"] = {
messages = {
name = "Messages", newMessage = "New message from {sender}", compose = "New Message",
search = "Search", to = "To:", message = "Message", send = "Send", details = "Details",
officialContact = "Official company contact", messagingUnavailable = "This company contact does not accept messages.",
filterUnread = "Show Unread Messages", smsLabel = "Text Message · SMS",
photo = "Photo", gif = "GIF", video = "Video", contact = "Contact", attachPhoto = "Attach Photo", takePhoto = "Take Photo",
attachGif = "Attach GIF", attachVideo = "Attach Video", photos = "Photos", gifs = "GIFs", videos = "Videos",
@@ -372,19 +379,322 @@ Locales["en"] = {
recording_in_progress = "A video is already being recorded.", recording_not_found = "No active video recording was found.",
gif_provider_unconfigured = "GIF search is not configured.", gif_provider_failed = "GIF search is temporarily unavailable.",
self_message = "You cannot message your own number.", recipient_not_found = "That number is unavailable.",
messaging_unavailable = "This company contact does not accept messages.",
no_sim = "This phone has no SIM card.", rate_limited = "Too many messages. Try again in a minute.",
request_failed = "Messages are temporarily unavailable.", default = "The message could not be sent.",
},
},
companies = {
name = "Companies",
navigation = "Companies navigation",
actions = "Request actions",
request = "Service Request",
verified = "Verified company",
back = "Back",
close = "Close",
tryAgain = "Try Again",
loadMore = "Load More",
routeSet = "GPS route set.",
tabs = {
directory = "Discover",
requests = "Requests",
work = "Work",
},
availability = {
available = "Available",
busy = "Busy",
closed = "Closed",
},
requestStatuses = {
new = "New",
assigned = "Assigned",
in_progress = "In Progress",
waiting_customer = "Waiting",
completed = "Completed",
cancelled = "Cancelled",
},
roles = {
employee = "Employee",
manager = "Manager",
},
categories = {
gastronomy = "Food & Drink",
vehicles = "Vehicles",
transport = "Transport",
crafts = "Trades",
retail = "Retail",
real_estate = "Real Estate",
media = "Media",
nightlife = "Nightlife",
public_services = "Public Services",
emergency = "Emergency Services",
medical = "Medical",
mechanics = "Mechanics",
government = "Government",
},
days = {
monday = "Monday",
tuesday = "Tuesday",
wednesday = "Wednesday",
thursday = "Thursday",
friday = "Friday",
saturday = "Saturday",
sunday = "Sunday",
},
directory = {
searchPlaceholder = "Search companies or services",
categories = "Company categories",
allCategories = "All",
allCompanies = "All Companies",
availableNow = "Available now",
availableNowHint = "Only show companies taking calls right now.",
availableSection = "Available Now",
moreFilters = "More filters",
hasLocation = "Location",
acceptsRequests = "Requests",
resetFilters = "Reset Filters",
},
loading = {
directory = "Loading companies...",
profile = "Loading company profile...",
request = "Loading request...",
requests = "Loading your requests...",
work = "Loading workspace...",
},
states = {
directoryError = "Companies are unavailable",
requestsError = "Requests are unavailable",
workError = "Workspace unavailable",
profileError = "Company unavailable",
requestError = "Request unavailable",
noCompanies = "No companies listed",
noCompaniesBody = "Public companies will appear here when configured.",
noResults = "No matching companies",
noResultsBody = "Try another search or reset your filters.",
noOpenRequests = "No open requests",
noClosedRequests = "No completed requests",
noRequestsBody = "Requests you create with a company will appear here.",
},
actionUnavailable = {
call = "This company cannot be called right now.",
message = "This service line does not accept text messages.",
route = "This company has no public location.",
request = "This company is not accepting service requests.",
},
profile = {
updated = "Status updated {time}",
announcement = "Latest Update",
location = "Location",
noLocation = "No public location",
hours = "Opening Hours",
closed = "Closed",
byAvailability = "Open by availability",
services = "Services",
noServices = "No public services listed",
call = "Call",
message = "Message",
route = "Route",
request = "Request",
},
composer = {
title = "New Service Request",
chooseService = "Choose a Service",
subject = "Subject",
subjectPlaceholder = "What do you need?",
description = "Details",
descriptionPlaceholder = "Describe what happened and how the company can help.",
contact = "Contact number",
registeredSim = "Replies go to your registered SIM {number}.",
registeredSimRequired = "A registered SIM is required to send a request.",
addPhotos = "Add Photos ({count}/3)",
selectedPhoto = "Selected request photo",
removePhoto = "Remove photo",
review = "Review Request",
confirmTitle = "Send this request?",
confirmBody = "Send your {service} request to {company}.",
edit = "Keep Editing",
send = "Send Request",
},
requests = {
open = "Open",
closed = "Completed",
findCompany = "Find a Company",
generalService = "General service",
attachments = "Request photos",
attachedPhoto = "Attached request photo",
timeline = "Timeline",
conversation = "Conversation",
noMessages = "No replies yet",
replyPlaceholder = "Write a reply",
sendReply = "Send reply",
call = "Call Company",
cancel = "Cancel Request",
cancelTitle = "Cancel this request?",
cancelBody = "The company will be notified and can no longer complete it.",
keep = "Keep Request",
cancelConfirm = "Cancel Request",
},
timeline = {
created = "Request created",
assigned = "Request assigned",
cancelled = "Request cancelled",
completed = "Request completed",
statusChanged = "Status changed to {status}",
},
time = {
justNow = "just now",
minutesAgo = "{count} min ago",
hoursAgo = "{count} hr ago",
daysAgo = "{count} days ago",
},
work = {
notAuthorized = "No company workspace",
notAuthorizedBody = "Your current job is not connected to a configured company.",
workspace = "Company Workspace",
publicAvailability = "Public Availability",
takeCalls = "Take company calls",
takeCallsBody = "Route new service-line calls to this active SIM.",
overview = "Today at a Glance",
metrics = {
new = "New",
assigned = "Mine",
waiting = "Waiting",
completedToday = "Done Today",
},
queue = "Request Queue",
filters = {
new = "New",
assigned = "Mine",
in_progress = "In Progress",
waiting_customer = "Waiting",
completed = "Done",
},
unassigned = "Unassigned",
emptyQueue = "Queue is clear",
emptyQueueBody = "No requests match this filter.",
},
workActions = {
claim = "Claim Request",
assign = "Assign Employee",
callCustomer = "Call Customer",
setStatus = "Set status: {status}",
},
assignment = {
title = "Assign Employee",
online = "Online",
offline = "Offline",
unknownMember = "Unknown employee",
confirm = "Assign Request",
},
assignmentLabels = {
you = "Assigned to you",
assigned = "Assigned to a colleague",
},
messageAuthors = {
you = "You",
customer = "Customer",
company = "Company",
},
manager = {
title = "Company Profile",
subtitle = "Manage public information, services, and announcements.",
revision = "Server revision {revision}",
availability = "Availability",
profile = "Public Profile",
description = "Description",
descriptionPlaceholder = "Describe the company and what it offers.",
phoneNumber = "Service number",
phoneNumberManaged = "Configured by the server and cannot be edited here.",
noPhoneNumber = "No service number configured",
chooseCover = "Choose cover photo",
chooseLogo = "Choose logo",
coverPhoto = "Company cover photo",
logoPhoto = "Company logo",
locationLabel = "Location name",
locationReady = "A map position is attached to this profile.",
useCurrentLocation = "Use Current Location",
address = "Address",
district = "District",
acceptRequests = "Accept service requests",
acceptRequestsBody = "Allow registered SIMs to open structured requests.",
saveProfile = "Save Profile",
hours = "Opening Hours",
dayOpen = "Company is open on this day",
opensAt = "Opens",
closesAt = "Closes",
saveHours = "Save Hours",
services = "Services",
serviceTitle = "Service name",
serviceDescription = "Description",
priceText = "Price text",
serviceActive = "Publicly visible",
serviceRequests = "Accept requests for this service",
removeService = "Remove Service",
addService = "Add Service",
saveServices = "Save Services",
announcement = "Current Announcement",
announcementText = "Announcement",
announcementPlaceholder = "Share a short, timely update.",
expiresAt = "Expires at",
publish = "Publish Announcement",
},
feedback = {
requestCreated = "Request sent.",
requestCancelled = "Request cancelled.",
requestClaimed = "Request claimed.",
requestAssigned = "Request assigned.",
statusUpdated = "Request status updated.",
availabilityUpdated = "Public availability updated.",
locationUpdated = "Current location selected.",
callsEnabled = "Company calls enabled.",
callsDisabled = "Company calls disabled.",
profileSaved = "Company profile saved.",
hoursSaved = "Opening hours saved.",
servicesSaved = "Services saved.",
announcementPublished = "Announcement published.",
},
conflict = {
title = "Newer changes are available",
body = "Another manager updated this information. Reload before saving again.",
reload = "Reload",
},
notifications = {
newRequest = "New company request",
requestUpdated = "A company request was updated.",
newMessage = "New reply to your company request.",
assigned = "A company request was assigned to you.",
},
errors = {
anonymous_sim = "A registered SIM is required for service requests.",
call_unavailable = "This service line is currently unavailable.",
company_not_found = "This company is no longer available.",
invalid_profile = "Check the company profile fields.",
invalid_request = "Check the subject and request details.",
invalid_media = "One or more attached photos are unavailable.",
invalid_expiration = "Choose a valid announcement expiration.",
invalid_service = "This service is no longer available.",
invalid_status = "That status change is not allowed.",
messaging_unavailable = "This company does not accept text messages.",
no_sim = "Insert a SIM card to continue.",
not_authorized = "You are not authorized for this company action.",
rate_limited = "Please wait before trying again.",
request_not_found = "This request is no longer available.",
revision_conflict = "The data changed on another device. Reload and try again.",
service_unavailable = "The Companies service is temporarily unavailable.",
too_many_open_requests = "You already have too many open requests.",
request_failed = "Companies could not complete the request.",
},
},
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", favorites = "Favorites",
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",
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.",
@@ -398,10 +708,13 @@ Locales["en"] = {
choosePhoneBody = "Select the phone that should receive {number}.", emptyPhone = "No SIM inserted",
errors = {
invalid_contact = "Enter a name and valid phone number.", invalid_number = "Enter a valid phone number.",
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.",
invalid_sim = "The active SIM card is unavailable.",
no_sim = "This phone has no SIM card.", airplane_mode = "Turn off Airplane Mode to make calls.",
self_call = "You cannot call your own number.", busy = "The line is busy.",
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
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.",
+14 -4
View File
@@ -7,16 +7,19 @@ author 'Sky-Systems'
description 'Sky Phone'
version '0.1.0'
escrow_ignore {
'config/**',
'source/bridge/**',
}
provide 'lb-phone'
provide '17mov_Phone'
provide 'high-phone'
provide 'qs-smartphone'
provide 'yseries'
shared_scripts {
'config/init.lua',
'source/bridge/shared.lua',
'source/shared/imei.lua',
'source/shared/sim_number.lua',
'source/shared/custom_apps.lua',
'source/shared/custom_app_compat.lua',
}
client_scripts {
@@ -34,6 +37,8 @@ client_scripts {
'source/client/crewlink.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
'source/client/custom_app_compat.lua',
'source/client/main.lua',
'source/client/radio.lua',
}
@@ -41,6 +46,7 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
'config/companies.lua',
'config/media.lua',
'config/music.lua',
'config/locales/*.lua',
@@ -53,8 +59,12 @@ server_scripts {
'source/bridge/server/housing/*.lua',
'source/bridge/server/inventory.lua',
'source/bridge/server/inventory/*.lua',
'source/server/custom_apps.lua',
'source/server/custom_app_compat.lua',
'source/server/db_migrate.lua',
'source/server/phone.lua',
'source/server/companies.lua',
'source/server/custom_app_storage.lua',
'source/server/sim.lua',
'source/server/calls.lua',
'source/server/media.lua',
@@ -2,6 +2,11 @@ if Bridge.Framework.GetName() ~= "esx" then
return
end
if GetResourceState("esx_property") ~= "started" then
print("[sky_phone] esx_property is not started; the housing provider is disabled.")
return
end
local ESX = exports["es_extended"]:getSharedObject()
local callback_names = {
@@ -2,6 +2,10 @@ if Bridge.Framework.Name ~= "esx" then
return
end
if GetResourceState("es_extended") ~= "started" then
error("[sky_phone] ESX is configured, but es_extended is not started.")
end
local ESX = exports["es_extended"]:getSharedObject()
local function get_player(source)
+15 -6
View File
@@ -1,4 +1,5 @@
local overhead_members = {}
local overhead_expires_at = 0
local function draw_overhead_label(coords, username, role)
SetDrawOrigin(coords.x, coords.y, coords.z + 1.05, 0)
@@ -14,17 +15,25 @@ local function draw_overhead_label(coords, username, role)
ClearDrawOrigin()
end
CreateThread(function()
while true do
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:overhead", {})
overhead_members = result and result.success and result.data and result.data.members or {}
Wait(Config.CrewLink.OverheadRefreshMilliseconds)
end
RegisterNUICallback("crewlink:live", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:live", data)
overhead_members = result and result.success and result.data and result.data.overheadMembers or {}
overhead_expires_at = GetGameTimer() + Config.CrewLink.OverheadRefreshMilliseconds * 2
cb(result or { success = false, error = "request_failed" })
end)
AddEventHandler("sky_phone:nuiClosed", function()
overhead_members = {}
overhead_expires_at = 0
end)
CreateThread(function()
while true do
local sleep = 1000
if overhead_expires_at > 0 and GetGameTimer() >= overhead_expires_at then
overhead_members = {}
overhead_expires_at = 0
end
if #overhead_members > 0 then
sleep = 0
local player_coords = GetEntityCoords(PlayerPedId())
@@ -0,0 +1,611 @@
local RESOURCE_NAME = GetCurrentResourceName()
local providers = SkyPhoneCompatibility.Providers
local core = SkyPhoneApps.CompatibilityCore
local provider_apps = {}
local high_client_apps = {}
local high_server_apps = {}
local function get_calling_resource(export_name)
local owner_resource = GetInvokingResource()
if owner_resource then
return owner_resource
end
print(("[%s] %s rejected: the export must be called by another resource."):format(
RESOURCE_NAME,
export_name
))
return nil, "invalid_owner"
end
local function copy_record_data(value)
if type(value) ~= "table" then
return nil
end
local copied = {}
for key, nested_value in pairs(value) do
copied[key] = nested_value
end
return copied
end
local function register_provider_app(provider, owner_resource, definition, vendor_data)
local app_id = definition.id
local existing = provider_apps[app_id]
if existing and (existing.owner_resource ~= owner_resource or existing.provider ~= provider) then
return false, "duplicate_app_id"
end
local success, error_message
if existing then
success, error_message = core.Update(owner_resource, definition)
else
success, error_message = core.Add(owner_resource, definition)
end
if success then
provider_apps[app_id] = {
definition = definition,
owner_resource = owner_resource,
provider = provider,
vendor_data = vendor_data,
}
end
return success, error_message
end
local function get_provider_app(owner_resource, app_id, allowed_providers)
if type(app_id) ~= "string" then
return nil, "invalid_app_id"
end
local record = provider_apps[app_id]
if not record then
return nil, "app_not_found"
end
if record.owner_resource ~= owner_resource then
return nil, "app_owner_mismatch"
end
if allowed_providers and not allowed_providers[record.provider] then
return nil, "app_provider_mismatch"
end
return record
end
local function remove_provider_app(owner_resource, app_id, allowed_providers)
local record, record_error = get_provider_app(owner_resource, app_id, allowed_providers)
if not record then
return false, record_error
end
return core.Remove(owner_resource, app_id)
end
function SkyPhoneApps.RegisterCompatibilityExport(owner_resource, app_data)
local provider
local definition
local definition_error
if app_data.identifier ~= nil then
provider = providers.lb
definition, definition_error = SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
elseif app_data.key ~= nil then
provider = providers.yseries
definition, definition_error = SkyPhoneCompatibility.BuildYSeriesDefinition(app_data)
else
return false, "unknown_app_provider"
end
if not definition then
print(("[%s] %s registration rejected for %s: %s."):format(
RESOURCE_NAME,
provider,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(provider, owner_resource, definition, copy_record_data(app_data))
end
SkyPhoneApps.OnCompatibilityAppRemoved = function(owner_resource, app_id)
local record = provider_apps[app_id]
if record and record.owner_resource == owner_resource then
provider_apps[app_id] = nil
end
end
local function add_17mov_application(app_data)
local owner_resource, owner_error = get_calling_resource("AddApplication")
if not owner_resource then
return false, owner_error
end
local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data)
if not definition then
print(("[%s] 17mov registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(
providers.seventeen,
owner_resource,
definition,
copy_record_data(app_data)
)
end
local function remove_17mov_application(app_name, _resource_name)
local owner_resource, owner_error = get_calling_resource("RemoveApplication")
if not owner_resource then
return false, owner_error
end
local app_id = type(app_name) == "table" and app_name.name or app_name
return remove_provider_app(owner_resource, app_id, {
[providers.seventeen] = true,
})
end
local function send_app_message(app_id, data)
local owner_resource, owner_error = get_calling_resource("SendAppMessage")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.seventeen] = true,
[providers.yseries] = true,
})
if not record then
return false, record_error
end
return core.SendMessage(owner_resource, app_id, data)
end
local function add_high_application(app_name, data, locales)
local owner_resource, owner_error = get_calling_resource("addApplication")
if not owner_resource then
return false, owner_error
end
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
owner_resource,
app_name,
data,
locales
)
if not definition then
print(("[%s] High Phone registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
high_client_apps[app_name] = {
definition = definition,
owner_resource = owner_resource,
}
local server_record = high_server_apps[app_name]
if server_record then
if server_record.owner_resource ~= owner_resource then
return false, "duplicate_app_id"
end
if server_record.registered then
return true
end
return false, "server_definition_not_registered"
end
return register_provider_app(
providers.high,
owner_resource,
definition,
copy_record_data(data)
)
end
local function send_high_app_nui(app_name, data)
local owner_resource, owner_error = get_calling_resource("sendAppNui")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_name, {
[providers.high] = true,
})
if not record then
return false, record_error
end
return core.SendMessage(owner_resource, app_name, data)
end
local function add_quasar_app_for_owner(owner_resource, app_data)
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data)
if not definition then
print(("[%s] Quasar registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(
providers.quasar,
owner_resource,
definition,
SkyPhoneCompatibility.CopyQuasarData(app_data)
)
end
local function add_quasar_app(app_data)
local owner_resource, owner_error = get_calling_resource("addCustomApp")
if not owner_resource then
return false, owner_error
end
if type(app_data) ~= "table" then
return false, "invalid_app_data"
end
return add_quasar_app_for_owner(owner_resource, app_data)
end
local function add_quasar_apps_batch(apps)
local owner_resource, owner_error = get_calling_resource("addCustomAppsBatch")
if not owner_resource then
return false, owner_error
end
if type(apps) ~= "table" then
return false, "invalid_app_batch"
end
for index = 1, #apps do
local success, error_message = add_quasar_app_for_owner(owner_resource, apps[index])
if not success then
return false, error_message
end
end
return true
end
local function update_quasar_app(app_id, patch)
local owner_resource, owner_error = get_calling_resource("updateCustomApp")
if not owner_resource then
return false, owner_error
end
if type(patch) ~= "table" then
return false, "invalid_patch"
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
if not record then
return false, record_error
end
local merged = SkyPhoneCompatibility.CopyQuasarData(record.vendor_data)
for key, value in pairs(patch) do
if key == "iframe" and type(value) == "table" then
merged.iframe = merged.iframe or {}
for iframe_key, iframe_value in pairs(value) do
merged.iframe[iframe_key] = iframe_value
end
elseif key ~= "id" then
merged[key] = value
end
end
merged.id = app_id
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(merged)
if not definition then
return false, definition_error
end
local success, error_message = core.Update(owner_resource, definition)
if success then
record.definition = definition
record.vendor_data = SkyPhoneCompatibility.CopyQuasarData(merged)
end
return success, error_message
end
local function remove_quasar_app(app_id)
local owner_resource, owner_error = get_calling_resource("removeCustomApp")
if not owner_resource then
return false, owner_error
end
return remove_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
end
local function get_quasar_apps()
local ids = {}
for app_id, record in pairs(provider_apps) do
if record.provider == providers.quasar then
ids[#ids + 1] = app_id
end
end
table.sort(ids)
local apps = {}
for index = 1, #ids do
apps[index] = SkyPhoneCompatibility.CopyQuasarData(provider_apps[ids[index]].vendor_data)
end
return apps
end
local function open_quasar_app(app_id)
local owner_resource, owner_error = get_calling_resource("OpenPhoneApp")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
if not record then
return false, record_error
end
return core.Open(owner_resource, app_id)
end
local function open_phone_app(app_id, data)
local owner_resource, owner_error = get_calling_resource("OpenApp")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id)
if not record then
return false, record_error
end
return core.Open(owner_resource, app_id, data)
end
local function close_phone_app(options)
local owner_resource, owner_error = get_calling_resource("CloseApp")
if not owner_resource then
return false, owner_error
end
local app_id = type(options) == "table" and options.app or nil
if app_id then
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.lb] = true,
[providers.yseries] = true,
})
if not record then
return false, record_error
end
return core.Close(owner_resource, app_id)
end
return core.CloseActive(owner_resource)
end
local function register_high_server_app(owner_resource, definition, revision)
if type(owner_resource) ~= "string"
or type(definition) ~= "table"
or type(definition.id) ~= "string"
or type(revision) ~= "number"
or revision ~= math.floor(revision)
or revision < 1
then
print(("[%s] Rejected invalid High Phone server application snapshot."):format(RESOURCE_NAME))
return nil
end
local app_id = definition.id
local existing = high_server_apps[app_id]
if existing and revision <= existing.revision then
if revision < existing.revision or existing.registered then
return app_id
end
if existing.owner_resource ~= owner_resource then
print(("[%s] Rejected conflicting High Phone owner for %s."):format(
RESOURCE_NAME,
app_id
))
return app_id
end
end
local provider_record = provider_apps[app_id]
local success, error_message
local retained_registration = false
if provider_record then
if provider_record.owner_resource ~= owner_resource or provider_record.provider ~= providers.high then
success, error_message = false, "duplicate_app_id"
else
retained_registration = true
success, error_message = core.Update(owner_resource, definition)
end
else
success, error_message = core.Add(owner_resource, definition)
end
high_server_apps[app_id] = {
definition = definition,
last_error = success and nil or error_message,
owner_resource = owner_resource,
registered = success or retained_registration,
revision = revision,
}
if success then
provider_apps[app_id] = {
definition = definition,
owner_resource = owner_resource,
provider = providers.high,
}
elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then
print(("[%s] Could not register High Phone server application %s revision %s: %s."):format(
RESOURCE_NAME,
app_id,
revision,
error_message or "unknown_error"
))
end
return app_id
end
local function remove_high_server_app(owner_resource, app_id)
local record = high_server_apps[app_id]
if not record or record.owner_resource ~= owner_resource then
return
end
high_server_apps[app_id] = nil
if record.registered then
core.Remove(owner_resource, app_id)
end
local client_record = high_client_apps[app_id]
if client_record then
local success, error_message = register_provider_app(
providers.high,
client_record.owner_resource,
client_record.definition,
nil
)
if not success then
print(("[%s] Could not restore High Phone client application %s: %s."):format(
RESOURCE_NAME,
app_id,
error_message or "unknown_error"
))
end
end
end
RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application sync."):format(RESOURCE_NAME))
return
end
register_high_server_app(owner_resource, definition, revision)
end)
RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application removal."):format(RESOURCE_NAME))
return
end
if type(owner_resource) ~= "string" or type(app_id) ~= "string" then
print(("[%s] Rejected invalid High Phone application removal."):format(RESOURCE_NAME))
return
end
remove_high_server_app(owner_resource, app_id)
end)
RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application snapshot."):format(RESOURCE_NAME))
return
end
if type(snapshot) ~= "table" then
print(("[%s] Rejected invalid High Phone application snapshot."):format(RESOURCE_NAME))
return
end
local seen = {}
for index = 1, #snapshot do
local record = snapshot[index]
if type(record) == "table" then
local app_id = register_high_server_app(
record.owner_resource,
record.definition,
record.revision
)
if app_id then
seen[app_id] = true
end
end
end
local removed = {}
for app_id, record in pairs(high_server_apps) do
if not seen[app_id] then
removed[#removed + 1] = {
app_id = app_id,
owner_resource = record.owner_resource,
}
end
end
for index = 1, #removed do
remove_high_server_app(removed[index].owner_resource, removed[index].app_id)
end
end)
AddEventHandler("onClientResourceStart", function(resource_name)
for _, record in pairs(high_server_apps) do
if record.owner_resource == resource_name and not record.registered then
register_high_server_app(record.owner_resource, record.definition, record.revision)
end
end
end)
AddEventHandler("onClientResourceStop", function(resource_name)
for app_id, record in pairs(high_client_apps) do
if record.owner_resource == resource_name then
high_client_apps[app_id] = nil
end
end
end)
exports("AddApplication", add_17mov_application)
exports("RemoveApplication", remove_17mov_application)
exports("SendAppMessage", send_app_message)
exports("addApplication", add_high_application)
exports("sendAppNui", send_high_app_nui)
exports("addCustomApp", add_quasar_app)
exports("addCustomAppsBatch", add_quasar_apps_batch)
exports("updateCustomApp", update_quasar_app)
exports("removeCustomApp", remove_quasar_app)
exports("getCustomApps", get_quasar_apps)
exports("OpenPhoneApp", open_quasar_app)
exports("OpenApp", open_phone_app)
exports("CloseApp", close_phone_app)
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "OpenApp", open_phone_app)
SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CloseApp", close_phone_app)
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "AddApplication", add_17mov_application)
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "RemoveApplication", remove_17mov_application)
SkyPhoneCompatibility.RegisterExportAlias("17mov_Phone", "SendAppMessage", send_app_message)
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_high_application)
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "sendAppNui", send_high_app_nui)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomApp", add_quasar_app)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "addCustomAppsBatch", add_quasar_apps_batch)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "updateCustomApp", update_quasar_app)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "removeCustomApp", remove_quasar_app)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "getCustomApps", get_quasar_apps)
SkyPhoneCompatibility.RegisterExportAlias("qs-smartphone", "OpenPhoneApp", open_quasar_app)
SkyPhoneCompatibility.RegisterExportAlias("yseries", "SendAppMessage", send_app_message)
SkyPhoneCompatibility.RegisterExportAlias("yseries", "CloseApp", close_phone_app)
SkyPhoneCompatibility.RegisterExportAlias("yseries", "GetDataLoaded", function()
return true
end)
CreateThread(function()
TriggerServerEvent("sky_phone:compat:high:server:requestSnapshot")
TriggerEvent("17mov_Phone:Client:Ready")
local provider_resources = {
"lb-phone",
"17mov_Phone",
"high-phone",
"qs-smartphone",
"yseries",
}
for index = 1, #provider_resources do
TriggerEvent("onResourceStart", provider_resources[index])
end
end)
File diff suppressed because it is too large Load Diff
+39 -1
View File
@@ -13,7 +13,10 @@ local server_callbacks = {
"security:change-passcode",
"security:disable-passcode",
"device:save",
"device:notification-open",
"notifications:save",
"custom-app:storage:get",
"custom-app:storage:set",
"device:factory-reset",
"account:login",
"account:register",
@@ -165,7 +168,26 @@ local server_callbacks = {
"crewlink:leave",
"crewlink:create-ping",
"crewlink:remove-ping",
"crewlink:live",
"companies:list",
"companies:get",
"companies:my-requests",
"companies:get-request",
"companies:work-context",
"companies:work-queue",
"companies:list-members",
"companies:create-request",
"companies:cancel-request",
"companies:send-message",
"companies:claim-request",
"companies:assign-request",
"companies:update-request-status",
"companies:update-availability",
"companies:update-profile",
"companies:update-hours",
"companies:update-services",
"companies:publish-announcement",
"companies:set-call-availability",
"companies:call-customer",
"sim:insert",
"sim:eject",
"contacts:list",
@@ -235,6 +257,7 @@ local function send_open_message()
local payload = device_payload
payload.lang = Config.Bridge.Locale
payload.locales = get_locale().Nui
SkyPhoneApps.SendCatalog()
SendNUIMessage({
type = "app:open",
data = payload,
@@ -257,6 +280,7 @@ local function close_phone()
end
is_open = false
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
SendNUIMessage({ type = "app:close" })
@@ -300,6 +324,7 @@ end
RegisterNUICallback("ui:ready", function(_, cb)
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
TriggerEvent("sky_phone:client:nuiReady")
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
@@ -319,6 +344,7 @@ RegisterNUICallback("ui:opened", function(_, cb)
end
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
notification_focus = false
SetNuiFocus(true, true)
TriggerEvent("sky_phone:animation:phone", true)
@@ -532,6 +558,18 @@ RegisterNetEvent("sky_phone:marketplace:changed", function(data)
SendNUIMessage({ type = "marketplace:changed", data = data })
end)
RegisterNetEvent("sky_phone:companies:changed", function(data)
SendNUIMessage({ type = "companies:changed", data = data })
end)
RegisterNetEvent("sky_phone:companies:notification", function(data)
local companies_locale = get_locale().Nui.Apps.companies
data.title = companies_locale.name
data.text = companies_locale.notifications[data.kind]
or companies_locale.notifications.requestUpdated
SendNUIMessage({ type = "companies:notification", data = data })
end)
RegisterNetEvent("sky_phone:fliptok:command-feedback", function(data)
Bridge.Framework.Notify("FlipTok", data.message, data.notificationType, 5000)
end)
+586 -153
View File
@@ -8,6 +8,7 @@ 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`", {})
@@ -240,11 +241,17 @@ local function finish_call(call, status)
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
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'",
query = [[
UPDATE `sky_phone_call_entries` SET `status` = ?
WHERE `call_id` = ? AND `direction` = 'incoming' AND `status` IN ('ringing', 'connected')
]],
params = { callee_status, call.id },
},
})
@@ -281,9 +288,17 @@ end
function SkyPhoneCalls.EndForSim(sim_id, reason)
local call_id = active_by_sim[sim_id]
if call_id then
finish_call(calls[call_id], reason or "ended")
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)
@@ -348,7 +363,27 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
`created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
]]):format(condition), params)
return { success = true, data = rows }
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)
@@ -367,6 +402,16 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
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
@@ -456,6 +501,14 @@ Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data)
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
@@ -485,13 +538,20 @@ Bridge.Callbacks.Register("sky_phone:calls:recents", function(source)
return { success = true, data = rows }
end)
local function create_terminal_call(scope, number, target_sim, status)
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, scope.device.phone_number, number, status })
]], {
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 {
@@ -503,6 +563,338 @@ local function create_terminal_call(scope, number, target_sim, status)
}
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" }
@@ -523,6 +915,16 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
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" }
@@ -541,6 +943,49 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
return { success = false, error = "busy" }
end
dialing_by_sim[scope.device.sim_id] = true
local service_line = SkyPhoneCompanies.GetServiceLine(number)
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`
@@ -552,84 +997,29 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
dial_locks[source] = nil
return { success = false, error = "recipient_not_found" }
end
if not target.imei then
local terminal = create_terminal_call(scope, number, target, "unavailable")
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
local blocks = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_call_blocks`
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1
]], { target.id, scope.device.sim_id })
if blocks[1] then
local terminal = create_terminal_call(scope, number, target, "declined")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
local callee_source = find_device_holder(target.imei)
if not callee_source or airplane_mode(target.imei) then
local terminal = create_terminal_call(scope, number, target, "unavailable")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
local terminal = create_terminal_call(scope, number, target, "busy")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
dialing_by_sim[target.id] = true
local id = uuid()
local call = {
id = id,
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(),
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),
}
Bridge.Database.Query([[
INSERT INTO `sky_phone_calls`
(`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`)
VALUES (?, ?, ?, ?, ?, 'ringing')
]], { id, call.caller_sim_id, call.callee_sim_id, call.caller_number, call.callee_number })
add_call_entry(id, scope.device, "outgoing", "ringing", number)
add_call_entry(id, target, "incoming", "ringing", call.caller_number)
calls[id] = call
active_by_source[source] = id
active_by_source[callee_source] = id
active_by_sim[call.caller_sim_id] = id
active_by_sim[call.callee_sim_id] = id
dialing_by_sim[call.caller_sim_id] = nil
dialing_by_sim[call.callee_sim_id] = nil
dial_locks[source] = nil
send_state(call, source, "ringing")
SkyPhone.OpenDeviceForCall(callee_source, target.imei)
TriggerClientEvent("sky_phone:call:incoming", callee_source, {
id = id,
state = "ringing",
direction = "incoming",
otherNumber = call.caller_number,
startedAt = call.started_at,
device = {
imei = target.imei,
name = target.device_name,
},
})
SetTimeout(Config.Calls.RingSeconds * 1000, function()
if calls[id] and not calls[id].answered_at then
finish_call(calls[id], "no_answer")
end
end)
return { success = true, data = { id = id, state = "ringing", direction = "outgoing", otherNumber = number, startedAt = call.started_at } }
end)
local payphone_models = {}
@@ -696,89 +1086,96 @@ Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data)
end
dial_locks[source] = true
local service_line = SkyPhoneCompanies.GetServiceLine(number)
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]
if not target or not target.imei then
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, "unavailable") }
return { success = true, data = payphone_terminal(number, target_status) }
end
local callee_source = find_device_holder(target.imei)
if not callee_source or callee_source == source or airplane_mode(target.imei) then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "unavailable") }
end
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
dial_locks[source] = nil
return { success = true, data = payphone_terminal(number, "busy") }
end
dialing_by_sim[target.id] = true
local id = uuid()
local call = {
id = id,
caller_source = source,
caller_number = Config.Payphones.CallerNumber,
callee_source = callee_source,
callee_sim_id = target.id,
callee_number = number,
callee_device = target,
started_at = os.time(),
payphone = {
elapsed_seconds = 0,
total_cost = 0,
coords = booth_coords,
model = booth_model,
price_per_second = price_per_second,
routing_bucket = GetPlayerRoutingBucket(source),
},
}
calls[id] = call
active_by_source[source] = id
active_by_source[callee_source] = id
active_by_sim[target.id] = id
dialing_by_sim[target.id] = nil
dial_locks[source] = nil
send_state(call, source, "ringing")
send_payphone_visual(call, "start")
SkyPhone.OpenDeviceForCall(callee_source, target.imei)
TriggerClientEvent("sky_phone:call:incoming", callee_source, {
id = id,
state = "ringing",
direction = "incoming",
otherNumber = call.caller_number,
startedAt = call.started_at,
device = {
imei = target.imei,
name = target.device_name,
},
})
SetTimeout(math.max(1, math.floor(tonumber(Config.Payphones.NoAnswerTimeoutSeconds) or 30)) * 1000, function()
if calls[id] and not calls[id].answered_at then
finish_call(calls[id], "no_answer")
end
end)
return {
success = true,
data = {
id = id,
state = "ringing",
direction = "outgoing",
otherNumber = number,
startedAt = call.started_at,
elapsedSeconds = 0,
totalCost = 0,
},
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 then
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
@@ -792,8 +1189,17 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data)
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` = ?", { call.id })
Bridge.Database.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
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)
@@ -802,10 +1208,12 @@ 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 then
if not call or call.callee_source ~= source or call.answered_at or call.rerouting then
return { success = false, error = "call_not_found" }
end
finish_call(call, "declined")
if not reroute_company_call(call, "declined") then
finish_call(call, "declined")
end
return { success = true }
end)
@@ -817,6 +1225,15 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
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)
@@ -896,8 +1313,16 @@ CreateThread(function()
end
local callee_valid = not call.callee_source
or SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1] ~= nil
if not caller_valid or not callee_valid then
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
@@ -921,9 +1346,17 @@ end)
AddEventHandler("playerDropped", function()
local call_id = active_by_source[source]
if call_id then
finish_call(calls[call_id], "disconnected")
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)
File diff suppressed because it is too large Load Diff
+24 -36
View File
@@ -1089,51 +1089,39 @@ Bridge.Callbacks.Register("sky_phone:crewlink:live", function(source)
if not profile then
return error_response
end
if not profile.active_group_id or not membership(profile.id, profile.active_group_id) then
return { success = true, data = { members = {}, pings = {} } }
if not profile.active_group_id then
return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } }
end
local active_membership = membership(profile.id, profile.active_group_id)
if not active_membership then
return { success = true, data = { members = {}, overheadMembers = {}, pings = {} } }
end
local members = live_group(profile.active_group_id)
local overhead_members = {}
if tonumber(profile.overhead_visible) == 1
and tonumber(active_membership.overhead_allowed) == 1
then
for _, member in ipairs(members) do
if member.source and member.source ~= source and member.overheadVisible then
overhead_members[#overhead_members + 1] = {
source = member.source,
username = member.username,
role = member.role,
roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2),
}
end
end
end
return {
success = true,
data = {
members = live_group(profile.active_group_id),
members = members,
overheadMembers = overhead_members,
pings = active_pings(profile.active_group_id),
},
}
end)
Bridge.Callbacks.Register("sky_phone:crewlink:overhead", function(source)
if not SkyPhone.AllowOperation(
source,
"crewlink:overhead",
Config.CrewLink.LiveRequestsPerMinute,
60
) then
return { success = false, error = "rate_limited" }
end
local profile = require_profile(source)
if not profile or not profile.active_group_id or tonumber(profile.overhead_visible) ~= 1 then
return { success = true, data = { members = {} } }
end
local active_membership = membership(profile.id, profile.active_group_id)
if not active_membership or tonumber(active_membership.overhead_allowed) ~= 1 then
return { success = true, data = { members = {} } }
end
local live_sources = live_sources_by_account()
local members = {}
for _, member in ipairs(member_dtos(profile.active_group_id)) do
local member_source = live_sources[member.account_id]
if member_source and member_source ~= source and member.overheadVisible then
members[#members + 1] = {
source = member_source,
username = member.username,
role = member.role,
roleLabel = member.role:sub(1, 1):upper() .. member.role:sub(2),
}
end
end
return { success = true, data = { members = members } }
end)
exports("GetCrewLinkActiveGroup", function(source)
local profile = require_profile(source)
if not profile or not profile.active_group_id then
@@ -0,0 +1,139 @@
local RESOURCE_NAME = GetCurrentResourceName()
local SNAPSHOT_COOLDOWN_MS = 2000
local SNAPSHOT_REJECTION_LOG_COOLDOWN_MS = 10000
local registered_apps = {}
local snapshot_requests = {}
local snapshot_rejection_logs = {}
local function get_calling_resource(export_name)
local owner_resource = GetInvokingResource()
if owner_resource then
return owner_resource
end
print(("[%s] %s rejected: the export must be called by another resource."):format(
RESOURCE_NAME,
export_name
))
return nil, "invalid_owner"
end
local function add_high_application(app_name, data, locales)
local owner_resource, owner_error = get_calling_resource("addApplication")
if not owner_resource then
return false, owner_error
end
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
owner_resource,
app_name,
data,
locales
)
if not definition then
print(("[%s] High Phone registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
local existing = registered_apps[app_name]
if existing and existing.owner_resource ~= owner_resource then
return false, "duplicate_app_id"
end
local revision = existing and existing.revision + 1 or 1
registered_apps[app_name] = {
definition = definition,
owner_resource = owner_resource,
revision = revision,
}
TriggerClientEvent(
"sky_phone:compat:high:client:syncApplication",
-1,
owner_resource,
definition,
revision
)
return true
end
local function build_snapshot()
local ids = {}
for app_id in pairs(registered_apps) do
ids[#ids + 1] = app_id
end
table.sort(ids)
local snapshot = {}
for index = 1, #ids do
snapshot[index] = registered_apps[ids[index]]
end
return snapshot
end
RegisterNetEvent("sky_phone:compat:high:server:requestSnapshot", function()
local player_source = source
if player_source <= 0 then
print(("[%s] Rejected High Phone snapshot request without a player source."):format(
RESOURCE_NAME
))
return
end
local now = GetGameTimer()
local last_request = snapshot_requests[player_source]
local elapsed = last_request and now - last_request or SNAPSHOT_COOLDOWN_MS
if elapsed >= 0 and elapsed < SNAPSHOT_COOLDOWN_MS then
local last_log = snapshot_rejection_logs[player_source]
local log_elapsed = last_log and now - last_log or SNAPSHOT_REJECTION_LOG_COOLDOWN_MS
if log_elapsed < 0 or log_elapsed >= SNAPSHOT_REJECTION_LOG_COOLDOWN_MS then
snapshot_rejection_logs[player_source] = now
print(("[%s] Rate-limited High Phone snapshot request from player %s."):format(
RESOURCE_NAME,
player_source
))
end
return
end
snapshot_requests[player_source] = now
TriggerClientEvent(
"sky_phone:compat:high:client:replaceSnapshot",
player_source,
build_snapshot()
)
end)
AddEventHandler("playerDropped", function()
snapshot_requests[source] = nil
snapshot_rejection_logs[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == RESOURCE_NAME then
return
end
local removed_ids = {}
for app_id, record in pairs(registered_apps) do
if record.owner_resource == resource_name then
removed_ids[#removed_ids + 1] = app_id
end
end
for index = 1, #removed_ids do
local app_id = removed_ids[index]
registered_apps[app_id] = nil
TriggerClientEvent(
"sky_phone:compat:high:client:removeApplication",
-1,
resource_name,
app_id
)
end
end)
exports("addApplication", add_high_application)
SkyPhoneCompatibility.RegisterExportAlias("high-phone", "addApplication", add_high_application)
@@ -0,0 +1,271 @@
Bridge.Database.AfterMigration("sky_phone", function()
local STORAGE_JSON_MAX_DEPTH = 8
local STORAGE_JSON_MAX_NODES = 512
local STORAGE_KEY_MAX_LENGTH = math.min(Config.CustomApps.MaximumStorageKeyLength, 64)
local STORAGE_VALUE_MAX_BYTES = math.min(Config.CustomApps.MaximumStorageValueBytes, 65536)
local storage_write_locks = {}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function validate_storage_json(value, depth, state)
state.nodes = state.nodes + 1
if state.nodes > STORAGE_JSON_MAX_NODES or depth > STORAGE_JSON_MAX_DEPTH then
return false
end
local value_type = type(value)
if value_type == "nil" or value_type == "boolean" then
return true
end
if value_type == "number" then
return value == value and value ~= math.huge and value ~= -math.huge
end
if value_type == "string" then
return #value <= STORAGE_VALUE_MAX_BYTES
end
if value_type ~= "table" or state.seen[value] then
return false
end
state.seen[value] = true
local key_type = nil
local numeric_keys = 0
local value_count = 0
for key, nested_value in pairs(value) do
value_count = value_count + 1
local current_key_type = type(key)
if current_key_type == "number" then
if key < 1 or key % 1 ~= 0 then
state.seen[value] = nil
return false
end
numeric_keys = numeric_keys + 1
elseif current_key_type ~= "string"
or #key == 0
or #key > 64
or key == "__proto__"
or key == "constructor"
or key == "prototype"
then
state.seen[value] = nil
return false
end
if key_type and key_type ~= current_key_type then
state.seen[value] = nil
return false
end
key_type = current_key_type
if not validate_storage_json(nested_value, depth + 1, state) then
state.seen[value] = nil
return false
end
end
if key_type == "number" and (numeric_keys ~= value_count or #value ~= value_count) then
state.seen[value] = nil
return false
end
state.seen[value] = nil
return true
end
local function with_storage_write_lock(lock_key, callback)
local previous_lock = storage_write_locks[lock_key]
local current_lock = promise.new()
storage_write_locks[lock_key] = current_lock
if previous_lock then
Citizen.Await(previous_lock)
end
local success, result = xpcall(callback, debug.traceback)
current_lock:resolve(true)
if storage_write_locks[lock_key] == current_lock then
storage_write_locks[lock_key] = nil
end
if not success then
error(result, 0)
end
return result
end
local function validate_request(source, data, operation)
if not Config.CustomApps.Enabled then
return nil, nil, { success = false, error = "custom_apps_disabled" }
end
if not SkyPhone.AllowOperation(
source,
"custom_app_storage_" .. operation,
Config.CustomApps.StorageRequestsPerMinute,
60
) then
return nil, nil, { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, nil, error_response
end
if type(data) ~= "table" or not SkyPhoneApps.HasPermission(data.appId, "device.storage") then
return nil, nil, { success = false, error = "storage_not_allowed" }
end
if type(data.key) ~= "string"
or #data.key == 0
or #data.key > STORAGE_KEY_MAX_LENGTH
or not data.key:match("^[%w._-]+$")
then
return nil, nil, { success = false, error = "invalid_storage_key" }
end
return session, data, nil
end
local function read_row(imei, app_id, data_key)
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_custom_app_data`
WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ?
LIMIT 1
]], { imei, app_id, data_key })
return rows[1]
end
local function conflict_response(row)
if not row then
return {
success = false,
error = "storage_conflict",
data = { exists = false, revision = 0 },
}
end
return {
success = false,
error = "storage_conflict",
data = {
exists = true,
revision = tonumber(row.revision) or 0,
value = json.decode(row.payload),
},
}
end
Bridge.Callbacks.Register("sky_phone:custom-app:storage:get", function(source, data)
local session, request, error_response = validate_request(source, data, "get")
if not session then
return error_response
end
local row = read_row(session.imei, request.appId, request.key)
if not row then
return { success = true, data = { exists = false, revision = 0 } }
end
return {
success = true,
data = {
exists = true,
revision = tonumber(row.revision) or 0,
value = json.decode(row.payload),
},
}
end)
Bridge.Callbacks.Register("sky_phone:custom-app:storage:set", function(source, data)
local session, request, error_response = validate_request(source, data, "set")
if not session then
return error_response
end
if request.value == nil then
return { success = false, error = "invalid_storage_value" }
end
local expected_revision = tonumber(request.revision)
if not expected_revision
or expected_revision ~= math.floor(expected_revision)
or expected_revision < 0
or expected_revision > 4294967294
then
return { success = false, error = "invalid_storage_revision" }
end
if not validate_storage_json(request.value, 0, { nodes = 0, seen = {} }) then
return { success = false, error = "invalid_storage_value" }
end
local encoded, payload = pcall(json.encode, request.value)
if not encoded
or type(payload) ~= "string"
or #payload > STORAGE_VALUE_MAX_BYTES
then
return { success = false, error = "storage_value_too_large" }
end
local lock_key = session.imei .. "\0" .. request.appId
return with_storage_write_lock(lock_key, function()
local current_session, session_error = SkyPhone.RequireSession(source)
if not current_session then
return session_error
end
if current_session.imei ~= session.imei
or not SkyPhoneApps.HasPermission(request.appId, "device.storage")
then
return { success = false, error = "storage_not_allowed" }
end
local current = read_row(session.imei, request.appId, request.key)
local current_revision = current and tonumber(current.revision) or 0
if current_revision ~= expected_revision then
return conflict_response(current)
end
local totals = Bridge.Database.Query([[
SELECT COALESCE(SUM(OCTET_LENGTH(`payload`)), 0) AS `total`, COUNT(*) AS `keys`
FROM `sky_phone_custom_app_data`
WHERE `device_imei` = ? AND `app_id` = ?
]], { session.imei, request.appId })
local current_size = current and #current.payload or 0
local next_total = (tonumber(totals[1] and totals[1].total) or 0) - current_size + #payload
if next_total > Config.CustomApps.MaximumStorageBytesPerApp then
return { success = false, error = "storage_quota_exceeded" }
end
if not current
and (tonumber(totals[1] and totals[1].keys) or 0) >= Config.CustomApps.MaximumStorageKeysPerApp
then
return { success = false, error = "storage_key_limit" }
end
if expected_revision == 0 then
local inserted = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_custom_app_data`
(`device_imei`, `app_id`, `data_key`, `payload`, `revision`)
VALUES (?, ?, ?, ?, 1)
]], { session.imei, request.appId, request.key, payload })
if affected_rows(inserted) ~= 1 then
return conflict_response(read_row(session.imei, request.appId, request.key))
end
return { success = true, data = { revision = 1 } }
end
local updated = Bridge.Database.Query([[
UPDATE `sky_phone_custom_app_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `app_id` = ? AND `data_key` = ? AND `revision` = ?
]], { payload, session.imei, request.appId, request.key, expected_revision })
if affected_rows(updated) ~= 1 then
return conflict_response(read_row(session.imei, request.appId, request.key))
end
return { success = true, data = { revision = expected_revision + 1 } }
end)
end)
end)
+385
View File
@@ -0,0 +1,385 @@
local policies_by_id = {}
local policy_ids_by_adapter = {}
local policy_ids_by_owner = {}
local current_resource = GetCurrentResourceName()
local function validate_owner_resource(owner_resource, allow_stopping)
if type(owner_resource) ~= "string"
or #owner_resource == 0
or #owner_resource > 64
or not owner_resource:match("^[%w][%w._-]*$")
then
return false, "invalid_owner_resource"
end
local state = GetResourceState(owner_resource)
if state == "started" or state == "starting" or (allow_stopping and state == "stopping") then
return true
end
return false, "owner_resource_not_running"
end
local function add_owner_index(index, resource_name, app_id)
local app_ids = index[resource_name]
if not app_ids then
app_ids = {}
index[resource_name] = app_ids
end
app_ids[app_id] = true
end
local function remove_owner_index(index, resource_name, app_id)
local app_ids = index[resource_name]
if not app_ids then
return
end
app_ids[app_id] = nil
if not next(app_ids) then
index[resource_name] = nil
end
end
local function build_permission_set(permissions)
local permission_set = {}
for index = 1, #permissions do
permission_set[permissions[index]] = true
end
return permission_set
end
local function register_policy(policy)
local app_id = policy.id
local existing = policies_by_id[app_id]
if existing then
print((
"[sky_phone] Rejected duplicate custom app policy '%s' from '%s'; it is already owned by '%s'."
):format(app_id, policy.ownerResource, existing.ownerResource))
return false, "duplicate_app_id"
end
policies_by_id[app_id] = policy
add_owner_index(policy_ids_by_owner, policy.ownerResource, app_id)
if policy.adapterResource then
add_owner_index(policy_ids_by_adapter, policy.adapterResource, app_id)
end
return true
end
local function remove_policy(app_id)
local policy = policies_by_id[app_id]
if not policy then
return false, "app_not_found"
end
policies_by_id[app_id] = nil
remove_owner_index(policy_ids_by_owner, policy.ownerResource, app_id)
if policy.adapterResource then
remove_owner_index(policy_ids_by_adapter, policy.adapterResource, app_id)
end
return true
end
local function get_direct_owner()
local owner_resource = GetInvokingResource()
if not owner_resource then
return nil, "missing_invoking_resource"
end
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
if not valid_owner then
return nil, owner_error
end
return owner_resource
end
local function get_trusted_adapter()
local adapter_resource = GetInvokingResource()
if not adapter_resource or not Config.CustomApps.TrustedAdapters[adapter_resource] then
return nil, "untrusted_adapter"
end
local valid_adapter, adapter_error = validate_owner_resource(adapter_resource, false)
if not valid_adapter then
return nil, adapter_error
end
return adapter_resource
end
local function normalize_policy(owner_resource, adapter_resource, definition)
if type(definition) ~= "table" then
return nil, "invalid_definition"
end
if definition.schemaVersion ~= nil and definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then
return nil, "unsupported_schema_version"
end
local valid_id, id_error = SkyPhoneApps.ValidateAppId(definition.id)
if not valid_id then
return nil, id_error
end
if SkyPhoneApps.ReservedAppIds[definition.id] then
return nil, "reserved_app_id"
end
local permissions, permissions_error = SkyPhoneApps.ValidatePermissions(definition.permissions)
if not permissions then
return nil, permissions_error
end
return {
adapterResource = adapter_resource,
bundled = false,
id = definition.id,
ownerResource = owner_resource,
permissions = build_permission_set(permissions),
}
end
local function add_custom_app_policy(definition)
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
return false, "external_apps_disabled"
end
local owner_resource, owner_error = get_direct_owner()
if not owner_resource then
return false, owner_error
end
local policy, policy_error = normalize_policy(owner_resource, nil, definition)
if not policy then
return false, policy_error
end
return register_policy(policy)
end
local function add_custom_app_policy_from_adapter(owner_resource, definition)
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
return false, "external_apps_disabled"
end
local adapter_resource, adapter_error = get_trusted_adapter()
if not adapter_resource then
return false, adapter_error
end
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
if not valid_owner then
return false, owner_error
end
local policy, policy_error = normalize_policy(owner_resource, adapter_resource, definition)
if not policy then
return false, policy_error
end
return register_policy(policy)
end
local function verify_owned_policy(owner_resource, app_id, adapter_resource)
local valid_id, id_error = SkyPhoneApps.ValidateAppId(app_id)
if not valid_id then
return nil, id_error
end
local policy = policies_by_id[app_id]
if not policy then
return nil, "app_not_found"
end
if policy.ownerResource ~= owner_resource then
return nil, "app_owner_mismatch"
end
if adapter_resource and policy.adapterResource ~= adapter_resource then
return nil, "app_adapter_mismatch"
end
return policy
end
local function replace_policy(policy, adapter_resource)
local existing, policy_error = verify_owned_policy(
policy.ownerResource,
policy.id,
adapter_resource
)
if not existing then
return false, policy_error
end
if existing.bundled then
return false, "bundled_app"
end
policies_by_id[policy.id] = policy
return true
end
local function update_custom_app_policy(definition)
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
return false, "external_apps_disabled"
end
local owner_resource, owner_error = get_direct_owner()
if not owner_resource then
return false, owner_error
end
local policy, policy_error = normalize_policy(owner_resource, nil, definition)
if not policy then
return false, policy_error
end
return replace_policy(policy, nil)
end
local function update_custom_app_policy_from_adapter(owner_resource, definition)
if not Config.CustomApps.Enabled or not Config.CustomApps.ExternalApps then
return false, "external_apps_disabled"
end
local adapter_resource, adapter_error = get_trusted_adapter()
if not adapter_resource then
return false, adapter_error
end
local valid_owner, owner_error = validate_owner_resource(owner_resource, false)
if not valid_owner then
return false, owner_error
end
local policy, policy_error = normalize_policy(owner_resource, adapter_resource, definition)
if not policy then
return false, policy_error
end
return replace_policy(policy, adapter_resource)
end
local function remove_custom_app_policy(app_id)
local owner_resource, owner_error = get_direct_owner()
if not owner_resource then
return false, owner_error
end
local policy, policy_error = verify_owned_policy(owner_resource, app_id)
if not policy then
return false, policy_error
end
if policy.bundled then
return false, "bundled_app"
end
return remove_policy(app_id)
end
local function remove_custom_app_policy_from_adapter(owner_resource, app_id)
local adapter_resource, adapter_error = get_trusted_adapter()
if not adapter_resource then
return false, adapter_error
end
local policy, policy_error = verify_owned_policy(owner_resource, app_id, adapter_resource)
if not policy then
return false, policy_error
end
return remove_policy(app_id)
end
local function has_custom_app_permission(app_id, permission)
local valid_id = SkyPhoneApps.ValidateAppId(app_id)
if not valid_id or type(permission) ~= "string" or not SkyPhoneApps.AllowedPermissions[permission] then
return false
end
local policy = policies_by_id[app_id]
return policy ~= nil and policy.permissions[permission] == true
end
local function get_custom_app_policy(app_id)
local valid_id = SkyPhoneApps.ValidateAppId(app_id)
if not valid_id then
return nil
end
local policy = policies_by_id[app_id]
if not policy then
return nil
end
local permissions = {}
for permission in pairs(policy.permissions) do
permissions[#permissions + 1] = permission
end
table.sort(permissions)
return {
bundled = policy.bundled,
id = policy.id,
ownerResource = policy.ownerResource,
permissions = permissions,
}
end
local function get_custom_app_capabilities()
return {
abiVersion = 1,
enabled = Config.CustomApps.Enabled,
policyRegistration = true,
policyUpdate = true,
protocolVersion = SkyPhoneApps.ProtocolVersion,
}
end
SkyPhoneApps.GetPolicy = get_custom_app_policy
SkyPhoneApps.HasPermission = has_custom_app_permission
if Config.CustomApps.Enabled and Config.CustomApps.BundledApps then
local bundled = SkyPhoneApps.GetBundledManifests()
for index = 1, #bundled do
local manifest = bundled[index]
local registered, register_error = register_policy({
adapterResource = nil,
bundled = true,
id = manifest.id,
ownerResource = current_resource,
permissions = build_permission_set(manifest.permissions),
})
if not registered then
error(("[sky_phone] Could not register bundled custom app policy '%s': %s"):format(
manifest.id,
register_error
))
end
end
end
exports("AddCustomAppPolicy", add_custom_app_policy)
exports("AddCustomAppPolicyFromAdapter", add_custom_app_policy_from_adapter)
exports("GetCustomAppCapabilities", get_custom_app_capabilities)
exports("GetCustomAppPolicy", get_custom_app_policy)
exports("HasCustomAppPermission", has_custom_app_permission)
exports("RemoveCustomAppPolicy", remove_custom_app_policy)
exports("RemoveCustomAppPolicyFromAdapter", remove_custom_app_policy_from_adapter)
exports("UpdateCustomAppPolicy", update_custom_app_policy)
exports("UpdateCustomAppPolicyFromAdapter", update_custom_app_policy_from_adapter)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == current_resource then
return
end
local app_ids = {}
local owner_policies = policy_ids_by_owner[resource_name]
if owner_policies then
for app_id in pairs(owner_policies) do
app_ids[app_id] = true
end
end
local adapter_policies = policy_ids_by_adapter[resource_name]
if adapter_policies then
for app_id in pairs(adapter_policies) do
app_ids[app_id] = true
end
end
for app_id in pairs(app_ids) do
remove_policy(app_id)
end
end)
+286
View File
@@ -242,6 +242,56 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_custom_app_data",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "app_id",
type = "VARCHAR(64) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "data_key",
type = "VARCHAR(64) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "payload", type = "LONGTEXT NOT NULL" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "id",
uniqueKeys = {
{
name = "uniq_sky_phone_custom_app_data",
columns = "(`device_imei`, `app_id`, `data_key`)",
},
},
indexes = {
{
name = "idx_sky_phone_custom_app_storage",
columns = "(`device_imei`, `app_id`, `updated_at`)",
},
},
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_security",
columns = {
@@ -2128,6 +2178,39 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_profiles",
columns = {
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
{ name = "district", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "location_label", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "address", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
{ name = "location_x", type = "DECIMAL(10,3) NULL" },
{ name = "location_y", type = "DECIMAL(10,3) NULL" },
{ name = "location_z", type = "DECIMAL(10,3) NULL" },
{ name = "logo_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "cover_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "availability", type = "ENUM('available','busy','closed') NOT NULL DEFAULT 'closed'" },
{ name = "availability_updated_by", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "availability_updated_at", type = "DATETIME NULL" },
{ name = "availability_expires_at", type = "DATETIME NULL" },
{ name = "accepts_requests", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{ name = "mutation_token", type = "CHAR(36) 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 = "company_id",
indexes = {
{ name = "idx_sky_phone_company_profiles_public", columns = "(`availability`, `updated_at`)" },
},
foreignKeys = {
{ column = "logo_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
{ column = "cover_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_easyshare_transfers",
columns = {
@@ -2155,6 +2238,209 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_hours",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "weekday", type = "TINYINT UNSIGNED NOT NULL" },
{ name = "is_closed", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "opens_at", type = "CHAR(5) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "closes_at", type = "CHAR(5) NULL", characterSet = "ascii", collation = "ascii_bin" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_company_hours_day", columns = "(`company_id`, `weekday`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_services",
columns = {
{ name = "id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "title", type = "VARCHAR(80) NOT NULL" },
{ name = "description", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "price_text", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "requests_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "active", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "archived", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "sort_order", type = "TINYINT UNSIGNED 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" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_company_services_list", columns = "(`company_id`, `archived`, `active`, `sort_order`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_announcements",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "title", type = "VARCHAR(120) NOT NULL" },
{ name = "body", type = "VARCHAR(1000) NOT NULL" },
{ name = "created_by", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "active", type = "TINYINT(1) NOT NULL DEFAULT 1" },
{ name = "expires_at", type = "DATETIME NULL" },
{ 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 = "id",
indexes = {
{ name = "idx_sky_phone_company_announcements_active", columns = "(`company_id`, `active`, `expires_at`, `created_at`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_requests",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "service_id", type = "VARCHAR(64) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "customer_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "subject", type = "VARCHAR(120) NOT NULL" },
{ name = "description", type = "VARCHAR(2000) NOT NULL" },
{ name = "status", type = "ENUM('new','assigned','in_progress','waiting_customer','completed','cancelled') NOT NULL DEFAULT 'new'" },
{ name = "assigned_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "customer_unread", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "company_activity_revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{ name = "mutation_token", type = "CHAR(36) 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" },
{ name = "completed_at", type = "DATETIME NULL" },
{ name = "cancelled_at", type = "DATETIME NULL" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_company_requests_customer", columns = "(`customer_sim_id`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_queue", columns = "(`company_id`, `status`, `updated_at`, `id`)" },
{ name = "idx_sky_phone_company_requests_assignee", columns = "(`company_id`, `assigned_identifier`, `status`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
{ column = "service_id", references = "`sky_phone_company_services` (`id`) ON DELETE SET NULL" },
{ column = "customer_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_request_reads",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "reader_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "read_revision", type = "INT UNSIGNED 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" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_company_request_reads_reader", columns = "(`request_id`, `reader_identifier`)" },
},
indexes = {
{ name = "idx_sky_phone_company_request_reads_identifier", columns = "(`reader_identifier`, `updated_at`)" },
},
foreignKeys = {
{ column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_request_media",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_company_request_media", columns = "(`request_id`, `media_id`)" },
{ name = "uniq_sky_phone_company_request_media_order", columns = "(`request_id`, `sort_order`)" },
},
foreignKeys = {
{ column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" },
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_request_messages",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_type", type = "ENUM('customer','company') NOT NULL" },
{ name = "sender_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sender_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "body", type = "VARCHAR(2000) NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_company_request_messages", columns = "(`request_id`, `created_at`, `id`)" },
},
foreignKeys = {
{ column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" },
{ column = "sender_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_request_events",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "request_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "event_type", type = "ENUM('created','assigned','status','cancelled') NOT NULL" },
{ name = "actor_type", type = "ENUM('customer','company','system') NOT NULL" },
{ name = "actor_identifier", type = "VARCHAR(80) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "from_status", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "to_status", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "detail", type = "VARCHAR(255) NOT NULL DEFAULT ''" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_company_request_events", columns = "(`request_id`, `created_at`, `id`)" },
},
foreignKeys = {
{ column = "request_id", references = "`sky_phone_company_requests` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_company_audit",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "company_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "actor_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "action", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "target_type", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "target_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "metadata", type = "LONGTEXT NOT NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_company_audit", columns = "(`company_id`, `created_at`, `id`)" },
},
foreignKeys = {
{ column = "company_id", references = "`sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
+32 -5
View File
@@ -492,6 +492,9 @@ local function bootstrap(source, security, security_loaded)
error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei))
end
session.account_id = device.account_id and tonumber(device.account_id) or nil
session.account_email = session.account_id and device.email or nil
return {
token = session.token,
security = security_status(device.imei, security, security_loaded),
@@ -701,6 +704,7 @@ function SkyPhone.RequireDeviceSession(source)
local matches = find_device_slots(source, session.imei)
if not matches[1] then
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
TriggerClientEvent("sky_phone:device:invalidated", source)
return nil, { success = false, error = "device_not_owned" }
@@ -746,14 +750,13 @@ function SkyPhone.RequireAccount(source)
if not session then
return nil, error_response
end
local device = load_device(session.imei)
if not device or not device.account_id then
if not session.account_id then
return nil, { success = false, error = "not_authenticated" }
end
return {
id = tonumber(device.account_id),
email = device.email,
imei = device.imei,
id = session.account_id,
email = session.account_email,
imei = session.imei,
}
end
@@ -880,6 +883,9 @@ local function open_phone(source, used_item)
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
sessions[source] = {
imei = imei,
slot = slot.slot,
@@ -908,6 +914,9 @@ function SkyPhone.OpenDeviceForCall(source, imei)
return false
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
SkyPhoneCompanies.ClearCallAvailability(source)
end
sessions[source] = {
imei = imei,
slot = matches[1].slot,
@@ -934,10 +943,23 @@ Bridge.Debug(
)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:device:notification-open", function(source, data)
if not SkyPhone.AllowOperation(source, "notification_open", 10, 60)
or type(data) ~= "table" or type(data.imei) ~= "string"
then
return { success = false, error = "invalid_request" }
end
if not SkyPhone.OpenDeviceForCall(source, data.imei) then
return { success = false, error = "device_not_owned" }
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data)
if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then
return { success = false, error = "rate_limited" }
@@ -1245,6 +1267,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
@@ -1287,6 +1313,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
end)
AddEventHandler("playerDropped", function()
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
auth_attempts[source] = nil
operation_attempts[source] = nil
+24
View File
@@ -29,6 +29,9 @@ end
local function reserve_sim(sim_type, is_virtual)
local sim_id
local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate)
if SkyPhoneCompanies.IsServiceNumber(candidate) then
return false
end
sim_id = uuid()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`)
@@ -177,6 +180,15 @@ local function ensure_sim(source, slot, sim_type)
) then
return nil, "invalid_sim"
end
if sim and SkyPhoneCompanies.IsServiceNumber(sim.phone_number) then
Bridge.Debug(
"error",
"[sky_phone] SIM %s uses reserved company service number %s.",
tostring(sim.id),
tostring(sim.phone_number)
)
return nil, "invalid_sim"
end
if not sim then
sim = reserve_sim(sim_type)
if not Bridge.Inventory.SetSlotMetadata(source, slot.slot, sim_metadata(sim)) then
@@ -237,6 +249,16 @@ local function insert_sim(source, phone_imei, confirmed)
pending_insertions[source] = nil
return { success = false, error = "sim_request_expired" }
end
if SkyPhoneCompanies.IsServiceNumber(pending.sim.phone_number) then
pending_insertions[source] = nil
Bridge.Debug(
"error",
"[sky_phone] Refused to insert SIM %s with reserved company service number %s.",
tostring(pending.sim.id),
tostring(pending.sim.phone_number)
)
return { success = false, error = "invalid_sim" }
end
local sim_slot = Bridge.Inventory.GetSlot(source, pending.slot)
if not sim_slot or sim_slot.name ~= pending.item_name or not sim_slot.metadata
or sim_slot.metadata.sim_id ~= pending.sim.id then
@@ -311,6 +333,7 @@ local function insert_sim(source, phone_imei, confirmed)
pending_insertions[source] = nil
operation_locks[source] = nil
if old_sim then
SkyPhoneCompanies.ClearCallAvailability(source)
SkyPhoneCalls.EndForSim(old_sim.id, "sim_removed")
end
SkyPhone.RefreshDevice(phone_imei)
@@ -426,6 +449,7 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
return { success = false, error = "request_failed" }
end
operation_locks[source] = nil
SkyPhoneCompanies.ClearCallAvailability(source)
SkyPhoneCalls.EndForSim(sim.id, "sim_removed")
SkyPhone.RefreshDevice(session.imei)
return { success = true }
@@ -0,0 +1,269 @@
SkyPhoneCompatibility = {}
function SkyPhoneCompatibility.RegisterExportAlias(resource_name, export_name, handler)
assert(type(resource_name) == "string" and resource_name ~= "", "Export alias resource must be a non-empty string")
assert(type(export_name) == "string" and export_name ~= "", "Export alias name must be a non-empty string")
assert(type(handler) == "function", "Export alias handler must be a function")
AddEventHandler(("__cfx_export_%s_%s"):format(resource_name, export_name), function(set_callback)
set_callback(handler)
end)
end
SkyPhoneCompatibility.Providers = {
lb = "lb_phone",
seventeen = "17mov",
high = "high_phone",
quasar = "quasar_v3",
yseries = "yseries",
}
local function normalize_at_resource_url(owner_resource, url, error_prefix)
if type(url) ~= "string" or url == "" then
return nil, "invalid_" .. error_prefix
end
if url:sub(1, 7) == "http://" then
return nil, "insecure_" .. error_prefix
end
if url:sub(1, 1) ~= "@" then
return url
end
local url_owner, url_path = url:match("^@([^/]+)/(.+)$")
if url_owner ~= owner_resource or not url_path then
return nil, error_prefix .. "_owner_mismatch"
end
return owner_resource .. "/" .. url_path
end
local function resolve_lb_asset_resource(owner_resource, app_data)
local ui = app_data.ui
if type(ui) ~= "string" then
return nil
end
local explicit_ui_resource = ui:match("^https://cfx%-nui%-([^/]+)/")
or ui:match("^nui://([^/]+)/")
local asset_resource = explicit_ui_resource or ui:match("^([%w][%w._-]*)/")
if not asset_resource or asset_resource == owner_resource then
return nil
end
if explicit_ui_resource or app_data.resource == asset_resource then
return asset_resource
end
local icon = app_data.icon
local icon_resource = type(icon) == "string" and (
icon:match("^https://cfx%-nui%-([^/]+)/") or icon:match("^nui://([^/]+)/")
) or nil
if icon_resource == asset_resource then
return asset_resource
end
return nil
end
local function build_locale_maps(app_name, locales)
local names = {}
local descriptions = {}
if type(locales) == "table" then
for locale, locale_data in pairs(locales) do
if type(locale) == "string" and type(locale_data) == "table" then
if type(locale_data.label) == "string" and locale_data.label ~= "" then
names[locale] = locale_data.label
end
if type(locale_data.description) == "string" and locale_data.description ~= "" then
descriptions[locale] = locale_data.description
end
end
end
end
if not next(names) then
return app_name, app_name
end
for locale, label in pairs(names) do
if not descriptions[locale] then
descriptions[locale] = label
end
end
return names, descriptions
end
function SkyPhoneCompatibility.CopyQuasarData(app_data)
local copy = {}
for key, value in pairs(app_data) do
if key == "iframe" and type(value) == "table" then
copy.iframe = {}
for iframe_key, iframe_value in pairs(value) do
copy.iframe[iframe_key] = iframe_value
end
else
copy[key] = value
end
end
return copy
end
function SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
if type(app_data) ~= "table" or type(app_data.identifier) ~= "string" then
return nil, "invalid_app_data"
end
local ui, ui_error = normalize_at_resource_url(owner_resource, app_data.ui, "ui")
if not ui then
return nil, ui_error
end
return {
schemaVersion = 1,
id = app_data.identifier,
name = app_data.name,
description = app_data.description,
developer = app_data.developer,
category = app_data.game and "games" or "utilities",
ui = ui,
assetResource = resolve_lb_asset_resource(owner_resource, app_data),
bridgeMode = "legacy",
icon = app_data.icon,
defaultInstalled = app_data.defaultApp or false,
removable = not app_data.defaultApp,
orientation = app_data.landscape and "landscape" or "portrait",
onInstall = app_data.onInstall,
onOpen = app_data.onOpen or app_data.onUse,
onClose = app_data.onClose,
compatibility = {
provider = SkyPhoneCompatibility.Providers.lb,
apiVersion = 1,
resourceName = type(app_data.resource) == "string" and app_data.resource or owner_resource,
},
}
end
function SkyPhoneCompatibility.Build17MovDefinition(app_data)
if type(app_data) ~= "table" or type(app_data.name) ~= "string" then
return nil, "invalid_app_data"
end
return {
schemaVersion = 1,
id = app_data.name,
name = app_data.label,
description = app_data.description or app_data.label,
category = "utilities",
ui = app_data.ui,
bridgeMode = "legacy",
icon = app_data.icon,
iconBackground = type(app_data.iconBackground) == "string" and app_data.iconBackground or nil,
defaultInstalled = app_data.default or app_data.preInstalled or false,
removable = not app_data.default,
orientation = "portrait",
compatibility = {
provider = SkyPhoneCompatibility.Providers.seventeen,
apiVersion = 1,
},
}
end
function SkyPhoneCompatibility.BuildHighDefinition(owner_resource, app_name, data, locales)
if type(owner_resource) ~= "string" or type(app_name) ~= "string" or type(data) ~= "table" then
return nil, "invalid_app_data"
end
if #app_name > 64 or not app_name:match("^[a-z0-9][a-z0-9._-]+$") then
return nil, "invalid_app_id"
end
local external_url, url_error = normalize_at_resource_url(
owner_resource,
data.externalUrl,
"external_url"
)
if not external_url then
return nil, url_error
end
local name, description = build_locale_maps(app_name, locales)
local icon = type(data.icon) == "table" and data.icon.imageUrl or data.icon
return {
schemaVersion = 1,
id = app_name,
name = name,
description = description,
developer = data.developer,
category = "utilities",
ui = external_url,
bridgeMode = "legacy",
icon = icon,
iconBackground = type(data.icon) == "table" and data.icon.background or nil,
defaultInstalled = data.preAdded or false,
removable = data.removable ~= false,
orientation = "portrait",
compatibility = {
provider = SkyPhoneCompatibility.Providers.high,
apiVersion = 1,
},
}
end
function SkyPhoneCompatibility.BuildQuasarDefinition(app_data)
local iframe_url = type(app_data) == "table"
and type(app_data.iframe) == "table"
and app_data.iframe.url
or nil
if type(app_data) ~= "table"
or type(app_data.id) ~= "string"
or type(app_data.label) ~= "string"
or type(iframe_url) ~= "string"
then
return nil, "invalid_app_data"
end
return {
schemaVersion = 1,
id = app_data.id,
name = app_data.label,
description = app_data.description or app_data.label,
category = "utilities",
ui = iframe_url,
bridgeMode = "legacy",
icon = app_data.icon,
defaultInstalled = app_data.defaultInstalled or false,
removable = true,
orientation = "portrait",
compatibility = {
provider = SkyPhoneCompatibility.Providers.quasar,
apiVersion = 1,
},
}
end
function SkyPhoneCompatibility.BuildYSeriesDefinition(app_data)
if type(app_data) ~= "table" or type(app_data.key) ~= "string" then
return nil, "invalid_app_data"
end
local icon = app_data.icon
if type(icon) == "table" then
icon = icon.yos or icon.humanoid
end
return {
schemaVersion = 1,
id = app_data.key,
name = app_data.name,
description = app_data.description or app_data.name,
category = app_data.game and "games" or "utilities",
ui = app_data.ui,
bridgeMode = "legacy",
icon = icon,
defaultInstalled = app_data.defaultApp or false,
removable = true,
orientation = "portrait",
compatibility = {
provider = SkyPhoneCompatibility.Providers.yseries,
apiVersion = 1,
},
}
end
+451
View File
@@ -0,0 +1,451 @@
local APP_ID_MAX_LENGTH = 64
local DESCRIPTION_MAX_LENGTH = 320
local DEVELOPER_MAX_LENGTH = 96
local NAME_MAX_LENGTH = 64
local PATH_MAX_LENGTH = 512
local VERSION_MAX_LENGTH = 32
local ALLOWED_CATEGORIES = {
games = true,
productivity = true,
shopping = true,
social = true,
utilities = true,
}
local ALLOWED_ORIENTATIONS = {
any = true,
landscape = true,
portrait = true,
}
local ALLOWED_PERMISSIONS = {
["app.close"] = true,
["app.open"] = true,
["camera.capture"] = true,
["contacts.pick"] = true,
["device.storage"] = true,
["locale.read"] = true,
["location.read"] = true,
["media.pick"] = true,
["notifications"] = true,
["notifications.critical"] = true,
["nui.fetch"] = true,
["theme.read"] = true,
}
local RESERVED_APP_IDS = {
["app-store"] = true,
banking = true,
billing = true,
calculator = true,
calendar = true,
camera = true,
citymarkt = true,
clock = true,
companies = true,
crewlink = true,
darkchat = true,
feather = true,
flare = true,
fliptok = true,
garage = true,
house = true,
["local-pages"] = true,
mail = true,
map = true,
memory = true,
messages = true,
minesweeper = true,
music = true,
["neon-drop"] = true,
notes = true,
["number-merge"] = true,
phone = true,
photos = true,
picstagram = true,
radio = true,
settings = true,
["sky-flappy"] = true,
skyride = true,
snake = true,
["tower-stack"] = true,
weather = true,
}
local bundled_manifests = {}
local bundled_manifests_by_id = {}
SkyPhoneApps = SkyPhoneApps or {}
SkyPhoneApps.ProtocolVersion = 1
local function trim(value)
return value:match("^%s*(.-)%s*$")
end
local function validate_app_id(app_id)
if type(app_id) ~= "string" then
return false, "invalid_app_id"
end
if #app_id == 0 or #app_id > APP_ID_MAX_LENGTH then
return false, "invalid_app_id"
end
if not app_id:match("^[a-z0-9][a-z0-9._-]+$") then
return false, "invalid_app_id"
end
return true
end
local function validate_text(value, error_code, maximum_length, required)
if value == nil and not required then
return nil
end
if type(value) ~= "string" then
return nil, error_code
end
local normalized = trim(value)
if (required and #normalized == 0) or #normalized > maximum_length then
return nil, error_code
end
return normalized
end
local function validate_localized_text(value, error_code, maximum_length, required)
if type(value) == "string" then
return validate_text(value, error_code, maximum_length, required)
end
if type(value) ~= "table" then
return nil, error_code
end
local normalized = {}
local count = 0
for locale, text in pairs(value) do
if type(locale) ~= "string" or not locale:match("^[a-zA-Z][a-zA-Z0-9_-]*$") then
return nil, error_code
end
local normalized_text = validate_text(text, error_code, maximum_length, true)
if not normalized_text then
return nil, error_code
end
count = count + 1
if count > 16 then
return nil, error_code
end
normalized[locale:lower():gsub("_", "-")] = normalized_text
end
if count == 0 then
return nil, error_code
end
return normalized
end
local function validate_local_path(path, required)
if path == nil and not required then
return nil
end
if type(path) ~= "string" or #path == 0 or #path > PATH_MAX_LENGTH then
return nil, "invalid_local_path"
end
if path:sub(1, 1) == "/" or path:find("\\", 1, true) or path:find(":", 1, true) then
return nil, "invalid_local_path"
end
if not path:match("^web/[%w%._%-%/]+$") then
return nil, "invalid_local_path"
end
for segment in path:gmatch("[^/]+") do
if segment == "." or segment == ".." then
return nil, "invalid_local_path"
end
end
return path
end
local function validate_string_array(value, error_code, maximum_items, validator)
if value == nil then
return {}
end
if type(value) ~= "table" then
return nil, error_code
end
local normalized = {}
local count = 0
for key in pairs(value) do
if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
return nil, error_code
end
count = count + 1
end
if count ~= #value or count > maximum_items then
return nil, error_code
end
for index = 1, count do
local item = value[index]
if type(item) ~= "string" then
return nil, error_code
end
local normalized_item, validation_error = validator(item)
if not normalized_item then
return nil, validation_error or error_code
end
normalized[index] = normalized_item
end
return normalized
end
local function validate_permissions(value)
local permissions, error_code = validate_string_array(
value,
"invalid_permissions",
32,
function(permission)
if not ALLOWED_PERMISSIONS[permission] then
return nil, "unknown_permission"
end
return permission
end
)
if not permissions then
return nil, error_code
end
local seen = {}
for index = 1, #permissions do
local permission = permissions[index]
if seen[permission] then
return nil, "duplicate_permission"
end
seen[permission] = true
end
return permissions
end
local function validate_grid_order(value)
if value == nil then
return nil
end
if type(value) ~= "number" or value ~= math.floor(value) or value < 0 or value > 9999 then
return nil, "invalid_grid_order"
end
return value
end
local function validate_icon_background(value)
if value == nil then
return nil
end
if type(value) ~= "string" or #value == 0 or #value > 64 then
return nil, "invalid_icon_background"
end
if value:find("[\r\n;{}]") then
return nil, "invalid_icon_background"
end
return value
end
local function normalize_manifest(folder, definition)
if type(folder) ~= "string" or type(definition) ~= "table" then
return nil, "invalid_manifest"
end
if definition.schemaVersion ~= SkyPhoneApps.ProtocolVersion then
return nil, "unsupported_schema_version"
end
local app_id = definition.id
local valid_id, id_error = validate_app_id(app_id)
if not valid_id then
return nil, id_error
end
if RESERVED_APP_IDS[app_id] then
return nil, "reserved_app_id"
end
if folder ~= app_id then
return nil, "folder_id_mismatch"
end
if definition.defaultInstalled ~= nil and type(definition.defaultInstalled) ~= "boolean" then
return nil, "invalid_default_installed"
end
if definition.removable ~= nil and type(definition.removable) ~= "boolean" then
return nil, "invalid_removable"
end
local version, version_error = validate_text(
definition.version or "1.0.0",
"invalid_version",
VERSION_MAX_LENGTH,
true
)
if not version then
return nil, version_error
end
if not version:match("^[%w%._+-]+$") then
return nil, "invalid_version"
end
local name, name_error = validate_localized_text(
definition.name,
"invalid_name",
NAME_MAX_LENGTH,
true
)
if not name then
return nil, name_error
end
local description, description_error = validate_localized_text(
definition.description,
"invalid_description",
DESCRIPTION_MAX_LENGTH,
true
)
if not description then
return nil, description_error
end
local developer, developer_error = validate_text(
definition.developer,
"invalid_developer",
DEVELOPER_MAX_LENGTH,
false
)
if definition.developer ~= nil and not developer then
return nil, developer_error
end
local category = definition.category or "utilities"
if not ALLOWED_CATEGORIES[category] then
return nil, "invalid_category"
end
local entry, entry_error = validate_local_path(definition.entry, true)
if not entry then
return nil, entry_error
end
local icon, icon_error = validate_local_path(definition.icon, false)
if definition.icon ~= nil and not icon then
return nil, icon_error
end
local screenshots, screenshots_error = validate_string_array(
definition.screenshots,
"invalid_screenshots",
8,
function(path)
return validate_local_path(path, true)
end
)
if not screenshots then
return nil, screenshots_error
end
local permissions, permissions_error = validate_permissions(definition.permissions)
if not permissions then
return nil, permissions_error
end
local orientation = definition.orientation or "portrait"
if not ALLOWED_ORIENTATIONS[orientation] then
return nil, "invalid_orientation"
end
local grid_order, grid_order_error = validate_grid_order(definition.gridOrder)
if definition.gridOrder ~= nil and not grid_order then
return nil, grid_order_error
end
local icon_background, background_error = validate_icon_background(definition.iconBackground)
if definition.iconBackground ~= nil and not icon_background then
return nil, background_error
end
local bridge_mode = definition.bridgeMode or "sky"
if bridge_mode ~= "sky" and bridge_mode ~= "legacy" then
return nil, "invalid_bridge_mode"
end
return {
bridgeMode = bridge_mode,
category = category,
defaultInstalled = definition.defaultInstalled == true,
description = description,
developer = developer,
entry = entry,
folder = folder,
gridOrder = grid_order,
icon = icon,
iconBackground = icon_background,
id = app_id,
name = name,
orientation = orientation,
permissions = permissions,
removable = definition.removable ~= false,
schemaVersion = SkyPhoneApps.ProtocolVersion,
screenshots = screenshots,
version = version,
}
end
---@param folder string App folder relative to custom_apps.
---@param definition table Declarative bundled app manifest.
function SkyPhoneApps.RegisterManifest(folder, definition)
local manifest, error_code = normalize_manifest(folder, definition)
if not manifest then
error(("[sky_phone] Invalid custom app manifest '%s': %s"):format(tostring(folder), error_code), 2)
end
if bundled_manifests_by_id[manifest.id] then
error(("[sky_phone] Duplicate bundled custom app id '%s'."):format(manifest.id), 2)
end
bundled_manifests[#bundled_manifests + 1] = manifest
bundled_manifests_by_id[manifest.id] = manifest
end
function SkyPhoneApps.GetBundledManifests()
return bundled_manifests
end
function SkyPhoneApps.GetBundledManifest(app_id)
return bundled_manifests_by_id[app_id]
end
SkyPhoneApps.AllowedCategories = ALLOWED_CATEGORIES
SkyPhoneApps.AllowedOrientations = ALLOWED_ORIENTATIONS
SkyPhoneApps.AllowedPermissions = ALLOWED_PERMISSIONS
SkyPhoneApps.ReservedAppIds = RESERVED_APP_IDS
SkyPhoneApps.ValidateAppId = validate_app_id
SkyPhoneApps.ValidateLocalizedText = validate_localized_text
SkyPhoneApps.ValidatePermissions = validate_permissions
+178
View File
@@ -103,6 +103,20 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
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_custom_app_data` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`app_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`data_key` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`payload` LONGTEXT NOT NULL,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_custom_app_data` (`device_imei`, `app_id`, `data_key`),
KEY `idx_sky_phone_custom_app_storage` (`device_imei`, `app_id`, `updated_at`),
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_security` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`passcode_hash` BINARY(32) NOT NULL,
@@ -957,3 +971,167 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_pings` (
FOREIGN KEY (`group_id`) REFERENCES `sky_phone_crewlink_groups` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`creator_profile_id`) REFERENCES `sky_phone_crewlink_profiles` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_profiles` (
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`description` VARCHAR(1000) NOT NULL DEFAULT '',
`district` VARCHAR(80) NOT NULL DEFAULT '',
`location_label` VARCHAR(80) NOT NULL DEFAULT '',
`address` VARCHAR(160) NOT NULL DEFAULT '',
`location_x` DECIMAL(10,3) NULL,
`location_y` DECIMAL(10,3) NULL,
`location_z` DECIMAL(10,3) NULL,
`logo_media_id` BIGINT UNSIGNED NULL,
`cover_media_id` BIGINT UNSIGNED NULL,
`availability` ENUM('available','busy','closed') NOT NULL DEFAULT 'closed',
`availability_updated_by` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`availability_updated_at` DATETIME NULL,
`availability_expires_at` DATETIME NULL,
`accepts_requests` TINYINT(1) NOT NULL DEFAULT 1,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`mutation_token` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`company_id`),
KEY `idx_sky_phone_company_profiles_public` (`availability`,`updated_at`),
FOREIGN KEY (`logo_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL,
FOREIGN KEY (`cover_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_company_hours` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`weekday` TINYINT UNSIGNED NOT NULL,
`is_closed` TINYINT(1) NOT NULL DEFAULT 0,
`opens_at` CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NULL,
`closes_at` CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_company_hours_day` (`company_id`,`weekday`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_services` (
`id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`title` VARCHAR(80) NOT NULL,
`description` VARCHAR(500) NOT NULL DEFAULT '',
`price_text` VARCHAR(80) NOT NULL DEFAULT '',
`requests_enabled` TINYINT(1) NOT NULL DEFAULT 1,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`archived` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` TINYINT UNSIGNED 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`),
KEY `idx_sky_phone_company_services_list` (`company_id`,`archived`,`active`,`sort_order`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_announcements` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`title` VARCHAR(120) NOT NULL,
`body` VARCHAR(1000) NOT NULL,
`created_by` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`expires_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_company_announcements_active` (`company_id`,`active`,`expires_at`,`created_at`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_requests` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`service_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
`customer_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`subject` VARCHAR(120) NOT NULL,
`description` VARCHAR(2000) NOT NULL,
`status` ENUM('new','assigned','in_progress','waiting_customer','completed','cancelled') NOT NULL DEFAULT 'new',
`assigned_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`customer_unread` INT UNSIGNED NOT NULL DEFAULT 0,
`company_activity_revision` INT UNSIGNED NOT NULL DEFAULT 1,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`mutation_token` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`completed_at` DATETIME NULL,
`cancelled_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_company_requests_customer` (`customer_sim_id`,`updated_at`,`id`),
KEY `idx_sky_phone_company_requests_queue` (`company_id`,`status`,`updated_at`,`id`),
KEY `idx_sky_phone_company_requests_assignee` (`company_id`,`assigned_identifier`,`status`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE,
FOREIGN KEY (`service_id`) REFERENCES `sky_phone_company_services` (`id`) ON DELETE SET NULL,
FOREIGN KEY (`customer_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_company_request_reads` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`reader_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`read_revision` INT UNSIGNED 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`),
UNIQUE KEY `uniq_sky_phone_company_request_reads_reader` (`request_id`,`reader_identifier`),
KEY `idx_sky_phone_company_request_reads_identifier` (`reader_identifier`,`updated_at`),
FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_request_media` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`media_id` BIGINT UNSIGNED NOT NULL,
`sort_order` TINYINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_company_request_media` (`request_id`,`media_id`),
UNIQUE KEY `uniq_sky_phone_company_request_media_order` (`request_id`,`sort_order`),
FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_request_messages` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sender_type` ENUM('customer','company') NOT NULL,
`sender_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`sender_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`body` VARCHAR(2000) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_company_request_messages` (`request_id`,`created_at`,`id`),
FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`sender_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_company_request_events` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`request_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`event_type` ENUM('created','assigned','status','cancelled') NOT NULL,
`actor_type` ENUM('customer','company','system') NOT NULL,
`actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NULL,
`from_status` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
`to_status` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
`detail` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_company_request_events` (`request_id`,`created_at`,`id`),
FOREIGN KEY (`request_id`) REFERENCES `sky_phone_company_requests` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_company_audit` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`company_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`actor_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`action` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`target_type` VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`target_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`metadata` LONGTEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sky_phone_company_audit` (`company_id`,`created_at`,`id`),
FOREIGN KEY (`company_id`) REFERENCES `sky_phone_company_profiles` (`company_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;