diff --git a/Orbit-activity.rbxmx b/Orbit-activity.rbxmx
index a1cbed44..3d4107cf 100644
--- a/Orbit-activity.rbxmx
+++ b/Orbit-activity.rbxmx
@@ -1,546 +1,803 @@
-
- true
- null
- nil
-
-
- ",
- auth = "",
-
- privateEnabled = false, -- Track private servers?
- studioEnabled = false, -- Track Studio sessions?
-
- -- Feature flags
- bansEnabled = false, -- Let Orbit handle bans?
- rankChecking = false, -- Only track members of the group?
- groupId = 0, -- Group ID for rank checks
- minTrackedRank = 0, -- Minimum rank to be tracked (fetched from Orbit on launch)
-
- -- Timing
- cooldownPeriod = 30, -- Seconds between HTTP retries
- minutesTillAFK = 2, -- Idle minutes before AFK flag
- scanInterval = 300, -- Seconds between full player scans (5 min)
- heartbeatInterval = 60, -- Seconds between lightweight heartbeats
-
- -- Chat capture
- maxStoredChatLines = 200,
- maxChatCharsPerLine = 512,
-}
-
-
-if not Configuration.studioEnabled and RunService:IsStudio() then
- return warn("Orbit: Tracking disabled in Studio. (studioEnabled = false)")
-end
-
-if not Configuration.privateEnabled then
- if game.PrivateServerId ~= "" and game.PrivateServerOwnerId ~= 0 then
- return warn("Orbit: Tracking disabled in private servers. (privateEnabled = false)")
- end
-end
-
-warn("Orbit: Module loaded — activity tracking active.")
-
-local chatLogTable = {}
-local rankTable = {}
-local afkTable = {}
-local sessionActive = {}
-
-local lastActivityTime: { [number]: number } = {}
-local afkStartTime: { [number]: number } = {}
-
-local function log(level: string, msg: string)
- local prefix = string.format("[Orbit][%s] %s", level, msg)
- if level == "WARN" or level == "ERROR" then
- warn(prefix)
- else
- print(prefix)
- end
-end
-
-local function safeEncode(data: unknown): string?
- local ok, result = pcall(HttpService.JSONEncode, HttpService, data)
- return ok and result or nil
-end
-
-local function safeDecode(raw: string): unknown?
- local ok, result = pcall(HttpService.JSONDecode, HttpService, raw)
- return ok and result or nil
-end
-
-local function isPlayerOnline(Player: Player): boolean
- return Players:GetPlayerByUserId(Player.UserId) ~= nil
-end
-
-local function stripChat(text: string): string
- return (text:gsub("^%s+", ""):gsub("%s+$", ""))
-end
-
-local function isAFK(Player: Player): boolean
- return table.find(afkTable, Player) ~= nil
-end
-
-local function setAFK(Player: Player, state: boolean)
- local idx = table.find(afkTable, Player)
- if state and not idx then
- table.insert(afkTable, Player)
- afkStartTime[Player.UserId] = os.clock()
- elseif not state and idx then
- table.remove(afkTable, idx)
- afkStartTime[Player.UserId] = nil
- end
-end
-
-local function bumpActivity(Player: Player)
- lastActivityTime[Player.UserId] = os.clock()
- setAFK(Player, false)
-end
-
-local function appendChatLine(userId: number, raw: unknown)
- local log_t = chatLogTable[userId]
- if not log_t then return end
-
- local text = type(raw) == "string" and raw or ""
- text = stripChat(text)
- if text == "" then return end
-
- local maxChars = Configuration.maxChatCharsPerLine
- if maxChars > 0 and #text > maxChars then
- text = text:sub(1, maxChars)
- end
-
- table.insert(log_t, text)
-
- local cap = Configuration.maxStoredChatLines
- while cap > 0 and #log_t > cap do
- table.remove(log_t, 1)
- end
-end
-
-local function collectSessionEndRow(Player: Player): { [string]: unknown }
- local afkTimer = Player:FindFirstChild("Orbit AFK Timer")
- local log_t = chatLogTable[Player.UserId] or {}
-
- local row: { [string]: unknown } = {
- userid = Player.UserId,
- username = Player.Name,
- placeid = game.GameId,
- idleTime = afkTimer and afkTimer.Value or 0,
- messages = #log_t,
- }
- if #log_t > 0 then
- row.chatBodies = log_t
- end
- return row
-end
-
-local function fetchRank(Player: Player): number
- if not Configuration.rankChecking then return math.huge end
- local ok, rank = pcall(Player.GetRankInGroupAsync, Player, Configuration.groupId)
- return ok and rank or 0
-end
-
-local function isUserTracked(Player: Player): boolean
- if not Configuration.rankChecking then return true end
- local entry = rankTable[Player.UserId]
- if not entry then return false end
- return entry.Rank >= Configuration.minTrackedRank
-end
-
-local function httpGet(endpoint: string): (boolean, unknown?)
- local ok, result = pcall(HttpService.RequestAsync, HttpService, {
- Url = Configuration.url .. endpoint,
- Method = "GET",
- Headers = { ["authorization"] = Configuration.auth },
- })
- if not ok then return false, nil end
- local decoded = safeDecode(result.Body)
- return decoded ~= nil, decoded
-end
-
-local function httpPost(endpoint: string, body: unknown): boolean
- local encoded = safeEncode(body)
- if not encoded then
- log("ERROR", "Failed to encode body for " .. endpoint)
- return false
- end
- local request = HttpQueue.HttpRequest.new(Configuration.url .. endpoint, "POST", encoded, nil,
- {
- ["Content-Type"] = "application/json",
- ["authorization"] = Configuration.auth,
- }
- )
- request:Send()
- return true
-end
-
-local function checkRemoteSessionActive(Player: Player): boolean
- local ok, response = httpGet("/api/activity/session?id=" .. Player.UserId)
- if ok and type(response) == "table" and response.success then
- return true
- end
- return false
-end
-
-local function CreateSession(Player: Player)
- if sessionActive[Player.UserId] then
- log("INFO", "Session already cached as active for " .. Player.Name .. ", skipping.")
- return
- end
-
- if checkRemoteSessionActive(Player) then
- log("WARN", "Remote session already exists for " .. Player.Name .. ", syncing local cache.")
- sessionActive[Player.UserId] = true
- return
- end
-
- local success = httpPost("/api/activity/session?type=create", {
- userid = Player.UserId,
- username = Player.Name,
- placeid = game.GameId,
- })
-
- if success then
- sessionActive[Player.UserId] = true
- log("INFO", "Session created for " .. Player.Name)
- else
- log("ERROR", "Failed to create session for " .. Player.Name)
- end
-end
-
-local function EndSession(Player: Player, reason: string?)
- if not sessionActive[Player.UserId] then
- if not checkRemoteSessionActive(Player) then
- log("INFO", "No session to end for " .. Player.Name)
- return
- end
- end
-
- local row = collectSessionEndRow(Player)
- row.endReason = reason or "natural"
-
- local success = httpPost("/api/activity/session?type=end", row)
- if success then
- sessionActive[Player.UserId] = false
- log("INFO", string.format("Session ended for %s (reason: %s)", Player.Name, row.endReason))
- else
- log("ERROR", "Failed to end session for " .. Player.Name)
- end
-end
-
-local function MovementDetection(Player: Player)
- local Character = Player.Character or Player.CharacterAdded:Wait()
- local Humanoid = Character:WaitForChild("Humanoid")
- local userId = Player.UserId
-
- if not lastActivityTime[userId] then
- lastActivityTime[userId] = os.clock()
- end
-
- local connections: { RBXScriptConnection } = {}
-
- local function onActivity(speed: number)
- if speed > 0 then
- bumpActivity(Player)
- else
- local snapshot = os.clock()
- lastActivityTime[userId] = snapshot
-
- FastWait(60 * Configuration.minutesTillAFK)
- if isPlayerOnline(Player) and lastActivityTime[userId] == snapshot then
- setAFK(Player, true)
- log("INFO", Player.Name .. " marked AFK (no movement for "
- .. Configuration.minutesTillAFK .. " min).")
- end
- end
- end
-
- table.insert(connections, Humanoid.Running:Connect(onActivity))
- table.insert(connections, Humanoid.Swimming:Connect(onActivity))
- table.insert(connections, Humanoid.Climbing:Connect(onActivity))
- table.insert(connections, Humanoid.Jumping:Connect(function(isJumping)
- if isJumping then
- bumpActivity(Player)
- end
- end))
- local charRemovedConn
- charRemovedConn = Character.AncestryChanged:Connect(function()
- if not Character:IsDescendantOf(game) then
- for _, c in ipairs(connections) do
- c:Disconnect()
- end
- charRemovedConn:Disconnect()
- end
- end)
-end
-
-local function InputChange(Player: Player, isIdle: boolean)
- if not isIdle then
- bumpActivity(Player)
- else
- local lastActive = lastActivityTime[Player.UserId]
- local idleSeconds = lastActive and (os.clock() - lastActive) or math.huge
- local threshold = 60 * Configuration.minutesTillAFK -- now matches MovementDetection's threshold
-
- if idleSeconds >= threshold then
- setAFK(Player, true)
- end
- end
-end
-
-TovyEvent.OnServerEvent:Connect(InputChange)
-
-local function InitiatePlayer(Player: Player)
- local rank = fetchRank(Player)
- rankTable[Player.UserId] = { Rank = rank }
- chatLogTable[Player.UserId] = {}
- lastActivityTime[Player.UserId] = os.clock()
-
- if not isUserTracked(Player) then
- log("INFO", Player.Name .. " is not at a tracked rank — skipping session.")
- return
- end
-
- local afkTimer = Instance.new("NumberValue")
- afkTimer.Name = "Orbit AFK Timer"
- afkTimer.Parent = Player
-
- local clientClone = TovyClient:Clone()
- clientClone.Parent = Player:WaitForChild("PlayerGui")
-
- CreateSession(Player)
- MovementDetection(Player)
-end
-
-local function CleanupPlayer(Player: Player, reason: string?)
- EndSession(Player, reason)
- setAFK(Player, false)
- rankTable[Player.UserId] = nil
- chatLogTable[Player.UserId] = nil
- sessionActive[Player.UserId] = nil
- lastActivityTime[Player.UserId] = nil
- afkStartTime[Player.UserId] = nil
-end
-
-Players.PlayerAdded:Connect(function(Player)
- InitiatePlayer(Player)
-
- Player.CharacterAdded:Connect(function()
- lastActivityTime[Player.UserId] = os.clock()
- MovementDetection(Player)
- end)
-
- Player.Chatted:Connect(function(Message)
- if isUserTracked(Player) then
- appendChatLine(Player.UserId, Message)
- bumpActivity(Player)
- end
- end)
-end)
-
-Players.PlayerRemoving:Connect(function(Player)
- CleanupPlayer(Player, "player_left")
-end)
-
-game:BindToClose(function()
- local sessions = {}
- for _, Player in ipairs(Players:GetPlayers()) do
- if isUserTracked(Player) and (sessionActive[Player.UserId] or checkRemoteSessionActive(Player)) then
- local row = collectSessionEndRow(Player)
- row.endReason = "server_shutdown"
- table.insert(sessions, row)
- end
- end
-
- if #sessions > 0 then
- local encoded = safeEncode({ sessions = sessions })
- if encoded then
- local ok, response = pcall(HttpService.RequestAsync, HttpService, {
- Url = Configuration.url .. "/api/activity/bulk-end",
- Method = "POST",
- Headers = {
- ["Content-Type"] = "application/json",
- ["authorization"] = Configuration.auth,
- },
- Body = encoded,
- })
- if ok then
- log("INFO", string.format("Shutdown: bulk-ended %d session(s).", #sessions))
- else
- log("ERROR", "Shutdown: bulk-end request failed — " .. tostring(response))
- end
- end
- else
- log("INFO", "Shutdown: no active sessions to end.")
- end
-
- task.wait(5)
-end)
-
-task.spawn(function()
- while true do
- task.wait(Configuration.heartbeatInterval)
- for idx, Player in ipairs(afkTable) do
- local online = isPlayerOnline(Player)
- if online then
- local afkTimer = Player:FindFirstChild("Orbit AFK Timer")
- local start = afkStartTime[Player.UserId]
- if afkTimer and start then
- afkTimer.Value = math.floor((os.clock() - start) / 60)
- end
- else
- log("WARN", "AFK table contained offline player — removing ghost entry.")
- table.remove(afkTable, idx)
- afkStartTime[Player.UserId] = nil
- end
- end
- end
-end)
-
-task.spawn(function()
- while true do
- task.wait(Configuration.scanInterval)
- log("INFO", "Running full player scan...")
-
- local onlinePlayers = Players:GetPlayers()
- local onlineSet = {}
- for _, p in ipairs(onlinePlayers) do
- onlineSet[p.UserId] = true
- end
- for userId, active in pairs(sessionActive) do
- if active and not onlineSet[userId] then
- log("WARN", string.format(
- "Ghost session detected for userId %d — ending immediately.", userId
- ))
- local ghostRow = {
- userid = userId,
- idleTime = 0,
- messages = #(chatLogTable[userId] or {}),
- endReason = "ghost_cleanup",
- }
- if chatLogTable[userId] and #chatLogTable[userId] > 0 then
- ghostRow.chatBodies = chatLogTable[userId]
- end
- httpPost("/api/activity/session?type=end", ghostRow)
- sessionActive[userId] = false
- chatLogTable[userId] = nil
- rankTable[userId] = nil
- lastActivityTime[userId] = nil
- afkStartTime[userId] = nil
- end
- end
- for _, Player in ipairs(onlinePlayers) do
- if not isUserTracked(Player) then continue end
-
- local hasSession = sessionActive[Player.UserId]
- if not hasSession then
- local remoteActive = checkRemoteSessionActive(Player)
- if remoteActive then
- log("WARN", Player.Name .. " has remote session but no local cache — syncing.")
- sessionActive[Player.UserId] = true
- else
- log("WARN", Player.Name .. " has no active session — recreating.")
- chatLogTable[Player.UserId] = chatLogTable[Player.UserId] or {}
- local afkTimer = Player:FindFirstChild("Orbit AFK Timer")
- if afkTimer then afkTimer.Value = 0 end
- CreateSession(Player)
- end
- end
- end
- if Configuration.rankChecking then
- for _, Player in ipairs(onlinePlayers) do
- local oldRank = rankTable[Player.UserId] and rankTable[Player.UserId].Rank or 0
- local newRank = fetchRank(Player)
- rankTable[Player.UserId] = { Rank = newRank }
-
- if newRank ~= oldRank then
- log("INFO", string.format("%s rank changed: %d → %d", Player.Name, oldRank, newRank))
-
- if oldRank >= Configuration.minTrackedRank
- and newRank < Configuration.minTrackedRank then
- log("WARN", Player.Name .. " fell below min rank, ending session.")
- EndSession(Player, "rank_below_minimum")
-
- elseif oldRank < Configuration.minTrackedRank
- and newRank >= Configuration.minTrackedRank then
- log("INFO", Player.Name .. " now meets min rank, starting session.")
- chatLogTable[Player.UserId] = {}
- lastActivityTime[Player.UserId] = os.clock()
- local afkTimer = Instance.new("NumberValue")
- afkTimer.Name = "Orbit AFK Timer"
- afkTimer.Parent = Player
- CreateSession(Player)
- end
- end
- end
- end
-
- for idx = #afkTable, 1, -1 do
- local Player = afkTable[idx]
- if not isPlayerOnline(Player) then
- log("WARN", "Scan: purging offline player from AFK table: " .. tostring(Player))
- table.remove(afkTable, idx)
- afkStartTime[Player.UserId] = nil
- end
- end
-
- for userId in pairs(chatLogTable) do
- if not onlineSet[userId] then
- log("WARN", string.format(
- "Orphan chat log found for offline userId %d — purging.", userId
- ))
- chatLogTable[userId] = nil
- end
- end
- end
-end)
-
-for _, Player in ipairs(Players:GetPlayers()) do
- task.spawn(InitiatePlayer, Player)
-end]]>
- false
-
- 0
- {81740BB3-280D-4363-BA2A-0CE80C16A785}
-
- 0
- false
- OrbitActivity
- -1
-
-
-
-
-
- 0
- false
- Components
- -1
-
-
-
-
-
+
+ true
+ null
+ nil
+
+
+ ",
+ auth = "",
+
+ privateEnabled = , -- Track private servers?
+ studioEnabled = , -- Track Studio sessions?
+
+ -- Feature flags
+ bansEnabled = false, -- Let Orbit handle bans?
+ rankChecking = true, -- Only track members of the group?
+ groupId = , -- Group ID for rank checks
+ minTrackedRank = , -- Minimum rank to be tracked (overridable via remote config)
+
+ -- Timing
+ cooldownPeriod = 30, -- Seconds between HTTP retries
+ minutesTillAFK = 2, -- Idle minutes before AFK flag
+ scanInterval = 300, -- Seconds between full player scans (5 min)
+ heartbeatInterval = 60, -- Seconds between lightweight heartbeats
+ rankRefreshInterval = 600, -- Seconds between per-player rank re-checks (throttles GroupService)
+ serverHeartbeatEvery = 120, -- Seconds between backend "server alive" pings
+ remoteEventCooldown = 3, -- Min seconds between accepted client Activity events per player
+
+ -- Chat capture
+ maxStoredChatLines = 200,
+ maxChatCharsPerLine = 512,
+
+ -- Reliability
+ deadLetterStoreName = "OrbitFailedRequests",
+ deadLetterFlushEvery = 60, -- Seconds between dead-letter retry sweeps
+ deadLetterMaxAttempts = 10, -- Give up (but keep for manual review) after this many failed retries
+}
+
+if not Configuration.studioEnabled and RunService:IsStudio() then
+ return warn("Orbit: Tracking disabled in Studio. (studioEnabled = false)")
+end
+
+if not Configuration.privateEnabled then
+ if game.PrivateServerId ~= "" and game.PrivateServerOwnerId ~= 0 then
+ return warn("Orbit: Tracking disabled in private servers. (privateEnabled = false)")
+ end
+end
+
+warn("Orbit: Module loaded — activity tracking active.")
+
+local function log(level: string, msg: string)
+ local prefix = string.format("[Orbit][%s] %s", level, msg)
+ if level == "WARN" or level == "ERROR" then
+ warn(prefix)
+ else
+ print(prefix)
+ end
+end
+
+local function safeEncode(data: unknown): string?
+ local ok, result = pcall(HttpService.JSONEncode, HttpService, data)
+ return ok and result or nil
+end
+
+local function safeDecode(raw: string): unknown?
+ local ok, result = pcall(HttpService.JSONDecode, HttpService, raw)
+ return ok and result or nil
+end
+
+type PlayerState = {
+ rank: number,
+ chatLog: { string },
+ sessionActive: boolean,
+ lastActivity: number,
+ afkStart: number?,
+ isAFK: boolean,
+ afkTimerInstance: NumberValue?,
+ pendingAFKCheck: boolean,
+ lastRankCheck: number,
+ lastRemoteEventAccepted: number,
+}
+
+local PlayerData: { [number]: PlayerState } = {}
+
+local function getState(Player: Player): PlayerState?
+ return PlayerData[Player.UserId]
+end
+
+local function isPlayerOnline(Player: Player): boolean
+ return Players:GetPlayerByUserId(Player.UserId) ~= nil
+end
+
+local function stripChat(text: string): string
+ return (text:gsub("^%s+", ""):gsub("%s+$", ""))
+end
+
+
+local function setAFK(Player: Player, state: boolean)
+ local st = getState(Player)
+ if not st then return end
+
+ if state and not st.isAFK then
+ st.isAFK = true
+ st.afkStart = os.clock()
+ elseif not state and st.isAFK then
+ st.isAFK = false
+ st.afkStart = nil
+ if st.afkTimerInstance then
+ st.afkTimerInstance.Value = 0
+ end
+ end
+end
+
+local function bumpActivity(Player: Player)
+ local st = getState(Player)
+ if not st then return end
+ st.lastActivity = os.clock()
+ setAFK(Player, false)
+end
+
+local function appendChatLine(userId: number, raw: unknown)
+ local st = PlayerData[userId]
+ if not st then return end
+
+ local text = type(raw) == "string" and raw or ""
+ text = stripChat(text)
+ if text == "" then return end
+
+ local maxChars = Configuration.maxChatCharsPerLine
+ if maxChars > 0 and #text > maxChars then
+ text = text:sub(1, maxChars)
+ end
+
+ table.insert(st.chatLog, text)
+
+ local cap = Configuration.maxStoredChatLines
+ while cap > 0 and #st.chatLog > cap do
+ table.remove(st.chatLog, 1)
+ end
+end
+
+local function collectSessionEndRow(Player: Player): { [string]: unknown }
+ local st = getState(Player)
+ local log_t = st and st.chatLog or {}
+ local idleMinutes = st and st.afkTimerInstance and st.afkTimerInstance.Value or 0
+
+ local row: { [string]: unknown } = {
+ userid = Player.UserId,
+ username = Player.Name,
+ placeid = game.GameId,
+ idleTime = idleMinutes,
+ messages = #log_t,
+ }
+ if #log_t > 0 then
+ row.chatBodies = log_t
+ end
+ return row
+end
+
+local function fetchRank(Player: Player): number
+ if not Configuration.rankChecking then return math.huge end
+ local success, rankInfo = pcall(function()
+ return GroupService:GetRolesInGroupAsync(Player.UserId, Configuration.groupId)
+ end)
+ if not success then return math.huge end
+ if not rankInfo.IsMember then return math.huge end
+
+ return rankInfo.Roles[1] and rankInfo.Roles[1].Rank or 0
+end
+
+local function isUserTracked(Player: Player): boolean
+ if not Configuration.rankChecking then return true end
+ local st = getState(Player)
+ if not st then return false end
+ return st.rank >= Configuration.minTrackedRank
+end
+
+local DeadLetterStore = DataStoreService:GetDataStore(Configuration.deadLetterStoreName)
+local deadLetterCounter = 0
+
+local function deadLetterKey(): string
+ deadLetterCounter += 1
+ return string.format("%d-%d-%d", game.JobId and #game.JobId or 0, os.time(), deadLetterCounter)
+end
+
+local function enqueueDeadLetter(endpoint: string, body: unknown)
+ local key = deadLetterKey()
+ local ok, err = pcall(function()
+ DeadLetterStore:SetAsync(key, {
+ endpoint = endpoint,
+ body = body,
+ attempts = 0,
+ queuedAt = os.time(),
+ })
+ end)
+ if ok then
+ log("WARN", "Queued failed request to dead-letter store: " .. endpoint)
+ else
+ log("ERROR", "Failed to enqueue dead-letter entry (" .. endpoint .. "): " .. tostring(err))
+ end
+end
+
+local function rawPost(endpoint: string, encodedBody: string): (boolean, unknown?)
+ local ok, result = pcall(HttpService.RequestAsync, HttpService, {
+ Url = Configuration.url .. endpoint,
+ Method = "POST",
+ Headers = {
+ ["Content-Type"] = "application/json",
+ ["authorization"] = Configuration.auth,
+ },
+ Body = encodedBody,
+ })
+ if not ok or not result.Success then return false, nil end
+ return true, safeDecode(result.Body)
+end
+
+local function httpGet(endpoint: string, maxAttempts: number?): (boolean, unknown?)
+ maxAttempts = maxAttempts or 2
+ for attempt = 1, maxAttempts do
+ local ok, result = pcall(HttpService.RequestAsync, HttpService, {
+ Url = Configuration.url .. endpoint,
+ Method = "GET",
+ Headers = { ["authorization"] = Configuration.auth },
+ })
+ if ok and result.Success then
+ local decoded = safeDecode(result.Body)
+ if decoded ~= nil then
+ return true, decoded
+ end
+ end
+ if attempt < maxAttempts then
+ task.wait(1)
+ end
+ end
+ return false, nil
+end
+
+local function httpPost(endpoint: string, body: unknown, allowDeadLetter: boolean?): boolean
+ local encoded = safeEncode(body)
+ if not encoded then
+ log("ERROR", "Failed to encode body for " .. endpoint)
+ return false
+ end
+
+ local ok = rawPost(endpoint, encoded)
+ if ok then
+ return true
+ end
+
+ local queuedOk = pcall(function()
+ HttpQueue.HttpRequest.new(Configuration.url .. endpoint, "POST", encoded, nil, {
+ ["Content-Type"] = "application/json",
+ ["authorization"] = Configuration.auth,
+ }):Send()
+ end)
+
+ if not queuedOk and allowDeadLetter ~= false then
+ enqueueDeadLetter(endpoint, body)
+ end
+
+ return queuedOk
+end
+
+local function handlePossibleBan(Player: Player, response: unknown)
+ if not Configuration.bansEnabled then return end
+ if type(response) ~= "table" then return end
+
+ if response.banned then
+ local reason = type(response.reason) == "string" and response.reason or "Banned"
+ log("WARN", string.format("%s is banned via Orbit (%s) — kicking.", Player.Name, reason))
+ Player:Kick("You have been banned: " .. reason)
+ end
+end
+
+local function checkRemoteSessionActive(Player: Player): boolean
+ local ok, response = httpGet("/api/activity/session?id=" .. Player.UserId)
+ if ok and type(response) == "table" and response.success then
+ return true
+ end
+ return false
+end
+
+local function CreateSession(Player: Player)
+ local st = getState(Player)
+ if not st then return end
+
+ if st.sessionActive then
+ log("INFO", "Session already cached as active for " .. Player.Name .. ", skipping.")
+ return
+ end
+
+ if checkRemoteSessionActive(Player) then
+ log("WARN", "Remote session already exists for " .. Player.Name .. ", syncing local cache.")
+ st.sessionActive = true
+ return
+ end
+
+ local encoded = safeEncode({
+ userid = Player.UserId,
+ username = Player.Name,
+ placeid = game.GameId,
+ })
+ local ok, response = false, nil
+ if encoded then
+ ok, response = rawPost("/api/activity/session?type=create", encoded)
+ end
+
+ if ok then
+ st.sessionActive = true
+ log("INFO", "Session created for " .. Player.Name)
+ handlePossibleBan(Player, response)
+ else
+ local fallbackOk = httpPost("/api/activity/session?type=create", {
+ userid = Player.UserId,
+ username = Player.Name,
+ placeid = game.GameId,
+ })
+ if fallbackOk then
+ st.sessionActive = true
+ log("INFO", "Session created for " .. Player.Name .. " (via retry path)")
+ else
+ log("ERROR", "Failed to create session for " .. Player.Name)
+ end
+ end
+end
+
+local function BulkCreateSessions(playersList: { Player })
+ if #playersList == 0 then return end
+
+ local rows = {}
+ for _, Player in ipairs(playersList) do
+ table.insert(rows, {
+ userid = Player.UserId,
+ username = Player.Name,
+ placeid = game.GameId,
+ })
+ end
+
+ local encoded = safeEncode({ sessions = rows })
+ local bulkSucceeded: { [number]: boolean } = {}
+
+ if encoded then
+ local ok, response = rawPost("/api/activity/bulk-start", encoded)
+ if ok and type(response) == "table" and type(response.started) == "table" then
+ for _, userId in ipairs(response.started) do
+ bulkSucceeded[userId] = true
+ end
+ log("INFO", string.format("Bulk-started %d/%d session(s).", #response.started, #playersList))
+ end
+ end
+
+ for _, Player in ipairs(playersList) do
+ local st = getState(Player)
+ if st then
+ if bulkSucceeded[Player.UserId] then
+ st.sessionActive = true
+ else
+ CreateSession(Player)
+ end
+ end
+ end
+end
+
+local function EndSession(Player: Player, reason: string?)
+ local st = getState(Player)
+ if not st then return end
+
+ if not st.sessionActive then
+ if not checkRemoteSessionActive(Player) then
+ log("INFO", "No session to end for " .. Player.Name)
+ return
+ end
+ end
+
+ local row = collectSessionEndRow(Player)
+ row.endReason = reason or "natural"
+
+ local success = httpPost("/api/activity/session?type=end", row)
+ if success then
+ st.sessionActive = false
+ log("INFO", string.format("Session ended for %s (reason: %s)", Player.Name, row.endReason))
+ else
+ log("ERROR", "Failed to end session for " .. Player.Name)
+ end
+end
+
+local function MovementDetection(Player: Player)
+ local Character = Player.Character or Player.CharacterAdded:Wait()
+ local Humanoid = Character:WaitForChild("Humanoid")
+
+ local st = getState(Player)
+ if not st then return end
+ if not st.lastActivity then
+ st.lastActivity = os.clock()
+ end
+
+ local connections: { RBXScriptConnection } = {}
+
+ local function onActivity(speed: number)
+ if speed > 0 then
+ bumpActivity(Player)
+ return
+ end
+
+ local myState = getState(Player)
+ if not myState or myState.pendingAFKCheck then
+ return
+ end
+
+ local snapshot = os.clock()
+ myState.lastActivity = snapshot
+ myState.pendingAFKCheck = true
+
+ task.spawn(function()
+ FastWait(60 * Configuration.minutesTillAFK)
+
+ local currentState = getState(Player)
+ if currentState then
+ currentState.pendingAFKCheck = false
+ end
+
+ if isPlayerOnline(Player) and currentState and currentState.lastActivity == snapshot then
+ setAFK(Player, true)
+ log("INFO", Player.Name .. " marked AFK (no movement for "
+ .. Configuration.minutesTillAFK .. " min).")
+ end
+ end)
+ end
+
+ table.insert(connections, Humanoid.Running:Connect(onActivity))
+ table.insert(connections, Humanoid.Swimming:Connect(onActivity))
+ table.insert(connections, Humanoid.Climbing:Connect(onActivity))
+ table.insert(connections, Humanoid.Jumping:Connect(function(isJumping)
+ if isJumping then
+ bumpActivity(Player)
+ end
+ end))
+
+ local charRemovedConn
+ charRemovedConn = Character.AncestryChanged:Connect(function()
+ if not Character:IsDescendantOf(game) then
+ for _, c in ipairs(connections) do
+ c:Disconnect()
+ end
+ charRemovedConn:Disconnect()
+ end
+ end)
+end
+
+local function InputChange(Player: Player, isIdle: unknown)
+ if typeof(isIdle) ~= "boolean" then
+ log("WARN", "Rejected malformed Activity event from " .. Player.Name)
+ return
+ end
+
+ local st = getState(Player)
+ if not st then return end
+
+ local now = os.clock()
+ if now - (st.lastRemoteEventAccepted or 0) < Configuration.remoteEventCooldown then
+ return
+ end
+ st.lastRemoteEventAccepted = now
+
+ if not isIdle then
+ bumpActivity(Player)
+ else
+ local idleSeconds = st.lastActivity and (now - st.lastActivity) or math.huge
+ local threshold = 60 * Configuration.minutesTillAFK -- matches MovementDetection's threshold
+
+ if idleSeconds >= threshold then
+ setAFK(Player, true)
+ end
+ end
+end
+
+TovyEvent.OnServerEvent:Connect(InputChange)
+
+
+local function InitiatePlayer(Player: Player): boolean
+ local rank = fetchRank(Player)
+
+ PlayerData[Player.UserId] = {
+ rank = rank,
+ chatLog = {},
+ sessionActive = false,
+ lastActivity = os.clock(),
+ afkStart = nil,
+ isAFK = false,
+ afkTimerInstance = nil,
+ pendingAFKCheck = false,
+ lastRankCheck = os.clock(),
+ lastRemoteEventAccepted = 0,
+ }
+
+ if not isUserTracked(Player) then
+ log("INFO", Player.Name .. " is not at a tracked rank — skipping session.")
+ return false
+ end
+
+ local afkTimer = Instance.new("NumberValue")
+ afkTimer.Name = "Orbit AFK Timer"
+ afkTimer.Parent = Player
+ PlayerData[Player.UserId].afkTimerInstance = afkTimer
+
+ local clientClone = TovyClient:Clone()
+ clientClone.Parent = Player:WaitForChild("PlayerGui")
+
+ task.spawn(MovementDetection, Player)
+ return true
+end
+
+local function CleanupPlayer(Player: Player, reason: string?)
+ EndSession(Player, reason)
+ PlayerData[Player.UserId] = nil
+end
+
+Players.PlayerAdded:Connect(function(Player)
+ local tracked = InitiatePlayer(Player)
+ if tracked then
+ CreateSession(Player)
+ end
+
+ Player.CharacterAdded:Connect(function()
+ local st = getState(Player)
+ if st then
+ st.lastActivity = os.clock()
+ end
+ MovementDetection(Player)
+ end)
+
+ Player.Chatted:Connect(function(Message)
+ if isUserTracked(Player) then
+ appendChatLine(Player.UserId, Message)
+ bumpActivity(Player)
+ end
+ end)
+end)
+
+Players.PlayerRemoving:Connect(function(Player)
+ CleanupPlayer(Player, "player_left")
+end)
+
+game:BindToClose(function()
+ local sessions = {}
+ for _, Player in ipairs(Players:GetPlayers()) do
+ local st = getState(Player)
+ if st and isUserTracked(Player) and (st.sessionActive or checkRemoteSessionActive(Player)) then
+ local row = collectSessionEndRow(Player)
+ row.endReason = "server_shutdown"
+ table.insert(sessions, row)
+ end
+ end
+
+ if #sessions > 0 then
+ local encoded = safeEncode({ sessions = sessions })
+ if encoded then
+ local ok = rawPost("/api/activity/bulk-end", encoded)
+ if ok then
+ log("INFO", string.format("Shutdown: bulk-ended %d session(s).", #sessions))
+ else
+ log("ERROR", "Shutdown: bulk-end request failed — queueing for dead-letter retry.")
+ enqueueDeadLetter("/api/activity/bulk-end", { sessions = sessions })
+ end
+ end
+ else
+ log("INFO", "Shutdown: no active sessions to end.")
+ end
+
+ task.wait(5)
+end)
+
+local function fetchRemoteConfig()
+ local ok, response = httpGet(`/api/activity/config?id={Configuration.groupId}`)
+ if not ok or type(response.data) ~= "table" then
+ log("WARN", "Could not fetch remote config — using local defaults.")
+ return
+ end
+
+ local overridable = {"minTrackedRank", "privateEnabled", "studioEnabled"}
+ local changed = {}
+ for _, key in ipairs(overridable) do
+ if response[key] ~= nil and response[key] ~= Configuration[key] then
+ Configuration[key] = response[key]
+ table.insert(changed, key)
+ end
+ end
+
+ if #changed > 0 then
+ log("INFO", "Applied remote config overrides: " .. table.concat(changed, ", "))
+ end
+end
+
+task.spawn(function()
+ while true do
+ task.wait(Configuration.heartbeatInterval)
+ for userId, st in pairs(PlayerData) do
+ if st.isAFK and st.afkStart and st.afkTimerInstance then
+ local Player = Players:GetPlayerByUserId(userId)
+ if Player and isPlayerOnline(Player) then
+ st.afkTimerInstance.Value = math.floor((os.clock() - st.afkStart) / 60)
+ end
+ end
+ end
+ end
+end)
+
+task.spawn(function()
+ while true do
+ task.wait(Configuration.deadLetterFlushEvery)
+
+ local ok, keysPage = pcall(function()
+ return DeadLetterStore:ListKeysAsync()
+ end)
+ if not ok then continue end
+
+ local currentPage = keysPage:GetCurrentPage()
+ for _, keyInfo in ipairs(currentPage) do
+ local key = keyInfo.KeyName
+ local getOk, entry = pcall(function()
+ local value = DeadLetterStore:GetAsync(key)
+ return value
+ end)
+
+ if getOk and type(entry) == "table" then
+ local encoded = safeEncode(entry.body)
+ if encoded then
+ local postOk = rawPost(entry.endpoint, encoded)
+ if postOk then
+ pcall(function() DeadLetterStore:RemoveAsync(key) end)
+ log("INFO", "Dead-letter retry succeeded for " .. entry.endpoint)
+ else
+ entry.attempts = (entry.attempts or 0) + 1
+ if entry.attempts >= Configuration.deadLetterMaxAttempts then
+ log("ERROR", string.format(
+ "Dead-letter entry %s exceeded max attempts (%d) — leaving in store for manual review.",
+ key, entry.attempts))
+ else
+ pcall(function() DeadLetterStore:SetAsync(key, entry) end)
+ end
+ end
+ end
+ end
+ end
+ end
+end)
+
+task.spawn(function()
+ while true do
+ task.wait(Configuration.scanInterval)
+ log("INFO", "Running full player scan...")
+
+ local onlinePlayers = Players:GetPlayers()
+ local onlineSet: { [number]: boolean } = {}
+ for _, p in ipairs(onlinePlayers) do
+ onlineSet[p.UserId] = true
+ end
+
+ for userId, st in pairs(PlayerData) do
+ if st.sessionActive and not onlineSet[userId] then
+ log("WARN", string.format(
+ "Ghost session detected for userId %d — ending immediately.", userId))
+ local ghostRow = {
+ userid = userId,
+ idleTime = 0,
+ messages = #st.chatLog,
+ endReason = "ghost_cleanup",
+ }
+ if #st.chatLog > 0 then
+ ghostRow.chatBodies = st.chatLog
+ end
+ httpPost("/api/activity/session?type=end", ghostRow)
+ PlayerData[userId] = nil
+ end
+ end
+
+ for _, Player in ipairs(onlinePlayers) do
+ if isUserTracked(Player) then
+ local st = getState(Player)
+ if st and not st.sessionActive then
+ local remoteActive = checkRemoteSessionActive(Player)
+ if remoteActive then
+ log("WARN", Player.Name .. " has remote session but no local cache — syncing.")
+ st.sessionActive = true
+ else
+ log("WARN", Player.Name .. " has no active session — recreating.")
+ if st.afkTimerInstance then st.afkTimerInstance.Value = 0 end
+ CreateSession(Player)
+ end
+ end
+ end
+ end
+
+ if Configuration.rankChecking then
+ for i, Player in ipairs(onlinePlayers) do
+ local st = getState(Player)
+ if st then
+ local dueForRecheck = (os.clock() - (st.lastRankCheck or 0)) >= Configuration.rankRefreshInterval
+ if dueForRecheck then
+ if i > 1 then task.wait(0.25) end
+
+ local oldRank = st.rank
+ local newRank = fetchRank(Player)
+ st.rank = newRank
+ st.lastRankCheck = os.clock()
+
+ if newRank ~= oldRank then
+ log("INFO", string.format("%s rank changed: %d → %d", Player.Name, oldRank, newRank))
+
+ if oldRank >= Configuration.minTrackedRank
+ and newRank < Configuration.minTrackedRank then
+ log("WARN", Player.Name .. " fell below min rank, ending session.")
+ EndSession(Player, "rank_below_minimum")
+
+ elseif oldRank < Configuration.minTrackedRank
+ and newRank >= Configuration.minTrackedRank then
+ log("INFO", Player.Name .. " now meets min rank, starting session.")
+ st.chatLog = {}
+ st.lastActivity = os.clock()
+ if not st.afkTimerInstance then
+ local afkTimer = Instance.new("NumberValue")
+ afkTimer.Name = "Orbit AFK Timer"
+ afkTimer.Parent = Player
+ st.afkTimerInstance = afkTimer
+ end
+ CreateSession(Player)
+ end
+ end
+ end
+ end
+ end
+ end
+ for userId, st in pairs(PlayerData) do
+ if st.isAFK and not onlineSet[userId] then
+ st.isAFK = false
+ st.afkStart = nil
+ end
+ end
+ end
+end)
+
+fetchRemoteConfig()
+
+do
+ local startupPlayers = Players:GetPlayers()
+ local trackedForBulkStart = {}
+
+ for _, Player in ipairs(startupPlayers) do
+ local tracked = InitiatePlayer(Player)
+ if tracked then
+ table.insert(trackedForBulkStart, Player)
+ end
+ end
+
+ BulkCreateSessions(trackedForBulkStart)
+end]]>
+ false
+
+ 0
+ {81740BB3-280D-4363-BA2A-0CE80C16A785}
+
+ 0
+ false
+ OrbitActivity
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+ 0
+ false
+ Components
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
- {FE2A771C-DD2C-45EB-B0B2-BA2830DA58B6}
-
- 0
- false
- HttpQueue
- -1
-
-
-
-
-
+]]>
+ {FE2A771C-DD2C-45EB-B0B2-BA2830DA58B6}
+
+ 0
+ false
+ HttpQueue
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
- {023794A1-6658-4BCB-92A9-5B9086A5DD23}
-
- 0
- false
- DataUtils
- -1
-
-
-
-
-
-
+]]>
+ {023794A1-6658-4BCB-92A9-5B9086A5DD23}
+
+ 0
+ false
+ DataUtils
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {9C00AA1B-2A14-48E5-998E-9BE6EC807541}
-
- 0
- false
- HttpQueue
- -1
-
-
-
-
-
-
+]]>
+ {9C00AA1B-2A14-48E5-998E-9BE6EC807541}
+
+ 0
+ false
+ HttpQueue
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {FB8696F6-C420-4A0B-A3F1-67434876F8E3}
-
- 0
- false
- HttpRequest
- -1
-
-
-
-
-
-
+]]>
+ {FB8696F6-C420-4A0B-A3F1-67434876F8E3}
+
+ 0
+ false
+ HttpRequest
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {D32001E5-379D-461F-8EE8-561C788CD8A0}
-
- 0
- false
- HttpRequestPriority
- -1
-
-
-
-
-
-
+]]>
+ {D32001E5-379D-461F-8EE8-561C788CD8A0}
+
+ 0
+ false
+ HttpRequestPriority
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {6852466C-23CA-4C7A-B4A1-D361FCD8FBA7}
-
- 0
- false
- HttpResponse
- -1
-
-
-
-
-
-
+]]>
+ {6852466C-23CA-4C7A-B4A1-D361FCD8FBA7}
+
+ 0
+ false
+ HttpResponse
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {1D92F075-DDEE-4908-82EB-2D4C5B267F5F}
-
- 0
- false
- TypeGuards
- -1
-
-
-
-
-
-
+]]>
+ {1D92F075-DDEE-4908-82EB-2D4C5B267F5F}
+
+ 0
+ false
+ TypeGuards
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {79511612-AC4E-4893-BDD8-E1AC2BCA9E0D}
-
- 0
- false
- DependencyLoader
- -1
-
-
-
-
-
+]]>
+ {79511612-AC4E-4893-BDD8-E1AC2BCA9E0D}
+
+ 0
+ false
+ DependencyLoader
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
- {B5558958-ED24-4D82-A4C2-8823C8DEDFA4}
-
- 0
- false
- Promise
- -1
-
-
-
-
-
-
+]]>
+ {B5558958-ED24-4D82-A4C2-8823C8DEDFA4}
+
+ 0
+ false
+ Promise
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
- {7425151D-C0F2-43DF-BB72-48FFEAA57BA2}
-
- 0
- false
- t
- -1
-
-
-
-
-
-
-
+]]>
+ {7425151D-C0F2-43DF-BB72-48FFEAA57BA2}
+
+ 0
+ false
+ t
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
+
- false
-
- 0
- {BC7BD4DB-5FE1-4C9C-B8A4-1CE5E63FCAD8}
-
- 0
- false
- OrbitClient
- -1
-
-
-
-
- null
-
- 0
- false
- Event
- -1
-
-
-
-
-
-
-
+end)]]>
+ false
+
+ 0
+ {BC7BD4DB-5FE1-4C9C-B8A4-1CE5E63FCAD8}
+
+ 0
+ false
+ OrbitClient
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+ null
+
+ 0
+ false
+ Event
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
+
- {84CDACAC-F18A-46DA-B9DF-C1258CA2406A}
-
- 0
- false
- FastWait
- -1
-
-
-
-
-
+return fastWait]]>
+ {84CDACAC-F18A-46DA-B9DF-C1258CA2406A}
+
+ 0
+ false
+ FastWait
+ -1
+ yuZpQdnvvUBOTYh1jqZ2cA==
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/settings/general/activity.tsx b/components/settings/general/activity.tsx
index fbb1d926..ba0600aa 100644
--- a/components/settings/general/activity.tsx
+++ b/components/settings/general/activity.tsx
@@ -51,6 +51,44 @@ function SettingField({
)
}
+function ToggleRow({
+ title,
+ hint,
+ checked,
+ disabled,
+ onChange,
+ badge,
+}: {
+ title: string
+ hint: string
+ checked: boolean
+ disabled?: boolean
+ onChange?: () => void
+ badge?: string
+}) {
+ return (
+