Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Discord game invites in multiplayer lobbies #27443

Merged
merged 21 commits into from
Mar 20, 2024
Merged
Changes from 19 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
143 changes: 135 additions & 8 deletions osu.Desktop/DiscordRichPresence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Diagnostics;
using System.Text;
using DiscordRPC;
using DiscordRPC.Message;
using Newtonsoft.Json;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Users;
using LogLevel = osu.Framework.Logging.LogLevel;
Expand All @@ -21,7 +28,7 @@ namespace osu.Desktop
{
internal partial class DiscordRichPresence : Component
{
private const string client_id = "367827983903490050";
private const string client_id = "1216669957799018608";

private DiscordRpcClient client = null!;

Expand All @@ -33,27 +40,48 @@ internal partial class DiscordRichPresence : Component
[Resolved]
private IAPIProvider api { get; set; } = null!;

[Resolved]
private OsuGame game { get; set; } = null!;

private LoginOverlay? login { get; set; }

[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;

private readonly IBindable<UserStatus?> status = new Bindable<UserStatus?>();
private readonly IBindable<UserActivity> activity = new Bindable<UserActivity>();

private readonly Bindable<DiscordRichPresenceMode> privacyMode = new Bindable<DiscordRichPresenceMode>();

private int usersCurrentlyInLobby;

private readonly RichPresence presence = new RichPresence
{
Assets = new Assets { LargeImageKey = "osu_logo_lazer", }
Assets = new Assets { LargeImageKey = "osu_logo_lazer" },
Secrets = new Secrets
{
JoinSecret = null,
SpectateSecret = null,
},
};

[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
login = game.Dependencies.Get<LoginOverlay>();

client = new DiscordRpcClient(client_id)
{
SkipIdenticalPresence = false // handles better on discord IPC loss, see updateStatus call in onReady.
};

client.OnReady += onReady;
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network, LogLevel.Error);

client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network);
// A URI scheme is required to support game invitations, as well as informing Discord of the game executable path to support launching the game when a user clicks on join/spectate.
client.RegisterUriScheme();
client.Subscribe(EventType.Join);
client.OnJoin += onJoin;

config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);

Expand All @@ -78,11 +106,13 @@ private void load(OsuConfigManager config)
private void onReady(object _, ReadyMessage __)
{
Logger.Log("Discord RPC Client ready.", LoggingTarget.Network, LogLevel.Debug);
updateStatus();
Schedule(updateStatus);
}

private void updateStatus()
{
Debug.Assert(ThreadSafety.IsUpdateThread);

if (!client.IsInitialized)
return;

Expand All @@ -92,10 +122,10 @@ private void updateStatus()
return;
}

bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb;

if (activity.Value != null)
{
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb;

presence.State = truncate(activity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = truncate(activity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);

Expand All @@ -121,6 +151,41 @@ private void updateStatus()
presence.Details = string.Empty;
}

if (!hideIdentifiableInformation && multiplayerClient.Room != null)
{
MultiplayerRoom room = multiplayerClient.Room;

if (room.Users.Count == usersCurrentlyInLobby)
return;

presence.Party = new Party
{
Privacy = string.IsNullOrEmpty(room.Settings.Password) ? Party.PrivacySetting.Public : Party.PrivacySetting.Private,
ID = room.RoomID.ToString(),
// technically lobbies can have infinite users, but Discord needs this to be set to something.
// to make party display sensible, assign a powers of two above participants count (8 at minimum).
Max = (int)Math.Max(8, Math.Pow(2, Math.Ceiling(Math.Log2(room.Users.Count)))),
Size = room.Users.Count,
};

RoomSecret roomSecret = new RoomSecret
{
RoomID = room.RoomID,
Password = room.Settings.Password,
};

presence.Secrets.JoinSecret = JsonConvert.SerializeObject(roomSecret);
usersCurrentlyInLobby = room.Users.Count;
}
else
{
presence.Party = null;
presence.Secrets.JoinSecret = null;
usersCurrentlyInLobby = 0;
}

Logger.Log($"Updating Discord RPC presence with activity status: {presence.State}, details: {presence.Details}", LoggingTarget.Network, LogLevel.Debug);

// update user information
if (privacyMode.Value == DiscordRichPresenceMode.Limited)
presence.Assets.LargeImageText = string.Empty;
Expand All @@ -139,9 +204,38 @@ private void updateStatus()
client.SetPresence(presence);
}

private void onJoin(object sender, JoinMessage args)
{
game.Window?.Raise();

if (!api.IsLoggedIn)
{
Schedule(() => login?.Show());
return;
}

Logger.Log($"Received room secret from Discord RPC Client: \"{args.Secret}\"", LoggingTarget.Network, LogLevel.Debug);

// Stable and lazer share the same Discord client ID, meaning they can accept join requests from each other.
// Since they aren't compatible in multi, see if stable's format is being used and log to avoid confusion.
if (args.Secret[0] != '{' || !tryParseRoomSecret(args.Secret, out long roomId, out string? password))
{
Logger.Log("Could not join multiplayer room, invitation is invalid or incompatible.", LoggingTarget.Network, LogLevel.Important);
return;
}

var request = new GetRoomRequest(roomId);
request.Success += room => Schedule(() =>
{
game.PresentMultiplayerMatch(room, password);
});
request.Failure += _ => Logger.Log($"Could not join multiplayer room, room could not be found (room ID: {roomId}).", LoggingTarget.Network, LogLevel.Important);
api.Queue(request);
}

private static readonly int ellipsis_length = Encoding.UTF8.GetByteCount(new[] { '…' });

private string truncate(string str)
private static string truncate(string str)
{
if (Encoding.UTF8.GetByteCount(str) <= 128)
return str;
Expand All @@ -160,7 +254,31 @@ private string truncate(string str)
});
}

private int? getBeatmapID(UserActivity activity)
private static bool tryParseRoomSecret(string secretJson, out long roomId, out string? password)
{
roomId = 0;
password = null;

RoomSecret? roomSecret;

try
{
roomSecret = JsonConvert.DeserializeObject<RoomSecret>(secretJson);
}
catch
{
return false;
}

if (roomSecret == null) return false;

roomId = roomSecret.RoomID;
password = roomSecret.Password;

return true;
}

private static int? getBeatmapID(UserActivity activity)
{
switch (activity)
{
Expand All @@ -179,5 +297,14 @@ protected override void Dispose(bool isDisposing)
client.Dispose();
base.Dispose(isDisposing);
}

private class RoomSecret
{
[JsonProperty(@"roomId", Required = Required.Always)]
public long RoomID { get; set; }

[JsonProperty(@"password", Required = Required.AllowNull)]
public string? Password { get; set; }
}
}
}
Loading