@rbxts/zyntex-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1509 @@
1
+ --[[
2
+ Zyntex Roblox SDK
3
+ Version: 5
4
+ Last Updated: 2025-08-02
5
+ Author: Deluct
6
+
7
+ This module is the main client for the Zyntex Advanced Admin/Ops Panel.
8
+ It handles communication with the Zyntex API, manages server status, player events,
9
+ and listens for administrative actions from the Zyntex dashboard.
10
+
11
+ For full documentation, visit: https://docs.zyntex.dev/
12
+ ]]
13
+
14
+ local Stats = game:GetService("Stats")
15
+ local RunService = game:GetService("RunService")
16
+ local Players = game:GetService("Players")
17
+ local LogService = game:GetService("LogService")
18
+ local Chat = game:GetService("Chat")
19
+ local TCS = game:GetService("TextChatService")
20
+ local MS = game:GetService("MarketplaceService")
21
+
22
+ local API = require(script.Parent:FindFirstChild("api"))
23
+ local TYPES = require(script.Parent:FindFirstChild("types"))
24
+ local Telemetry = require(script.Parent:FindFirstChild("Telemetry"))
25
+
26
+ local CLIENT_VERSION = 5
27
+
28
+ --[[
29
+ @function now
30
+ @return string
31
+
32
+ Generates a UTC timestamp in ISO 8601 format. This is used for 'since'
33
+ parameters in API calls to ensure all data is synchronized correctly.
34
+ ]]
35
+ local function now()
36
+ local dt = DateTime.now():ToUniversalTime()
37
+ return string.format(
38
+ "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
39
+ dt.Year, dt.Month, dt.Day,
40
+ dt.Hour, dt.Minute, dt.Second,
41
+ dt.Millisecond
42
+ )
43
+ end
44
+
45
+ --[[
46
+ @function toFormat
47
+ @param date DateTime
48
+ @return string
49
+
50
+ Converts a given Roblox DateTime object into a UTC timestamp in ISO 8601 format.
51
+ ]]
52
+ local function toFormat(date: DateTime)
53
+ local date = date:ToUniversalTime()
54
+ return string.format(
55
+ "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
56
+ date.Year, date.Month, date.Day,
57
+ date.Hour, date.Minute, date.Second,
58
+ date.Millisecond
59
+ )
60
+ end
61
+
62
+ --[[
63
+ https://devforum.roblox.com/t/mute-players-with-textchatservice/2519300/4?u=deluc_t
64
+ ]]
65
+ local function muteUserId(mutedUserId)
66
+ -- listen for future TextSources
67
+ TCS.DescendantAdded:Connect(function(child)
68
+ if child:IsA("TextSource") then
69
+ if child.UserId == mutedUserId then
70
+ child:Destroy()
71
+ end
72
+ end
73
+ end)
74
+
75
+ -- mute any current TextSources
76
+ for _, child in TCS:GetDescendants() do
77
+ if child:IsA("TextSource") then
78
+ if child.UserId == mutedUserId then
79
+ child:Destroy()
80
+ end
81
+ end
82
+ end
83
+
84
+ game:GetService("ReplicatedStorage"):FindFirstChild("zyntex.events"):FindFirstChild("SystemChat"):FireClient(Players:GetPlayerByUserId(mutedUserId), `You're muted.`)
85
+ end
86
+
87
+ --[[
88
+ _____ _
89
+ | __ \| |
90
+ | |__) | | __ _ _ _ ___ _ __
91
+ | ___/| |/ _` | | | |/ _ \ '__|
92
+ | | | | (_| | |_| | __/ |
93
+ |_| |_|\__,_|\__, |\___|_|
94
+ __/ |
95
+ |___/
96
+
97
+ This section defines the 'ZyntexPlayer' object, which represents a Player
98
+ in the Zyntex dashboard.
99
+ ]]
100
+
101
+ local ZyntexPlayer = {}
102
+ ZyntexPlayer.__index = ZyntexPlayer
103
+
104
+ -- Represents a single, recorded instance of an invoked event.
105
+ type ZyntexPlayerType = {
106
+ id: number; -- The unique ID of this specific player. Equivalent to the Roblox UserId
107
+ name: string; -- The player's username as it appears on Roblox.
108
+ avatar_url: string; -- The player's headshot avatar url.
109
+ avatar_url_cache_expiry: string; -- When the player's avatar URL expires. Not important for Roblox.
110
+ reputation: string; -- The player's reputation enum. Can be: 'clean', 'suspect', or 'offender'. https://docs.zyntex.dev/moderation/reputation
111
+ raw_reputation: number; -- The player's raw reputation as an integer. https://docs.zyntex.dev/moderation/reputation
112
+ }
113
+
114
+ export type ZyntexPlayer = typeof(setmetatable({} :: ZyntexPlayerType, ZyntexPlayer))
115
+
116
+ function ZyntexPlayer.new(id: number, name: string, avatar_url: string, avatar_url_cache_expiry: string, reputation: string, raw_reputation: number)
117
+ local self = {}
118
+ self.id = id;
119
+ self.name = name
120
+ self.avatar_url = avatar_url
121
+ self.avatar_url_cache_expiry = avatar_url_cache_expiry
122
+ self.reputation = reputation
123
+ self.raw_reputation = raw_reputation
124
+
125
+ return setmetatable(self, ZyntexPlayer)
126
+ end
127
+
128
+ local SERVER_ACTIVITY = {} -- stores player joins & leaves to send to Zyntex in the case that the servers go down during the server
129
+ local sendingStatus = false
130
+
131
+ --[[
132
+
133
+ _____ _ _
134
+ |_ _| | | (_)
135
+ | | _ ____ _____ ___ __ _ | |_ _ ___ _ __ ___
136
+ | | | '_ \ \ / / _ \ / __/ _` | __| |/ _ \| '_ \ / __|
137
+ _| |_| | | \ V / (_) | (_| (_| | |_| | (_) | | | \__ \
138
+ |_____|_| |_|\_/ \___/ \___\__,_|\__|_|\___/|_| |_|___/
139
+
140
+
141
+
142
+ This section defines the 'Invocation' object, which represents a single,
143
+ successful execution of an Event.
144
+ ]]
145
+
146
+ export type Data = {{["key"]: string, ["value"]: any}}
147
+ export type DataSchema = {{["key"]: string, ["type"]: string}}
148
+
149
+ local Invocation = {}
150
+ Invocation.__index = Invocation
151
+
152
+ -- Represents a single, recorded instance of an invoked event.
153
+ type InvocationType = {
154
+ id: number; -- The unique ID of this specific invocation.
155
+ eventId: number; -- The ID of the Event that was invoked.
156
+ data: Data; -- The data payload that was sent with the invocation.
157
+ to: string; -- The destination of the invocation (e.g., a specific server or 'global').
158
+ sender: string; -- Who or what sent the invocation (e.g., 'dashboard', 'server').
159
+ fromServer: string; -- The ID of the server that sent the invocation, if applicable.
160
+ invokedAt: string; -- The ISO 8601 timestamp of when the invocation occurred.
161
+ toServer: string; -- The ID of the specific server this was sent to, if applicable.
162
+ gameId: number; -- The Roblox Place ID where the invocation originated.
163
+ invokedBy: number; -- The UserID of the admin who triggered the invocation from the dashboard, if applicable.
164
+ }
165
+
166
+ export type Invocation = typeof(setmetatable({} :: InvocationType, Invocation))
167
+
168
+ --[[
169
+ @constructor Invocation.new
170
+ @description Constructs a new Invocation object from raw data. This is typically used internally
171
+ by the client to wrap data received from the API into a usable object.
172
+ @param id number
173
+ @param eventId number
174
+ @param data Data
175
+ @param to string
176
+ @param sender string
177
+ @param fromServer string
178
+ @param invokedAt string
179
+ @param toServer string
180
+ @param gameId string
181
+ @param invokedBy number
182
+ @return Invocation
183
+ ]]
184
+ function Invocation.new(id: number, eventId: number, data: Data, to: string, sender: string, fromServer: string, invokedAt: string, toServer: string, gameId: string, invokedBy: number)
185
+ local self = {}
186
+ self.id = id;
187
+ self.eventId = eventId;
188
+ self.data = data;
189
+ self.to = to;
190
+ self.sender = sender;
191
+ self.fromServer = fromServer;
192
+ self.invokedAt = invokedAt;
193
+ self.toServer = toServer;
194
+ self.gameId = gameId;
195
+ self.invokedBy = invokedBy;
196
+
197
+ return setmetatable(self, Invocation)
198
+ end
199
+
200
+ --[[
201
+
202
+ ______ _
203
+ | ____| | |
204
+ | |__ _____ _ __ | |_ ___
205
+ | __| / / _ \ '_ \| __/ __|
206
+ | |____ V / __/ | | | |_\__ \
207
+ |______\_/ \___|_| |_|\__|___/
208
+
209
+
210
+ This section defines the 'Event' object, which represents a configurable,
211
+ remote event that can be invoked from the game server or listened to.
212
+ ]]
213
+
214
+ local Event = {}
215
+ Event.__index = Event
216
+
217
+ -- Represents a remote event defined in the Zyntex dashboard.
218
+ type EventType = {
219
+ id: number; -- The unique ID of the event.
220
+ name: string; -- The user-defined name of the event.
221
+ description: string; -- The user-defined description of the event.
222
+ dataStructure: Data; -- The schema defining the expected data payload.
223
+ deleted: boolean; -- Whether the event has been marked as deleted.
224
+ _zyntex: Zyntex; -- Internal reference to the main Zyntex client instance.
225
+ }
226
+
227
+ export type Event = typeof(setmetatable({} :: EventType, Event))
228
+
229
+ --[[
230
+ @method Event:Invoke
231
+ @description Sends an invocation for this Event to the Zyntex backend.
232
+ This allows the game server to trigger events that can be logged or listened to by other systems.
233
+ The data structure provided must match the schema defined on the Zyntex dashboard.
234
+
235
+ @param self Event -- The event object to invoke.
236
+ @param data Data -- A table containing the key-value data payload for the event.
237
+ @return Invocation -- Returns an Invocation object if the API call is successful.
238
+
239
+ @see https://docs.zyntex.dev/ for more details on creating and using events.
240
+
241
+ @example
242
+ local killEvent = Zyntex:GetEvent("PlayerKilled")
243
+ killEvent:Invoke({
244
+ killerId = 12345,
245
+ victimId = 54321,
246
+ weapon = "Sword"
247
+ })
248
+ ]]
249
+ function Event.Invoke(self: Event, data: Data): Invocation
250
+ local res = self._zyntex._session:post(
251
+ `/roblox/events/invoke`,
252
+ {
253
+ ["event_id"] = self.id;
254
+ ["data"] = data
255
+ }
256
+ )
257
+
258
+ if not res.success then
259
+ error(`Could not invoke event {self.name}: {res.user_message}`)
260
+ end
261
+
262
+ return Invocation.new(
263
+ res.data.id,
264
+ res.data.event_id,
265
+ res.data.data,
266
+ res.data.to,
267
+ res.data.sender,
268
+ res.data.from_server,
269
+ res.data.invoked_at,
270
+ res.data.to_server,
271
+ res.data.game_id,
272
+ res.data.invoked_by
273
+ )
274
+ end
275
+
276
+ --[[
277
+ @method Event:Connect
278
+ @description Establishes a listener for this specific event. The provided callback function
279
+ will be executed whenever an invocation for this event is received from the Zyntex backend (e.g., sent from the dashboard).
280
+
281
+ @param self Event -- The event object to listen to.
282
+ @param listener (({[string]: any}, Invocation) -> nil) -- The callback function to execute.
283
+ - The first argument passed to the listener is a simplified data table: `{["key"] = value}`.
284
+ - The second argument is the full, raw Invocation object.
285
+
286
+ @example
287
+ local broadcastEvent = Zyntex:GetEvent("BroadcastMessage")
288
+ broadcastEvent:Connect(function(data, invocation)
289
+ print(`Received broadcast from user {invocation.invokedBy}: {data.Message}`)
290
+ end)
291
+ ]]
292
+ function Event.Connect(self: Event, listener: ({[string]: any}, Invocation) -> nil)
293
+ local function wrapper(inv)
294
+ if inv.event_id == self.id then
295
+ local simpleData = {}
296
+
297
+ for _,v in pairs(inv.data) do
298
+ simpleData[v["key"]] = v["value"]
299
+ end
300
+
301
+ listener(
302
+ simpleData,
303
+ Invocation.new(
304
+ inv.id,
305
+ inv.event_id,
306
+ inv.data,
307
+ inv.to,
308
+ inv.sender,
309
+ inv.from_server,
310
+ inv.invoked_at,
311
+ inv.to_server,
312
+ inv.game_id,
313
+ inv.invoked_by
314
+ )
315
+ )
316
+ end
317
+ end
318
+ if not self._zyntex._listeners["invocation"] then
319
+ self._zyntex._listeners["invocation"] = {}
320
+ end
321
+ table.insert(
322
+ self._zyntex._listeners["invocation"],
323
+ wrapper
324
+ )
325
+ end
326
+
327
+ --[[
328
+ @constructor Event.new
329
+ @description Constructs a new Event object. This is used internally during the initialization
330
+ process to populate the list of available events from the Zyntex backend.
331
+ ]]
332
+ function Event.new(zyntex: Zyntex, id: number, name: string, description: string, dataStructure: DataSchema, deleted: boolean)
333
+ local self = {}
334
+ self.id = id
335
+ self.name = name
336
+ self.description = description
337
+ self.dataStructure = dataStructure
338
+ self.deleted = deleted
339
+ self._zyntex = zyntex
340
+
341
+ return setmetatable(self, Event)
342
+ end
343
+
344
+ --[[
345
+ ______ _ _
346
+ | ____| | | (_)
347
+ | |__ _ _ _ __ ___| |_ _ ___ _ __ ___
348
+ | __| | | | '_ \ / __| __| |/ _ \| '_ \/ __|
349
+ | | | |_| | | | | (__| |_| | (_) | | | \__ \
350
+ |_| \__,_|_| |_|\___|\__|_|\___/|_| |_|___/
351
+
352
+ This section defines the 'Function' object, which is similar to events, but
353
+ can only be invoked from the web-dashboard and it sends back data
354
+ ]]
355
+
356
+ local Function = {}
357
+ Function.__index = Function
358
+
359
+ type FunctionParameter = {
360
+ name: string; -- The name of the paramater
361
+ type: string; -- The type of the parameter's value
362
+ }
363
+
364
+ -- Represents a remote function defined in the Zyntex dashboard.
365
+ type FunctionType = {
366
+ id: number; -- The unique ID of the function.
367
+ name: string; -- The user-defined name of the event.
368
+ description: string; -- The user-defined description of the event.
369
+ parameters: {FunctionParameter}; -- The list of parameters the function accepts
370
+ _zyntex: Zyntex;
371
+
372
+ Connect: (self: Function, listener: ({[string]: any}) -> any) -> nil;
373
+
374
+ }
375
+
376
+ export type Function = typeof(setmetatable({} :: FunctionType, Function))
377
+
378
+ --[[
379
+ @constructor Function.new
380
+ @description Constructs a new Function object that represents a callable remote function
381
+ defined in the Zyntex dashboard. This is typically used internally when the client
382
+ initializes and materializes the functions available to your experience.
383
+
384
+ @param zyntex Zyntex -- The active Zyntex client, used for networking and configuration.
385
+ @param id number -- The unique ID of the remote function.
386
+ @param name string -- The user-defined name of the function.
387
+ @param description string -- A human-readable description of the function.
388
+ @param parameters {FunctionParameter} -- The schema describing the parameters this function accepts.
389
+
390
+ @return Function -- The constructed Function instance.
391
+ ]]
392
+ function Function.new(zyntex: Zyntex, id: number, name: string, description: string, parameters: {FunctionParameter})
393
+ local self = {}
394
+ self.id = id
395
+ self.name = name
396
+ self.description = description
397
+ self.parameters = parameters
398
+ self._zyntex = zyntex
399
+
400
+ return setmetatable(self, Function)
401
+ end
402
+
403
+ --[[
404
+ @method Function:Connect
405
+ @description Registers a listener that will be invoked whenever a call targeting this
406
+ remote function is received from the Zyntex backend. The listener is passed a simple
407
+ parameters table and is expected to return a value (typically a table) which will be
408
+ sent back to Zyntex as the payload response.
409
+
410
+ @param self Function -- The Function instance to listen on.
411
+ @param listener (({[string]: any}) -> any) -- Callback executed when this function is invoked.
412
+ - Receives: a table of parameter key/value pairs (`metadata.parameters`).
413
+ - Returns: any serializable value (commonly a table) that becomes the payload response.
414
+
415
+ @return nil -- This method registers the listener and does not return a connection handle.
416
+
417
+ @errors Asserts if the payload POST fails; the assertion message is the API's `user_message`.
418
+
419
+ @example
420
+ -- Assume a remote function "Add" with parameters: { a: number, b: number }
421
+ local addFn = Zyntex:GetFunction("Add")
422
+ addFn:Connect(function(params)
423
+ local a = params.a
424
+ local b = params.b
425
+ -- Whatever is returned here is sent back to Zyntex as the payload:
426
+ return { sum = a + b }
427
+ end)
428
+ ]]
429
+ function Function.Connect(self: Function, listener: ({[string]: any}) -> any)
430
+ local function wrapper(data)
431
+ if data.metadata.function_id == self.id then
432
+ if self._zyntex._config.debug then
433
+ print(`[Zyntex]: Function received, calling listener...`)
434
+ end
435
+
436
+ local payload = listener(
437
+ data.metadata.parameters
438
+ )
439
+
440
+ if self._zyntex._config.debug then
441
+ print(`[Zyntex]: Sending payload response...`)
442
+ end
443
+
444
+ assert(payload ~= nil, 'Function:Connect hook must return a value')
445
+
446
+ local callRes = self._zyntex._session:post(
447
+ `/roblox/actions/{data.id}/send_payload`,
448
+ { ["data"] = payload }
449
+ )
450
+
451
+ assert(callRes.success, callRes.user_message)
452
+
453
+ if self._zyntex._config.debug then
454
+ print(`[Zyntex]: Successfully sent payload response to function`)
455
+ end
456
+ end
457
+ end
458
+ if not self._zyntex._listeners["function"] then
459
+ self._zyntex._listeners["function"] = {}
460
+ end
461
+ table.insert(
462
+ self._zyntex._listeners["function"],
463
+ wrapper
464
+ )
465
+ end
466
+
467
+
468
+ --[[
469
+
470
+ ______ _
471
+ |___ / | |
472
+ / /_ __ _ _ __ | |_ _____ __
473
+ / /| | | | '_ \| __/ _ \ \/ /
474
+ / /_| |_| | | | | || __/> <
475
+ /_____\__, |_| |_|\__\___/_/\_\
476
+ __/ |
477
+ |___/
478
+
479
+ This section defines the main 'Zyntex' client object, which serves as the primary
480
+ interface for interacting with the entire Zyntex system.
481
+ ]]
482
+
483
+ local Zyntex = { VERSION = CLIENT_VERSION }
484
+ Zyntex.__index = Zyntex
485
+
486
+ -- The main state container for the Zyntex client.
487
+ type ZyntexType = {
488
+ _session: API.Session; -- Handles authenticated requests to the Zyntex API.
489
+ _config: TYPES.Config; -- Stores the user-defined configuration for the client.
490
+ _events: {Event}; -- A list of all available Event objects fetched from the backend.
491
+ _functions: {Function}; -- A list of all available Function objects fetched from the backend.
492
+ _listeners: {[string]: ({[string]: any}) -> nil}; -- A dictionary of listeners for incoming actions (e.g., 'shutdown', 'rce').
493
+ _version: number
494
+ }
495
+
496
+ export type Zyntex = typeof(setmetatable({} :: ZyntexType, Zyntex))
497
+
498
+ local maxPlayers = 0;
499
+
500
+ --[[
501
+ @function serverStatus
502
+ @description Gathers real-time performance metrics about the current server instance.
503
+ @return table -- A dictionary containing health status, memory usage, network traffic, and FPS.
504
+ ]]
505
+ local function serverStatus()
506
+ local maxMemory = 6400 + 100 * maxPlayers
507
+ local stats = {
508
+ ["memory_usage"] = Stats:GetTotalMemoryUsageMb() / maxMemory,
509
+ ["data_receive_kbps"] = Stats.DataReceiveKbps or 0,
510
+ ["data_send_kbps"] = Stats.DataSendKbps or 0,
511
+ ["server_fps"] = math.clamp(1/RunService.Heartbeat:Wait(), 0, 100),
512
+ ["activity"] = SERVER_ACTIVITY
513
+ }
514
+
515
+ if stats.memory_usage > .5 or stats.data_send_kbps > 500 or stats.data_receive_kbps > 500 or stats.server_fps < 30 then
516
+ stats["health"] = "unhealthy"
517
+ return stats
518
+ end
519
+
520
+ stats["health"] = "healthy"
521
+ return stats
522
+ end
523
+
524
+ --[[
525
+ @method Zyntex:statusUpdate
526
+ @description Sends the server's current health and performance metrics to the Zyntex dashboard.
527
+ @param self Zyntex
528
+ @param status {string: number} -- A table of metrics generated by `serverStatus()`.
529
+ ]]
530
+ function Zyntex.statusUpdate(self: Zyntex, status: {string: number})
531
+ if sendingStatus then return end
532
+ sendingStatus = true
533
+ if self._config.debug then
534
+ print("[Zyntex]: Submitting server status update...")
535
+ end
536
+
537
+ pcall(function()
538
+ local res = self._session:post(
539
+ "/roblox/servers/status",
540
+ status
541
+ )
542
+
543
+ if not res.success then
544
+ warn(`Failed to submit server status update: {res.user_message}`)
545
+ end
546
+
547
+ if self._config.debug then
548
+ print("[Zyntex]: Sent server status update")
549
+ end
550
+ end)
551
+
552
+ sendingStatus = false
553
+ end
554
+
555
+ --[[
556
+ @function statusUpdateLoop
557
+ @description A background loop that periodically collects and sends server status updates.
558
+ It intelligently sends updates only when metrics have changed to reduce unnecessary network traffic.
559
+ @param self Zyntex
560
+ ]]
561
+ local function statusUpdateLoop(self: Zyntex)
562
+ local lastStatus = serverStatus()
563
+
564
+ while true do
565
+ task.wait(15)
566
+
567
+ local newStatus = serverStatus()
568
+
569
+ self:statusUpdate(newStatus)
570
+ end
571
+ end
572
+
573
+ --[[
574
+ @function onPlayerAdd
575
+ @description Handles all tasks associated with a player joining the game, including
576
+ registering them with the Zyntex backend and setting up client-side scripts.
577
+ @param self Zyntex
578
+ @param player Player -- The player who just joined.
579
+ ]]
580
+ local function onPlayerAdd(self: Zyntex, player: Player)
581
+ if self._config.debug then
582
+ print(`[Zyntex]: Handling player join for {player.Name}`)
583
+ end
584
+
585
+ local when = now()
586
+ local activity = {
587
+ player_id = player.UserId,
588
+ player_name = player.Name,
589
+ joined_at = when
590
+ }
591
+
592
+ -- Register the player's session with the Zyntex backend.
593
+ local res = self._session:post(
594
+ "/roblox/players",
595
+ {
596
+ ["id"] = player.UserId,
597
+ ["name"] = player.Name,
598
+ ["timestamp"] = when
599
+ },
600
+ false
601
+ )
602
+
603
+ pcall(function()
604
+ if not res.success then
605
+ local _warn = true
606
+ -- Handle if the player is banned.
607
+ if res.statusCode == 403 then
608
+ if #self._listeners["ban"] == 0 then
609
+ player:Kick(res.user_message)
610
+ warn(`[Zyntex]: {player.Name} attempted to join but they are banned.`)
611
+ else
612
+ for _,listener in self._listeners["ban"] do
613
+ listener(player.UserId, res.user_message)
614
+ end
615
+ end
616
+ return
617
+ end
618
+ if res.statusCode == 403 then
619
+ if #self._listeners["ban"] == 0 then
620
+ muteUserId(player.UserId)
621
+ warn(`[Zyntex]: {player.Name} attempted to join and are automatically muted.`)
622
+ else
623
+ for _,listener in self._listeners["ban"] do
624
+ listener(player.UserId, res.user_message)
625
+ end
626
+ end
627
+ _warn = false
628
+ end
629
+ if _warn then
630
+ warn(`[Zyntex]: Could not submit POST /roblox/players: "{res.user_message}"`)
631
+ end
632
+ end
633
+
634
+ if self._config.debug then
635
+ print("[Zyntex]: Player join submitted.")
636
+ end
637
+
638
+ local zyntexEvents = Instance.new("Folder")
639
+ zyntexEvents.Name = "zyntex.events"
640
+ zyntexEvents.Parent = script.Parent
641
+
642
+ local SystemChat = Instance.new("RemoteEvent")
643
+ SystemChat.Name = "SystemChat"
644
+ SystemChat.Parent = zyntexEvents
645
+
646
+ -- Clone necessary client-side remote events into ReplicatedStorage if they don't exist.
647
+ if not game:GetService("ReplicatedStorage"):FindFirstChild("zyntex.events") then
648
+ zyntexEvents:Clone().Parent = game:GetService("ReplicatedStorage")
649
+ end
650
+
651
+ -- Provide the player with the client-side script handler.
652
+ script.Parent:FindFirstChild("zyntex.client"):Clone().Parent = player.PlayerGui
653
+
654
+ -- Listen for player chats and log them.
655
+ pcall(function()
656
+ player.Chatted:Connect(function(msg)
657
+ self._session:post(
658
+ "/roblox/players/chat",
659
+ {
660
+ ["message"] = msg;
661
+ ["player_id"] = player.UserId
662
+ }
663
+ )
664
+ end)
665
+ end)
666
+
667
+ -- Track the maximum concurrent players for server health calculations.
668
+ local count = #Players:GetPlayers()
669
+ if count > maxPlayers then
670
+ maxPlayers = count
671
+ end
672
+ end)
673
+
674
+ if tonumber(res.data) then
675
+ activity["visit_id"] = tonumber(res.data)
676
+ end
677
+
678
+ table.insert(SERVER_ACTIVITY, activity)
679
+ end
680
+
681
+ --[[
682
+ @function onPlayerRemove
683
+ @description Handles a player leaving the game by notifying the Zyntex backend,
684
+ which marks the player's session as ended.
685
+ @param self Zyntex
686
+ @param player Player -- The player who just left.
687
+ ]]
688
+ local function onPlayerRemove(self: Zyntex, player: Player)
689
+ if self._config.debug then
690
+ print(`[Zyntex]: Handling player leave for {player.Name}`)
691
+ end
692
+
693
+ local when = now()
694
+
695
+ local res = self._session:delete(
696
+ "/roblox/players",
697
+ {
698
+ ["id"] = player.UserId,
699
+ ["timestamp"] = when
700
+ },
701
+ false
702
+ )
703
+
704
+ if not res.success then
705
+ warn(`[Zyntex]: Could not submit DELETE /roblox/players: "{res.user_message}"`)
706
+ end
707
+
708
+ if self._config.debug then
709
+ print("[Zyntex]: Player leave submitted.")
710
+ end
711
+
712
+ for _,activity in SERVER_ACTIVITY do
713
+ if activity.player_id == player.UserId and activity.left_at == nil then
714
+ activity["left_at"] = when
715
+ activity["visit_id"] = res.data
716
+ end
717
+ end
718
+ end
719
+
720
+ --[[
721
+ @method Zyntex:GetEventByID
722
+ @description Fetches a pre-loaded Event object using its unique numerical ID.
723
+ @param self Zyntex
724
+ @param eventId number -- The ID of the event to retrieve.
725
+ @return Event? -- The corresponding Event object, if exists.
726
+ ]]
727
+ function Zyntex.GetEventByID(self: Zyntex, eventId: number): Event?
728
+ for i,event in pairs(self._events) do
729
+ if event.id == eventId then
730
+ return event
731
+ end
732
+ end
733
+ return nil
734
+ end
735
+
736
+ --[[
737
+ @method Zyntex:GetEvent
738
+ @description Fetches a pre-loaded Event object using its unique string name. This is the most common way to get an event.
739
+ @param self Zyntex
740
+ @param eventName string -- The case-sensitive name of the event.
741
+ @return Event? -- The corresponding Event object, if it exists.
742
+ ]]
743
+ function Zyntex.GetEvent(self: Zyntex, eventName: string): Event?
744
+ for i,event in pairs(self._events) do
745
+ if event.name == eventName then
746
+ return event
747
+ end
748
+ end
749
+ return nil
750
+ end
751
+
752
+ --[[
753
+ @method Zyntex:GetFunctionByID
754
+ @description Fetches a pre-loaded Function object using its unique numerical ID.
755
+ @param self Zyntex
756
+ @param functionId number -- The ID of the function to retrieve.
757
+ @return Function? -- The corresponding Function object, if it exists.
758
+ ]]
759
+ function Zyntex.GetFunctionByID(self: Zyntex, functionId: number): Function?
760
+ for i,func in self._functions do
761
+ if func.id == functionId then
762
+ return func
763
+ end
764
+ end
765
+ return nil
766
+ end
767
+
768
+ --[[
769
+ @method Zyntex:GetFunction
770
+ @description Fetches a pre-loaded Function object using its unique string name. This is the most common way to get a function.
771
+ @param self Zyntex
772
+ @param functionName string -- The case-sensitive name of the function.
773
+ @return Function? -- The corresponding Function object, if it exists.
774
+ ]]
775
+ function Zyntex.GetFunction(self: Zyntex, functionName: string): Function?
776
+ for i,func in self._functions do
777
+ if func.name == functionName then
778
+ return func
779
+ end
780
+ end
781
+ return nil
782
+ end
783
+
784
+ --[[
785
+ @method Zyntex:Moderate
786
+ @description Submits a generic moderation action to the Zyntex backend. This is a flexible, low-level function;
787
+ it is often easier to use the shorthand methods (:Ban, :Kick, :Mute, :Report).
788
+
789
+ @warning If the `test` parameter is `false` or `nil` (and not in Studio), this action WILL permanently affect a player's reputation.
790
+
791
+ @param self Zyntex
792
+ @param player (Player | number) -- The Player object or the UserId of the target.
793
+ @param type string -- The type of moderation (e.g., "ban", "kick", "mute", "report").
794
+ @param reason string -- The reason for the moderation action. This will be visible to staff.
795
+ @param expiresAt (DateTime?) -- An optional DateTime for when the moderation expires. If nil, it may be permanent depending on the type.
796
+ @param test (boolean?) -- If `true`, the moderation is treated as a test and does not affect reputation. Defaults to `true` in Studio.
797
+ @return boolean -- Returns `true` on success.
798
+ ]]
799
+ function Zyntex.Moderate(self: Zyntex, player: Player | number, type: string, reason: string, expiresAt: DateTime?, test: boolean?)
800
+ local playerId = if typeof(player) == "Player" then player.UserId else player
801
+ local playerObject = Players:GetPlayerByUserId(playerId)
802
+ if self._config.debug then
803
+ print(`[Zyntex]: Creating moderation {type} for {playerId}...`)
804
+ end
805
+ if expiresAt then
806
+ expiresAt = toFormat(expiresAt)
807
+ end
808
+
809
+ -- Default to test mode if running in Roblox Studio unless explicitly overridden.
810
+ if test == nil then
811
+ test = RunService:IsStudio()
812
+ end
813
+
814
+ if type == "ban" then
815
+ playerObject:Kick(reason)
816
+ end
817
+
818
+ local res = self._session:post(
819
+ `/roblox/players/{playerId}/moderate`,
820
+ {
821
+ ["type"] = type,
822
+ ["reason"] = reason,
823
+ ["expires_at"] = expiresAt,
824
+ ["test"] = test
825
+ }
826
+ )
827
+
828
+ assert(res.success, `Failure when attempting to create moderation: {res.user_message}`)
829
+
830
+ if self._config.debug then
831
+ print(`[Zyntex]: Moderation created successfully.`)
832
+ end
833
+
834
+ return res.success
835
+ end
836
+
837
+ --[[
838
+ @method Zyntex:Report
839
+ @description Shorthand method to create a "report" moderation. Reports are used for logging player behavior
840
+ and contribute to their reputation score but do not have direct in-game consequences by default.
841
+ @param self Zyntex
842
+ @param player (Player | number) -- The Player object or the UserId of the target.
843
+ @param reason string -- The reason for the report.
844
+ @param test (boolean?) -- If true, the report will not affect player reputation. Defaults to `true` in Studio.
845
+ @return boolean -- Returns `true` on success.
846
+ ]]
847
+ function Zyntex.Report(self: Zyntex, player: Player | number, reason: string, test: boolean?)
848
+ return self:Moderate(player, "report", reason, nil, test)
849
+ end
850
+
851
+ --[[
852
+ @method Zyntex:Ban
853
+ @description Shorthand method to create a "ban" moderation. This logs the ban action.
854
+ Handles automatic kick automatically.
855
+ @param self Zyntex
856
+ @param player (Player | number) -- The Player object or the UserId of the target.
857
+ @param reason string -- The reason for the ban.
858
+ @param expiresAt (DateTime?) -- Optional expiration for the ban. If nil, the ban is permanent.
859
+ @param test (boolean?) -- If true, the ban will not affect player reputation. Defaults to `true` in Studio.
860
+ @return boolean -- Returns `true` on success.
861
+ ]]
862
+ function Zyntex.Ban(self: Zyntex, player: Player | number, reason: string, expiresAt: DateTime?, test: boolean?)
863
+ return self:Moderate(player, "ban", reason, expiresAt, test)
864
+ end
865
+
866
+ --[[
867
+ @method Zyntex:Mute
868
+ @description Shorthand method to create a "mute" moderation. This logs the mute action.
869
+ Mutes the player automatically.
870
+ @param self Zyntex
871
+ @param player (Player | number) -- The Player object or the UserId of the target.
872
+ @param reason string -- The reason for the mute.
873
+ @param expiresAt (DateTime?) -- Optional expiration for the mute. If nil, the mute is permanent.
874
+ @param test (boolean?) -- If true, the mute will not affect player reputation. Defaults to `true` in Studio.
875
+ @return boolean -- Returns `true` on success.
876
+ ]]
877
+ function Zyntex.Mute(self: Zyntex, player: Player | number, reason: string, expiresAt: DateTime?, test: boolean?)
878
+ return self:Moderate(player, "mute", reason, expiresAt, test)
879
+ end
880
+
881
+ --[[
882
+ @method Zyntex:Kick
883
+ @description Shorthand method to create a "kick" moderation. This logs the kick action.
884
+ Calls Player:Kick() automatically.
885
+ @param self Zyntex
886
+ @param player (Player | number) -- The Player object or the UserId of the target.
887
+ @param reason string -- The reason for the kick.
888
+ @param test (boolean?) -- If true, the kick will not affect player reputation. Defaults to `true` in Studio.
889
+ @return boolean -- Returns `true` on success.
890
+ ]]
891
+ function Zyntex.Kick(self: Zyntex, player: Player | number, reason: string, test: boolean?)
892
+ return self:Moderate(player, "kick", reason, nil, test)
893
+ end
894
+
895
+ --[[
896
+ @method Zyntex:poll
897
+ @description Initiates the long-polling loop to listen for real-time actions and invocations
898
+ from the Zyntex dashboard. This runs in its own thread.
899
+ @param self Zyntex
900
+ @param since string -- An ISO 8601 timestamp to start listening from.
901
+ @return (-> ()) -- Returns the polling function to be spawned in a new thread.
902
+ ]]
903
+ function Zyntex.poll(self: Zyntex, since: string)
904
+ return function()
905
+ -- The wait time between polls, increases dynamically when no events are received.
906
+ local nextWaitTime = 2;
907
+ while true do
908
+ if self._config.debug then
909
+ print("[Zyntex]: Polling for updates...")
910
+ end
911
+
912
+ local res
913
+
914
+ local success,data = pcall(function()
915
+ res = self._session:get(`/roblox/listen?since={since}`)
916
+
917
+ if not res.success then
918
+ error(`[Zyntex]: Failure to poll for updates: {res.user_message}`)
919
+ end
920
+ end)
921
+
922
+ if not success then
923
+ warn(data)
924
+ task.wait(nextWaitTime + 10) -- Longer wait on error
925
+ continue
926
+ end
927
+
928
+ -- If the response was empty, slightly increase the wait time for the next poll.
929
+ if #res.data == 0 then
930
+ nextWaitTime = math.clamp(nextWaitTime + .1, 2, 5)
931
+ end
932
+
933
+ -- If we received data, reset the poll timer and update the 'since' timestamp.
934
+ if #res.data > 0 then
935
+ nextWaitTime = 2
936
+ since = now()
937
+ end
938
+
939
+ -- Dispatch received events to their corresponding listeners.
940
+ for i,event in pairs(res.data) do
941
+ for _,listener in pairs(self._listeners[event.type]) do
942
+ listener(event.data)
943
+ end
944
+ end
945
+
946
+ task.wait(nextWaitTime)
947
+ end
948
+ end
949
+ end
950
+
951
+ --[[
952
+ @method Zyntex:logServiceMessage
953
+ @description Internal handler for capturing messages from Roblox's LogService (print, warn, error)
954
+ and forwarding them to the Zyntex logs for the server.
955
+ @param self Zyntex
956
+ @param msg string -- The content of the log message.
957
+ @param type Enum.MessageType -- The type of message (Output, Info, Warning, Error).
958
+ ]]
959
+ function Zyntex.logServiceMessage(self: Zyntex, msg: string, type: Enum.MessageType)
960
+ local convertedType: string?
961
+ if type == Enum.MessageType.MessageOutput then
962
+ convertedType = "info.print"
963
+ end
964
+ if type == Enum.MessageType.MessageError then
965
+ convertedType = "info.error"
966
+ end
967
+ if type == Enum.MessageType.MessageWarning then
968
+ convertedType = "info.warning"
969
+ end
970
+ if convertedType == nil then
971
+ return type
972
+ end
973
+
974
+ -- Truncate long messages to prevent overly large payloads.
975
+ local msg = string.sub(msg, 1, 100)
976
+
977
+ pcall(function()
978
+ self._session:post(
979
+ "/roblox/logservice",
980
+ {
981
+ ["message"] = msg,
982
+ ["type"] = convertedType
983
+ }
984
+ )
985
+ end)
986
+ end
987
+
988
+ --[[
989
+ @method Zyntex:Log
990
+ @description Logs a custom message to the Zyntex servers. This is useful for tracking specific
991
+ game events. The log will appear in the main logs, the server's log tab, and the associated player's log tab if a player is provided.
992
+
993
+ @param self Zyntex
994
+ @param message string -- The custom message to log.
995
+ @param player (Player? | number?) -- Optional. The Player or UserId to associate with this log entry.
996
+ @return boolean -- Returns `true` on success.
997
+ ]]
998
+ function Zyntex.Log(self: Zyntex, message: string, player: Player? | number?)
999
+ if self._config.debug then
1000
+ print(`[Zyntex]: Posting log...`)
1001
+ end
1002
+ local playerId, playerName;
1003
+ if player then
1004
+ playerId = if typeof(player) == "Player" then player.UserId else player
1005
+ playerName = if typeof(player) == "Player" then player.Name else Players:GetPlayerByUserId(player).Name
1006
+ end
1007
+
1008
+ local payload = {
1009
+ ["message"] = message
1010
+ }
1011
+
1012
+ if playerId then
1013
+ payload["player_id"] = playerId
1014
+ payload["player_name"] = playerName
1015
+ end
1016
+
1017
+ local res = self._session:post(
1018
+ `/roblox/log`,
1019
+ payload
1020
+ )
1021
+
1022
+ assert(res.success, `Failure when attempting to post log: {res.user_message}`)
1023
+
1024
+ if self._config.debug then
1025
+ print(`[Zyntex]: Log posted succesfully.`)
1026
+ end
1027
+
1028
+ return res.success
1029
+ end
1030
+
1031
+ --[[
1032
+ @method Zyntex:ProcessPurchase
1033
+ @description Logs a player's in-game purchase. This should be wired up to `MarketplaceService.ProcessReceipt`
1034
+ to track player spending and LTV on the dashboard.
1035
+
1036
+ @param self Zyntex
1037
+ @param player (Player | number) -- The Player object or UserId who made the purchase.
1038
+ @param price number -- The price of the item in Robux.
1039
+ @param productName string -- The name of the product purchased. Using the name is preferred over the ID for clarity on the dashboard.
1040
+ @return boolean -- Returns `true` on success.
1041
+ ]]
1042
+ function Zyntex.ProcessPurchase(self: Zyntex, player: Player | number, price: number, productName: string): boolean
1043
+ local res = self._session:post(
1044
+ `/roblox/players/{if type(player) == "number" then player else player.UserId}/robux-spent`,
1045
+ {
1046
+ ["robux_spent"] = price;
1047
+ ["metadata"] = productName
1048
+ }
1049
+ )
1050
+
1051
+ assert(res.success, `Failed when attempting to process purchase: {res.user_message}`)
1052
+
1053
+ return res.success
1054
+ end
1055
+
1056
+ --[[
1057
+ @constructor Zyntex.new
1058
+ @description Constructs the main Zyntex client object and validates the API connection.
1059
+ It also checks if the client version is up-to-date with the latest version recommended by the backend.
1060
+
1061
+ @param gameToken string -- The unique game token obtained from the Zyntex dashboard.
1062
+ @return Zyntex -- The newly created Zyntex instance.
1063
+ ]]
1064
+ function Zyntex.new(gameToken: string): Zyntex
1065
+ local self = {}
1066
+
1067
+ self._session = API.new(gameToken)
1068
+ self._config = {}
1069
+ self._events = {}
1070
+ self._functions = {}
1071
+ self._listeners = {}
1072
+ self._version = CLIENT_VERSION
1073
+
1074
+ local current_version = self._session:get("/roblox/latest-client-version")
1075
+
1076
+ assert(current_version.success, `Zyntex API is currently down.`)
1077
+
1078
+ if (current_version.data ~= CLIENT_VERSION) then
1079
+ warn(`[Zyntex]: Your client is outdated, please download the latest version. current: v{CLIENT_VERSION}; latest: v{current_version.data}`)
1080
+ end
1081
+
1082
+ return setmetatable(self, Zyntex)
1083
+ end
1084
+
1085
+ --[[
1086
+ @function randomUsername
1087
+ @description Generates a random username for simulation purposes.
1088
+ ]]
1089
+ local function randomUsername()
1090
+ return "User_" .. math.random(1000, 9999)
1091
+ end
1092
+
1093
+ --[[
1094
+ @function simulateActivity
1095
+ @description When `config.simulate` is true, this function creates fake player join/leave events
1096
+ to help test the system in Studio without needing real players.
1097
+ ]]
1098
+ local function simulateActivity(self)
1099
+ local fakePlayers = {}
1100
+
1101
+ while true do
1102
+ task.wait(math.random(0.1, 10))
1103
+
1104
+ local action = math.random(1, 4)
1105
+
1106
+ if action == 1 or action == 2 then
1107
+ -- Simulate Player Join
1108
+ local name = randomUsername()
1109
+ local id = 16054156146
1110
+ local fakePlayer = {
1111
+ Name = name,
1112
+ UserId = id
1113
+ }
1114
+ table.insert(fakePlayers, fakePlayer)
1115
+ pcall(onPlayerAdd, self, fakePlayer)
1116
+
1117
+ elseif (action == 3 or action == 4) and #fakePlayers > 0 then
1118
+ -- Simulate Player Leave
1119
+ local index = math.random(1, #fakePlayers)
1120
+ local fakePlayer = table.remove(fakePlayers, index)
1121
+ pcall(onPlayerRemove, self, fakePlayer)
1122
+ end
1123
+ end
1124
+ end
1125
+
1126
+ --[[
1127
+ @method Zyntex:GetPlayerInfo
1128
+ @description Fetches and returns player information, such as reputation, total robux spent, and total time played.
1129
+
1130
+ @param self Zyntex
1131
+ @param player Player|number -- Either a Player object or the player's UserId. If UserId, the player does not have to be in the server.
1132
+ ]]
1133
+ function Zyntex.GetPlayerInfo(self: Zyntex, player: Player | number): {player: ZyntexPlayer, total_robux_spent: number, total_time_played: number}
1134
+ local res = self._session:get(`/roblox/players/{if type(player) == "number" then player else player.UserId}`)
1135
+
1136
+ assert(res.success, `Failed when attempting to fetch player: {res.user_message}`)
1137
+
1138
+ local playerInfo = res.data
1139
+ local playerInfoRaw = res.data.player
1140
+
1141
+ playerInfo["player"] = ZyntexPlayer.new(
1142
+ playerInfoRaw.id,
1143
+ playerInfoRaw.name,
1144
+ playerInfoRaw.avatar_url,
1145
+ playerInfoRaw.avatar_url_cache_expiry,
1146
+ playerInfoRaw.reputation,
1147
+ playerInfoRaw.raw_reputation
1148
+ )
1149
+
1150
+ return playerInfo
1151
+ end
1152
+
1153
+ --[[
1154
+ @method Zyntex:OnModeration
1155
+ @description Creates a hook to the moderation action.
1156
+
1157
+ @param self Zyntex
1158
+ @param moderationType string -- Returns a moderationType. Either 'ban', 'mute', or 'kick'.
1159
+ @param callback (playerId: number, reason: string) -> nil -- The callback that is called whenever the moderation occurs.
1160
+ ]]
1161
+ function Zyntex.OnModeration(self: Zyntex, moderationType: string, callback: (player: number, reason: string) -> nil)
1162
+ table.insert(self._listeners[moderationType], callback)
1163
+ end
1164
+
1165
+ --[[
1166
+ @method Zyntex:OnModeration
1167
+ @description Creates a hook to the 'kick' mo eration action.
1168
+
1169
+ @param self Zyntex
1170
+ @param callback (playerId: number, reason: string) -> nil -- The callback that is called whenever the kick occurs.
1171
+ ]]
1172
+ function Zyntex.OnKick(self: Zyntex, callback: (player: number, reason: string) -> nil)
1173
+ return self:OnModeration("kick", callback)
1174
+ end
1175
+
1176
+ --[[
1177
+ @method Zyntex:OnModeration
1178
+ @description Creates a hook to the 'ban' moderation action.
1179
+
1180
+ @param self Zyntex
1181
+ @param callback (playerId: number, reason: string) -> nil -- The callback that is called whenever the ban occurs.
1182
+ ]]
1183
+ function Zyntex.OnBan(self: Zyntex, callback: (player: number, reason: string) -> nil)
1184
+ return self:OnModeration("ban", callback)
1185
+ end
1186
+
1187
+ --[[
1188
+ @method Zyntex:OnMute
1189
+ @description Creates a hook to the 'mute' moderation action.
1190
+
1191
+ @param self Zyntex
1192
+ @param callback (playerId: number, reason: string) -> nil -- The callback that is called whenever the mute occurs.
1193
+ ]]
1194
+ function Zyntex.OnMute(self: Zyntex, callback: (player: number, reason: string) -> nil)
1195
+ return self:OnModeration("mute", callback)
1196
+ end
1197
+
1198
+ --[[
1199
+ @method Zyntex:init
1200
+ @description The main entry point to start the Zyntex client. This function initializes all core processes:
1201
+ it registers the server with the Zyntex backend, starts the status update and polling loops,
1202
+ sets up event listeners, and connects player tracking signals.
1203
+
1204
+ @param self Zyntex
1205
+ @param config TYPES.Config -- A configuration table that controls client behavior (e.g., `debug`, `simulate`).
1206
+ ]]
1207
+ function Zyntex.init(self: Zyntex, config: TYPES.Config)
1208
+ self._config = config
1209
+
1210
+ local since = now()
1211
+
1212
+ if config.debug then
1213
+ print("[Zyntex]: Zyntex.init")
1214
+ print("[Zyntex]: Initializing server...")
1215
+ end
1216
+
1217
+ --// Initial server creation
1218
+ local privateServerId = game.PrivateServerId
1219
+ local privateServerParameter = ""
1220
+ if privateServerId ~= "" and game.PrivateServerOwnerId ~= 0 then
1221
+ privateServerParameter = `&isPrivate={privateServerId}`
1222
+ end
1223
+ local response = self._session:post(`/roblox/servers?version={game.PlaceVersion}{privateServerParameter}`)
1224
+
1225
+ if response.success == false then
1226
+ error(`Error when attempting to initialize server: {response.user_message}`)
1227
+ end
1228
+
1229
+ --// Capture and send historical logs from before the client initialized.
1230
+ -- Don't want to send 300 messages all at once :)
1231
+ task.spawn(function()
1232
+ local history = LogService:GetLogHistory()
1233
+ local delay = 0
1234
+ if #history > 5 then
1235
+ delay = 1
1236
+ end
1237
+ -- Avoid sending an excessive number of historical logs.
1238
+ if #history > 30 then
1239
+ return
1240
+ end
1241
+ for _,message in pairs(history) do
1242
+ self:logServiceMessage(message.message, message.messageType)
1243
+ task.wait(delay)
1244
+ end
1245
+ end)
1246
+
1247
+ --// Connect live LogService listener.
1248
+ LogService.MessageOut:Connect(function(msg: string, type: Enum.MessageType)
1249
+ self:logServiceMessage(msg, type)
1250
+ end)
1251
+
1252
+ if config.debug then
1253
+ print("[Zyntex]: Server initialized")
1254
+ end
1255
+
1256
+ --// On server shutdown, notify the Zyntex backend.
1257
+ game:BindToClose(function()
1258
+ if config.debug then
1259
+ print("[Zyntex]: Server closing...")
1260
+ end
1261
+ sendingStatus = true
1262
+ self._session:delete("/roblox/servers")
1263
+ end)
1264
+
1265
+ --// Start the background processes.
1266
+ task.spawn(statusUpdateLoop, self)
1267
+ task.spawn(self:poll(since))
1268
+
1269
+ if config.debug then
1270
+ print("[Zyntex]: Fetching events...")
1271
+ end
1272
+
1273
+ --// Fetch event + function manifest
1274
+ local res = self._session:get("/roblox/manifest")
1275
+
1276
+ if not res.success then
1277
+ warn(`[Zyntex]: Could not fetch manifest: {res.user_message}`)
1278
+ end
1279
+
1280
+ for i,v in pairs(res.data) do
1281
+ if v.type == "function" then
1282
+ table.insert(self._functions, Function.new(
1283
+ self,
1284
+ v.id,
1285
+ v.name,
1286
+ v.description,
1287
+ v.parameters
1288
+ )
1289
+ )
1290
+ continue
1291
+ end
1292
+ table.insert(self._events, Event.new(
1293
+ self,
1294
+ v.id,
1295
+ v.name,
1296
+ v.description,
1297
+ v.data,
1298
+ false
1299
+ )
1300
+ )
1301
+ end
1302
+
1303
+ --// Handle server shutdown action from the dashboard.
1304
+ self._listeners["shutdown"] = {}
1305
+ table.insert(self._listeners["shutdown"], function(action)
1306
+ local reason = action.metadata
1307
+
1308
+ -- Kick all current and future players with the provided reason.
1309
+ for _,player in pairs(Players:GetPlayers()) do
1310
+ player:Kick(reason)
1311
+ end
1312
+
1313
+ Players.PlayerAdded:Connect(function(player)
1314
+ player:Kick(reason)
1315
+ end)
1316
+
1317
+ -- Notify the dashboard that the action has been fulfilled.
1318
+ local res = self._session:post(
1319
+ `/roblox/actions/{action.id}/fulfill`
1320
+ )
1321
+
1322
+ if not res.success then
1323
+ warn(`[Zyntex]: Server shutdown fulfillment failed: {res.user_message}`)
1324
+ end
1325
+
1326
+ if config.debug then
1327
+ print(`[Zyntex]: Server shutdown fulfilled.`)
1328
+ end
1329
+ end)
1330
+
1331
+ --// Handle remote code execution (RCE) action from the dashboard.
1332
+ self._listeners["rce"] = {}
1333
+ table.insert(self._listeners["rce"], function(action)
1334
+ local code = action.metadata
1335
+
1336
+ if config.debug then
1337
+ print(`[Zyntex]: Fulfilling RCE request...`)
1338
+ end
1339
+
1340
+ -- Execute the code in a protected call to prevent crashes.
1341
+ task.spawn(function()
1342
+ local success,data = pcall(function()
1343
+ local executable,msg = require(script.Parent:FindFirstChild("Loadstring") :: ModuleScript)(code)
1344
+ executable()
1345
+ end)
1346
+
1347
+ if not success then
1348
+ warn(`RCE Failure: {data}`)
1349
+ end
1350
+ end)
1351
+
1352
+ local res = self._session:post(
1353
+ `/roblox/actions/{action.id}/fulfill`
1354
+ )
1355
+
1356
+ if not res.success then
1357
+ warn(`[Zyntex]: RCE fulfillment failed: {res.user_message}`)
1358
+ end
1359
+
1360
+ if config.debug then
1361
+ print(`[Zyntex]: RCE fulfilled.`)
1362
+ end
1363
+ end)
1364
+
1365
+ --// Handle system chat message action from the dashboard.
1366
+ self._listeners["chat"] = {}
1367
+ table.insert(self._listeners["chat"], function(action)
1368
+ if config.debug then
1369
+ print(`[Zyntex]: Fulfilling chat request...`)
1370
+ end
1371
+
1372
+ -- Fire a remote event to all clients to display a system message.
1373
+ game:GetService("ReplicatedStorage"):FindFirstChild("zyntex.events"):FindFirstChild("SystemChat"):FireAllClients(action.metadata)
1374
+
1375
+ local res = self._session:post(
1376
+ `/roblox/actions/{action.id}/fulfill`
1377
+ )
1378
+
1379
+ if not res.success then
1380
+ warn(`[Zyntex]: Chat fulfillment failed: {res.user_message}`)
1381
+ end
1382
+
1383
+ if config.debug then
1384
+ print(`[Zyntex]: Chat fulfilled.`)
1385
+ end
1386
+ end)
1387
+
1388
+ --// Handle moderation action from the dashboard.
1389
+ self._listeners["moderation"] = {}
1390
+ self._listeners["ban"] = {}
1391
+ self._listeners["mute"] = {}
1392
+ self._listeners["kick"] = {}
1393
+ self._listeners["report"] = {}
1394
+ table.insert(self._listeners["moderation"], function(action)
1395
+ if config.debug then
1396
+ print(`[Zyntex]: Fulfilling moderation request...`)
1397
+ print(`[Zyntex]: {action}`)
1398
+ end
1399
+
1400
+ local fullfilled = false
1401
+
1402
+ local type = action.metadata.type
1403
+ local player_id = action.metadata.player_id
1404
+ local reason = action.metadata.reason
1405
+
1406
+
1407
+ if #self._listeners[type] == 0 then
1408
+ if type == "ban" or type == "kick" then
1409
+ local player = Players:GetPlayerByUserId(player_id)
1410
+ if player then
1411
+ player:Kick(reason)
1412
+ fullfilled = true
1413
+ end
1414
+ end
1415
+
1416
+ if type == "mute" then
1417
+ local player = Players:GetPlayerByUserId(player_id)
1418
+ if player then
1419
+ muteUserId(player.UserId)
1420
+ fullfilled = true
1421
+ end
1422
+ end
1423
+ else
1424
+ for _,listener in self._listeners[type] do
1425
+ listener(player_id, reason)
1426
+ end
1427
+ fullfilled = true
1428
+ end
1429
+
1430
+ if fullfilled then
1431
+ local res = self._session:post(
1432
+ `/roblox/actions/{action.id}/fulfill`
1433
+ )
1434
+
1435
+ if not res.success then
1436
+ warn(`[Zyntex]: Moderation fulfillment failed: {res.user_message}`)
1437
+ end
1438
+ end
1439
+
1440
+ if config.debug then
1441
+ print(`[Zyntex]: Moderation request fulfilled.`)
1442
+ end
1443
+ end)
1444
+
1445
+ --// If simulation is enabled in the config, start the activity simulator.
1446
+ if config.simulate then
1447
+ task.spawn(function()
1448
+ simulateActivity(self)
1449
+ end)
1450
+ end
1451
+
1452
+ --// Connect player join/leave listeners.
1453
+ Players.PlayerAdded:Connect(function(player: Player)
1454
+ return onPlayerAdd(self, player)
1455
+ end)
1456
+
1457
+ --// Ensure any players already present at initialization are registered.
1458
+ for i,player in Players:GetPlayers() do
1459
+ onPlayerAdd(self, player)
1460
+ end
1461
+
1462
+ Players.PlayerRemoving:Connect(function(player: Player)
1463
+ return onPlayerRemove(self, player)
1464
+ end)
1465
+ end
1466
+
1467
+ --[[
1468
+ @method Zyntex:link
1469
+ @description A utility function used during initial setup. When called, it attempts
1470
+ to link the provided game token to the user's Roblox account via the Zyntex backend.
1471
+ This is typically only run once from the command bar in Studio.
1472
+ ]]
1473
+ function Zyntex.link(self: Zyntex)
1474
+ local response = self._session:post("/links/games/link")
1475
+
1476
+ if response.success == false then
1477
+ if response.statusCode == 404 then
1478
+ error(`Game token "{self._session.gameToken}" not found. Please make sure you pasted the full linking script from the site.`)
1479
+ end
1480
+ error(`Linking failed: {response.user_message}`)
1481
+ end
1482
+
1483
+ print(`[Zyntex]: Linking success!`)
1484
+ end
1485
+
1486
+ --[[
1487
+ @method Zyntex:Telemetry
1488
+ @description Constructs a new Telemetry object used for prometheus-style metrics.
1489
+ @param flushEvery number? -- How often to flush the buffer in seconds. Default is 10,
1490
+ which is the minimum to not get ratelimited.
1491
+ @param registryName string? -- The name of the registry. Default is "default"
1492
+ ]]
1493
+ function Zyntex.Telemetry(self: Zyntex, flushEvery: number?, registryName: string?)
1494
+ if not flushEvery then
1495
+ flushEvery = 10
1496
+ end
1497
+ assert(flushEvery >= 10, `flushEvery must be no less than 10`)
1498
+ return Telemetry.new(
1499
+ registryName,
1500
+ flushEvery,
1501
+ self._session
1502
+ )
1503
+ end
1504
+
1505
+ --[[
1506
+ The Zyntex module is returned to be required and used by other server scripts.
1507
+ ]]
1508
+
1509
+ return Zyntex