Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"fmt"
"log"
"net/http"
"os"

"github.com/anaminus/rbxauth"
)

const cookieFilename = "/data/cookies.txt"
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie file path /data/cookies.txt is hardcoded. If the /data directory doesn't exist or isn't writable (e.g., in environments where the volume isn't mounted), the authentication functions will fail when trying to create or read the file.

Consider making the cookie file path configurable via an environment variable with a sensible default, and adding proper error messages that indicate when the directory is missing or not writable.

Copilot uses AI. Check for mistakes.

func testAuth() {
cfg := &rbxauth.Config{}

cookies, err := loadCookiesFromFile()
if err == nil {
// If cookies are successfully loaded from file, no need to login again
log.Println("Using cookies from file")
} else {
log.Println("No valid cookies found in file, logging in...")
username := "your_username"
password := []byte("your_password")

cookies, step, err := cfg.Login(username, password)
if err != nil {
if step != nil {
// Handle multi-step verification.
log.Println("Two-step verification required.")
// You can use step.Resend() and step.Verify() methods to handle the verification process.
} else {
log.Fatalf("Error logging in: %v", err)
}
return
}

err = saveCookiesToFile(cookies)
if err != nil {
log.Printf("Error saving cookies to file: %v", err)
}
}

fmt.Println("Successfully logged in!")
printCookies(cookies)
}

Comment on lines +14 to +47
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The testAuth() function contains hardcoded placeholder credentials ("your_username" and "your_password"). This function appears to be test/example code that should not be included in production. If this function is ever called in production, it will fail authentication and potentially log sensitive placeholder data.

Consider removing this function or moving it to a test file, or at minimum add clear documentation that this is example code only.

Suggested change
func testAuth() {
cfg := &rbxauth.Config{}
cookies, err := loadCookiesFromFile()
if err == nil {
// If cookies are successfully loaded from file, no need to login again
log.Println("Using cookies from file")
} else {
log.Println("No valid cookies found in file, logging in...")
username := "your_username"
password := []byte("your_password")
cookies, step, err := cfg.Login(username, password)
if err != nil {
if step != nil {
// Handle multi-step verification.
log.Println("Two-step verification required.")
// You can use step.Resend() and step.Verify() methods to handle the verification process.
} else {
log.Fatalf("Error logging in: %v", err)
}
return
}
err = saveCookiesToFile(cookies)
if err != nil {
log.Printf("Error saving cookies to file: %v", err)
}
}
fmt.Println("Successfully logged in!")
printCookies(cookies)
}

Copilot uses AI. Check for mistakes.
func loadCookiesFromFile() ([]*http.Cookie, error) {
file, err := os.Open(cookieFilename)
if err != nil {
return nil, err
}
defer file.Close()

cookies, err := rbxauth.ReadCookies(file)
if err != nil {
return nil, err
}

return cookies, nil
}

func saveCookiesToFile(cookies []*http.Cookie) error {
file, err := os.Create(cookieFilename)
if err != nil {
return err
}
defer file.Close()

err = rbxauth.WriteCookies(file, cookies)
if err != nil {
return err
}

return nil
}

func printCookies(cookies []*http.Cookie) {
fmt.Println("Cookies:")
for _, cookie := range cookies {
fmt.Printf("%s: %s\n", cookie.Name, cookie.Value)
}
}
Comment on lines +78 to +83
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The printCookies() function prints authentication cookies to stdout, which could expose sensitive session tokens in logs. This is a security risk as these cookies could be used to hijack authenticated sessions.

Consider removing this function or ensuring it's only used in development/debugging contexts, never in production.

Copilot uses AI. Check for mistakes.
32 changes: 31 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: "3.7"
---
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The version line was removed from docker-compose.yml (previously version: "3.7"). While docker-compose version 1.27.0+ supports compose files without a version field (defaulting to the latest schema), explicitly specifying the version is considered a best practice for clarity and compatibility.

Consider adding back the version field (e.g., version: "3.8" or later) to make the compose file schema version explicit.

Suggested change
---
---
version: "3.8"

Copilot uses AI. Check for mistakes.
services:
robloxtracker:
image: robloxtracker:latest
Expand All @@ -9,6 +9,12 @@ services:
- 8080/tcp
networks:
- roblox
volumes:
- robloxtracker-data:/data
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The robloxtracker service has a hard dependency on warp-socks with condition: service_healthy, but the proxy configuration is optional based on environment variables. If PROXY_HOST and PROXY_PORT are not set, the service will still wait for warp-socks to be healthy before starting, creating an unnecessary dependency.

Consider making the depends_on conditional or documenting that the warp-socks dependency should be removed if not using a proxy.

Suggested change
- robloxtracker-data:/data
- robloxtracker-data:/data
# NOTE: The following dependency on warp-socks is only required if you are using a proxy
# (i.e., if PROXY_HOST and PROXY_PORT are set). If you are not using a proxy,
# you should remove the depends_on block below to avoid unnecessary startup delays.

Copilot uses AI. Check for mistakes.
depends_on:
warp-socks:
condition: service_healthy

prometheus:
image: prom/prometheus:v2.42.0
restart: unless-stopped
Expand All @@ -26,8 +32,32 @@ services:
networks:
- roblox

warp-socks:
image: monius/docker-warp-socks:latest
privileged: true
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running the warp-socks container with privileged: true grants the container full access to the host system, which is a significant security risk. While the container also has specific capabilities (NET_ADMIN and SYS_ADMIN), the privileged flag is overly permissive.

Consider removing privileged: true and relying only on the specific capabilities that are actually needed. If privileged mode is absolutely necessary, add a comment explaining why.

Suggested change
privileged: true

Copilot uses AI. Check for mistakes.
restart: unless-stopped
expose:
- 9091/tcp
- 9091/udp
networks:
- roblox
cap_add:
- NET_ADMIN
- SYS_ADMIN
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv4.conf.all.src_valid_mark: 1
healthcheck:
test: ["CMD", "curl", "-f", "https://www.cloudflare.com/cdn-cgi/trace"]
interval: 30s
timeout: 10s
retries: 5
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warp-socks service is configured with replicas: 5 under the deploy section. However, the deploy section is only used in Docker Swarm mode, not with docker-compose up. This configuration will be ignored when using standard docker-compose, meaning only 1 replica will run instead of the intended 5.

If you need multiple replicas with docker-compose, you should use the scale parameter or deploy with Swarm mode. If this is intentional for Swarm deployments only, consider adding a comment to clarify.

Suggested change
retries: 5
retries: 5
# The 'deploy' section is only used in Docker Swarm mode.
# To run multiple replicas with standard docker-compose, use:
# docker-compose up --scale warp-socks=5

Copilot uses AI. Check for mistakes.
deploy:
replicas: 5

networks:
roblox:

volumes:
prometheus-data:
robloxtracker-data:
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
module github.com/joecryptotoo/robloxtracker

go 1.19
go 1.18
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The Go version was downgraded from 1.19 to 1.18 in go.mod. This is generally not recommended as it may remove access to bug fixes, security patches, and language features available in 1.19.

Unless there's a specific compatibility requirement, consider keeping the Go version at 1.19 or upgrading to a newer version.

Suggested change
go 1.18
go 1.19

Copilot uses AI. Check for mistakes.

require (
github.com/anaminus/rbxauth v0.4.0
github.com/gregdel/pushover v1.1.0
github.com/prometheus/client_golang v1.14.0
golang.org/x/net v0.0.0-20220225172249-27dd8689420f
)

require (
Expand All @@ -15,6 +17,7 @@ require (
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
google.golang.org/protobuf v1.28.1 // indirect
)
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/anaminus/but v0.2.0/go.mod h1:44z5qYo/3MWnZDi6ifH3IgrFWa1VFfdTttL3IYN/9R4=
github.com/anaminus/rbxauth v0.4.0 h1:ifXqqGIJ3Ov9Z+Of38kqeFxT5KXm2XYjQtDTsLzNxyk=
github.com/anaminus/rbxauth v0.4.0/go.mod h1:5NdP/0UdIZ9oHk8IphCNLmPlKeLcewhr0i65ajQol4E=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
Expand Down Expand Up @@ -206,7 +209,9 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
Expand Down Expand Up @@ -268,6 +273,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
Expand Down Expand Up @@ -297,6 +303,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down
71 changes: 50 additions & 21 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,58 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {

// Create a non-global registry.
reg := prometheus.NewRegistry()

// Get the metrics
metrics := robloxMetrics(reg)

// Check for required environment variables
userID, err := strconv.ParseInt(os.Getenv("ROBLOX_USER_ID"), 10, 64)
if err != nil {
log.Println(err)
return
userIDsStr := os.Getenv("ROBLOX_USER_IDS")
userIDsStrSlice := strings.Split(userIDsStr, ",")
userIDs := make([]int64, len(userIDsStrSlice))

for i, idStr := range userIDsStrSlice {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
log.Println(err)
return
}
userIDs[i] = id
}
Comment on lines +23 to +34
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing of ROBLOX_USER_IDS will fail if the environment variable is empty or not set, as strings.Split("", ",") returns a slice with one empty string element. This will cause strconv.ParseInt to fail with an error, causing the application to exit.

Consider adding validation to ensure at least one valid user ID is provided before starting the monitoring process.

Copilot uses AI. Check for mistakes.

notifyIDsStr := os.Getenv("NOTIFY_ROBLOX_USER_IDS")
notifyIDsStrSlice := strings.Split(notifyIDsStr, ",")
notifyIDs := make([]int64, len(notifyIDsStrSlice))

for i, idStr := range notifyIDsStrSlice {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
log.Println(err)
return
}
notifyIDs[i] = id
Comment on lines +37 to +46
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing of NOTIFY_ROBLOX_USER_IDS will fail if the environment variable is empty or not set, as strings.Split("", ",") returns a slice with one empty string element. This will cause strconv.ParseInt to fail with an error, causing the application to exit.

Consider checking if the variable is empty before attempting to parse it, or handle the empty string case gracefully.

Suggested change
notifyIDsStrSlice := strings.Split(notifyIDsStr, ",")
notifyIDs := make([]int64, len(notifyIDsStrSlice))
for i, idStr := range notifyIDsStrSlice {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
log.Println(err)
return
}
notifyIDs[i] = id
var notifyIDs []int64
if strings.TrimSpace(notifyIDsStr) != "" {
notifyIDsStrSlice := strings.Split(notifyIDsStr, ",")
notifyIDs = make([]int64, 0, len(notifyIDsStrSlice))
for _, idStr := range notifyIDsStrSlice {
idStr = strings.TrimSpace(idStr)
if idStr == "" {
continue
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
log.Println(err)
return
}
notifyIDs = append(notifyIDs, id)
}

Copilot uses AI. Check for mistakes.
}

for _, userID := range userIDs {
go monitorUser(reg, userID, metrics, notifyIDs)
time.Sleep(time.Second * 1) // Rate limit requests to the API by staggering the requests
}

// Expose metrics and custom registry via an HTTP server
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
log.Fatal(http.ListenAndServe(":8080", nil))
Comment on lines +49 to +56
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP server is started without waiting for the monitoring goroutines to be fully initialized. If all monitoring goroutines exit early due to errors (e.g., invalid user IDs), the main function will immediately call log.Fatal(http.ListenAndServe(":8080", nil)) and the server will start even though no monitoring is occurring.

Consider checking that at least one goroutine successfully initialized before starting the HTTP server, or use a WaitGroup to ensure proper synchronization.

Copilot uses AI. Check for mistakes.
}

func monitorUser(reg *prometheus.Registry, userID int64, metrics *Metrics, notifyIDs []int64) {
// Get the username string
user, err := getUsernameFromID(userID)
if err != nil {
Expand All @@ -32,20 +66,11 @@ func main() {

user.LastPresenceChange = time.Now().UTC()

user.Metrics = *robloxMetrics(reg)

// Start presence checker
presenceState := 0
user.LastPresenceType = presenceState
t := time.NewTicker(time.Second * 5)

// Expose metrics and custom registry via an HTTP server
// using the HandleFor function. "/metrics" is the usual endpoint for that.
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
go func() {
log.Fatal(http.ListenAndServe(":8080", nil))
}()

// Check presence every 5 seconds
for range t.C {
// Check presence
Expand All @@ -59,23 +84,27 @@ func main() {
if presenceState != user.Presence.UserPresenceType {
// Update last online time
user.LastPresenceType = presenceState
minutesSinceLastOnline := int(time.Now().UTC().Sub(user.Presence.LastOnline).Minutes())

// Log presence change
log.Printf("User %s is %s, last online %d minutes ago\n", user.Name, presenceTypeToString(user.Presence.UserPresenceType), minutesSinceLastOnline)
log.Printf("User %s is %s, last online: %s\n", user.Name, presenceTypeToString(user.Presence.UserPresenceType), formatLastOnline(user.Presence))

log.Printf("Presence: %#v\n", user.Presence)

// Notify if user is online
notifyPresenceChange(user)
user.LastPresenceChange = time.Now().UTC()
// Check if the user is in the list of IDs that we want to receive notifications for
for _, id := range notifyIDs {
if user.ID == id {
// Notify if user is online
notifyPresenceChange(user)
user.LastPresenceChange = time.Now().UTC()
break
}
}
}

// Update metrics
user.Metrics.UserPresenceType.Set(float64(user.Presence.UserPresenceType))
metrics.UserPresenceType.With(prometheus.Labels{"userid": user.Name}).Set(float64(user.Presence.UserPresenceType))

// Update presence state
presenceState = user.Presence.UserPresenceType
}

}
6 changes: 3 additions & 3 deletions metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import (
)

type Metrics struct {
UserPresenceType prometheus.Gauge
UserPresenceType *prometheus.GaugeVec
}

func robloxMetrics(reg prometheus.Registerer) *Metrics {
m := &Metrics{
UserPresenceType: prometheus.NewGauge(prometheus.GaugeOpts{
UserPresenceType: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "UserPresenceType",
Help: "Offline, Online, InGame, InStudio, Unknown",
}),
}, []string{"userid"}),
}

reg.MustRegister(m.UserPresenceType)
Expand Down
6 changes: 3 additions & 3 deletions pushover.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ func notifyPresenceChange(user User) {
recipient := pushover.NewRecipient(os.Getenv("PUSHOVER_USER_KEY"))

minutesSinceLastState := int(time.Now().UTC().Sub(user.LastPresenceChange).Minutes())
minutesSinceLastOnline := int(time.Now().UTC().Sub(user.Presence.LastOnline).Minutes())
lastOnlineStr := formatLastOnline(user.Presence)

lastPresenceType := presenceTypeToString(user.LastPresenceType)
presenceType := presenceTypeToString(user.Presence.UserPresenceType)
message := pushover.NewMessage(fmt.Sprintf("User %s is now %s, was %s for %d minutes, last online %d minutes ago.",
user.Name, presenceType, lastPresenceType, minutesSinceLastState, minutesSinceLastOnline))
message := pushover.NewMessage(fmt.Sprintf("User %s is now %s, was %s for %d minutes, last online: %s",
user.Name, presenceType, lastPresenceType, minutesSinceLastState, lastOnlineStr))
message.Title = "Roblox Presence Change"
message.URL = fmt.Sprintf("https://www.roblox.com/users/%d/profile", user.ID)
message.URLTitle = "View Profile"
Expand Down
Loading