2019-12-18 05:07:53 +00:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
2019-12-23 10:34:12 +00:00
using System ;
2024-03-19 20:03:32 +00:00
using System.Diagnostics ;
2019-12-21 14:48:15 +00:00
using System.Text ;
2019-12-18 05:07:53 +00:00
using DiscordRPC ;
using DiscordRPC.Message ;
2024-03-05 23:15:53 +00:00
using Newtonsoft.Json ;
2019-12-18 05:07:53 +00:00
using osu.Framework.Allocation ;
using osu.Framework.Bindables ;
2024-03-19 20:03:32 +00:00
using osu.Framework.Development ;
2019-12-18 05:07:53 +00:00
using osu.Framework.Graphics ;
using osu.Framework.Logging ;
2024-03-01 00:57:32 +00:00
using osu.Game ;
2020-12-30 05:29:51 +00:00
using osu.Game.Configuration ;
2022-03-03 05:15:25 +00:00
using osu.Game.Extensions ;
2019-12-18 05:07:53 +00:00
using osu.Game.Online.API ;
2021-11-04 09:02:44 +00:00
using osu.Game.Online.API.Requests.Responses ;
2024-03-01 00:57:32 +00:00
using osu.Game.Online.Multiplayer ;
using osu.Game.Online.Rooms ;
2019-12-18 05:07:53 +00:00
using osu.Game.Rulesets ;
using osu.Game.Users ;
using LogLevel = osu . Framework . Logging . LogLevel ;
namespace osu.Desktop
{
internal partial class DiscordRichPresence : Component
{
2024-03-11 08:55:49 +00:00
private const string client_id = "1216669957799018608" ;
2019-12-18 05:07:53 +00:00
2022-08-02 14:23:54 +00:00
private DiscordRpcClient client = null ! ;
2019-12-18 05:07:53 +00:00
[Resolved]
2022-08-02 14:23:54 +00:00
private IBindable < RulesetInfo > ruleset { get ; set ; } = null ! ;
2019-12-18 05:07:53 +00:00
2022-08-02 14:23:54 +00:00
private IBindable < APIUser > user = null ! ;
2019-12-18 05:07:53 +00:00
2022-05-30 20:38:47 +00:00
[Resolved]
2022-08-02 14:23:54 +00:00
private IAPIProvider api { get ; set ; } = null ! ;
2022-05-30 20:38:47 +00:00
2024-03-01 00:57:32 +00:00
[Resolved]
private OsuGame game { get ; set ; } = null ! ;
[Resolved]
private MultiplayerClient multiplayerClient { get ; set ; } = null ! ;
2023-12-06 17:21:44 +00:00
private readonly IBindable < UserStatus ? > status = new Bindable < UserStatus ? > ( ) ;
2019-12-18 05:07:53 +00:00
private readonly IBindable < UserActivity > activity = new Bindable < UserActivity > ( ) ;
2021-01-06 15:05:12 +00:00
private readonly Bindable < DiscordRichPresenceMode > privacyMode = new Bindable < DiscordRichPresenceMode > ( ) ;
2019-12-18 05:07:53 +00:00
2024-03-19 20:03:32 +00:00
private int usersCurrentlyInLobby = 0 ;
2019-12-18 05:07:53 +00:00
private readonly RichPresence presence = new RichPresence
{
2024-03-01 00:57:32 +00:00
Assets = new Assets { LargeImageKey = "osu_logo_lazer" } ,
Secrets = new Secrets
{
JoinSecret = null ,
SpectateSecret = null ,
} ,
2019-12-18 05:07:53 +00:00
} ;
[BackgroundDependencyLoader]
2022-05-30 21:32:55 +00:00
private void load ( OsuConfigManager config )
2019-12-18 05:07:53 +00:00
{
client = new DiscordRpcClient ( client_id )
{
SkipIdenticalPresence = false // handles better on discord IPC loss, see updateStatus call in onReady.
} ;
client . OnReady + = onReady ;
2024-03-01 00:57:32 +00:00
client . OnError + = ( _ , e ) = > Logger . Log ( $"An error occurred with Discord RPC Client: {e.Code} {e.Message}" , LoggingTarget . Network , LogLevel . Error ) ;
2019-12-23 10:50:35 +00:00
2024-03-01 19:32:44 +00:00
// 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.
2024-03-01 00:57:32 +00:00
client . RegisterUriScheme ( ) ;
2024-03-01 19:32:44 +00:00
client . Subscribe ( EventType . Join ) ;
2024-03-01 00:57:32 +00:00
client . OnJoin + = onJoin ;
2019-12-18 05:07:53 +00:00
2021-01-06 15:05:12 +00:00
config . BindWith ( OsuSetting . DiscordRichPresence , privacyMode ) ;
2022-06-09 03:32:30 +00:00
user = api . LocalUser . GetBoundCopy ( ) ;
user . BindValueChanged ( u = >
2019-12-18 05:07:53 +00:00
{
status . UnbindBindings ( ) ;
status . BindTo ( u . NewValue . Status ) ;
activity . UnbindBindings ( ) ;
activity . BindTo ( u . NewValue . Activity ) ;
} , true ) ;
ruleset . BindValueChanged ( _ = > updateStatus ( ) ) ;
status . BindValueChanged ( _ = > updateStatus ( ) ) ;
activity . BindValueChanged ( _ = > updateStatus ( ) ) ;
2021-01-06 15:05:12 +00:00
privacyMode . BindValueChanged ( _ = > updateStatus ( ) ) ;
2019-12-18 05:07:53 +00:00
client . Initialize ( ) ;
}
private void onReady ( object _ , ReadyMessage __ )
{
Logger . Log ( "Discord RPC Client ready." , LoggingTarget . Network , LogLevel . Debug ) ;
2024-03-19 20:02:06 +00:00
Schedule ( updateStatus ) ;
2019-12-18 05:07:53 +00:00
}
private void updateStatus ( )
{
2024-03-19 20:02:06 +00:00
Debug . Assert ( ThreadSafety . IsUpdateThread ) ;
2020-01-11 15:03:00 +00:00
if ( ! client . IsInitialized )
return ;
2023-12-06 17:21:44 +00:00
if ( status . Value = = UserStatus . Offline | | privacyMode . Value = = DiscordRichPresenceMode . Off )
2019-12-18 05:07:53 +00:00
{
client . ClearPresence ( ) ;
return ;
}
2024-03-19 20:03:32 +00:00
bool hideIdentifiableInformation = privacyMode . Value = = DiscordRichPresenceMode . Limited | | status . Value = = UserStatus . DoNotDisturb ;
2024-02-24 03:07:47 +00:00
if ( activity . Value ! = null )
2019-12-18 05:07:53 +00:00
{
2023-12-06 17:16:45 +00:00
presence . State = truncate ( activity . Value . GetStatus ( hideIdentifiableInformation ) ) ;
2024-02-28 04:58:02 +00:00
presence . Details = truncate ( activity . Value . GetDetails ( hideIdentifiableInformation ) ? ? string . Empty ) ;
2021-11-20 12:41:01 +00:00
2023-12-06 17:16:45 +00:00
if ( getBeatmapID ( activity . Value ) is int beatmapId & & beatmapId > 0 )
2021-11-20 12:41:01 +00:00
{
2022-06-14 17:25:06 +00:00
presence . Buttons = new [ ]
2021-11-20 12:41:01 +00:00
{
2022-06-14 17:25:06 +00:00
new Button
{
Label = "View beatmap" ,
2023-12-06 17:16:45 +00:00
Url = $@"{api.WebsiteRootUrl}/beatmaps/{beatmapId}?mode={ruleset.Value.ShortName}"
2022-06-14 17:25:06 +00:00
}
2021-11-20 12:41:01 +00:00
} ;
}
else
{
presence . Buttons = null ;
}
2024-03-19 20:03:32 +00:00
}
else
{
presence . State = "Idle" ;
presence . Details = string . Empty ;
}
2024-03-01 00:57:32 +00:00
2024-03-19 20:03:32 +00:00
if ( ! hideIdentifiableInformation & & multiplayerClient . Room ! = null )
{
MultiplayerRoom room = multiplayerClient . Room ;
2024-03-01 00:57:32 +00:00
2024-03-19 20:03:32 +00:00
if ( room . Users . Count = = usersCurrentlyInLobby )
return ;
2024-03-05 23:15:53 +00:00
2024-03-19 20:03:32 +00:00
presence . Party = new Party
2024-03-01 00:57:32 +00:00
{
2024-03-19 20:03:32 +00:00
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 ;
2019-12-18 05:07:53 +00:00
}
else
{
2024-03-19 20:03:32 +00:00
presence . Party = null ;
presence . Secrets . JoinSecret = null ;
usersCurrentlyInLobby = 0 ;
2019-12-18 05:07:53 +00:00
}
2024-03-19 20:05:13 +00:00
Logger . Log ( $"Updating Discord RPC presence with activity status: {presence.State}, details: {presence.Details}" , LoggingTarget . Network , LogLevel . Debug ) ;
2019-12-18 05:07:53 +00:00
// update user information
2021-01-06 15:05:12 +00:00
if ( privacyMode . Value = = DiscordRichPresenceMode . Limited )
2020-12-30 05:29:51 +00:00
presence . Assets . LargeImageText = string . Empty ;
else
2022-05-30 20:38:47 +00:00
{
2022-08-02 14:23:54 +00:00
if ( user . Value . RulesetsStatistics ! = null & & user . Value . RulesetsStatistics . TryGetValue ( ruleset . Value . ShortName , out UserStatistics ? statistics ) )
2022-05-30 20:38:47 +00:00
presence . Assets . LargeImageText = $"{user.Value.Username}" + ( statistics . GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string . Empty ) ;
else
presence . Assets . LargeImageText = $"{user.Value.Username}" + ( user . Value . Statistics ? . GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string . Empty ) ;
}
2019-12-18 05:07:53 +00:00
// update ruleset
2022-03-03 05:15:25 +00:00
presence . Assets . SmallImageKey = ruleset . Value . IsLegacyRuleset ( ) ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom" ;
2019-12-18 05:07:53 +00:00
presence . Assets . SmallImageText = ruleset . Value . Name ;
client . SetPresence ( presence ) ;
}
2024-03-01 00:57:32 +00:00
private void onJoin ( object sender , JoinMessage args )
{
2024-03-01 19:32:44 +00:00
game . Window ? . Raise ( ) ;
2024-03-01 00:57:32 +00:00
2024-03-10 22:01:26 +00:00
Logger . Log ( $"Received room secret from Discord RPC Client: \" { args . Secret } \ "" , LoggingTarget . Network , LogLevel . Debug ) ;
2024-03-06 06:17:11 +00:00
2024-03-10 22:01:26 +00:00
// 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 ) )
2024-03-05 23:15:53 +00:00
{
2024-03-10 22:01:26 +00:00
Logger . Log ( "Could not join multiplayer room, invitation is invalid or incompatible." , LoggingTarget . Network , LogLevel . Important ) ;
2024-03-05 23:15:53 +00:00
return ;
}
2024-03-01 00:57:32 +00:00
var request = new GetRoomRequest ( roomId ) ;
request . Success + = room = > Schedule ( ( ) = >
{
game . PresentMultiplayerMatch ( room , password ) ;
} ) ;
2024-03-10 22:01:26 +00:00
request . Failure + = _ = > Logger . Log ( $"Could not join multiplayer room, room could not be found (room ID: {roomId})." , LoggingTarget . Network , LogLevel . Important ) ;
2024-03-01 00:57:32 +00:00
api . Queue ( request ) ;
}
2019-12-25 02:14:40 +00:00
private static readonly int ellipsis_length = Encoding . UTF8 . GetByteCount ( new [ ] { '…' } ) ;
2024-03-01 05:02:20 +00:00
private static string truncate ( string str )
2019-12-23 09:55:44 +00:00
{
2019-12-23 10:56:05 +00:00
if ( Encoding . UTF8 . GetByteCount ( str ) < = 128 )
2019-12-25 03:04:28 +00:00
return str ;
ReadOnlyMemory < char > strMem = str . AsMemory ( ) ;
2019-12-23 09:55:44 +00:00
2019-12-23 10:34:12 +00:00
do
{
2019-12-25 03:04:28 +00:00
strMem = strMem [ . . ^ 1 ] ;
} while ( Encoding . UTF8 . GetByteCount ( strMem . Span ) + ellipsis_length > 128 ) ;
2019-12-23 10:34:12 +00:00
2019-12-25 03:04:28 +00:00
return string . Create ( strMem . Length + 1 , strMem , ( span , mem ) = >
{
mem . Span . CopyTo ( span ) ;
span [ ^ 1 ] = '…' ;
} ) ;
2019-12-23 09:55:44 +00:00
}
2019-12-21 14:48:15 +00:00
2024-03-05 23:15:53 +00:00
private static bool tryParseRoomSecret ( string secretJson , out long roomId , out string? password )
2024-03-01 00:57:32 +00:00
{
roomId = 0 ;
password = null ;
2024-03-05 23:15:53 +00:00
RoomSecret ? roomSecret ;
2024-03-01 00:57:32 +00:00
2024-03-05 23:15:53 +00:00
try
{
roomSecret = JsonConvert . DeserializeObject < RoomSecret > ( secretJson ) ;
}
catch
{
2024-03-01 00:57:32 +00:00
return false ;
2024-03-05 23:15:53 +00:00
}
2024-03-01 00:57:32 +00:00
2024-03-05 23:15:53 +00:00
if ( roomSecret = = null ) return false ;
2024-03-01 00:57:32 +00:00
2024-03-05 23:15:53 +00:00
roomId = roomSecret . RoomID ;
password = roomSecret . Password ;
2024-03-01 00:57:32 +00:00
return true ;
}
2024-03-01 05:02:20 +00:00
private static int? getBeatmapID ( UserActivity activity )
2022-06-14 17:25:06 +00:00
{
switch ( activity )
{
case UserActivity . InGame game :
2023-12-06 17:16:45 +00:00
return game . BeatmapID ;
2022-06-14 17:25:06 +00:00
2023-02-12 20:32:17 +00:00
case UserActivity . EditingBeatmap edit :
2023-12-06 17:16:45 +00:00
return edit . BeatmapID ;
2022-06-14 17:25:06 +00:00
}
return null ;
}
2019-12-18 05:07:53 +00:00
protected override void Dispose ( bool isDisposing )
{
client . Dispose ( ) ;
base . Dispose ( isDisposing ) ;
}
2024-03-05 23:15:53 +00:00
private class RoomSecret
{
[JsonProperty(@"roomId", Required = Required.Always)]
public long RoomID { get ; set ; }
[JsonProperty(@"password", Required = Required.AllowNull)]
public string? Password { get ; set ; }
}
2019-12-18 05:07:53 +00:00
}
}