mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ENH - integrate latest dev into EasyShare
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user