Files
sky_phone-sky-systems/sky_phone/source/server/messages.lua
T
2026-08-12 18:59:14 +02:00

586 lines
21 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local allowed_voice_mimes = {
["audio/webm"] = true,
["audio/webm;codecs=opus"] = true,
}
local attachment_assets = {
gif = {
celebrate = true,
hearts = true,
party = true,
thumbs_up = true,
wow = true,
},
image = {
["camera-1"] = true,
["camera-2"] = true,
["camera-3"] = true,
["city-lights"] = true,
["desert-road"] = true,
["ocean-air"] = true,
["sunset-drive"] = true,
},
video = {
["city-loop"] = true,
["ocean-loop"] = true,
["sunset-loop"] = true,
},
}
local attachment_mimes = {
gif = "image/gif",
image = "image/jpeg",
video = "video/webm",
}
local function allowed_media_url(value)
if type(value) ~= "string" or #value == 0 or #value > Config.Media.UrlMaxLength then
return false
end
local host = value:lower():match("^https://([^/:?#]+)")
if not host then
return false
end
for _, allowed_host in ipairs(Config.Media.AllowedGifHosts) do
local suffix = "." .. allowed_host
if host == allowed_host or host:sub(-#suffix) == suffix then
return true
end
end
return false
end
local function valid_attachment_asset(message_type, value)
return attachment_assets[message_type][value]
or message_type == "gif" and allowed_media_url(value)
end
local function valid_stored_attachment(message_type, value)
if valid_attachment_asset(message_type, value) then
return true
end
return (message_type == "image" or message_type == "video")
and type(value) == "string"
and #value <= Config.Media.UrlMaxLength
and value:match("^https://") ~= nil
end
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate an SMS UUID.")
end
return rows[1].id
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function current_device(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
if not device.sim_id then
return nil, { success = false, error = "no_sim" }
end
return device
end
local function shared_contact(device, contact_id)
if type(contact_id) ~= "string" or contact_id == "" or #contact_id > 36 then
return nil, "invalid_contact"
end
local condition
local params
if device.account_id then
condition = "contact.`account_id` = ?"
params = { contact_id, tonumber(device.account_id) }
else
condition = "contact.`account_id` IS NULL AND contact.`device_imei` = ?"
params = { contact_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT contact.`name`, contact.`organization`, contact.`phone_number`, media.`url` AS `avatar_url`
FROM `sky_phone_contacts` contact
LEFT JOIN `sky_phone_media` media ON media.`id` = contact.`avatar_media_id`
WHERE contact.`contact_id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local contact = rows[1]
if not contact then
return nil, "contact_not_found"
end
local name = trim(contact.name)
local organization = trim(contact.organization)
local number = SkyPhoneSimNumber.Normalize(
contact.phone_number,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength
or (organization and #organization > Config.Calls.ContactNameMaxLength) or not number
then
error(("[sky_phone] Contact %s cannot be shared because its stored data is invalid."):format(contact_id))
end
local snapshot = {
avatar_url = contact.avatar_url,
name = name,
organization = organization ~= "" and organization or nil,
phone_number = number,
}
return {
data = snapshot,
payload = json.encode(snapshot),
}
end
local function format_message(row)
row.media_duration_ms = tonumber(row.media_duration_ms)
row.media_asset_id = nil
row.contact = nil
row.share = nil
if row.message_type == "voice" then
local waveform = row.media_waveform and json.decode(row.media_waveform) or nil
if type(waveform) ~= "table" then
error(("[sky_phone] Voice message %s has an invalid waveform payload."):format(tostring(row.id)))
end
row.media_waveform = waveform
row.media_payload = nil
elseif row.message_type == "contact" then
local contact = row.media_payload and json.decode(row.media_payload) or nil
if type(contact) ~= "table" or type(contact.name) ~= "string"
or type(contact.phone_number) ~= "string"
or (contact.avatar_url ~= nil and type(contact.avatar_url) ~= "string")
or (contact.organization ~= nil and type(contact.organization) ~= "string")
then
error(("[sky_phone] Message %s has an invalid contact payload."):format(tostring(row.id)))
end
row.contact = contact
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
elseif row.message_type == "share" then
local share = row.media_payload and json.decode(row.media_payload) or nil
if type(share) ~= "table" or type(share.appId) ~= "string"
or type(share.kind) ~= "string" or type(share.title) ~= "string"
or type(share.copyText) ~= "string"
then
error(("[sky_phone] Message %s has an invalid share payload."):format(tostring(row.id)))
end
row.share = share
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
elseif attachment_assets[row.message_type] then
if not valid_stored_attachment(row.message_type, row.media_payload) then
error(("[sky_phone] Message %s has an invalid attachment asset."):format(tostring(row.id)))
end
row.media_asset_id = row.media_payload
row.media_payload = nil
row.media_waveform = nil
if row.message_type ~= "video" then
row.media_duration_ms = nil
end
else
row.media_payload = nil
row.media_duration_ms = nil
row.media_mime = nil
row.media_waveform = nil
end
return row
end
local function validate_attachment(source, device, message_type, data)
if type(data.mediaAssetId) ~= "string" then
return nil
end
local payload = data.mediaAssetId
local built_in_asset = valid_attachment_asset(message_type, payload)
local mime = built_in_asset and attachment_mimes[message_type] or nil
if not built_in_asset then
if message_type == "gif" then
return nil
end
local media_id = tonumber(payload)
if not media_id or media_id < 1 or media_id ~= math.floor(media_id) then
return nil
end
local condition
local params
if device.account_id then
condition = "`account_id` = ?"
params = { media_id, tonumber(device.account_id) }
else
condition = "`account_id` IS NULL AND `device_imei` = ?"
params = { media_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
local media = rows[1]
local expected_type = message_type == "image" and "photo" or "video"
if not media or media.media_type ~= expected_type
or type(media.url) ~= "string"
or #media.url > Config.Media.UrlMaxLength
or not media.url:match("^https://")
then
Bridge.Debug("warn", ("[sky_phone] Rejected unowned SMS media from source %s."):format(tostring(source)))
return nil
end
payload = media.url
mime = type(media.mime_type) == "string" and media.mime_type ~= "" and media.mime_type or nil
end
local duration = nil
if message_type == "video" and data.mediaDurationMs ~= nil then
duration = tonumber(data.mediaDurationMs)
if not duration or duration < 1000 or duration > Config.Messages.VideoMaxDurationMs then
return nil
end
duration = math.floor(duration)
end
return {
duration = duration,
mime = mime,
payload = payload,
}
end
local function validate_voice(data)
if type(data.mediaPayload) ~= "string"
or #data.mediaPayload == 0
or #data.mediaPayload > Config.Messages.VoiceMaxBase64Length
or data.mediaPayload:find("[^A-Za-z0-9+/=]") then
return nil
end
if type(data.mediaMime) ~= "string" or not allowed_voice_mimes[data.mediaMime] then
return nil
end
local duration = tonumber(data.mediaDurationMs)
if not duration or duration < 300 or duration > Config.Messages.VoiceMaxDurationMs then
return nil
end
if type(data.mediaWaveform) ~= "table"
or #data.mediaWaveform < 8
or #data.mediaWaveform > Config.Messages.VoiceWaveformSamples then
return nil
end
local waveform = {}
for index = 1, #data.mediaWaveform do
local sample = tonumber(data.mediaWaveform[index])
if not sample or sample < 0 or sample > 1 then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return {
duration = math.floor(duration),
mime = data.mediaMime,
payload = data.mediaPayload,
waveform = json.encode(waveform),
}
end
local function notify_sim(sim_id, event_name, data)
local devices = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
WHERE d.`sim_id` = ?
]], { sim_id })
for _, device in ipairs(devices) do
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
if SkyPhone.FindDeviceSlots(source, device.imei)[1] then
local payload = {}
for key, value in pairs(data) do
payload[key] = value
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, source, payload)
end
end
end
end
Bridge.Callbacks.Register("sky_phone:messages:conversations", function(source)
local device, error_response = current_device(source)
if not device then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`, `read_at`, `created_at`
FROM `sky_phone_sms_messages`
WHERE `sender_sim_id` = ? OR `recipient_sim_id` = ?
ORDER BY `created_at` DESC, `id` DESC LIMIT ?
]], { device.sim_id, device.sim_id, Config.Messages.ConversationScanLimit })
local conversations = {}
local ordered = {}
for _, row in ipairs(rows) do
local received = row.recipient_sim_id == device.sim_id
local number = received and row.sender_number or row.recipient_number
local conversation = conversations[number]
if not conversation then
conversation = {
phoneNumber = number,
lastMessage = row.body,
lastMessageAt = row.created_at,
lastMessageType = row.message_type,
unread = 0,
}
conversations[number] = conversation
ordered[#ordered + 1] = conversation
end
if received and not row.read_at then
conversation.unread = conversation.unread + 1
end
end
return { success = true, data = ordered }
end)
Bridge.Callbacks.Register("sky_phone:messages:thread", function(source, data)
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
local rows = Bridge.Database.Query([[
SELECT * FROM (
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`,
`media_duration_ms`, `media_waveform`, `read_at`, `created_at`,
CASE WHEN `sender_sim_id` = ? THEN 'sent' ELSE 'received' END AS `direction`
FROM `sky_phone_sms_messages`
WHERE (`sender_sim_id` = ? AND `recipient_number` = ?)
OR (`recipient_sim_id` = ? AND `sender_number` = ?)
ORDER BY `created_at` DESC, `id` DESC LIMIT ?
) recent_messages
ORDER BY `created_at` ASC, `id` ASC
]], { device.sim_id, device.sim_id, number, device.sim_id, number, Config.Messages.ThreadPageSize })
for index = 1, #rows do
rows[index] = format_message(rows[index])
end
Bridge.Database.Query([[
UPDATE `sky_phone_sms_messages` SET `read_at` = CURRENT_TIMESTAMP
WHERE `recipient_sim_id` = ? AND `sender_number` = ? AND `read_at` IS NULL
]], { device.sim_id, number })
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:messages:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "message_delete", 10, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.phoneNumbers) ~= "table"
or #data.phoneNumbers == 0 or #data.phoneNumbers > Config.Messages.DeleteBatchSize then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local numbers = {}
local seen = {}
for index = 1, #data.phoneNumbers do
local number = SkyPhoneSimNumber.Normalize(
data.phoneNumbers[index],
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not number then
return { success = false, error = "invalid_number" }
end
if not seen[number] then
seen[number] = true
numbers[#numbers + 1] = number
end
end
local placeholders = {}
for index = 1, #numbers do
placeholders[index] = "?"
end
local values = { device.sim_id }
for _, number in ipairs(numbers) do
values[#values + 1] = number
end
values[#values + 1] = device.sim_id
for _, number in ipairs(numbers) do
values[#values + 1] = number
end
local list = table.concat(placeholders, ", ")
Bridge.Database.Query(([[
DELETE FROM `sky_phone_sms_messages`
WHERE (`sender_sim_id` = ? AND `recipient_number` IN (%s))
OR (`recipient_sim_id` = ? AND `sender_number` IN (%s))
]]):format(list, list), values)
TriggerClientEvent("sky_phone:messages:changed", source, {})
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:messages:media", function(source, data)
if not SkyPhone.AllowOperation(source, "message_media", Config.Messages.MediaLoadsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local rows = Bridge.Database.Query([[
SELECT `media_payload`, `media_mime`
FROM `sky_phone_sms_messages`
WHERE `id` = ? AND `message_type` = 'voice'
AND (`sender_sim_id` = ? OR `recipient_sim_id` = ?)
LIMIT 1
]], { data.id, device.sim_id, device.sim_id })
if not rows[1] or type(rows[1].media_payload) ~= "string" then
return { success = false, error = "message_not_found" }
end
return {
success = true,
data = {
mime = rows[1].media_mime,
payload = rows[1].media_payload,
},
}
end)
Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
if not SkyPhone.AllowOperation(source, "message_send", Config.Messages.SendsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local device, error_response = current_device(source)
if not device then
return error_response
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
return { success = false, error = "invalid_number" }
end
if number == device.phone_number then
return { success = false, error = "self_message" }
end
local message_type = data.messageType or "text"
local body = trim(data.body) or ""
local voice = nil
local attachment = nil
local contact = nil
local share = nil
if message_type == "text" then
if body == "" or #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
elseif message_type == "voice" then
voice = validate_voice(data)
if not voice then
return { success = false, error = "invalid_voice" }
end
body = ""
elseif message_type == "contact" then
local contact_error
contact, contact_error = shared_contact(device, data.contactId)
if not contact then
return { success = false, error = contact_error }
end
body = contact.data.name
elseif message_type == "share" then
local share_error
local encoded
share, share_error, encoded = SkyPhoneEasyShare.SanitizeChatPayload(source, data.sharePayload)
if not share then
return { success = false, error = share_error or "invalid_payload" }
end
if body ~= "" and #body > Config.Messages.BodyMaxLength then
return { success = false, error = "invalid_message" }
end
share = { data = share, payload = encoded }
body = body ~= "" and body or share.data.title
elseif attachment_assets[message_type] then
attachment = validate_attachment(source, device, message_type, data)
if not attachment then
return { success = false, error = "invalid_attachment" }
end
body = ""
else
return { success = false, error = "invalid_request" }
end
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ?
AND (s.`is_virtual` = 0 OR d.`imei` IS NOT NULL)
LIMIT 1
]], { number })
local recipient = recipients[1]
if not recipient then
return { success = false, error = "recipient_not_found" }
end
local id = uuid()
Bridge.Database.Query([[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `message_type`,
`body`, `media_payload`, `media_mime`, `media_duration_ms`, `media_waveform`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
id,
device.sim_id,
recipient.id,
device.phone_number,
number,
message_type,
body,
voice and voice.payload or attachment and attachment.payload or contact and contact.payload
or share and share.payload or nil,
voice and voice.mime or attachment and attachment.mime or nil,
voice and voice.duration or attachment and attachment.duration or nil,
voice and voice.waveform or nil,
})
local rows = Bridge.Database.Query([[
SELECT `id`, `sender_number`, `recipient_number`, `message_type`, `body`, `media_payload`, `media_mime`,
`media_duration_ms`, `media_waveform`, `read_at`, `created_at`, 'sent' AS `direction`
FROM `sky_phone_sms_messages` WHERE `id` = ? LIMIT 1
]], { id })
local message = format_message(rows[1])
TriggerClientEvent("sky_phone:messages:changed", source, { phoneNumber = number })
notify_sim(recipient.id, "sky_phone:messages:new", {
message = message,
phoneNumber = device.phone_number,
sender = device.phone_number,
voice = message_type == "voice",
})
return { success = true, data = message }
end)
end)