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 if body ~= "" and #body > Config.Messages.BodyMaxLength then return { success = false, error = "invalid_message" } end 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 blocks = Bridge.Database.Query([[ SELECT 1 AS `blocked` FROM `sky_phone_call_blocks` WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1 ]], { recipient.id, device.sim_id }) if blocks[1] then return { success = false, error = "blocked" } 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)