Browse Source

Refactor Games, support reading Rich Presences (#877)

* Add API-level support for Rich Presences

* Add library-level support for Game presences

* Add model conversions for outgoing+incoming rich presences

* Refactored Game into Activities

* Integrated Activities with user entities

rebase hell from 5f3cb947a9

* Fix JSON converters for Activities

* Finish rebase, activity should be set on BaseSocketClient

* Use ApplicationId to define a rich presence

* Added SetActivityAsync to Base and Sharded Socket clients

* Remove public parameterless Game constructor

* Remove GameAssets, refactored to GameAsset

* Hide constructors for types that should be read-only

* Revert changes to Discord.Net.sln

got damned visual studio caching

* Refactor GameParty to use dedicated current/capacity values

Per feedback from @khionu
tags/2.0.0-beta
Christopher F GitHub 7 years ago
parent
commit
34b4e5a6d2
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 376 additions and 58 deletions
  1. +6
    -0
      src/Discord.Net.Core/CDN.cs
  2. +19
    -0
      src/Discord.Net.Core/Entities/Activities/Game.cs
  3. +15
    -0
      src/Discord.Net.Core/Entities/Activities/GameAsset.cs
  4. +11
    -0
      src/Discord.Net.Core/Entities/Activities/GameParty.cs
  5. +16
    -0
      src/Discord.Net.Core/Entities/Activities/GameSecrets.cs
  6. +16
    -0
      src/Discord.Net.Core/Entities/Activities/GameTimestamps.cs
  7. +13
    -0
      src/Discord.Net.Core/Entities/Activities/IActivity.cs
  8. +22
    -0
      src/Discord.Net.Core/Entities/Activities/RichGame.cs
  9. +21
    -0
      src/Discord.Net.Core/Entities/Activities/StreamingGame.cs
  10. +0
    -24
      src/Discord.Net.Core/Entities/Users/Game.cs
  11. +2
    -2
      src/Discord.Net.Core/Entities/Users/IPresence.cs
  12. +16
    -0
      src/Discord.Net.Rest/API/Common/Game.cs
  13. +16
    -0
      src/Discord.Net.Rest/API/Common/GameAssets.cs
  14. +12
    -0
      src/Discord.Net.Rest/API/Common/GameParty.cs
  15. +14
    -0
      src/Discord.Net.Rest/API/Common/GameSecrets.cs
  16. +15
    -0
      src/Discord.Net.Rest/API/Common/GameTimestamps.cs
  17. +7
    -0
      src/Discord.Net.Rest/API/UnixTimestampAttribute.cs
  18. +2
    -1
      src/Discord.Net.Rest/Discord.Net.Rest.csproj
  19. +1
    -1
      src/Discord.Net.Rest/Entities/Users/RestUser.cs
  20. +6
    -0
      src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs
  21. +28
    -0
      src/Discord.Net.Rest/Net/Converters/UnixTimestampConverter.cs
  22. +1
    -1
      src/Discord.Net.Rpc/Entities/Users/RpcUser.cs
  23. +2
    -1
      src/Discord.Net.WebSocket/BaseSocketClient.cs
  24. +11
    -2
      src/Discord.Net.WebSocket/DiscordShardedClient.cs
  25. +22
    -16
      src/Discord.Net.WebSocket/DiscordSocketClient.cs
  26. +5
    -5
      src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs
  27. +1
    -1
      src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs
  28. +76
    -4
      src/Discord.Net.WebSocket/Extensions/EntityExtensions.cs

+ 6
- 0
src/Discord.Net.Core/CDN.cs View File

@@ -22,6 +22,12 @@ namespace Discord
public static string GetEmojiUrl(ulong emojiId)
=> $"{DiscordConfig.CDNUrl}emojis/{emojiId}.png";

public static string GetRichAssetUrl(ulong appId, string assetId, ushort size, ImageFormat format)
{
string extension = FormatToExtension(format, "");
return $"{DiscordConfig.CDNUrl}app-assets/{appId}/{assetId}.{extension}?size={size}";
}

private static string FormatToExtension(ImageFormat format, string imageId)
{
if (format == ImageFormat.Auto)


+ 19
- 0
src/Discord.Net.Core/Entities/Activities/Game.cs View File

@@ -0,0 +1,19 @@
using System.Diagnostics;

namespace Discord
{
[DebuggerDisplay(@"{DebuggerDisplay,nq}")]
public class Game : IActivity
{
public string Name { get; internal set; }

internal Game() { }
public Game(string name)
{
Name = name;
}
public override string ToString() => Name;
private string DebuggerDisplay => Name;
}
}

+ 15
- 0
src/Discord.Net.Core/Entities/Activities/GameAsset.cs View File

@@ -0,0 +1,15 @@
namespace Discord
{
public class GameAsset
{
internal GameAsset() { }

internal ulong ApplicationId { get; set; }
public string Text { get; internal set; }
public string ImageId { get; internal set; }
public string GetImageUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128)
=> CDN.GetRichAssetUrl(ApplicationId, ImageId, size, format);
}
}

+ 11
- 0
src/Discord.Net.Core/Entities/Activities/GameParty.cs View File

@@ -0,0 +1,11 @@
namespace Discord
{
public class GameParty
{
internal GameParty() { }

public string Id { get; internal set; }
public int Members { get; internal set; }
public int Capacity { get; internal set; }
}
}

+ 16
- 0
src/Discord.Net.Core/Entities/Activities/GameSecrets.cs View File

@@ -0,0 +1,16 @@
namespace Discord
{
public class GameSecrets
{
public string Match { get; }
public string Join { get; }
public string Spectate { get; }

internal GameSecrets(string match, string join, string spectate)
{
Match = match;
Join = join;
Spectate = spectate;
}
}
}

+ 16
- 0
src/Discord.Net.Core/Entities/Activities/GameTimestamps.cs View File

@@ -0,0 +1,16 @@
using System;

namespace Discord
{
public class GameTimestamps
{
public DateTimeOffset? Start { get; }
public DateTimeOffset? End { get; }

internal GameTimestamps(DateTimeOffset? start, DateTimeOffset? end)
{
Start = start;
End = end;
}
}
}

+ 13
- 0
src/Discord.Net.Core/Entities/Activities/IActivity.cs View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Discord
{
public interface IActivity
{
string Name { get; }
}
}

+ 22
- 0
src/Discord.Net.Core/Entities/Activities/RichGame.cs View File

@@ -0,0 +1,22 @@
using System.Diagnostics;

namespace Discord
{
[DebuggerDisplay(@"{DebuggerDisplay,nq}")]
public class RichGame : Game
{
internal RichGame() { }

public string Details { get; internal set;}
public string State { get; internal set;}
public ulong ApplicationId { get; internal set; }
public GameAsset SmallAsset { get; internal set; }
public GameAsset LargeAsset { get; internal set; }
public GameParty Party { get; internal set; }
public GameSecrets Secrets { get; internal set; }
public GameTimestamps Timestamps { get; internal set; }
public override string ToString() => Name;
private string DebuggerDisplay => $"{Name} (Rich)";
}
}

+ 21
- 0
src/Discord.Net.Core/Entities/Activities/StreamingGame.cs View File

@@ -0,0 +1,21 @@
using System.Diagnostics;

namespace Discord
{
[DebuggerDisplay(@"{DebuggerDisplay,nq}")]
public class StreamingGame : Game
{
public string Url { get; internal set; }
public StreamType StreamType { get; internal set; }

public StreamingGame(string name, string url, StreamType streamType)
{
Name = name;
Url = url;
StreamType = streamType;
}
public override string ToString() => Name;
private string DebuggerDisplay => $"{Name} ({Url})";
}
}

+ 0
- 24
src/Discord.Net.Core/Entities/Users/Game.cs View File

@@ -1,24 +0,0 @@
using System.Diagnostics;

namespace Discord
{
[DebuggerDisplay(@"{DebuggerDisplay,nq}")]
public struct Game
{
public string Name { get; }
public string StreamUrl { get; }
public StreamType StreamType { get; }

public Game(string name, string streamUrl, StreamType type)
{
Name = name;
StreamUrl = streamUrl;
StreamType = type;
}
private Game(string name)
: this(name, null, StreamType.NotStreaming) { }

public override string ToString() => Name;
private string DebuggerDisplay => StreamUrl != null ? $"{Name} ({StreamUrl})" : Name;
}
}

+ 2
- 2
src/Discord.Net.Core/Entities/Users/IPresence.cs View File

@@ -2,8 +2,8 @@
{
public interface IPresence
{
/// <summary> Gets the game this user is currently playing, if any. </summary>
Game? Game { get; }
/// <summary> Gets the activity this user is currently doing. </summary>
IActivity Activity { get; }
/// <summary> Gets the current status of this user. </summary>
UserStatus Status { get; }
}

+ 16
- 0
src/Discord.Net.Rest/API/Common/Game.cs View File

@@ -13,6 +13,22 @@ namespace Discord.API
public Optional<string> StreamUrl { get; set; }
[JsonProperty("type")]
public Optional<StreamType?> StreamType { get; set; }
[JsonProperty("details")]
public Optional<string> Details { get; set; }
[JsonProperty("state")]
public Optional<string> State { get; set; }
[JsonProperty("application_id")]
public Optional<ulong> ApplicationId { get; set; }
[JsonProperty("assets")]
public Optional<API.GameAssets> Assets { get; set; }
[JsonProperty("party")]
public Optional<API.GameParty> Party { get; set; }
[JsonProperty("secrets")]
public Optional<API.GameSecrets> Secrets { get; set; }
[JsonProperty("timestamps")]
public Optional<API.GameTimestamps> Timestamps { get; set; }
[JsonProperty("instance")]
public Optional<bool> Instance { get; set; }

[OnError]
internal void OnError(StreamingContext context, ErrorContext errorContext)


+ 16
- 0
src/Discord.Net.Rest/API/Common/GameAssets.cs View File

@@ -0,0 +1,16 @@
using Newtonsoft.Json;

namespace Discord.API
{
internal class GameAssets
{
[JsonProperty("small_text")]
public Optional<string> SmallText { get; set; }
[JsonProperty("small_image")]
public Optional<string> SmallImage { get; set; }
[JsonProperty("large_image")]
public Optional<string> LargeText { get; set; }
[JsonProperty("large_text")]
public Optional<string> LargeImage { get; set; }
}
}

+ 12
- 0
src/Discord.Net.Rest/API/Common/GameParty.cs View File

@@ -0,0 +1,12 @@
using Newtonsoft.Json;

namespace Discord.API
{
internal class GameParty
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("size")]
public int[] Size { get; set; }
}
}

+ 14
- 0
src/Discord.Net.Rest/API/Common/GameSecrets.cs View File

@@ -0,0 +1,14 @@
using Newtonsoft.Json;

namespace Discord.API
{
internal class GameSecrets
{
[JsonProperty("match")]
public string Match { get; set; }
[JsonProperty("join")]
public string Join { get; set; }
[JsonProperty("spectate")]
public string Spectate { get; set; }
}
}

+ 15
- 0
src/Discord.Net.Rest/API/Common/GameTimestamps.cs View File

@@ -0,0 +1,15 @@
using System;
using Newtonsoft.Json;

namespace Discord.API
{
internal class GameTimestamps
{
[JsonProperty("start")]
[UnixTimestamp]
public Optional<DateTimeOffset> Start { get; set; }
[JsonProperty("end")]
[UnixTimestamp]
public Optional<DateTimeOffset> End { get; set; }
}
}

+ 7
- 0
src/Discord.Net.Rest/API/UnixTimestampAttribute.cs View File

@@ -0,0 +1,7 @@
using System;

namespace Discord.API
{
[AttributeUsage(AttributeTargets.Property)]
internal class UnixTimestampAttribute : Attribute { }
}

+ 2
- 1
src/Discord.Net.Rest/Discord.Net.Rest.csproj View File

@@ -10,7 +10,8 @@
<ProjectReference Include="..\Discord.Net.Core\Discord.Net.Core.csproj" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' != 'net45' ">
<PackageReference Include="System.Net.Http" Version="4.3.2" /> <!-- https://github.com/dotnet/corefx/issues/19535 -->
<PackageReference Include="System.Net.Http" Version="4.3.2" />
<!-- https://github.com/dotnet/corefx/issues/19535 -->
</ItemGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'net45' ">
<Reference Include="System.Net.Http" />


+ 1
- 1
src/Discord.Net.Rest/Entities/Users/RestUser.cs View File

@@ -16,7 +16,7 @@ namespace Discord.Rest
public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id);
public string Discriminator => DiscriminatorValue.ToString("D4");
public string Mention => MentionUtils.MentionUser(Id);
public virtual Game? Game => null;
public virtual IActivity Activity => null;
public virtual UserStatus Status => UserStatus.Offline;
public virtual bool IsWebhook => false;



+ 6
- 0
src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs View File

@@ -66,6 +66,12 @@ namespace Discord.Net.Converters
if (type == typeof(ulong))
return UInt64Converter.Instance;
}
bool hasUnixStamp = propInfo.GetCustomAttribute<UnixTimestampAttribute>() != null;
if (hasUnixStamp)
{
if (type == typeof(DateTimeOffset))
return UnixTimestampConverter.Instance;
}

//Enums
if (type == typeof(PermissionTarget))


+ 28
- 0
src/Discord.Net.Rest/Net/Converters/UnixTimestampConverter.cs View File

@@ -0,0 +1,28 @@
using System;
using Newtonsoft.Json;

namespace Discord.Net.Converters
{
public class UnixTimestampConverter : JsonConverter
{
public static readonly UnixTimestampConverter Instance = new UnixTimestampConverter();

public override bool CanConvert(Type objectType) => true;
public override bool CanRead => true;
public override bool CanWrite => true;

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
// Discord doesn't validate if timestamps contain decimals or not
if (reader.Value is double d)
return new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(d);
long offset = (long)reader.Value;
return new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(offset);
}

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
}
}

+ 1
- 1
src/Discord.Net.Rpc/Entities/Users/RpcUser.cs View File

@@ -18,7 +18,7 @@ namespace Discord.Rpc
public string Discriminator => DiscriminatorValue.ToString("D4");
public string Mention => MentionUtils.MentionUser(Id);
public virtual bool IsWebhook => false;
public virtual Game? Game => null;
public virtual IActivity Activity => null;
public virtual UserStatus Status => UserStatus.Offline;

internal RpcUser(DiscordRpcClient discord, ulong id)


+ 2
- 1
src/Discord.Net.WebSocket/BaseSocketClient.cs View File

@@ -13,7 +13,7 @@ namespace Discord.WebSocket
/// <summary> Gets the estimated round-trip latency, in milliseconds, to the gateway server. </summary>
public abstract int Latency { get; protected set; }
public abstract UserStatus Status { get; protected set; }
public abstract Game? Game { get; protected set; }
public abstract IActivity Activity { get; protected set; }

internal new DiscordSocketApiClient ApiClient => base.ApiClient as DiscordSocketApiClient;

@@ -45,6 +45,7 @@ namespace Discord.WebSocket
public abstract Task StopAsync();
public abstract Task SetStatusAsync(UserStatus status);
public abstract Task SetGameAsync(string name, string streamUrl = null, StreamType streamType = StreamType.NotStreaming);
public abstract Task SetActivityAsync(IActivity activity);
public abstract Task DownloadUsersAsync(IEnumerable<IGuild> guilds);

/// <inheritdoc />


+ 11
- 2
src/Discord.Net.WebSocket/DiscordShardedClient.cs View File

@@ -22,7 +22,7 @@ namespace Discord.WebSocket
/// <summary> Gets the estimated round-trip latency, in milliseconds, to the gateway server. </summary>
public override int Latency { get => GetLatency(); protected set { } }
public override UserStatus Status { get => _shards[0].Status; protected set { } }
public override Game? Game { get => _shards[0].Game; protected set { } }
public override IActivity Activity { get => _shards[0].Activity; protected set { } }

internal new DiscordSocketApiClient ApiClient => base.ApiClient as DiscordSocketApiClient;
public override IReadOnlyCollection<SocketGuild> Guilds => GetGuilds().ToReadOnlyCollection(() => GetGuildCount());
@@ -239,9 +239,18 @@ namespace Discord.WebSocket
await _shards[i].SetStatusAsync(status).ConfigureAwait(false);
}
public override async Task SetGameAsync(string name, string streamUrl = null, StreamType streamType = StreamType.NotStreaming)
{
IActivity activity = null;
if (streamUrl != null)
activity = new StreamingGame(name, streamUrl, streamType);
else if (name != null)
activity = new Game(name);
await SetActivityAsync(activity).ConfigureAwait(false);
}
public override async Task SetActivityAsync(IActivity activity)
{
for (int i = 0; i < _shards.Length; i++)
await _shards[i].SetGameAsync(name, streamUrl, streamType).ConfigureAwait(false);
await _shards[i].SetActivityAsync(activity).ConfigureAwait(false);
}

private void RegisterEvents(DiscordSocketClient client, bool isPrimary)


+ 22
- 16
src/Discord.Net.WebSocket/DiscordSocketClient.cs View File

@@ -48,7 +48,7 @@ namespace Discord.WebSocket
/// <inheritdoc />
public override int Latency { get; protected set; }
public override UserStatus Status { get; protected set; } = UserStatus.Online;
public override Game? Game { get; protected set; }
public override IActivity Activity { get; protected set; }

//From DiscordSocketConfig
internal int TotalShards { get; private set; }
@@ -328,33 +328,39 @@ namespace Discord.WebSocket
}
public override async Task SetGameAsync(string name, string streamUrl = null, StreamType streamType = StreamType.NotStreaming)
{
if (name != null)
Game = new Game(name, streamUrl, streamType);
if (streamUrl != null)
Activity = new StreamingGame(name, streamUrl, streamType);
else if (name != null)
Activity = new Game(name);
else
Game = null;
Activity = null;
await SendStatusAsync().ConfigureAwait(false);
}
public override async Task SetActivityAsync(IActivity activity)
{
Activity = activity;
await SendStatusAsync().ConfigureAwait(false);
}
private async Task SendStatusAsync()
{
if (CurrentUser == null)
return;
var game = Game;
var activity = Activity;
var status = Status;
var statusSince = _statusSince;
CurrentUser.Presence = new SocketPresence(status, game);
CurrentUser.Presence = new SocketPresence(status, activity);

GameModel gameModel;
if (game != null)
var gameModel = new GameModel();
// Discord only accepts rich presence over RPC, don't even bother building a payload
if (activity is RichGame game) throw new NotSupportedException("Outgoing Rich Presences are not supported");
if (activity is StreamingGame stream)
{
gameModel = new API.Game
{
Name = game.Value.Name,
StreamType = game.Value.StreamType,
StreamUrl = game.Value.StreamUrl
};
gameModel.StreamUrl = stream.Url;
gameModel.StreamType = stream.StreamType;
}
else
gameModel = null;
else if (activity != null)
gameModel.Name = activity.Name;

await ApiClient.SendStatusUpdateAsync(
status,


+ 5
- 5
src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs View File

@@ -8,20 +8,20 @@ namespace Discord.WebSocket
public struct SocketPresence : IPresence
{
public UserStatus Status { get; }
public Game? Game { get; }
public IActivity Activity { get; }

internal SocketPresence(UserStatus status, Game? game)
internal SocketPresence(UserStatus status, IActivity activity)
{
Status = status;
Game = game;
Activity= activity;
}
internal static SocketPresence Create(Model model)
{
return new SocketPresence(model.Status, model.Game != null ? model.Game.ToEntity() : (Game?)null);
return new SocketPresence(model.Status, model.Game?.ToEntity());
}

public override string ToString() => Status.ToString();
private string DebuggerDisplay => $"{Status}{(Game != null ? $", {Game.Value.Name} ({Game.Value.StreamType})" : "")}";
private string DebuggerDisplay => $"{Status}{(Activity != null ? $", {Activity.Name}": "")}";

internal SocketPresence Clone() => this;
}


+ 1
- 1
src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs View File

@@ -18,7 +18,7 @@ namespace Discord.WebSocket
public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id);
public string Discriminator => DiscriminatorValue.ToString("D4");
public string Mention => MentionUtils.MentionUser(Id);
public Game? Game => Presence.Game;
public IActivity Activity => Presence.Activity;
public UserStatus Status => Presence.Status;

internal SocketUser(DiscordSocketClient discord, ulong id)


+ 76
- 4
src/Discord.Net.WebSocket/Extensions/EntityExtensions.cs View File

@@ -2,11 +2,83 @@
{
internal static class EntityExtensions
{
public static Game ToEntity(this API.Game model)
public static IActivity ToEntity(this API.Game model)
{
return new Game(model.Name,
model.StreamUrl.GetValueOrDefault(null),
model.StreamType.GetValueOrDefault(null) ?? StreamType.NotStreaming);
// Rich Game
if (model.ApplicationId.IsSpecified)
{
ulong appId = model.ApplicationId.Value;
var assets = model.Assets.GetValueOrDefault()?.ToEntity(appId);
return new RichGame
{
ApplicationId = appId,
Name = model.Name,
Details = model.Details.GetValueOrDefault(),
State = model.State.GetValueOrDefault(),
SmallAsset = assets?[0],
LargeAsset = assets?[1],
Party = model.Party.GetValueOrDefault()?.ToEntity(),
Secrets = model.Secrets.GetValueOrDefault()?.ToEntity(),
Timestamps = model.Timestamps.GetValueOrDefault()?.ToEntity()
};
}
// Stream Game
if (model.StreamUrl.IsSpecified)
{
return new StreamingGame(
model.Name,
model.StreamUrl.Value,
model.StreamType.Value.GetValueOrDefault());
}
// Normal Game
return new Game(model.Name);
}

// (Small, Large)
public static GameAsset[] ToEntity(this API.GameAssets model, ulong appId)
{
return new GameAsset[]
{
model.SmallImage.IsSpecified ? new GameAsset
{
ApplicationId = appId,
ImageId = model.SmallImage.GetValueOrDefault(),
Text = model.SmallText.GetValueOrDefault()
} : null,
model.LargeImage.IsSpecified ? new GameAsset
{
ApplicationId = appId,
ImageId = model.LargeImage.GetValueOrDefault(),
Text = model.LargeText.GetValueOrDefault()
} : null,
};
}

public static GameParty ToEntity(this API.GameParty model)
{
// Discord will probably send bad data since they don't validate anything
int current = 0, cap = 0;
if (model.Size.Length == 2)
{
current = model.Size[0];
cap = model.Size[1];
}
return new GameParty
{
Id = model.Id,
Members = current,
Capacity = cap,
};
}

public static GameSecrets ToEntity(this API.GameSecrets model)
{
return new GameSecrets(model.Match, model.Join, model.Spectate);
}

public static GameTimestamps ToEntity(this API.GameTimestamps model)
{
return new GameTimestamps(model.Start.ToNullable(), model.End.ToNullable());
}
}
}

Loading…
Cancel
Save