Bridge.Database.AfterMigration("sky_phone", function() local role_levels = { guest = 1, member = 2, moderator = 3, coordinator = 4, owner = 5, } local member_roles = { guest = true, member = true, moderator = true, coordinator = true, } local group_colours = { cyan = true, blue = true, violet = true, orange = true, green = true, rose = true, } local ping_types = { meeting = true, danger = true, help = true, target = true, info = true, } local live_sources_cache = { expires_at = 0, sources = {}, } 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 trim(value) if type(value) ~= "string" then return nil end return value:match("^%s*(.-)%s*$") end local function valid_text(value, minimum, maximum) local length = type(value) == "string" and utf8.len(value) or nil return length and length >= minimum and length <= maximum end local function new_id() 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 a CrewLink id.") end return rows[1].id end local function new_invite_code() for _ = 1, 5 do local rows = Bridge.Database.Query([[ SELECT UPPER(SUBSTRING(REPLACE(UUID(), '-', ''), 1, ?)) AS `code` ]], { Config.CrewLink.InviteCodeLength }) local code = rows[1] and rows[1].code if type(code) == "string" then local existing = Bridge.Database.Query( "SELECT 1 FROM `sky_phone_crewlink_groups` WHERE `invite_code` = ? LIMIT 1", { code } ) if not existing[1] then return code end end end error("[sky_phone] Could not generate a unique CrewLink invite code.") end local function profile_dto(row) return { id = row.id, username = row.username, avatarMediaId = row.avatar_media_id and tonumber(row.avatar_media_id) or nil, avatarUrl = row.avatar_url, activeGroupId = row.active_group_id, mapVisible = tonumber(row.map_visible) == 1, overheadVisible = tonumber(row.overhead_visible) == 1, } end local function require_profile(source) local account, error_response = SkyPhone.RequireAccount(source) if not account then return nil, error_response end local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`, p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url` FROM `sky_phone_crewlink_profiles` p LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE p.`account_id` = ? LIMIT 1 ]], { account.id }) if not rows[1] then return nil, { success = false, error = "profile_required" } end rows[1].account_id = tonumber(rows[1].account_id) return rows[1] end local function membership(profile_id, group_id) local rows = Bridge.Database.Query([[ SELECT m.`group_id`, m.`profile_id`, m.`role`, m.`joined_at`, g.`name` AS `group_name`, g.`colour`, g.`allow_member_pings`, g.`overhead_allowed`, g.`invite_code`, g.`owner_profile_id` FROM `sky_phone_crewlink_memberships` m JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id` WHERE m.`profile_id` = ? AND m.`group_id` = ? LIMIT 1 ]], { profile_id, group_id }) return rows[1] end local function group_count(group_id) local rows = Bridge.Database.Query([[ SELECT COUNT(*) AS `count` FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? ]], { group_id }) return tonumber(rows[1] and rows[1].count) or 0 end local function member_dtos(group_id) local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`, m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at` FROM `sky_phone_crewlink_memberships` m JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id` LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE m.`group_id` = ? ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at` ]], { group_id }) for _, row in ipairs(rows) do row.account_id = tonumber(row.account_id) row.mapVisible = tonumber(row.map_visible) == 1 row.overheadVisible = tonumber(row.overhead_visible) == 1 row.avatarUrl = row.avatar_url row.map_visible = nil row.overhead_visible = nil row.avatar_url = nil row.joinedAt = (tonumber(row.joined_at) or 0) * 1000 row.joined_at = nil end return rows end local function group_dto(row, profile_id) local role = row.role return { id = row.id or row.group_id, name = row.name or row.group_name, colour = row.colour, role = role, inviteCode = role_levels[role] >= role_levels.coordinator and row.invite_code or nil, allowMemberPings = tonumber(row.allow_member_pings) == 1, overheadAllowed = tonumber(row.overhead_allowed) == 1, memberCount = tonumber(row.member_count) or group_count(row.id or row.group_id), isOwner = row.owner_profile_id == profile_id, } end local function list_groups(profile) local rows = Bridge.Database.Query([[ SELECT g.`id`, g.`name`, g.`colour`, g.`invite_code`, g.`owner_profile_id`, g.`allow_member_pings`, g.`overhead_allowed`, m.`role`, (SELECT COUNT(*) FROM `sky_phone_crewlink_memberships` gm WHERE gm.`group_id` = g.`id`) AS `member_count` FROM `sky_phone_crewlink_memberships` m JOIN `sky_phone_crewlink_groups` g ON g.`id` = m.`group_id` WHERE m.`profile_id` = ? ORDER BY (g.`id` = ?) DESC, g.`name` ]], { profile.id, profile.active_group_id or "" }) local groups = {} for index, row in ipairs(rows) do groups[index] = group_dto(row, profile.id) end return groups end local function active_pings(group_id) local rows = Bridge.Database.Query([[ SELECT pg.`id`, pg.`type`, pg.`label`, pg.`position_x`, pg.`position_y`, pg.`position_z`, pg.`creator_profile_id`, pg.`source_resource`, cp.`username` AS `creator_username`, UNIX_TIMESTAMP(pg.`created_at`) AS `created_at`, UNIX_TIMESTAMP(pg.`expires_at`) AS `expires_at` FROM `sky_phone_crewlink_pings` pg LEFT JOIN `sky_phone_crewlink_profiles` cp ON cp.`id` = pg.`creator_profile_id` WHERE pg.`group_id` = ? AND pg.`expires_at` > CURRENT_TIMESTAMP ORDER BY pg.`created_at` DESC LIMIT ? ]], { group_id, Config.CrewLink.MaximumActivePings }) for _, row in ipairs(rows) do row.coords = { x = tonumber(row.position_x) or 0.0, y = tonumber(row.position_y) or 0.0, z = tonumber(row.position_z) or 0.0, } row.position_x = nil row.position_y = nil row.position_z = nil row.creatorProfileId = row.creator_profile_id row.creatorUsername = row.creator_username or row.source_resource or "CrewLink" row.sourceResource = row.source_resource row.creator_profile_id = nil row.creator_username = nil row.source_resource = nil row.createdAt = (tonumber(row.created_at) or 0) * 1000 row.expiresAt = (tonumber(row.expires_at) or 0) * 1000 row.created_at = nil row.expires_at = nil end return rows end local function pending_invitations(profile_id) local rows = Bridge.Database.Query([[ SELECT i.`id`, i.`group_id`, g.`name` AS `group_name`, g.`colour`, p.`username` AS `inviter_username`, UNIX_TIMESTAMP(i.`expires_at`) AS `expires_at` FROM `sky_phone_crewlink_invitations` i JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id` JOIN `sky_phone_crewlink_profiles` p ON p.`id` = i.`inviter_profile_id` WHERE i.`invitee_profile_id` = ? AND i.`status` = 'pending' AND i.`expires_at` > CURRENT_TIMESTAMP ORDER BY i.`created_at` DESC ]], { profile_id }) for _, row in ipairs(rows) do row.groupId = row.group_id row.groupName = row.group_name row.inviterUsername = row.inviter_username row.expiresAt = (tonumber(row.expires_at) or 0) * 1000 row.group_id = nil row.group_name = nil row.inviter_username = nil row.expires_at = nil end return rows end local function live_sources_by_account() local now = os.time() if live_sources_cache.expires_at >= now then return live_sources_cache.sources end local live = {} for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do local source = tonumber(player_source) or player_source local account = SkyPhone.RequireAccount(source) if account and not live[account.id] then live[account.id] = source end end live_sources_cache.expires_at = now + 1 live_sources_cache.sources = live return live end local function live_group(group_id) local members = member_dtos(group_id) local live_sources = live_sources_by_account() for _, member in ipairs(members) do local source = live_sources[member.account_id] member.online = source ~= nil member.source = source if source and member.mapVisible then local ped = GetPlayerPed(source) local coords = GetEntityCoords(ped) member.coords = { x = coords.x, y = coords.y, z = coords.z } end member.account_id = nil end return members end local function group_account_ids(group_id) local rows = Bridge.Database.Query([[ SELECT p.`account_id` FROM `sky_phone_crewlink_memberships` m JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id` WHERE m.`group_id` = ? ]], { group_id }) local account_ids = {} for _, row in ipairs(rows) do account_ids[#account_ids + 1] = tonumber(row.account_id) end return account_ids end local function profile_account_id(profile_id) local rows = Bridge.Database.Query( "SELECT `account_id` FROM `sky_phone_crewlink_profiles` WHERE `id` = ? LIMIT 1", { profile_id } ) return rows[1] and tonumber(rows[1].account_id) or nil end local function notify_group(group_id, kind, actor, extra) for _, account_id in ipairs(group_account_ids(group_id)) do local payload = { kind = kind, actor = actor } for key, value in pairs(extra or {}) do payload[key] = value end SkyPhone.NotifyAccountDevices(account_id, "sky_phone:crewlink:notification", payload) end end local function refresh_group(group_id) for _, account_id in ipairs(group_account_ids(group_id)) do SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = group_id }) end end local function bootstrap(profile) local groups = list_groups(profile) local active_group = nil if profile.active_group_id then local active_membership = membership(profile.id, profile.active_group_id) if active_membership then active_group = group_dto(active_membership, profile.id) active_group.members = live_group(profile.active_group_id) active_group.pings = active_pings(profile.active_group_id) end end return { profile = profile_dto(profile), groups = groups, activeGroup = active_group, invitations = pending_invitations(profile.id), limits = { maximumGroups = Config.CrewLink.MaximumGroupsPerProfile, maximumMembers = Config.CrewLink.MaximumMembersPerGroup, nearbyDistance = Config.CrewLink.NearbyInviteDistance, pingLifetimeSeconds = Config.CrewLink.PingLifetimeSeconds, overheadDistance = Config.CrewLink.OverheadDistance, }, } end local function valid_username(value) local username = trim(value) if not valid_text(username, Config.CrewLink.UsernameMinLength, Config.CrewLink.UsernameMaxLength) or not username:match("^[A-Za-z0-9][A-Za-z0-9._]*[A-Za-z0-9]$") then return nil end return username end local function valid_group_name(value) local name = trim(value) return valid_text(name, Config.CrewLink.GroupNameMinLength, Config.CrewLink.GroupNameMaxLength) and name or nil end local function validate_coords(value) if type(value) ~= "table" then return nil end local x = tonumber(value.x) local y = tonumber(value.y) local z = tonumber(value.z) or 0.0 if not x or not y or x ~= x or y ~= y or z ~= z or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or z < -1000.0 or z > 3000.0 then return nil end return { x = x, y = y, z = z } end local function allow(source, operation) return SkyPhone.AllowOperation( source, "crewlink:" .. operation, Config.CrewLink.ActionsPerMinute, 60 ) end Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source) if not SkyPhone.AllowOperation( source, "crewlink:bootstrap", Config.CrewLink.LiveRequestsPerMinute, 60 ) then return { success = false, error = "rate_limited" } end local account, error_response = SkyPhone.RequireAccount(source) if not account then return error_response end local rows = Bridge.Database.Query([[ SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`, p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url` FROM `sky_phone_crewlink_profiles` p LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` WHERE p.`account_id` = ? LIMIT 1 ]], { account.id }) if not rows[1] then return { success = true, data = { profile = nil, groups = {}, invitations = {} } } end rows[1].account_id = tonumber(rows[1].account_id) return { success = true, data = bootstrap(rows[1]) } end) Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source, data) if not allow(source, "profile") then return { success = false, error = "rate_limited" } end local account, error_response = SkyPhone.RequireAccount(source) if not account then return error_response end data = type(data) == "table" and data or {} local username = valid_username(data.username) if not username then return { success = false, error = "invalid_username" } end local avatar_media_id = tonumber(data.avatarMediaId) or 0 if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then return { success = false, error = "invalid_profile_image" } end if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then return { success = false, error = "invalid_profile_image" } end local result = Bridge.Database.Query([[ INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `avatar_media_id`) VALUES (?, ?, ?, NULLIF(?, 0)) ]], { new_id(), account.id, username, avatar_media_id }) if affected_rows(result) ~= 1 then return { success = false, error = "username_taken" } end local profile = require_profile(source) return { success = true, data = bootstrap(profile) } end) Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source, data) if not allow(source, "profile") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local username = valid_username(data and data.username) if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then return { success = false, error = "invalid_profile" } end local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil if data.avatarMediaId ~= nil then local submitted_avatar_id = tonumber(data.avatarMediaId) if not submitted_avatar_id or submitted_avatar_id < 0 or submitted_avatar_id ~= math.floor(submitted_avatar_id) then return { success = false, error = "invalid_profile_image" } end if submitted_avatar_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, submitted_avatar_id, "photo") then return { success = false, error = "invalid_profile_image" } end avatar_media_id = submitted_avatar_id > 0 and submitted_avatar_id or nil end local result = Bridge.Database.Query([[ UPDATE IGNORE `sky_phone_crewlink_profiles` SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?, `avatar_media_id` = ? WHERE `id` = ? ]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, avatar_media_id, profile.id }) if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then return { success = false, error = "username_taken" } end if profile.active_group_id then refresh_group(profile.active_group_id) end local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) Bridge.Callbacks.Register("sky_phone:crewlink:create-group", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local name = valid_group_name(data and data.name) local colour = data and data.colour if not name or not group_colours[colour] then return { success = false, error = "invalid_group" } end local groups = list_groups(profile) if #groups >= Config.CrewLink.MaximumGroupsPerProfile then return { success = false, error = "group_limit" } end local group_id = new_id() local success = Bridge.Database.Transaction({ { query = [[INSERT INTO `sky_phone_crewlink_groups` (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`) VALUES (?, ?, ?, ?, ?)]], params = { group_id, name, colour, profile.id, new_invite_code() }, }, { query = [[INSERT INTO `sky_phone_crewlink_memberships` (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'owner')]], params = { group_id, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", params = { group_id, profile.id }, }, }) if not success then return { success = false, error = "request_failed" } end TriggerEvent("sky_phone:crewlink:memberJoined", group_id, profile.id) TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, group_id) local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) Bridge.Callbacks.Register("sky_phone:crewlink:update-group", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = type(data) == "table" and membership(profile.id, data.groupId) or nil if not member or role_levels[member.role] < role_levels.coordinator then return { success = false, error = "forbidden" } end local name = valid_group_name(data.name) if not name or not group_colours[data.colour] or type(data.allowMemberPings) ~= "boolean" or type(data.overheadAllowed) ~= "boolean" then return { success = false, error = "invalid_group" } end Bridge.Database.Query([[ UPDATE `sky_phone_crewlink_groups` SET `name` = ?, `colour` = ?, `allow_member_pings` = ?, `overhead_allowed` = ? WHERE `id` = ? ]], { name, data.colour, data.allowMemberPings and 1 or 0, data.overheadAllowed and 1 or 0, data.groupId }) refresh_group(data.groupId) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:delete-group", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = type(data) == "table" and membership(profile.id, data.groupId) or nil if not member or member.role ~= "owner" then return { success = false, error = "forbidden" } end local affected_accounts = group_account_ids(data.groupId) local success = Bridge.Database.Transaction({ { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `active_group_id` = ?", params = { data.groupId }, }, { query = "DELETE FROM `sky_phone_crewlink_groups` WHERE `id` = ? AND `owner_profile_id` = ?", params = { data.groupId, profile.id }, }, }) if not success then return { success = false, error = "request_failed" } end for _, account_id in ipairs(affected_accounts) do SkyPhone.NotifyAccount(account_id, "sky_phone:crewlink:changed", { groupId = data.groupId }) end TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:set-active", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end if type(data) ~= "table" or type(data.groupId) ~= "string" or not membership(profile.id, data.groupId) then return { success = false, error = "group_not_found" } end Bridge.Database.Query( "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", { data.groupId, profile.id } ) TriggerEvent("sky_phone:crewlink:activeChanged", profile.id, data.groupId) local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) Bridge.Callbacks.Register("sky_phone:crewlink:join-code", function(source, data) if not allow(source, "invite") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local code = type(data) == "table" and trim(data.code) or nil if not code or #code ~= Config.CrewLink.InviteCodeLength then return { success = false, error = "invalid_code" } end local rows = Bridge.Database.Query([[ SELECT `id`, `name` FROM `sky_phone_crewlink_groups` WHERE `invite_code` = ? LIMIT 1 ]], { code:upper() }) local group = rows[1] if not group then return { success = false, error = "invalid_code" } end if membership(profile.id, group.id) then return { success = false, error = "already_member" } end if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then return { success = false, error = "group_limit" } end if group_count(group.id) >= Config.CrewLink.MaximumMembersPerGroup then return { success = false, error = "member_limit" } end local success = Bridge.Database.Transaction({ { query = [[INSERT INTO `sky_phone_crewlink_memberships` (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]], params = { group.id, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", params = { group.id, profile.id }, }, }) if not success then return { success = false, error = "request_failed" } end notify_group(group.id, "member_joined", profile.username, { groupName = group.name }) refresh_group(group.id) TriggerEvent("sky_phone:crewlink:memberJoined", group.id, profile.id) local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) Bridge.Callbacks.Register("sky_phone:crewlink:rotate-code", function(source, data) if not allow(source, "invite") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = type(data) == "table" and membership(profile.id, data.groupId) or nil if not member or role_levels[member.role] < role_levels.coordinator then return { success = false, error = "forbidden" } end local code = new_invite_code() Bridge.Database.Query("UPDATE `sky_phone_crewlink_groups` SET `invite_code` = ? WHERE `id` = ?", { code, data.groupId, }) refresh_group(data.groupId) return { success = true, data = { inviteCode = code } } end) Bridge.Callbacks.Register("sky_phone:crewlink:nearby", function(source) if not allow(source, "nearby") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil if not member or role_levels[member.role] < role_levels.moderator then return { success = false, error = "forbidden" } end local player_ped = GetPlayerPed(source) local player_coords = GetEntityCoords(player_ped) local player_bucket = GetPlayerRoutingBucket(source) local candidates = {} for _, target_value in ipairs(Bridge.Framework.GetPlayers()) do local target = tonumber(target_value) or target_value if target ~= source and GetPlayerRoutingBucket(target) == player_bucket then local target_coords = GetEntityCoords(GetPlayerPed(target)) local distance = #(player_coords - target_coords) if distance <= Config.CrewLink.NearbyInviteDistance then local target_account = SkyPhone.RequireAccount(target) if target_account then local rows = Bridge.Database.Query([[ SELECT `id`, `username` FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1 ]], { target_account.id }) local target_profile = rows[1] if target_profile and not membership(target_profile.id, profile.active_group_id) then candidates[#candidates + 1] = { source = target, username = target_profile.username, distance = math.floor(distance * 10 + 0.5) / 10, } if #candidates >= Config.CrewLink.NearbyScanLimit then break end end end end end end return { success = true, data = candidates } end) Bridge.Callbacks.Register("sky_phone:crewlink:invite-nearby", function(source, data) if not allow(source, "invite") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = profile.active_group_id and membership(profile.id, profile.active_group_id) or nil if not member or role_levels[member.role] < role_levels.moderator then return { success = false, error = "forbidden" } end local target = type(data) == "table" and math.floor(tonumber(data.targetSource) or 0) or 0 if target <= 0 or GetPlayerRoutingBucket(target) ~= GetPlayerRoutingBucket(source) then return { success = false, error = "player_not_nearby" } end local distance = #(GetEntityCoords(GetPlayerPed(source)) - GetEntityCoords(GetPlayerPed(target))) if distance > Config.CrewLink.NearbyInviteDistance then return { success = false, error = "player_not_nearby" } end local target_account = SkyPhone.RequireAccount(target) if not target_account then return { success = false, error = "player_unavailable" } end local rows = Bridge.Database.Query([[ SELECT `id`, `username` FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1 ]], { target_account.id }) local target_profile = rows[1] if not target_profile or membership(target_profile.id, profile.active_group_id) then return { success = false, error = "player_unavailable" } end local invitation_id = new_id() Bridge.Database.Query([[ INSERT INTO `sky_phone_crewlink_invitations` (`id`, `group_id`, `inviter_profile_id`, `invitee_profile_id`, `expires_at`) VALUES (?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) ON DUPLICATE KEY UPDATE `inviter_profile_id` = VALUES(`inviter_profile_id`), `status` = 'pending', `expires_at` = VALUES(`expires_at`), `created_at` = CURRENT_TIMESTAMP ]], { invitation_id, profile.active_group_id, profile.id, target_profile.id, Config.CrewLink.InviteLifetimeSeconds, }) SkyPhone.NotifyAccountDevices(target_account.id, "sky_phone:crewlink:notification", { kind = "invite", actor = profile.username, groupName = member.group_name, }) TriggerClientEvent("sky_phone:crewlink:changed", target, { groupId = profile.active_group_id }) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:respond-invite", function(source, data) if not allow(source, "invite") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local accepted = type(data) == "table" and data.accepted if type(accepted) ~= "boolean" or type(data.invitationId) ~= "string" then return { success = false, error = "invalid_invitation" } end local rows = Bridge.Database.Query([[ SELECT i.`id`, i.`group_id`, g.`name` AS `group_name` FROM `sky_phone_crewlink_invitations` i JOIN `sky_phone_crewlink_groups` g ON g.`id` = i.`group_id` WHERE i.`id` = ? AND i.`invitee_profile_id` = ? AND i.`status` = 'pending' AND i.`expires_at` > CURRENT_TIMESTAMP LIMIT 1 ]], { data.invitationId, profile.id }) local invitation = rows[1] if not invitation then return { success = false, error = "invitation_expired" } end if not accepted then Bridge.Database.Query( "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'declined' WHERE `id` = ?", { invitation.id } ) return { success = true } end if #list_groups(profile) >= Config.CrewLink.MaximumGroupsPerProfile then return { success = false, error = "group_limit" } end if group_count(invitation.group_id) >= Config.CrewLink.MaximumMembersPerGroup then return { success = false, error = "member_limit" } end local success = Bridge.Database.Transaction({ { query = [[INSERT INTO `sky_phone_crewlink_memberships` (`group_id`, `profile_id`, `role`) VALUES (?, ?, 'member')]], params = { invitation.group_id, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_invitations` SET `status` = 'accepted' WHERE `id` = ? AND `status` = 'pending'", params = { invitation.id }, }, { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` = ?", params = { invitation.group_id, profile.id }, }, }) if not success then return { success = false, error = "request_failed" } end notify_group(invitation.group_id, "member_joined", profile.username, { groupName = invitation.group_name }) refresh_group(invitation.group_id) TriggerEvent("sky_phone:crewlink:memberJoined", invitation.group_id, profile.id) local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) Bridge.Callbacks.Register("sky_phone:crewlink:update-member", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end if type(data) ~= "table" or not member_roles[data.role] or type(data.profileId) ~= "string" then return { success = false, error = "invalid_role" } end local actor = membership(profile.id, data.groupId) local target = membership(data.profileId, data.groupId) if not actor or not target or role_levels[actor.role] < role_levels.coordinator or role_levels[target.role] >= role_levels[actor.role] or (actor.role ~= "owner" and role_levels[data.role] >= role_levels[actor.role]) then return { success = false, error = "forbidden" } end Bridge.Database.Query([[ UPDATE `sky_phone_crewlink_memberships` SET `role` = ? WHERE `group_id` = ? AND `profile_id` = ? ]], { data.role, data.groupId, data.profileId }) local target_account_id = profile_account_id(data.profileId) if target_account_id then SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", { kind = "role", actor = profile.username, groupName = target.group_name, }) end refresh_group(data.groupId) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:transfer-owner", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil if not actor or actor.role ~= "owner" or not target or target.profile_id == profile.id then return { success = false, error = "forbidden" } end local success = Bridge.Database.Transaction({ { query = "UPDATE `sky_phone_crewlink_groups` SET `owner_profile_id` = ? WHERE `id` = ? AND `owner_profile_id` = ?", params = { data.profileId, data.groupId, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'coordinator' WHERE `group_id` = ? AND `profile_id` = ?", params = { data.groupId, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_memberships` SET `role` = 'owner' WHERE `group_id` = ? AND `profile_id` = ?", params = { data.groupId, data.profileId }, }, }) if not success then return { success = false, error = "request_failed" } end refresh_group(data.groupId) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:remove-member", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local actor = type(data) == "table" and membership(profile.id, data.groupId) or nil local target = type(data) == "table" and membership(data.profileId, data.groupId) or nil if not actor or not target or role_levels[actor.role] < role_levels.moderator or role_levels[target.role] >= role_levels[actor.role] then return { success = false, error = "forbidden" } end local target_account_id = profile_account_id(data.profileId) local success = Bridge.Database.Transaction({ { query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?", params = { data.groupId, data.profileId }, }, { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?", params = { data.profileId, data.groupId }, }, }) if not success then return { success = false, error = "request_failed" } end if target_account_id then SkyPhone.NotifyAccount(target_account_id, "sky_phone:crewlink:changed", { groupId = data.groupId }) SkyPhone.NotifyAccountDevices(target_account_id, "sky_phone:crewlink:notification", { kind = "removed", actor = profile.username, groupName = target.group_name, }) end TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, data.profileId) refresh_group(data.groupId) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:leave", function(source, data) if not allow(source, "group") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local member = type(data) == "table" and membership(profile.id, data.groupId) or nil if not member then return { success = false, error = "group_not_found" } end if member.role == "owner" then return { success = false, error = "owner_must_transfer" } end local success = Bridge.Database.Transaction({ { query = "DELETE FROM `sky_phone_crewlink_memberships` WHERE `group_id` = ? AND `profile_id` = ?", params = { data.groupId, profile.id }, }, { query = "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = NULL WHERE `id` = ? AND `active_group_id` = ?", params = { profile.id, data.groupId }, }, }) if not success then return { success = false, error = "request_failed" } end TriggerEvent("sky_phone:crewlink:memberLeft", data.groupId, profile.id) refresh_group(data.groupId) local updated = require_profile(source) return { success = true, data = bootstrap(updated) } end) local function create_ping(group_id, creator_profile_id, source_resource, data) local label = trim(data and data.label) local coords = validate_coords(data and data.coords) if not ping_types[data and data.type] or not coords or not valid_text(label, 1, Config.CrewLink.PingLabelMaxLength) then return nil, "invalid_ping" end local count_rows = Bridge.Database.Query([[ SELECT COUNT(*) AS `count` FROM `sky_phone_crewlink_pings` WHERE `group_id` = ? AND `expires_at` > CURRENT_TIMESTAMP ]], { group_id }) if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.CrewLink.MaximumActivePings then return nil, "ping_limit" end local lifetime = math.max(15, math.min( Config.CrewLink.PingLifetimeSeconds, math.floor(tonumber(data.lifetimeSeconds) or Config.CrewLink.PingLifetimeSeconds) )) local id = new_id() Bridge.Database.Query([[ INSERT INTO `sky_phone_crewlink_pings` (`id`, `group_id`, `creator_profile_id`, `source_resource`, `type`, `label`, `position_x`, `position_y`, `position_z`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? SECOND)) ]], { id, group_id, creator_profile_id, source_resource, data.type, label, coords.x, coords.y, coords.z, lifetime, }) local ping = { id = id, type = data.type, label = label, coords = coords, creatorProfileId = creator_profile_id, sourceResource = source_resource, createdAt = os.time() * 1000, expiresAt = (os.time() + lifetime) * 1000, } TriggerEvent("sky_phone:crewlink:pingCreated", group_id, ping) return ping end Bridge.Callbacks.Register("sky_phone:crewlink:create-ping", function(source, data) if not allow(source, "ping") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local group_id = profile.active_group_id local member = group_id and membership(profile.id, group_id) or nil if not member or (role_levels[member.role] < role_levels.moderator and tonumber(member.allow_member_pings) ~= 1) then return { success = false, error = "forbidden" } end local ping_data = data or {} if data and data.useCurrent then local coords = GetEntityCoords(GetPlayerPed(source)) ping_data = { type = data.type, label = data.label, coords = { x = coords.x, y = coords.y, z = coords.z }, } end local ping, error_code = create_ping(group_id, profile.id, nil, ping_data) if not ping then return { success = false, error = error_code } end notify_group(group_id, "ping", profile.username, { groupName = member.group_name, pingType = ping.type, pingLabel = ping.label, }) refresh_group(group_id) return { success = true, data = ping } end) Bridge.Callbacks.Register("sky_phone:crewlink:remove-ping", function(source, data) if not allow(source, "ping") then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end local group_id = profile.active_group_id local member = group_id and membership(profile.id, group_id) or nil if not member or type(data) ~= "table" or type(data.pingId) ~= "string" then return { success = false, error = "ping_not_found" } end local rows = Bridge.Database.Query([[ SELECT `creator_profile_id` FROM `sky_phone_crewlink_pings` WHERE `id` = ? AND `group_id` = ? LIMIT 1 ]], { data.pingId, group_id }) local ping = rows[1] if not ping or (ping.creator_profile_id ~= profile.id and role_levels[member.role] < role_levels.moderator) then return { success = false, error = "forbidden" } end Bridge.Database.Query("DELETE FROM `sky_phone_crewlink_pings` WHERE `id` = ? AND `group_id` = ?", { data.pingId, group_id, }) TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, data.pingId) refresh_group(group_id) return { success = true } end) Bridge.Callbacks.Register("sky_phone:crewlink:live", function(source) if not SkyPhone.AllowOperation( source, "crewlink:live", Config.CrewLink.LiveRequestsPerMinute, 60 ) then return { success = false, error = "rate_limited" } end local profile, error_response = require_profile(source) if not profile then return error_response end 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 = members, overheadMembers = overhead_members, pings = active_pings(profile.active_group_id), }, } end) exports("GetCrewLinkActiveGroup", function(source) local profile = require_profile(source) if not profile or not profile.active_group_id then return nil end local member = membership(profile.id, profile.active_group_id) return member and group_dto(member, profile.id) or nil end) exports("GetCrewLinkGroupMembers", function(group_id) if type(group_id) ~= "string" then return {} end local members = {} for _, member in ipairs(member_dtos(group_id)) do members[#members + 1] = { id = member.id, username = member.username, role = member.role, joinedAt = member.joinedAt, } end return members end) exports("IsCrewLinkGroupMember", function(source, group_id) local profile = require_profile(source) return profile and type(group_id) == "string" and membership(profile.id, group_id) ~= nil or false end) exports("GetCrewLinkGroupRole", function(source, group_id) local profile = require_profile(source) local member = profile and type(group_id) == "string" and membership(profile.id, group_id) or nil return member and member.role or nil end) exports("CreateCrewLinkPing", function(group_id, data) local invoking_resource = GetInvokingResource() if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then return nil, "resource_not_allowed" end local groups = Bridge.Database.Query( "SELECT `id`, `name` FROM `sky_phone_crewlink_groups` WHERE `id` = ? LIMIT 1", { group_id } ) if not groups[1] then return nil, "group_not_found" end local ping, error_code = create_ping(group_id, nil, invoking_resource, data) if ping then notify_group(group_id, "ping", invoking_resource, { groupName = groups[1].name, pingType = ping.type, pingLabel = ping.label, }) refresh_group(group_id) end return ping, error_code end) exports("RemoveCrewLinkPing", function(group_id, ping_id) local invoking_resource = GetInvokingResource() if not invoking_resource or not Config.CrewLink.ExternalPingResources[invoking_resource] then return false end local result = Bridge.Database.Query([[ DELETE FROM `sky_phone_crewlink_pings` WHERE `id` = ? AND `group_id` = ? AND `source_resource` = ? ]], { ping_id, group_id, invoking_resource }) if affected_rows(result) ~= 1 then return false end TriggerEvent("sky_phone:crewlink:pingRemoved", group_id, ping_id) refresh_group(group_id) return true end) end)