From 790ac47df88254007f18d95f6b448ef443643557 Mon Sep 17 00:00:00 2001 From: sewn Date: Sat, 9 Mar 2024 22:33:36 +0300 Subject: [PATCH] refactor rich presence handling; initial StudioRPC --- bloxstraprpc/bloxstraprpc.go | 180 ------------------ bloxstraprpc/discordrpc.go | 88 --------- cmd/vinegar/binary.go | 26 ++- cmd/vinegar/binary_setup.go | 2 +- cmd/vinegar/sysinfo.go | 2 +- config/config.go | 3 +- go.mod | 2 + richpresence/bloxstraprpc/bloxstraprpc.go | 180 ++++++++++++++++++ richpresence/bloxstraprpc/discordrpc.go | 90 +++++++++ .../bloxstraprpc}/message.go | 25 ++- richpresence/richpresence.go | 8 + richpresence/studiorpc/discordrpc.go | 48 +++++ richpresence/studiorpc/studiorpc.go | 73 +++++++ 13 files changed, 436 insertions(+), 291 deletions(-) delete mode 100644 bloxstraprpc/bloxstraprpc.go delete mode 100644 bloxstraprpc/discordrpc.go create mode 100644 richpresence/bloxstraprpc/bloxstraprpc.go create mode 100644 richpresence/bloxstraprpc/discordrpc.go rename {bloxstraprpc => richpresence/bloxstraprpc}/message.go (80%) create mode 100644 richpresence/richpresence.go create mode 100644 richpresence/studiorpc/discordrpc.go create mode 100644 richpresence/studiorpc/studiorpc.go diff --git a/bloxstraprpc/bloxstraprpc.go b/bloxstraprpc/bloxstraprpc.go deleted file mode 100644 index 3889a2a..0000000 --- a/bloxstraprpc/bloxstraprpc.go +++ /dev/null @@ -1,180 +0,0 @@ -// Package bloxstraprpc implements the BloxstrapRPC protocol. -// -// For more information regarding the protocol, view [Bloxstrap's BloxstrapRPC wiki page] -// -// [Bloxstrap's BloxstrapRPC wiki page]: https://github.com/pizzaboxer/bloxstrap/wiki/Integrating-Bloxstrap-functionality-into-your-game -package bloxstraprpc - -import ( - "fmt" - "log/slog" - "regexp" - "strconv" - "strings" - "time" - - "github.com/altfoxie/drpc" - "github.com/apprehensions/rbxweb/games" -) - -const Reset = "" - -const ( - GameJoinRequestEntry = "[FLog::GameJoinUtil] GameJoinUtil::makePlaceLauncherRequest" - GameJoiningEntry = "[FLog::Output] ! Joining game" - GameJoinReportEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:" - GameJoinedEntry = "[FLog::Output] Connection accepted from" - BloxstrapRPCEntry = "[FLog::Output] [BloxstrapRPC]" - GameLeaveEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal" -) - -var ( - GameJoinRequestEntryPattern = regexp.MustCompile(`makePlaceLauncherRequest(ForTeleport)?: requestCount: [0-9], url: https:\/\/gamejoin\.roblox\.com\/v1\/([^\s\/]+)`) - GameJoiningEntryPattern = regexp.MustCompile(`! Joining game '([0-9a-f\-]{36})'`) - GameJoinReportEntryPattern = regexp.MustCompile(`Report game_join_loadtime: placeid:([0-9]+).*universeid:([0-9]+)`) -) - -type ServerType int - -const ( - Public ServerType = iota - Private - Reserved -) - -type Activity struct { - presence drpc.Activity - client *drpc.Client - - gameTime time.Time - teleporting bool - server ServerType - - universeID games.UniverseID - placeID string - jobID string -} - -func New() Activity { - c, _ := drpc.New("1159891020956323923") - return Activity{ - client: c, - } -} - -// HandleRobloxLog handles the given Roblox log entry, to set data -// and call functions based on the log entry, declared as *Entry(Pattern) constants. -func (a *Activity) HandleRobloxLog(line string) error { - entries := map[string]func(string) error{ - // In order of which it should appear in log file - GameJoinRequestEntry: a.handleGameJoinRequest, // For game join type is private, reserved - GameJoiningEntry: a.handleGameJoining, // For JobID (server ID, to join from Discord) - GameJoinReportEntry: a.handleGameJoinReport, // For PlaceID and UniverseID - GameJoinedEntry: func(_ string) error { return a.handleGameJoined() }, // Sets presence and time - BloxstrapRPCEntry: a.handleBloxstrapRPC, // BloxstrapRPC - GameLeaveEntry: func(_ string) error { return a.handleGameLeave() }, // Clears presence and time - } - - for e, h := range entries { - if strings.Contains(line, e) { - return h(line) - } - } - - return nil -} - -func (a *Activity) handleGameJoinRequest(line string) error { - m := GameJoinRequestEntryPattern.FindStringSubmatch(line) - // There are multiple outputs for makePlaceLauncherRequest - if len(m) != 3 { - return fmt.Errorf("log game join request entry is invalid") - } - - if m[1] == "ForTeleport" { - a.teleporting = true - } - - // Keep up to date from upstream Roblox GameJoin API - a.server = map[string]ServerType{ - "join-private-game": Private, - "join-reserved-game": Reserved, - "join-game": Public, - "join-game-instance": Public, - "join-play-together-game": Public, - }[m[2]] - - slog.Info("Handled GameJoinRequest", "server_type", a.server, "teleporting", a.teleporting) - - return nil -} - -func (a *Activity) handleGameJoining(line string) error { - m := GameJoiningEntryPattern.FindStringSubmatch(line) - if len(m) != 2 { - return fmt.Errorf("log game joining entry is invalid") - } - - a.jobID = m[1] - - slog.Info("Handled GameJoining", "jobid", a.jobID) - - return nil -} - -func (a *Activity) handleGameJoinReport(line string) error { - m := GameJoinReportEntryPattern.FindStringSubmatch(line) - if len(m) != 3 { - return fmt.Errorf("log game join report entry is invalid") - } - - uid, err := strconv.ParseInt(m[2], 10, 64) - if err != nil { - return err - } - - a.placeID = m[1] - a.universeID = games.UniverseID(uid) - - slog.Info("Handled GameJoinReport", "universeid", a.universeID, "placeid", a.placeID) - - return nil -} - -func (a *Activity) handleGameJoined() error { - if !a.teleporting { - a.gameTime = time.Now() - } - - a.teleporting = false - - slog.Info("Handled GameJoined", "time", a.gameTime) - - return a.UpdateGamePresence(true) -} - -func (a *Activity) handleBloxstrapRPC(line string) error { - m, err := NewMessage(line) - if err != nil { - return fmt.Errorf("parse bloxstraprpc message: %w", err) - } - m.ApplyRichPresence(&a.presence) - - slog.Info("Handled BloxstrapRPC", "message", m) - - return a.UpdateGamePresence(false) -} - -func (a *Activity) handleGameLeave() error { - a.presence = drpc.Activity{} - a.gameTime = time.Time{} - a.teleporting = false - a.server = Public - a.universeID = games.UniverseID(0) - a.placeID = "" - a.jobID = "" - - slog.Info("Handled GameLeave") - - return a.client.SetActivity(a.presence) -} diff --git a/bloxstraprpc/discordrpc.go b/bloxstraprpc/discordrpc.go deleted file mode 100644 index 69f68db..0000000 --- a/bloxstraprpc/discordrpc.go +++ /dev/null @@ -1,88 +0,0 @@ -package bloxstraprpc - -import ( - "log/slog" - - "github.com/altfoxie/drpc" - "github.com/apprehensions/rbxweb/games" - "github.com/apprehensions/rbxweb/thumbnails" -) - -// UpdateGamePresence sets the activity based on the current -// game information present in Activity. 'initial' is used -// to fetch game information required for rich presence, regardless -// if the Activity properties have been set to Reset. -func (a *Activity) UpdateGamePresence(initial bool) error { - a.presence.Buttons = []drpc.Button{{ - Label: "See game page", - URL: "https://www.roblox.com/games/" + a.placeID, - }} - - if a.server == Public { - joinurl := "roblox://experiences/start?placeId=" + a.placeID + "&gameInstanceId=" + a.jobID - a.presence.Buttons = append(a.presence.Buttons, drpc.Button{ - Label: "Join server", - URL: joinurl, - }) - } - - if a.presence.Assets == nil { - a.presence.Assets = new(drpc.Assets) - } - - if initial || (a.presence.Details == Reset || - a.presence.State == Reset || - a.presence.Assets.LargeText == Reset) { - gd, err := games.GetGameDetail(a.universeID) - if err != nil { - return err - } - - if initial || a.presence.Details == Reset { - a.presence.Details = "Playing " + gd.Name - } - - if initial || a.presence.State == Reset { - a.presence.State = "by " + gd.Creator.Name - - switch a.server { - case Private: - a.presence.State = "In a private server" - case Reserved: - a.presence.State = "In a reserved server" - } - } - - if initial || a.presence.Assets.LargeText == Reset { - a.presence.Assets.LargeText = gd.Name - } - } - - if initial || a.presence.Assets.LargeImage == Reset { - tn, err := thumbnails.GetGameIcon(a.universeID, - thumbnails.PlaceHolder, "512x512", thumbnails.Png, false) - if err != nil { - return err - } - - a.presence.Assets.LargeImage = tn.ImageURL - } - - if initial || a.presence.Assets.SmallImage == Reset { - a.presence.Assets.SmallImage = "roblox" - } - - if initial || a.presence.Assets.SmallText == Reset { - a.presence.Assets.SmallText = "Roblox" - } - - if a.presence.Timestamps == nil { - a.presence.Timestamps = &drpc.Timestamps{ - Start: a.gameTime, - } - } - - slog.Info("Updating Discord Rich Presence", "presence", a.presence) - - return a.client.SetActivity(a.presence) -} diff --git a/cmd/vinegar/binary.go b/cmd/vinegar/binary.go index 5ba1714..e06c231 100644 --- a/cmd/vinegar/binary.go +++ b/cmd/vinegar/binary.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "io" "log/slog" "os" "os/signal" @@ -13,18 +14,20 @@ import ( "github.com/apprehensions/rbxbin" "github.com/apprehensions/rbxweb/clientsettings" + "github.com/apprehensions/wine" "github.com/fsnotify/fsnotify" "github.com/godbus/dbus/v5" "github.com/lmittmann/tint" "github.com/nxadm/tail" slogmulti "github.com/samber/slog-multi" - bsrpc "github.com/vinegarhq/vinegar/bloxstraprpc" "github.com/vinegarhq/vinegar/config" "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/internal/state" + "github.com/vinegarhq/vinegar/richpresence" + "github.com/vinegarhq/vinegar/richpresence/bloxstraprpc" + "github.com/vinegarhq/vinegar/richpresence/studiorpc" "github.com/vinegarhq/vinegar/splash" "github.com/vinegarhq/vinegar/sysinfo" - "github.com/apprehensions/wine" "golang.org/x/term" ) @@ -33,6 +36,11 @@ const ( DieTimeout = 3 * time.Second ) +const ( + // TODO: find better entries + PlayerShutdownEntry = "[FLog::SingleSurfaceApp] shutDown:" +) + const ( DialogUseBrowser = "WebView/InternalBrowser is broken, please use the browser for the action that you were doing." DialogQuickLogin = "WebView/InternalBrowser is broken, use Quick Log In to authenticate ('Log In With Another Device' button)" @@ -57,7 +65,7 @@ type Binary struct { // Logging Auth bool - Activity bsrpc.Activity + Presence richpresence.BinaryRichPresence } func BinaryPrefixDir(bt clientsettings.BinaryType) string { @@ -67,6 +75,7 @@ func BinaryPrefixDir(bt clientsettings.BinaryType) string { func NewBinary(bt clientsettings.BinaryType, cfg *config.Config) (*Binary, error) { var bcfg *config.Binary var bstate *state.Binary + var rp richpresence.BinaryRichPresence s, err := state.Load() if err != nil { @@ -77,16 +86,18 @@ func NewBinary(bt clientsettings.BinaryType, cfg *config.Config) (*Binary, error case clientsettings.WindowsPlayer: bcfg = &cfg.Player bstate = &s.Player + rp = bloxstraprpc.New() case clientsettings.WindowsStudio64: bcfg = &cfg.Studio bstate = &s.Studio + rp = studiorpc.New() } pfx := wine.New(BinaryPrefixDir(bt), bcfg.WineRoot) os.Setenv("GAMEID", "ulwgl-roblox") return &Binary{ - Activity: bsrpc.New(), + Presence: rp, GlobalState: &s, State: bstate, @@ -113,6 +124,7 @@ func (b *Binary) Main(args ...string) int { ))) b.Splash = splash.New(&b.GlobalConfig.Splash) + b.Prefix.Stderr = io.MultiWriter(os.Stderr, logFile) b.Config.Env.Setenv() go func() { @@ -368,7 +380,7 @@ func (b *Binary) Tail(name string) { // Roblox shut down, give it atleast a few seconds, and then send an // internal signal to kill it. // This is due to Roblox occasionally refusing to die. We must kill it. - if strings.Contains(line.Text, "[FLog::SingleSurfaceApp] shutDown:") { + if strings.Contains(line.Text, PlayerShutdownEntry) { go func() { time.Sleep(DieTimeout) syscall.Kill(syscall.Getpid(), syscall.SIGUSR1) @@ -376,8 +388,8 @@ func (b *Binary) Tail(name string) { } if b.Config.DiscordRPC { - if err := b.Activity.HandleRobloxLog(line.Text); err != nil { - slog.Error("Activity Roblox log handle failed", "error", err) + if err := b.Presence.Handle(line.Text); err != nil { + slog.Error("Presence handling failed", "error", err) } } } diff --git a/cmd/vinegar/binary_setup.go b/cmd/vinegar/binary_setup.go index 61656f4..899b02a 100644 --- a/cmd/vinegar/binary_setup.go +++ b/cmd/vinegar/binary_setup.go @@ -12,9 +12,9 @@ import ( "github.com/apprehensions/rbxbin" "github.com/apprehensions/rbxweb/clientsettings" cp "github.com/otiai10/copy" + "github.com/vinegarhq/vinegar/dxvk" "github.com/vinegarhq/vinegar/internal/dirs" "github.com/vinegarhq/vinegar/internal/netutil" - "github.com/vinegarhq/vinegar/dxvk" "golang.org/x/sync/errgroup" ) diff --git a/cmd/vinegar/sysinfo.go b/cmd/vinegar/sysinfo.go index 940d5be..6c73b75 100644 --- a/cmd/vinegar/sysinfo.go +++ b/cmd/vinegar/sysinfo.go @@ -6,9 +6,9 @@ import ( "runtime/debug" "github.com/apprehensions/rbxweb/clientsettings" + "github.com/apprehensions/wine" "github.com/vinegarhq/vinegar/config" "github.com/vinegarhq/vinegar/sysinfo" - "github.com/apprehensions/wine" ) func PrintSysinfo(cfg *config.Config) { diff --git a/config/config.go b/config/config.go index 47678bc..486943e 100644 --- a/config/config.go +++ b/config/config.go @@ -108,9 +108,10 @@ func Default() Config { Dxvk: true, DxvkVersion: "2.3", GameMode: true, - Channel: "", // Default upstream ForcedGpu: "prime-discrete", Renderer: "D3D11", + Channel: "", // Default upstream + DiscordRPC: true, // TODO: fill with studio fflag/env goodies FFlags: make(rbxbin.FFlags), Env: make(Environment), diff --git a/go.mod b/go.mod index 8f8c5cb..b7315a4 100644 --- a/go.mod +++ b/go.mod @@ -45,3 +45,5 @@ retract ( [v1.0.0, v1.1.3] v0.0.1 ) + +replace github.com/apprehensions/rbxweb => ../rbxweb diff --git a/richpresence/bloxstraprpc/bloxstraprpc.go b/richpresence/bloxstraprpc/bloxstraprpc.go new file mode 100644 index 0000000..d38595d --- /dev/null +++ b/richpresence/bloxstraprpc/bloxstraprpc.go @@ -0,0 +1,180 @@ +// Package bloxstraprpc implements the BloxstrapRPC protocol. +// +// For more information regarding the protocol, view [Bloxstrap's BloxstrapRPC wiki page] +// +// [Bloxstrap's BloxstrapRPC wiki page]: https://github.com/pizzaboxer/bloxstrap/wiki/Integrating-Bloxstrap-functionality-into-your-game +package bloxstraprpc + +import ( + "fmt" + "log/slog" + "regexp" + "strconv" + "strings" + "time" + + "github.com/altfoxie/drpc" + "github.com/apprehensions/rbxweb" + "github.com/vinegarhq/vinegar/richpresence" +) + +const reset = "" + +const ( + gameJoinRequestEntry = "[FLog::GameJoinUtil] GameJoinUtil::makePlaceLauncherRequest" + gameJoiningEntry = "[FLog::Output] ! Joining game" + gameJoinReportEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:" + gameJoinedEntry = "[FLog::Output] Connection accepted from" + bloxstrapRPCEntry = "[FLog::Output] [BloxstrapRPC]" + gameLeaveEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal" +) + +var ( + gameJoinRequestEntryPattern = regexp.MustCompile(`makePlaceLauncherRequest(ForTeleport)?: requestCount: [0-9], url: https:\/\/gamejoin\.roblox\.com\/v1\/([^\s\/]+)`) + gameJoiningEntryPattern = regexp.MustCompile(`! Joining game '([0-9a-f\-]{36})'`) + gameJoinReportEntryPattern = regexp.MustCompile(`Report game_join_loadtime: placeid:([0-9]+).*universeid:([0-9]+)`) +) + +type ServerType int + +const ( + Public ServerType = iota + Private + Reserved +) + +type BloxstrapRPC struct { + presence drpc.Activity + client *drpc.Client + + gameTime time.Time + teleporting bool + server ServerType + + universeID rbxweb.UniverseID + placeID string + jobID string +} + +func New() *BloxstrapRPC { + c, _ := drpc.New(richpresence.AppID) + return &BloxstrapRPC{ + client: c, + } +} + +// Handle implements the BinaryRichPresence interface +func (b *BloxstrapRPC) Handle(line string) error { + entries := map[string]func(string) error{ + // In order of which it should appear in log file + gameJoinRequestEntry: b.handleGameJoinRequest, // For game join type is private, reserved + gameJoiningEntry: b.handleGameJoining, // For JobID (server ID, to join from Discord) + gameJoinReportEntry: b.handleGameJoinReport, // For PlaceID and UniverseID + gameJoinedEntry: func(_ string) error { return b.handleGameJoined() }, // Sets presence and time + bloxstrapRPCEntry: b.handleBloxstrapRPC, // BloxstrapRPC + gameLeaveEntry: func(_ string) error { return b.handleGameLeave() }, // Clears presence and time + } + + for e, h := range entries { + if strings.Contains(line, e) { + return h(line) + } + } + + return nil +} + +func (b *BloxstrapRPC) handleGameJoinRequest(line string) error { + m := gameJoinRequestEntryPattern.FindStringSubmatch(line) + // There are multiple outputs for makePlaceLauncherRequest + if len(m) != 3 { + return fmt.Errorf("log game join request entry is invalid") + } + + if m[1] == "ForTeleport" { + b.teleporting = true + } + + // Keep up to date from upstream Roblox GameJoin API + b.server = map[string]ServerType{ + "join-private-game": Private, + "join-reserved-game": Reserved, + "join-game": Public, + "join-game-instance": Public, + "join-play-together-game": Public, + }[m[2]] + + slog.Info("Handled GameJoinRequest", "server_type", b.server, "teleporting", b.teleporting) + + return nil +} + +func (b *BloxstrapRPC) handleGameJoining(line string) error { + m := gameJoiningEntryPattern.FindStringSubmatch(line) + if len(m) != 2 { + return fmt.Errorf("log game joining entry is invalid") + } + + b.jobID = m[1] + + slog.Info("Handled GameJoining", "jobid", b.jobID) + + return nil +} + +func (b *BloxstrapRPC) handleGameJoinReport(line string) error { + m := gameJoinReportEntryPattern.FindStringSubmatch(line) + if len(m) != 3 { + return fmt.Errorf("log game join report entry is invalid") + } + + uid, err := strconv.ParseInt(m[2], 10, 64) + if err != nil { + return err + } + + b.placeID = m[1] + b.universeID = rbxweb.UniverseID(uid) + + slog.Info("Handled GameJoinReport", "universeid", b.universeID, "placeid", b.placeID) + + return nil +} + +func (b *BloxstrapRPC) handleGameJoined() error { + if !b.teleporting { + b.gameTime = time.Now() + } + + b.teleporting = false + + slog.Info("Handled GameJoined", "time", b.gameTime) + + return b.UpdateGamePresence(true) +} + +func (b *BloxstrapRPC) handleBloxstrapRPC(line string) error { + m, err := ParseMessage(line) + if err != nil { + return fmt.Errorf("parse bloxstraprpc message: %w", err) + } + m.ApplyRichPresence(&b.presence) + + slog.Info("Handled BloxstrapRPC", "message", m) + + return b.UpdateGamePresence(false) +} + +func (b *BloxstrapRPC) handleGameLeave() error { + b.presence = drpc.Activity{} + b.gameTime = time.Time{} + b.teleporting = false + b.server = Public + b.universeID = rbxweb.UniverseID(0) + b.placeID = "" + b.jobID = "" + + slog.Info("Handled GameLeave") + + return b.client.SetActivity(b.presence) +} diff --git a/richpresence/bloxstraprpc/discordrpc.go b/richpresence/bloxstraprpc/discordrpc.go new file mode 100644 index 0000000..0cc83b5 --- /dev/null +++ b/richpresence/bloxstraprpc/discordrpc.go @@ -0,0 +1,90 @@ +package bloxstraprpc + +import ( + "log/slog" + + "github.com/altfoxie/drpc" + "github.com/apprehensions/rbxweb/games" + "github.com/apprehensions/rbxweb/thumbnails" +) + +// UpdateGamePresence sets the activity based on the current +// game information present in BloxstrapRPC. 'initial' is used +// to fetch game information required for rich presence, regardless +// if the BloxstrapRPC properties have been set for resetting. +// +// UpdateGamePresence is called by Handle whenever needed. +func (b *BloxstrapRPC) UpdateGamePresence(initial bool) error { + b.presence.Buttons = []drpc.Button{{ + Label: "See game page", + URL: "https://www.roblox.com/games/" + b.placeID, + }} + + if b.server == Public { + joinurl := "roblox://experiences/start?placeId=" + b.placeID + "&gameInstanceId=" + b.jobID + b.presence.Buttons = append(b.presence.Buttons, drpc.Button{ + Label: "Join server", + URL: joinurl, + }) + } + + if b.presence.Assets == nil { + b.presence.Assets = new(drpc.Assets) + } + + if initial || (b.presence.Details == reset || + b.presence.State == reset || + b.presence.Assets.LargeText == reset) { + gd, err := games.GetGameDetail(b.universeID) + if err != nil { + return err + } + + if initial || b.presence.Details == reset { + b.presence.Details = "Playing " + gd.Name + } + + if initial || b.presence.State == reset { + b.presence.State = "by " + gd.Creator.Name + + switch b.server { + case Private: + b.presence.State = "In a private server" + case Reserved: + b.presence.State = "In a reserved server" + } + } + + if initial || b.presence.Assets.LargeText == reset { + b.presence.Assets.LargeText = gd.Name + } + } + + if initial || b.presence.Assets.LargeImage == reset { + tn, err := thumbnails.GetGameIcon(b.universeID, + thumbnails.PlaceHolder, "512x512", thumbnails.Png, false) + if err != nil { + return err + } + + b.presence.Assets.LargeImage = tn.ImageURL + } + + if initial || b.presence.Assets.SmallImage == reset { + b.presence.Assets.SmallImage = "roblox" + } + + if initial || b.presence.Assets.SmallText == reset { + b.presence.Assets.SmallText = "Roblox" + } + + if b.presence.Timestamps == nil { + b.presence.Timestamps = &drpc.Timestamps{ + Start: b.gameTime, + } + } + + slog.Info("Updating Discord Rich Presence", "presence", b.presence) + + return b.client.SetActivity(b.presence) +} diff --git a/bloxstraprpc/message.go b/richpresence/bloxstraprpc/message.go similarity index 80% rename from bloxstraprpc/message.go rename to richpresence/bloxstraprpc/message.go index 8983248..4789847 100644 --- a/bloxstraprpc/message.go +++ b/richpresence/bloxstraprpc/message.go @@ -20,7 +20,7 @@ type RichPresenceImage struct { Reset bool `json:"reset"` } -type Data struct { +type MessageData struct { Details *string `json:"details"` State *string `json:"state"` TimestampStart *Timestamp `json:"timeStart"` @@ -30,16 +30,16 @@ type Data struct { } type Message struct { - Command string `json:"command"` - Data `json:"data"` + Command string `json:"command"` + Data MessageData `json:"data"` } // NewMessage constructs a new Message from a BloxstrapRPC message // log entry from the Roblox client. -func NewMessage(line string) (*Message, error) { +func ParseMessage(line string) (*Message, error) { var m Message - msg := line[strings.Index(line, BloxstrapRPCEntry)+len(BloxstrapRPCEntry)+1:] + msg := line[strings.Index(line, bloxstrapRPCEntry)+len(bloxstrapRPCEntry)+1:] if err := json.Unmarshal([]byte(msg), &m); err != nil { return nil, err @@ -62,7 +62,7 @@ func NewMessage(line string) (*Message, error) { } // ApplyRichPresence applies/appends Message's properties to the given -// [drpc.Activity] for use in Discord's Rich Presence. +// [drpc.BloxstrapRPC] for use in Discord's Rich Presence. // // UpdateGamePresence should be called as some of the properties are specific // to BloxstrapRPC. @@ -80,10 +80,10 @@ func (m *Message) ApplyRichPresence(p *drpc.Activity) { p.State = *m.Data.State } - m.TimestampStart.ApplyRichPresence(&p.Timestamps.Start) - m.TimestampEnd.ApplyRichPresence(&p.Timestamps.End) - m.SmallImage.ApplyRichPresence(&p.Assets.SmallImage, &p.Assets.SmallText) - m.LargeImage.ApplyRichPresence(&p.Assets.LargeImage, &p.Assets.LargeText) + m.Data.TimestampStart.ApplyRichPresence(&p.Timestamps.Start) + m.Data.TimestampEnd.ApplyRichPresence(&p.Timestamps.End) + m.Data.SmallImage.ApplyRichPresence(&p.Assets.SmallImage, &p.Assets.SmallText) + m.Data.LargeImage.ApplyRichPresence(&p.Assets.LargeImage, &p.Assets.LargeText) } // ApplyRichPresence applies/appends the Timestamp to the given drpc timestamp. @@ -109,8 +109,8 @@ func (i *RichPresenceImage) ApplyRichPresence(drpcImage, drpcText *string) { } if i.Reset { - *drpcImage = Reset - *drpcText = Reset + *drpcImage = reset + *drpcText = reset } if i.AssetID != nil { @@ -122,4 +122,3 @@ func (i *RichPresenceImage) ApplyRichPresence(drpcImage, drpcText *string) { *drpcText = *i.HoverText } } - diff --git a/richpresence/richpresence.go b/richpresence/richpresence.go new file mode 100644 index 0000000..2327c58 --- /dev/null +++ b/richpresence/richpresence.go @@ -0,0 +1,8 @@ +// Package richpresence provides interfaces against Roblox Binaries for Discord Rich Presence +package richpresence + +const AppID = "1159891020956323923" + +type BinaryRichPresence interface { + Handle(string) error // Log entry handler +} diff --git a/richpresence/studiorpc/discordrpc.go b/richpresence/studiorpc/discordrpc.go new file mode 100644 index 0000000..232db44 --- /dev/null +++ b/richpresence/studiorpc/discordrpc.go @@ -0,0 +1,48 @@ +package studiorpc + +import ( + "log/slog" + "time" + + "github.com/altfoxie/drpc" + "github.com/apprehensions/rbxweb" + "github.com/apprehensions/rbxweb/games" +) + +// UpdateGamePresence sets the activity based on the current +// game information present in StudioRPC. +// +// UpdateGamePresence is called by Handle whenever needed. +func (s *StudioRPC) UpdateGamePresence() error { + details := "" + + uid, err := rbxweb.GetPlaceUniverse(s.placeID) + if err != nil { + return err + } + + pd, err := games.GetGameDetail(uid) + // Sometimes the game itself is actually just a template, and is not owned by the + // user, which is why details won't be fetched. + if err != nil { + slog.Error("Failed to fetch place details", "placeid", s.placeID, "error", err) + } else { + details = "Workspace " + pd.Name + } + + s.presence = drpc.Activity{ + State: "Developing", + Details: details, + Assets: &drpc.Assets{ + LargeImage: "studio", + LargeText: "studio", + }, + Timestamps: &drpc.Timestamps{ + Start: time.Now(), + }, + } + + slog.Info("Updating Discord Rich Presence", "presence", s.presence) + + return s.client.SetActivity(s.presence) +} diff --git a/richpresence/studiorpc/studiorpc.go b/richpresence/studiorpc/studiorpc.go new file mode 100644 index 0000000..3232277 --- /dev/null +++ b/richpresence/studiorpc/studiorpc.go @@ -0,0 +1,73 @@ +// Package studiorpc implements basic Roblox Studio rich presence. +package studiorpc + +import ( + "fmt" + "log/slog" + "regexp" + "strconv" + "strings" + + "github.com/altfoxie/drpc" + "github.com/apprehensions/rbxweb" + "github.com/vinegarhq/vinegar/richpresence" +) + +const ( + gameOpenEntry = "[FLog::LifecycleManager] Entered PlaceSessionScope:" + gameCloseEntry = "[FLog::Output] RobloxIDEDoc::doClose" +) + +var gameOpenEntryPattern = regexp.MustCompile(`Entered PlaceSessionScope:'([0-9]+)'`) + +type StudioRPC struct { + presence drpc.Activity + client *drpc.Client + + placeID rbxweb.PlaceID +} + +func New() *StudioRPC { + c, _ := drpc.New(richpresence.AppID) + return &StudioRPC{ + client: c, + } +} + +// Handle implements the BinaryRichPresence interface +func (s *StudioRPC) Handle(line string) error { + if strings.Contains(line, gameOpenEntry) { + return s.handleGameOpen(line) + } else if strings.Contains(line, gameCloseEntry) { + return s.handleGameClose() + } + + return nil +} + +func (s *StudioRPC) handleGameOpen(line string) error { + m := gameOpenEntryPattern.FindStringSubmatch(line) + if len(m) != 2 { + return fmt.Errorf("log game join report entry is invalid") + } + + pid, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + return err + } + + s.placeID = rbxweb.PlaceID(pid) + + slog.Info("Handled GameOpen", "placeid", s.placeID) + + return s.UpdateGamePresence() +} + +func (s *StudioRPC) handleGameClose() error { + s.presence = drpc.Activity{} + s.placeID = rbxweb.PlaceID(0) + + slog.Info("Handled GameClose") + + return s.client.SetActivity(s.presence) +}