Merge branch 'main' into user-pfp-discord-rpc

This commit is contained in:
axell 2024-09-03 20:42:32 +02:00 committed by GitHub
commit abb08f11b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 399 additions and 266 deletions

View File

@ -1,29 +1,31 @@
using System.Web;
namespace Bloxstrap.Integrations
namespace Bloxstrap.Integrations
{
public class ActivityWatcher : IDisposable
{
// i'm thinking the functionality for parsing roblox logs could be broadened for more features than just rich presence,
// like checking the ping and region of the current connected server. maybe that's something to add?
private const string GameJoiningEntry = "[FLog::Output] ! Joining game";
private const string GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer";
private const string GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer";
private const string GameJoiningUniverseEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:";
private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = ";
private const string GameJoinedEntry = "[FLog::Network] serverId:";
private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:";
private const string GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport";
private const string GameMessageEntry = "[FLog::Output] [BloxstrapRPC]";
private const string GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal";
private const string GameMessageEntry = "[FLog::Output] [BloxstrapRPC]";
private const string GameJoiningEntry = "[FLog::Output] ! Joining game";
// these entries are technically volatile!
// they only get printed depending on their configured FLog level, which could change at any time
// while levels being changed is fairly rare, please limit the number of varying number of FLog types you have to use, if possible
private const string GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer";
private const string GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer";
private const string GameJoiningUniverseEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:";
private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = ";
private const string GameJoinedEntry = "[FLog::Network] serverId:";
private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:";
private const string GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport";
private const string GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal";
private const string GameJoinLoadTimeEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:";
private const string GameJoinLoadTimeEntryPattern = ", userid:([0-9]+)";
private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)";
private const string GameJoiningUniversePattern = @"universeid:([0-9]+)";
private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+";
private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+";
private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)";
private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)";
private const string GameJoiningPrivateServerPattern = @"""accessCode"":""([0-9a-f\-]{36})""";
private const string GameJoiningUniversePattern = @"universeid:([0-9]+)";
private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+";
private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+";
private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)";
private int _logEntriesRead = 0;
private bool _teleportMarker = false;
@ -41,21 +43,14 @@ namespace Bloxstrap.Integrations
public string LogLocation = null!;
// these are values to use assuming the player isn't currently in a game
// hmm... do i move this to a model?
public DateTime ActivityTimeJoined;
public bool ActivityInGame = false;
public long ActivityPlaceId = 0;
public long ActivityUniverseId = 0;
public string ActivityJobId = "";
public string ActivityUserId = "";
public string ActivityMachineAddress = "";
public bool ActivityMachineUDMUX = false;
public bool ActivityIsTeleport = false;
public string ActivityLaunchData = "";
public ServerType ActivityServerType = ServerType.Public;
public bool InGame = false;
public List<ActivityHistoryEntry> ActivityHistory = new();
public ActivityData Data { get; private set; } = new();
/// <summary>
/// Ordered by newest to oldest
/// </summary>
public List<ActivityData> History = new();
public bool IsDisposed = false;
@ -130,16 +125,6 @@ namespace Bloxstrap.Integrations
}
}
public string GetActivityDeeplink()
{
string deeplink = $"roblox://experiences/start?placeId={ActivityPlaceId}&gameInstanceId={ActivityJobId}";
if (!String.IsNullOrEmpty(ActivityLaunchData))
deeplink += "&launchData=" + HttpUtility.UrlEncode(ActivityLaunchData);
return deeplink;
}
// TODO: i need to double check how this handles failed game joins (connection error, invalid permissions, etc)
private void ReadLogEntry(string entry)
{
@ -159,27 +144,41 @@ namespace Bloxstrap.Integrations
if (entry.Contains(GameLeavingEntry))
OnAppClose?.Invoke(this, new EventArgs());
if (ActivityUserId == "" && entry.Contains(GameJoinLoadTimeEntry))
{
Match match = Regex.Match(entry, GameJoinLoadTimeEntryPattern);
if (match.Groups.Count != 2)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game join load time entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
ActivityUserId = match.Groups[1].Value;
}
if (!ActivityInGame && ActivityPlaceId == 0)
if (!InGame && Data.PlaceId == 0)
{
// We are not in a game, nor are in the process of joining one
//gameJoinLoadTime is written to the log file regardless of the server being private or not
if (entry.Contains(GameJoinLoadTimeEntry))
{
Match match = Regex.Match(entry, GameJoinLoadTimeEntryPattern);
if (match.Groups.Count != 2)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game join load time entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
Data.ActivityUserId = match.Groups[1].Value;
}
if (entry.Contains(GameJoiningPrivateServerEntry))
{
// we only expect to be joining a private server if we're not already in a game
ActivityServerType = ServerType.Private;
Data.ServerType = ServerType.Private;
var match = Regex.Match(entry, GameJoiningPrivateServerPattern);
if (match.Groups.Count != 2)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join private server entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
Data.AccessCode = match.Groups[1].Value;
}
else if (entry.Contains(GameJoiningEntry))
{
@ -192,27 +191,27 @@ namespace Bloxstrap.Integrations
return;
}
ActivityInGame = false;
ActivityPlaceId = long.Parse(match.Groups[2].Value);
ActivityJobId = match.Groups[1].Value;
ActivityMachineAddress = match.Groups[3].Value;
InGame = false;
Data.PlaceId = long.Parse(match.Groups[2].Value);
Data.JobId = match.Groups[1].Value;
Data.MachineAddress = match.Groups[3].Value;
if (_teleportMarker)
{
ActivityIsTeleport = true;
Data.IsTeleport = true;
_teleportMarker = false;
}
if (_reservedTeleportMarker)
{
ActivityServerType = ServerType.Reserved;
Data.ServerType = ServerType.Reserved;
_reservedTeleportMarker = false;
}
App.Logger.WriteLine(LOG_IDENT, $"Joining Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})");
App.Logger.WriteLine(LOG_IDENT, $"Joining Game ({Data.PlaceId}/{Data.JobId}/{Data.MachineAddress})");
}
}
else if (!ActivityInGame && ActivityPlaceId != 0)
else if (!InGame && Data.PlaceId != 0)
{
// We are not confirmed to be in a game, but we are in the process of joining one
@ -227,80 +226,69 @@ namespace Bloxstrap.Integrations
return;
}
ActivityUniverseId = long.Parse(match.Groups[1].Value);
Data.UniverseId = long.Parse(match.Groups[1].Value);
if (History.Any())
{
var lastActivity = History.First();
if (lastActivity is not null && Data.UniverseId == lastActivity.UniverseId && Data.IsTeleport)
Data.RootActivity = lastActivity.RootActivity ?? lastActivity;
}
}
else if (entry.Contains(GameJoiningUDMUXEntry))
{
Match match = Regex.Match(entry, GameJoiningUDMUXPattern);
var match = Regex.Match(entry, GameJoiningUDMUXPattern);
if (match.Groups.Count != 3 || match.Groups[2].Value != ActivityMachineAddress)
if (match.Groups.Count != 3 || match.Groups[2].Value != Data.MachineAddress)
{
App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join UDMUX entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
ActivityMachineAddress = match.Groups[1].Value;
ActivityMachineUDMUX = true;
Data.MachineAddress = match.Groups[1].Value;
App.Logger.WriteLine(LOG_IDENT, $"Server is UDMUX protected ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})");
App.Logger.WriteLine(LOG_IDENT, $"Server is UDMUX protected ({Data.PlaceId}/{Data.JobId}/{Data.MachineAddress})");
}
else if (entry.Contains(GameJoinedEntry))
{
Match match = Regex.Match(entry, GameJoinedEntryPattern);
if (match.Groups.Count != 2 || match.Groups[1].Value != ActivityMachineAddress)
if (match.Groups.Count != 2 || match.Groups[1].Value != Data.MachineAddress)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game joined entry");
App.Logger.WriteLine(LOG_IDENT, entry);
return;
}
App.Logger.WriteLine(LOG_IDENT, $"Joined Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})");
App.Logger.WriteLine(LOG_IDENT, $"Joined Game ({Data.PlaceId}/{Data.JobId}/{Data.MachineAddress})");
ActivityInGame = true;
ActivityTimeJoined = DateTime.Now;
InGame = true;
Data.TimeJoined = DateTime.Now;
OnGameJoin?.Invoke(this, new EventArgs());
}
}
else if (ActivityInGame && ActivityPlaceId != 0)
else if (InGame && Data.PlaceId != 0)
{
// We are confirmed to be in a game
if (entry.Contains(GameDisconnectedEntry))
{
App.Logger.WriteLine(LOG_IDENT, $"Disconnected from Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})");
App.Logger.WriteLine(LOG_IDENT, $"Disconnected from Game ({Data.PlaceId}/{Data.JobId}/{Data.MachineAddress})");
// TODO: should this be including launchdata?
if (ActivityServerType != ServerType.Reserved)
{
ActivityHistory.Insert(0, new ActivityHistoryEntry
{
PlaceId = ActivityPlaceId,
UniverseId = ActivityUniverseId,
JobId = ActivityJobId,
TimeJoined = ActivityTimeJoined,
TimeLeft = DateTime.Now
});
}
Data.TimeLeft = DateTime.Now;
History.Insert(0, Data);
ActivityInGame = false;
ActivityPlaceId = 0;
ActivityUniverseId = 0;
ActivityJobId = "";
ActivityMachineAddress = "";
ActivityMachineUDMUX = false;
ActivityIsTeleport = false;
ActivityLaunchData = "";
ActivityServerType = ServerType.Public;
ActivityUserId = "";
InGame = false;
Data = new();
OnGameLeave?.Invoke(this, new EventArgs());
}
else if (entry.Contains(GameTeleportingEntry))
{
App.Logger.WriteLine(LOG_IDENT, $"Initiating teleport to server ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})");
App.Logger.WriteLine(LOG_IDENT, $"Initiating teleport to server ({Data.PlaceId}/{Data.JobId}/{Data.MachineAddress})");
_teleportMarker = true;
}
else if (_teleportMarker && entry.Contains(GameJoiningReservedServerEntry))
@ -378,7 +366,7 @@ namespace Bloxstrap.Integrations
return;
}
ActivityLaunchData = data;
Data.RPCLaunchData = data;
}
OnRPCMessage?.Invoke(this, message);
@ -392,13 +380,13 @@ namespace Bloxstrap.Integrations
{
const string LOG_IDENT = "ActivityWatcher::GetServerLocation";
if (GeolocationCache.ContainsKey(ActivityMachineAddress))
return GeolocationCache[ActivityMachineAddress];
if (GeolocationCache.ContainsKey(Data.MachineAddress))
return GeolocationCache[Data.MachineAddress];
try
{
string location = "";
var ipInfo = await Http.GetJson<IPInfoResponse>($"https://ipinfo.io/{ActivityMachineAddress}/json");
var ipInfo = await Http.GetJson<IPInfoResponse>($"https://ipinfo.io/{Data.MachineAddress}/json");
if (ipInfo is null)
return $"? ({Strings.ActivityTracker_LookupFailed})";
@ -410,16 +398,16 @@ namespace Bloxstrap.Integrations
else
location = $"{ipInfo.City}, {ipInfo.Region}, {ipInfo.Country}";
if (!ActivityInGame)
if (!InGame)
return $"? ({Strings.ActivityTracker_LeftGame})";
GeolocationCache[ActivityMachineAddress] = location;
GeolocationCache[Data.MachineAddress] = location;
return location;
}
catch (Exception ex)
{
App.Logger.WriteLine(LOG_IDENT, $"Failed to get server location for {ActivityMachineAddress}");
App.Logger.WriteLine(LOG_IDENT, $"Failed to get server location for {Data.MachineAddress}");
App.Logger.WriteException(LOG_IDENT, ex);
return $"? ({Strings.ActivityTracker_LookupFailed})";

View File

@ -6,14 +6,12 @@ namespace Bloxstrap.Integrations
{
private readonly DiscordRpcClient _rpcClient = new("1005469189907173486");
private readonly ActivityWatcher _activityWatcher;
private readonly Queue<Message> _messageQueue = new();
private DiscordRPC.RichPresence? _currentPresence;
private DiscordRPC.RichPresence? _currentPresenceCopy;
private Queue<Message> _messageQueue = new();
private DiscordRPC.RichPresence? _originalPresence;
private bool _visible = true;
private long _currentUniverseId;
private DateTime? _timeStartedUniverse;
public DiscordRichPresence(ActivityWatcher activityWatcher)
{
@ -54,7 +52,7 @@ namespace Bloxstrap.Integrations
if (message.Command != "SetRichPresence" && message.Command != "SetLaunchData")
return;
if (_currentPresence is null || _currentPresenceCopy is null)
if (_currentPresence is null || _originalPresence is null)
{
App.Logger.WriteLine(LOG_IDENT, "Presence is not set, enqueuing message");
_messageQueue.Enqueue(message);
@ -65,12 +63,7 @@ namespace Bloxstrap.Integrations
if (message.Command == "SetLaunchData")
{
var buttonQuery = _currentPresence.Buttons.Where(x => x.Label == "Join server");
if (!buttonQuery.Any())
return;
buttonQuery.First().Url = _activityWatcher.GetActivityDeeplink();
_currentPresence.Buttons = GetButtons();
}
else if (message.Command == "SetRichPresence")
{
@ -97,7 +90,7 @@ namespace Bloxstrap.Integrations
if (presenceData.Details.Length > 128)
App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters");
else if (presenceData.Details == "<reset>")
_currentPresence.Details = _currentPresenceCopy.Details;
_currentPresence.Details = _originalPresence.Details;
else
_currentPresence.Details = presenceData.Details;
}
@ -107,7 +100,7 @@ namespace Bloxstrap.Integrations
if (presenceData.State.Length > 128)
App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters");
else if (presenceData.State == "<reset>")
_currentPresence.State = _currentPresenceCopy.State;
_currentPresence.State = _originalPresence.State;
else
_currentPresence.State = presenceData.State;
}
@ -130,8 +123,8 @@ namespace Bloxstrap.Integrations
}
else if (presenceData.SmallImage.Reset)
{
_currentPresence.Assets.SmallImageText = _currentPresenceCopy.Assets.SmallImageText;
_currentPresence.Assets.SmallImageKey = _currentPresenceCopy.Assets.SmallImageKey;
_currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText;
_currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey;
}
else
{
@ -151,8 +144,8 @@ namespace Bloxstrap.Integrations
}
else if (presenceData.LargeImage.Reset)
{
_currentPresence.Assets.LargeImageText = _currentPresenceCopy.Assets.LargeImageText;
_currentPresence.Assets.LargeImageKey = _currentPresenceCopy.Assets.LargeImageKey;
_currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText;
_currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey;
}
else
{
@ -185,11 +178,11 @@ namespace Bloxstrap.Integrations
{
const string LOG_IDENT = "DiscordRichPresence::SetCurrentGame";
if (!_activityWatcher.ActivityInGame)
if (!_activityWatcher.InGame)
{
App.Logger.WriteLine(LOG_IDENT, "Not in game, clearing presence");
_currentPresence = _currentPresenceCopy = null;
_currentPresence = _originalPresence = null;
_messageQueue.Clear();
UpdatePresence();
@ -200,46 +193,37 @@ namespace Bloxstrap.Integrations
string smallImageText = "Roblox";
string smallImage = "roblox";
long placeId = _activityWatcher.ActivityPlaceId;
string userId = _activityWatcher.ActivityUserId;
var activity = _activityWatcher.Data;
long placeId = activity.PlaceId;
App.Logger.WriteLine(LOG_IDENT, $"Setting presence for Place ID {placeId}");
// TODO: move this to its own function under the activity watcher?
// TODO: show error if information cannot be queried instead of silently failing
long universeId = _activityWatcher.ActivityUniverseId;
// preserve time spent playing if we're teleporting between places in the same universe
if (_timeStartedUniverse is null || !_activityWatcher.ActivityIsTeleport || universeId != _currentUniverseId)
_timeStartedUniverse = DateTime.UtcNow;
var timeStarted = activity.TimeJoined;
_currentUniverseId = universeId;
if (activity.RootActivity is not null)
timeStarted = activity.RootActivity.TimeJoined;
var gameDetailResponse = await Http.GetJson<ApiArrayResponse<GameDetailResponse>>($"https://games.roblox.com/v1/games?universeIds={universeId}");
if (gameDetailResponse is null || !gameDetailResponse.Data.Any())
if (activity.UniverseDetails is null)
{
App.Logger.WriteLine(LOG_IDENT, "Could not get Universe info!");
await UniverseDetails.FetchSingle(activity.UniverseId);
activity.UniverseDetails = UniverseDetails.LoadFromCache(activity.UniverseId);
}
var universeDetails = activity.UniverseDetails;
if (universeDetails is null)
{
Frontend.ShowMessageBox(Strings.ActivityTracker_RichPresenceLoadFailed, System.Windows.MessageBoxImage.Warning);
return false;
}
GameDetailResponse universeDetails = gameDetailResponse.Data.ToArray()[0];
App.Logger.WriteLine(LOG_IDENT, "Got Universe details");
var universeThumbnailResponse = await Http.GetJson<ApiArrayResponse<ThumbnailResponse>>($"https://thumbnails.roblox.com/v1/games/icons?universeIds={universeId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false");
if (universeThumbnailResponse is null || !universeThumbnailResponse.Data.Any())
{
App.Logger.WriteLine(LOG_IDENT, "Could not get Universe thumbnail info!");
}
else
{
icon = universeThumbnailResponse.Data.ToArray()[0].ImageUrl;
App.Logger.WriteLine(LOG_IDENT, $"Got Universe thumbnail as {icon}");
}
icon = universeDetails.Thumbnail.ImageUrl;
if (App.Settings.Prop.AccountShownOnProfile)
{
var userPfpResponse = await Http.GetJson<ApiArrayResponse<ThumbnailResponse>>($"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={userId}&size=180x180&format=Png&isCircular=false"); //we can remove '-headshot' from the url if we want a full avatar picture
var userPfpResponse = await Http.GetJson<ApiArrayResponse<ThumbnailResponse>>($"https://thumbnails.roblox.com/v1/users/avatar-headshot?userIds={activity.userId}&size=180x180&format=Png&isCircular=false"); //we can remove '-headshot' from the url if we want a full avatar picture
if (userPfpResponse is null || !userPfpResponse.Data.Any())
{
App.Logger.WriteLine(LOG_IDENT, "Could not get user thumbnail info!");
@ -250,7 +234,7 @@ namespace Bloxstrap.Integrations
App.Logger.WriteLine(LOG_IDENT, $"Got user thumbnail as {smallImage}");
}
var userInfoResponse = await Http.GetJson<UserInfoResponse>($"https://users.roblox.com/v1/users/{userId}");
var userInfoResponse = await Http.GetJson<UserInfoResponse>($"https://users.roblox.com/v1/users/{activity.userId}");
if (userInfoResponse is null)
{
App.Logger.WriteLine(LOG_IDENT, "Could not get user info!");
@ -262,46 +246,30 @@ namespace Bloxstrap.Integrations
}
}
List<Button> buttons = new();
if (!App.Settings.Prop.HideRPCButtons && _activityWatcher.ActivityServerType == ServerType.Public)
{
buttons.Add(new Button
{
Label = "Join server",
Url = _activityWatcher.GetActivityDeeplink()
});
}
buttons.Add(new Button
{
Label = "See game page",
Url = $"https://www.roblox.com/games/{placeId}"
});
if (!_activityWatcher.ActivityInGame || placeId != _activityWatcher.ActivityPlaceId)
if (!_activityWatcher.InGame || placeId != activity.PlaceId)
{
App.Logger.WriteLine(LOG_IDENT, "Aborting presence set because game activity has changed");
return false;
}
string status = _activityWatcher.ActivityServerType switch
string status = _activityWatcher.Data.ServerType switch
{
ServerType.Private => "In a private server",
ServerType.Reserved => "In a reserved server",
_ => $"by {universeDetails.Creator.Name}" + (universeDetails.Creator.HasVerifiedBadge ? " ☑️" : ""),
_ => $"by {universeDetails.Data.Creator.Name}" + (universeDetails.Data.Creator.HasVerifiedBadge ? " ☑️" : ""),
};
if (universeDetails.Name.Length < 2)
universeDetails.Name = $"{universeDetails.Name}\x2800\x2800\x2800";
string universeName = universeDetails.Data.Name;
if (universeName.Length < 2)
universeName = $"{universeName}\x2800\x2800\x2800";
_currentPresence = new DiscordRPC.RichPresence
{
Details = $"Playing {universeDetails.Name}",
Details = $"Playing {universeName}",
State = status,
Timestamps = new Timestamps { Start = _timeStartedUniverse },
Buttons = buttons.ToArray(),
Timestamps = new Timestamps { Start = timeStarted.ToUniversalTime() },
Buttons = GetButtons(),
Assets = new Assets
{
LargeImageKey = icon,
@ -312,7 +280,7 @@ namespace Bloxstrap.Integrations
};
// this is used for configuration from BloxstrapRPC
_currentPresenceCopy = _currentPresence.Clone();
_originalPresence = _currentPresence.Clone();
if (_messageQueue.Any())
{
@ -325,6 +293,40 @@ namespace Bloxstrap.Integrations
return true;
}
public Button[] GetButtons()
{
var buttons = new List<Button>();
var data = _activityWatcher.Data;
if (!App.Settings.Prop.HideRPCButtons)
{
bool show = false;
if (data.ServerType == ServerType.Public)
show = true;
else if (data.ServerType == ServerType.Reserved && !String.IsNullOrEmpty(data.RPCLaunchData))
show = true;
if (show)
{
buttons.Add(new Button
{
Label = "Join server",
Url = data.GetInviteDeeplink()
});
}
}
buttons.Add(new Button
{
Label = "See game page",
Url = $"https://www.roblox.com/games/{data.PlaceId}"
});
return buttons.ToArray();
}
public void UpdatePresence()
{
const string LOG_IDENT = "DiscordRichPresence::UpdatePresence";

View File

@ -0,0 +1,93 @@
using System.Web;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
namespace Bloxstrap.Models
{
public class ActivityData
{
private long _universeId = 0;
/// <summary>
/// If the current activity stems from an in-universe teleport, then this will be
/// set to the activity that corresponds to the initial game join
/// </summary>
public ActivityData? RootActivity;
public long UniverseId
{
get => _universeId;
set
{
_universeId = value;
UniverseDetails.LoadFromCache(value);
}
}
public long PlaceId { get; set; } = 0;
public string JobId { get; set; } = String.Empty;
/// <summary>
/// This will be empty unless the server joined is a private server
/// </summary>
public string AccessCode { get; set; } = String.Empty;
public string MachineAddress { get; set; } = String.Empty;
public bool IsTeleport { get; set; } = false;
public ServerType ServerType { get; set; } = ServerType.Public;
public DateTime TimeJoined { get; set; }
public DateTime? TimeLeft { get; set; }
// everything below here is optional strictly for bloxstraprpc, discord rich presence, or game history
/// <summary>
/// This is intended only for other people to use, i.e. context menu invite link, rich presence joining
/// </summary>
public string RPCLaunchData { get; set; } = String.Empty;
public UniverseDetails? UniverseDetails { get; set; }
public string GameHistoryDescription
{
get
{
string desc = String.Format("{0} • {1} - {2}", UniverseDetails?.Data.Creator.Name, TimeJoined.ToString("h:mm tt"), TimeLeft?.ToString("h:mm tt"));
if (ServerType != ServerType.Public)
desc += " • " + ServerType.ToTranslatedString();
return desc;
}
}
public ICommand RejoinServerCommand => new RelayCommand(RejoinServer);
public string GetInviteDeeplink(bool launchData = true)
{
string deeplink = $"roblox://experiences/start?placeId={PlaceId}";
if (ServerType == ServerType.Private)
deeplink += "&accessCode=" + AccessCode;
else
deeplink += "&gameInstanceId=" + JobId;
if (launchData && !String.IsNullOrEmpty(RPCLaunchData))
deeplink += "&launchData=" + HttpUtility.UrlEncode(RPCLaunchData);
return deeplink;
}
private void RejoinServer()
{
string playerPath = Path.Combine(Paths.Versions, App.State.Prop.PlayerVersionGuid, "RobloxPlayerBeta.exe");
Process.Start(playerPath, GetInviteDeeplink(false));
}
}
}

View File

@ -1,38 +0,0 @@
using CommunityToolkit.Mvvm.Input;
using System.Windows.Input;
namespace Bloxstrap.Models
{
public class ActivityHistoryEntry
{
public long UniverseId { get; set; }
public long PlaceId { get; set; }
public string JobId { get; set; } = String.Empty;
public DateTime TimeJoined { get; set; }
public DateTime TimeLeft { get; set; }
public string TimeJoinedFriendly => String.Format("{0} - {1}", TimeJoined.ToString("h:mm tt"), TimeLeft.ToString("h:mm tt"));
public bool DetailsLoaded = false;
public string GameName { get; set; } = String.Empty;
public string GameThumbnail { get; set; } = String.Empty;
public ICommand RejoinServerCommand => new RelayCommand(RejoinServer);
private void RejoinServer()
{
string playerPath = Path.Combine(Paths.Versions, App.State.Prop.PlayerVersionGuid, "RobloxPlayerBeta.exe");
string deeplink = $"roblox://experiences/start?placeId={PlaceId}&gameInstanceId={JobId}";
// start RobloxPlayerBeta.exe directly since Roblox can reuse the existing window
// ideally, i'd like to find out how roblox is doing it
Process.Start(playerPath, deeplink);
}
}
}

View File

@ -0,0 +1,50 @@
namespace Bloxstrap.Models
{
public class UniverseDetails
{
private static List<UniverseDetails> _cache { get; set; } = new();
public GameDetailResponse Data { get; set; } = null!;
/// <summary>
/// Returns data for a 128x128 icon
/// </summary>
public ThumbnailResponse Thumbnail { get; set; } = null!;
public static UniverseDetails? LoadFromCache(long id)
{
var cacheQuery = _cache.Where(x => x.Data?.Id == id);
if (cacheQuery.Any())
return cacheQuery.First();
return null;
}
public static Task<bool> FetchSingle(long id) => FetchBulk(id.ToString());
public static async Task<bool> FetchBulk(string ids)
{
var gameDetailResponse = await Http.GetJson<ApiArrayResponse<GameDetailResponse>>($"https://games.roblox.com/v1/games?universeIds={ids}");
if (gameDetailResponse is null || !gameDetailResponse.Data.Any())
return false;
var universeThumbnailResponse = await Http.GetJson<ApiArrayResponse<ThumbnailResponse>>($"https://thumbnails.roblox.com/v1/games/icons?universeIds={ids}&returnPolicy=PlaceHolder&size=128x128&format=Png&isCircular=false");
if (universeThumbnailResponse is null || !universeThumbnailResponse.Data.Any())
return false;
foreach (string strId in ids.Split(','))
{
long id = long.Parse(strId);
_cache.Add(new UniverseDetails
{
Data = gameDetailResponse.Data.Where(x => x.Id == id).First(),
Thumbnail = universeThumbnailResponse.Data.Where(x => x.TargetId == id).First(),
});
}
return true;
}
}
}

View File

@ -123,6 +123,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Your current game will not show on your Discord presence because an error occurred when loading the game information..
/// </summary>
public static string ActivityTracker_RichPresenceLoadFailed {
get {
return ResourceManager.GetString("ActivityTracker.RichPresenceLoadFailed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bloxstrap was unable to automatically update to version {0}. Please update it manually by downloading and running it from the website..
/// </summary>
@ -665,6 +674,15 @@ namespace Bloxstrap.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Game history is only recorded for your current Roblox session. Games will appear here as you leave them or teleport within them..
/// </summary>
public static string ContextMenu_GameHistory_Description {
get {
return ResourceManager.GetString("ContextMenu.GameHistory.Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Rejoin.
/// </summary>
@ -1229,7 +1247,7 @@ namespace Bloxstrap.Resources {
}
/// <summary>
/// Looks up a localized string similar to Private.
/// Looks up a localized string similar to Private server.
/// </summary>
public static string Enums_ServerType_Private {
get {
@ -1238,7 +1256,7 @@ namespace Bloxstrap.Resources {
}
/// <summary>
/// Looks up a localized string similar to Public.
/// Looks up a localized string similar to Public server.
/// </summary>
public static string Enums_ServerType_Public {
get {
@ -1247,7 +1265,7 @@ namespace Bloxstrap.Resources {
}
/// <summary>
/// Looks up a localized string similar to Reserved.
/// Looks up a localized string similar to Reserved server.
/// </summary>
public static string Enums_ServerType_Reserved {
get {

View File

@ -398,13 +398,13 @@ If not, then please report this exception through a [GitHub issue]({1}) along wi
<value>Direct3D 11</value>
</data>
<data name="Enums.ServerType.Private" xml:space="preserve">
<value>Private</value>
<value>Private server</value>
</data>
<data name="Enums.ServerType.Public" xml:space="preserve">
<value>Public</value>
<value>Public server</value>
</data>
<data name="Enums.ServerType.Reserved" xml:space="preserve">
<value>Reserved</value>
<value>Reserved server</value>
</data>
<data name="Enums.Theme.Dark" xml:space="preserve">
<value>Dark</value>
@ -1177,4 +1177,10 @@ Are you sure you want to continue?</value>
<data name="ContextMenu.GameHistory.Rejoin" xml:space="preserve">
<value>Rejoin</value>
</data>
<data name="ActivityTracker.RichPresenceLoadFailed" xml:space="preserve">
<value>Your current game will not show on your Discord presence because an error occurred when loading the game information.</value>
</data>
<data name="ContextMenu.GameHistory.Description" xml:space="preserve">
<value>Game history is only recorded for your current Roblox session. Games will appear here as you leave them or teleport within them.</value>
</data>
</root>

View File

@ -61,7 +61,7 @@
</Grid>
</MenuItem.Header>
</MenuItem>
<MenuItem x:Name="JoinLastServerMenuItem" Visibility="Collapsed" Click="JoinLastServerMenuItem_Click">
<MenuItem Click="JoinLastServerMenuItem_Click">
<MenuItem.Header>
<Grid>
<Grid.ColumnDefinitions>

View File

@ -70,7 +70,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
return;
Dispatcher.Invoke(() => {
if (_activityWatcher.ActivityServerType == ServerType.Public)
if (_activityWatcher.Data.ServerType == ServerType.Public)
InviteDeeplinkMenuItem.Visibility = Visibility.Visible;
ServerDetailsMenuItem.Visibility = Visibility.Visible;
@ -80,7 +80,6 @@ namespace Bloxstrap.UI.Elements.ContextMenu
public void ActivityWatcher_OnGameLeave(object? sender, EventArgs e)
{
Dispatcher.Invoke(() => {
JoinLastServerMenuItem.Visibility = Visibility.Visible;
InviteDeeplinkMenuItem.Visibility = Visibility.Collapsed;
ServerDetailsMenuItem.Visibility = Visibility.Collapsed;
@ -105,7 +104,7 @@ namespace Bloxstrap.UI.Elements.ContextMenu
private void RichPresenceMenuItem_Click(object sender, RoutedEventArgs e) => _watcher.RichPresence?.SetVisibility(((MenuItem)sender).IsChecked);
private void InviteDeeplinkMenuItem_Click(object sender, RoutedEventArgs e) => Clipboard.SetDataObject(_activityWatcher?.GetActivityDeeplink());
private void InviteDeeplinkMenuItem_Click(object sender, RoutedEventArgs e) => Clipboard.SetDataObject(_activityWatcher?.Data.GetInviteDeeplink());
private void ServerDetailsMenuItem_Click(object sender, RoutedEventArgs e) => ShowServerInformationWindow();

View File

@ -20,6 +20,7 @@
WindowStartupLocation="CenterScreen">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
@ -27,11 +28,13 @@
<ui:TitleBar Grid.Row="0" Grid.ColumnSpan="2" Padding="8" x:Name="RootTitleBar" Title="{x:Static resources:Strings.ContextMenu_GameHistory_Title}" ShowMinimize="False" ShowMaximize="False" CanMaximize="False" KeyboardNavigation.TabNavigation="None" Icon="pack://application:,,,/Bloxstrap.ico" />
<Border Grid.Row="1">
<TextBlock Grid.Row="1" Margin="16,8,16,0" Text="{x:Static resources:Strings.ContextMenu_GameHistory_Description}" TextWrapping="Wrap" />
<Border Grid.Row="2">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding ActivityHistory, Mode=OneWay}" Value="{x:Null}">
<DataTrigger Binding="{Binding GameHistory, Mode=OneWay}" Value="{x:Null}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
@ -42,11 +45,11 @@
<ui:ProgressRing Grid.Row="1" IsIndeterminate="True" />
</Border>
<ListView Grid.Row="1" ItemsSource="{Binding ActivityHistory, Mode=OneWay}" Margin="8">
<ListView Grid.Row="2" ItemsSource="{Binding GameHistory, Mode=OneWay}" Margin="8">
<ListView.Style>
<Style TargetType="ListView" BasedOn="{StaticResource {x:Type ListView}}">
<Style.Triggers>
<DataTrigger Binding="{Binding ActivityHistory, Mode=OneWay}" Value="{x:Null}">
<DataTrigger Binding="{Binding GameHistory, Mode=OneWay}" Value="{x:Null}">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
@ -65,14 +68,15 @@
<Border Grid.Column="0" Width="84" Height="84" CornerRadius="4">
<Border.Background>
<ImageBrush ImageSource="{Binding GameThumbnail, IsAsync=True}" />
<ImageBrush ImageSource="{Binding UniverseDetails.Thumbnail.ImageUrl, IsAsync=True}" />
</Border.Background>
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding GameName}" FontSize="18" FontWeight="Medium" />
<TextBlock Text="{Binding TimeJoinedFriendly}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<ui:Button Margin="0,8,0,0" Content="{x:Static resources:Strings.ContextMenu_GameHistory_Rejoin}" Command="{Binding RejoinServerCommand}" Appearance="Success" Icon="Play28" IconFilled="True" />
<TextBlock Text="{Binding UniverseDetails.Data.Name}" FontSize="18" FontWeight="Medium" />
<TextBlock Text="{Binding GameHistoryDescription}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<ui:Button Margin="0,8,0,0" Content="{x:Static resources:Strings.ContextMenu_GameHistory_Rejoin}" Command="{Binding RejoinServerCommand}"
Appearance="Success" Foreground="White" IconForeground="White" Icon="Play28" IconFilled="True" />
</StackPanel>
</Grid>
</ui:Card>
@ -80,7 +84,7 @@
</ListView.ItemTemplate>
</ListView>
<Border Grid.Row="2" Padding="15" Background="{ui:ThemeResource SolidBackgroundFillColorSecondaryBrush}">
<Border Grid.Row="3" Padding="15" Background="{ui:ThemeResource SolidBackgroundFillColorSecondaryBrush}">
<StackPanel Orientation="Horizontal" FlowDirection="LeftToRight" HorizontalAlignment="Right">
<Button Margin="12,0,0,0" MinWidth="100" Content="{x:Static resources:Strings.Common_Close}" IsCancel="True" />
</StackPanel>

View File

@ -60,7 +60,7 @@ namespace Bloxstrap.UI
return;
string serverLocation = await _activityWatcher.GetServerLocation();
string title = _activityWatcher.ActivityServerType switch
string title = _activityWatcher.Data.ServerType switch
{
ServerType.Public => Strings.ContextMenu_ServerInformation_Notification_Title_Public,
ServerType.Private => Strings.ContextMenu_ServerInformation_Notification_Title_Private,

View File

@ -1,6 +1,4 @@
using System.Collections.ObjectModel;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Input;
using Bloxstrap.Integrations;
using CommunityToolkit.Mvvm.Input;
@ -10,7 +8,7 @@ namespace Bloxstrap.UI.ViewModels.ContextMenu
{
private readonly ActivityWatcher _activityWatcher;
public List<ActivityHistoryEntry>? ActivityHistory { get; private set; }
public List<ActivityData>? GameHistory { get; private set; }
public ICommand CloseWindowCommand => new RelayCommand(RequestClose);
@ -27,32 +25,45 @@ namespace Bloxstrap.UI.ViewModels.ContextMenu
private async void LoadData()
{
var entries = _activityWatcher.ActivityHistory.Where(x => !x.DetailsLoaded);
var entries = _activityWatcher.History.Where(x => x.UniverseDetails is null);
if (entries.Any())
{
// TODO: this will duplicate universe ids
string universeIds = String.Join(',', entries.Select(x => x.UniverseId));
var gameDetailResponse = await Http.GetJson<ApiArrayResponse<GameDetailResponse>>($"https://games.roblox.com/v1/games?universeIds={universeIds}");
if (gameDetailResponse is null || !gameDetailResponse.Data.Any())
return;
var universeThumbnailResponse = await Http.GetJson<ApiArrayResponse<ThumbnailResponse>>($"https://thumbnails.roblox.com/v1/games/icons?universeIds={universeIds}&returnPolicy=PlaceHolder&size=128x128&format=Png&isCircular=false");
if (universeThumbnailResponse is null || !universeThumbnailResponse.Data.Any())
if (!await UniverseDetails.FetchBulk(universeIds))
return;
foreach (var entry in entries)
entry.UniverseDetails = UniverseDetails.LoadFromCache(entry.UniverseId);
}
GameHistory = new(_activityWatcher.History);
var consolidatedJobIds = new List<ActivityData>();
// consolidate activity entries from in-universe teleports
// the time left of the latest activity gets moved to the root activity
// the job id of the latest public server activity gets moved to the root activity
foreach (var entry in _activityWatcher.History)
{
if (entry.RootActivity is not null)
{
entry.GameName = gameDetailResponse.Data.Where(x => x.Id == entry.UniverseId).Select(x => x.Name).First();
entry.GameThumbnail = universeThumbnailResponse.Data.Where(x => x.TargetId == entry.UniverseId).Select(x => x.ImageUrl).First();
entry.DetailsLoaded = true;
if (entry.RootActivity.TimeLeft < entry.TimeLeft)
entry.RootActivity.TimeLeft = entry.TimeLeft;
if (entry.ServerType == ServerType.Public && !consolidatedJobIds.Contains(entry))
{
entry.RootActivity.JobId = entry.JobId;
consolidatedJobIds.Add(entry);
}
GameHistory.Remove(entry);
}
}
ActivityHistory = new(_activityWatcher.ActivityHistory);
OnPropertyChanged(nameof(ActivityHistory));
OnPropertyChanged(nameof(GameHistory));
}
private void RequestClose() => RequestCloseEvent?.Invoke(this, EventArgs.Empty);

View File

@ -9,9 +9,9 @@ namespace Bloxstrap.UI.ViewModels.ContextMenu
{
private readonly ActivityWatcher _activityWatcher;
public string InstanceId => _activityWatcher.ActivityJobId;
public string InstanceId => _activityWatcher.Data.JobId;
public string ServerType => Strings.ResourceManager.GetStringSafe($"Enums.ServerType.{_activityWatcher.ActivityServerType}");
public string ServerType => _activityWatcher.Data.ServerType.ToTranslatedString();
public string ServerLocation { get; private set; } = Strings.ContextMenu_ServerInformation_Loading;