diff --git a/src/Discord.Net.Core/DiscordConfig.cs b/src/Discord.Net.Core/DiscordConfig.cs index 6957c02f..8686c845 100644 --- a/src/Discord.Net.Core/DiscordConfig.cs +++ b/src/Discord.Net.Core/DiscordConfig.cs @@ -237,5 +237,10 @@ namespace Discord /// Returns the max amount of tags applied to an application. /// public const int MaxApplicationTagCount = 5; + + /// + /// Returns the maximum number of entitlements that can be gotten per-batch. + /// + public const int MaxEntitlementsPerBatch = 100; } } diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs new file mode 100644 index 00000000..6dac3676 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs @@ -0,0 +1,9 @@ +namespace Discord; + +public enum EntitlementType +{ + /// + /// The entitlement was purchased as an app subscription. + /// + ApplicationSubscription = 8 +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs new file mode 100644 index 00000000..19b2276c --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs @@ -0,0 +1,61 @@ +using System; + +namespace Discord; + +public interface IEntitlement : ISnowflakeEntity +{ + /// + /// Gets the ID of the SKU this entitlement is for. + /// + ulong SkuId { get; } + + /// + /// Gets the ID of the user that is granted access to the entitlement's SKU. + /// + /// + /// if the entitlement is for a guild. + /// + ulong? UserId { get; } + + /// + /// Gets the ID of the guild that is granted access to the entitlement's SKU. + /// + /// + /// if the entitlement is for a user. + /// + ulong? GuildId { get; } + + /// + /// Gets the ID of the parent application. + /// + ulong ApplicationId { get; } + + /// + /// Gets the type of the entitlement. + /// + EntitlementType Type { get; } + + /// + /// Gets whether this entitlement has been consumed. + /// + /// + /// Not applicable for App Subscriptions. + /// + bool IsConsumed { get; } + + /// + /// Gets the start date at which the entitlement is valid. + /// + /// + /// when using test entitlements. + /// + DateTimeOffset? StartsAt { get; } + + /// + /// Gets the end date at which the entitlement is no longer valid. + /// + /// + /// when using test entitlements. + /// + DateTimeOffset? EndsAt { get; } +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs new file mode 100644 index 00000000..338b14ea --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs @@ -0,0 +1,41 @@ +using System; + +namespace Discord; + +public struct SKU : ISnowflakeEntity +{ + /// + public ulong Id { get; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Gets the type of the SKU. + /// + public SKUType Type { get; } + + /// + /// Gets the ID of the parent application. + /// + public ulong ApplicationId { get; } + + /// + /// Gets the customer-facing name of your premium offering. + /// + public string Name { get; } + + /// + /// Gets the system-generated URL slug based on the SKU's name. + /// + public string Slug { get; } + + internal SKU(ulong id, SKUType type, ulong applicationId, string name, string slug) + { + Id = id; + Type = type; + ApplicationId = applicationId; + Name = name; + Slug = slug; + } +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs new file mode 100644 index 00000000..d748bf7b --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord; + +[Flags] +public enum SKUFlags +{ + GuildSubscription = 1 << 7, + + UserSubscription = 1 << 8 +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs new file mode 100644 index 00000000..a15b6c59 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum SKUType +{ + /// + /// Represents a recurring subscription. + /// + Subscription = 5, + + /// + /// System-generated group for each SKU created. + /// + SubscriptionGroup = 6, +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs new file mode 100644 index 00000000..eb042649 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum SubscriptionOwnerType +{ + /// + /// The owner of the application subscription is a guild. + /// + Guild = 1, + + /// + /// The owner of the application subscription is a user. + /// + User = 2, +} diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs index fca9b769..9519c30d 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs @@ -91,6 +91,11 @@ namespace Discord /// ulong ApplicationId { get; } + /// + /// Gets entitlements for the invoking user. + /// + IReadOnlyCollection Entitlements { get; } + /// /// Responds to an Interaction with type . /// @@ -368,5 +373,12 @@ namespace Discord /// The request options for this request. /// A task that represents the asynchronous operation of responding to the interaction. Task RespondWithModalAsync(Modal modal, RequestOptions options = null); + + /// + /// Responds to the interaction with an ephemeral message the invoking user, + /// instructing them that whatever they tried to do requires the premium benefits of your app. + /// + /// A task that represents the asynchronous operation of responding to the interaction. + Task RespondWithPremiumRequiredAsync(RequestOptions options = null); } } diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs index b0c2384e..b52d6b04 100644 --- a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs @@ -47,5 +47,10 @@ namespace Discord /// Respond by showing the user a modal. /// Modal = 9, + + /// + /// Respond to an interaction with an upgrade button, only available for apps with monetization enabled. + /// + PremiumRequired = 10 } } diff --git a/src/Discord.Net.Core/IDiscordClient.cs b/src/Discord.Net.Core/IDiscordClient.cs index ddf8c32d..a87a25c7 100644 --- a/src/Discord.Net.Core/IDiscordClient.cs +++ b/src/Discord.Net.Core/IDiscordClient.cs @@ -327,5 +327,27 @@ namespace Discord /// that represents the gateway information related to the bot. /// Task GetBotGatewayAsync(RequestOptions options = null); + + /// + /// Creates a test entitlement to a given SKU for a given guild or user. + /// + Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null); + + /// + /// Deletes a currently-active test entitlement. + /// + Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null); + + /// + /// Returns all entitlements for a given app, active and expired. + /// + IAsyncEnumerable> GetEntitlementsAsync(int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null); + + /// + /// Returns all SKUs for a given application. + /// + Task> GetSKUsAsync(RequestOptions options = null); } } diff --git a/src/Discord.Net.Interactions/InteractionModuleBase.cs b/src/Discord.Net.Interactions/InteractionModuleBase.cs index 41857582..76b2da25 100644 --- a/src/Discord.Net.Interactions/InteractionModuleBase.cs +++ b/src/Discord.Net.Interactions/InteractionModuleBase.cs @@ -123,6 +123,10 @@ namespace Discord.Interactions protected virtual async Task RespondWithModalAsync(string customId, RequestOptions options = null) where TModal : class, IModal => await Context.Interaction.RespondWithModalAsync(customId, options); + /// + protected virtual Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => Context.Interaction.RespondWithPremiumRequiredAsync(options); + //IInteractionModuleBase /// diff --git a/src/Discord.Net.Rest/API/Common/Entitlement.cs b/src/Discord.Net.Rest/API/Common/Entitlement.cs new file mode 100644 index 00000000..f12945e4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Entitlement.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API; + +internal class Entitlement +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } + + [JsonProperty("user_id")] + public Optional UserId { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("type")] + public EntitlementType Type { get; set; } + + [JsonProperty("consumed")] + public bool IsConsumed { get; set; } + + [JsonProperty("starts_at")] + public Optional StartsAt { get; set; } + + [JsonProperty("ends_at")] + public Optional EndsAt { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/Interaction.cs b/src/Discord.Net.Rest/API/Common/Interaction.cs index 7ac81432..14148ee7 100644 --- a/src/Discord.Net.Rest/API/Common/Interaction.cs +++ b/src/Discord.Net.Rest/API/Common/Interaction.cs @@ -46,5 +46,8 @@ namespace Discord.API [JsonProperty("guild_locale")] public Optional GuildLocale { get; set; } + + [JsonProperty("entitlements")] + public Entitlement[] Entitlements { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/SKU.cs b/src/Discord.Net.Rest/API/Common/SKU.cs new file mode 100644 index 00000000..dd3bb483 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SKU.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class SKU +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public SKUType Type { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs b/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs new file mode 100644 index 00000000..e6443d0e --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class CreateEntitlementParams +{ + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } + + [JsonProperty("owner_id")] + public ulong OwnerId { get; set; } + + [JsonProperty("owner_type")] + public SubscriptionOwnerType Type { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs b/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs new file mode 100644 index 00000000..6e569efc --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs @@ -0,0 +1,18 @@ +namespace Discord.API.Rest; + +internal class ListEntitlementsParams +{ + public Optional UserId { get; set; } + + public Optional SkuIds { get; set; } + + public Optional BeforeId { get; set; } + + public Optional AfterId { get; set; } + + public Optional Limit { get; set; } + + public Optional GuildId { get; set; } + + public Optional ExcludeEnded { get; set; } +} diff --git a/src/Discord.Net.Rest/BaseDiscordClient.cs b/src/Discord.Net.Rest/BaseDiscordClient.cs index 2af84a9c..67a5baa7 100644 --- a/src/Discord.Net.Rest/BaseDiscordClient.cs +++ b/src/Discord.Net.Rest/BaseDiscordClient.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -258,6 +259,30 @@ namespace Discord.Rest /// Task IDiscordClient.StopAsync() => Task.Delay(0); + + /// + /// Creates a test entitlement to a given SKU for a given guild or user. + /// + Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => Task.FromResult(null); + + /// + /// Deletes a currently-active test entitlement. + /// + Task IDiscordClient.DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options) + => Task.CompletedTask; + + /// + /// Returns all entitlements for a given app. + /// + IAsyncEnumerable> IDiscordClient.GetEntitlementsAsync(int? limit, ulong? afterId, ulong? beforeId, + bool excludeEnded, ulong? guildId, ulong? userId, ulong[] skuIds, RequestOptions options) => AsyncEnumerable.Empty>(); + + /// + /// Gets all SKUs for a given application. + /// + Task> IDiscordClient.GetSKUsAsync(RequestOptions options) => Task.FromResult>(Array.Empty()); + #endregion } } diff --git a/src/Discord.Net.Rest/ClientHelper.cs b/src/Discord.Net.Rest/ClientHelper.cs index 1fb7b38e..5c4ee019 100644 --- a/src/Discord.Net.Rest/ClientHelper.cs +++ b/src/Discord.Net.Rest/ClientHelper.cs @@ -379,6 +379,66 @@ namespace Discord.Rest } + #endregion + + #region App Subscriptions + + public static async Task CreateTestEntitlementAsync(BaseDiscordClient client, ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, + RequestOptions options = null) + { + var model = await client.ApiClient.CreateEntitlementAsync(new CreateEntitlementParams + { + Type = ownerType, + OwnerId = ownerId, + SkuId = skuId + }, options); + + return RestEntitlement.Create(client, model); + } + + public static IAsyncEnumerable> ListEntitlementsAsync(BaseDiscordClient client, int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxEntitlementsPerBatch, + async (info, ct) => + { + var args = new ListEntitlementsParams() + { + Limit = info.PageSize, + BeforeId = beforeId ?? Optional.Unspecified, + ExcludeEnded = excludeEnded, + GuildId = guildId ?? Optional.Unspecified, + UserId = userId ?? Optional.Unspecified, + SkuIds = skuIds ?? Optional.Unspecified, + }; + if (info.Position != null) + args.AfterId = info.Position.Value; + var models = await client.ApiClient.ListEntitlementAsync(args, options).ConfigureAwait(false); + return models + .Select(x => RestEntitlement.Create(client, x)) + .ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxEntitlementsPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: afterId, + count: limit + ); + } + + public static async Task> ListSKUsAsync(BaseDiscordClient client, RequestOptions options = null) + { + var models = await client.ApiClient.ListSKUsAsync(options).ConfigureAwait(false); + + return models.Select(x => new SKU(x.Id, x.Type, x.ApplicationId, x.Name, x.Slug)).ToImmutableArray(); + } + #endregion } } diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index e9b0c157..4a6d6274 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -7,6 +7,7 @@ using Discord.Net.Rest; using Newtonsoft.Json; using System; +using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.Design; @@ -2254,7 +2255,7 @@ namespace Discord.API return await SendAsync("GET", () => $"guilds/{guildId}/onboarding", new BucketIds(guildId: guildId), options: options); } - public async Task ModifyGuildOnboardingAsync(ulong guildId, ModifyGuildOnboardingParams args, RequestOptions options) + public async Task ModifyGuildOnboardingAsync(ulong guildId, ModifyGuildOnboardingParams args, RequestOptions options) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); @@ -2689,5 +2690,55 @@ namespace Discord.API => await SendJsonAsync("PUT", () => $"users/@me/applications/{applicationId}/role-connection", connection, new BucketIds(), options: options); #endregion + + #region App Monetization + + public async Task CreateEntitlementAsync(CreateEntitlementParams args, RequestOptions options = null) + => await SendJsonAsync("POST", () => $"applications/{CurrentApplicationId}/entitlements", args, new BucketIds(), options: options).ConfigureAwait(false); + + public async Task DeleteEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => await SendAsync("DELETE", () => $"applications/{CurrentApplicationId}/entitlements/{entitlementId}", new BucketIds(), options: options).ConfigureAwait(false); + + public async Task ListEntitlementAsync(ListEntitlementsParams args, RequestOptions options = null) + { + var query = $"?limit={args.Limit.GetValueOrDefault(100)}"; + + if (args.UserId.IsSpecified) + { + query += $"&user_id={args.UserId.Value}"; + } + + if (args.SkuIds.IsSpecified) + { + query += $"&sku_ids={WebUtility.UrlEncode(string.Join(",", args.SkuIds.Value))}"; + } + + if (args.BeforeId.IsSpecified) + { + query += $"&before={args.BeforeId.Value}"; + } + + if (args.AfterId.IsSpecified) + { + query += $"&after={args.AfterId.Value}"; + } + + if (args.GuildId.IsSpecified) + { + query += $"&guild_id={args.GuildId.Value}"; + } + + if (args.ExcludeEnded.IsSpecified) + { + query += $"&exclude_ended={args.ExcludeEnded.Value}"; + } + + return await SendAsync("GET", () => $"applications/{CurrentApplicationId}/entitlements{query}", new BucketIds(), options: options).ConfigureAwait(false); + } + + public async Task ListSKUsAsync(RequestOptions options = null) + => await SendAsync("GET", () => $"applications/{CurrentApplicationId}/skus", new BucketIds(), options: options).ConfigureAwait(false); + + #endregion } } diff --git a/src/Discord.Net.Rest/DiscordRestClient.cs b/src/Discord.Net.Rest/DiscordRestClient.cs index de9f2313..4d78d678 100644 --- a/src/Discord.Net.Rest/DiscordRestClient.cs +++ b/src/Discord.Net.Rest/DiscordRestClient.cs @@ -278,9 +278,30 @@ namespace Discord.Rest public Task ModifyUserApplicationRoleConnectionAsync(ulong applicationId, RoleConnectionProperties roleConnection, RequestOptions options = null) => ClientHelper.ModifyUserRoleConnectionAsync(applicationId, roleConnection, this, options); + /// + public Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null) + => ClientHelper.CreateTestEntitlementAsync(this, skuId, ownerId, ownerType, options); + + /// + public Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => ApiClient.DeleteEntitlementAsync(entitlementId, options); + + /// + public IAsyncEnumerable> GetEntitlementsAsync(int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + => ClientHelper.ListEntitlementsAsync(this, limit, afterId, beforeId, excludeEnded, guildId, userId, skuIds, options); + + /// + public Task> GetSKUsAsync(RequestOptions options = null) + => ClientHelper.ListSKUsAsync(this, options); + #endregion #region IDiscordClient + async Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => await CreateTestEntitlementAsync(skuId, ownerId, ownerType, options).ConfigureAwait(false); + /// async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) => await GetApplicationInfoAsync(options).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs b/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs new file mode 100644 index 00000000..e1d2b61e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs @@ -0,0 +1,66 @@ +using System; + +using Model = Discord.API.Entitlement; + +namespace Discord.Rest; + +public class RestEntitlement : RestEntity, IEntitlement +{ + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public ulong SkuId { get; private set; } + + /// + public ulong? UserId { get; private set; } + + /// + public ulong? GuildId { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public EntitlementType Type { get; private set; } + + /// + public bool IsConsumed { get; private set; } + + /// + public DateTimeOffset? StartsAt { get; private set; } + + /// + public DateTimeOffset? EndsAt { get; private set; } + + internal RestEntitlement(BaseDiscordClient discord, ulong id) : base(discord, id) + { + } + + internal static RestEntitlement Create(BaseDiscordClient discord, Model model) + { + var entity = new RestEntitlement(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + SkuId = model.SkuId; + UserId = model.UserId.IsSpecified + ? model.UserId.Value + : null; + GuildId = model.GuildId.IsSpecified + ? model.GuildId.Value + : null; + ApplicationId = model.ApplicationId; + Type = model.Type; + IsConsumed = model.IsConsumed; + StartsAt = model.StartsAt.IsSpecified + ? model.StartsAt.Value + : null; + EndsAt = model.EndsAt.IsSpecified + ? model.EndsAt.Value + : null; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs index d412fc41..a889aeac 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -522,6 +522,17 @@ namespace Discord.Rest return client.ApiClient.CreateInteractionResponseAsync(apiArgs, interactionId, interactionToken, options); } + + public static async Task RespondWithPremiumRequiredAsync(BaseDiscordClient client, ulong interactionId, + string interactionToken, RequestOptions options = null) + { + await client.ApiClient.CreateInteractionResponseAsync(new InteractionResponse + { + Type = InteractionResponseType.PremiumRequired, + Data = Optional.Unspecified + }, interactionId, interactionToken, options); + } + #endregion #region Guild permissions diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs index 70445f80..4860f60e 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs @@ -2,7 +2,9 @@ using Discord.Net; using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using DataModel = Discord.API.ApplicationCommandInteractionData; @@ -88,6 +90,9 @@ namespace Discord.Rest /// public ulong ApplicationId { get; private set; } + /// + public IReadOnlyCollection Entitlements { get; private set; } + internal RestInteraction(BaseDiscordClient discord, ulong id) : base(discord, id) { @@ -223,6 +228,8 @@ namespace Discord.Rest GuildLocale = model.GuildLocale.IsSpecified ? model.GuildLocale.Value : null; + + Entitlements = model.Entitlements.Select(x => RestEntitlement.Create(discord, x)).ToImmutableArray(); } internal string SerializePayload(object payload) @@ -413,7 +420,14 @@ namespace Discord.Rest public Task DeleteOriginalResponseAsync(RequestOptions options = null) => InteractionHelper.DeleteInteractionResponseAsync(Discord, this, options); + /// + public Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => InteractionHelper.RespondWithPremiumRequiredAsync(Discord, Id, Token, options); + #region IDiscordInteraction + /// + IReadOnlyCollection IDiscordInteraction.Entitlements => Entitlements; + /// IUser IDiscordInteraction.User => User; diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 7ae8d6af..1109f4f7 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -1,4 +1,5 @@ using Discord.Rest; + using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -907,7 +908,7 @@ namespace Discord.WebSocket internal readonly AsyncEvent> _auditLogCreated = new(); #endregion - + #region AutoModeration /// @@ -918,7 +919,7 @@ namespace Discord.WebSocket add => _autoModRuleCreated.Add(value); remove => _autoModRuleCreated.Remove(value); } - internal readonly AsyncEvent> _autoModRuleCreated = new (); + internal readonly AsyncEvent> _autoModRuleCreated = new(); /// /// Fired when an auto moderation rule is modified. @@ -928,7 +929,7 @@ namespace Discord.WebSocket add => _autoModRuleUpdated.Add(value); remove => _autoModRuleUpdated.Remove(value); } - internal readonly AsyncEvent, SocketAutoModRule, Task>> _autoModRuleUpdated = new (); + internal readonly AsyncEvent, SocketAutoModRule, Task>> _autoModRuleUpdated = new(); /// /// Fired when an auto moderation rule is deleted. @@ -938,7 +939,7 @@ namespace Discord.WebSocket add => _autoModRuleDeleted.Add(value); remove => _autoModRuleDeleted.Remove(value); } - internal readonly AsyncEvent> _autoModRuleDeleted = new (); + internal readonly AsyncEvent> _autoModRuleDeleted = new(); /// /// Fired when an auto moderation rule is triggered by a user. @@ -948,8 +949,48 @@ namespace Discord.WebSocket add => _autoModActionExecuted.Add(value); remove => _autoModActionExecuted.Remove(value); } - internal readonly AsyncEvent> _autoModActionExecuted = new (); - + internal readonly AsyncEvent> _autoModActionExecuted = new(); + + #endregion + + #region App Subscriptions + + /// + /// Fired when a user subscribes to a SKU. + /// + public event Func EntitlementCreated + { + add { _entitlementCreated.Add(value); } + remove { _entitlementCreated.Remove(value); } + } + + internal readonly AsyncEvent> _entitlementCreated = new(); + + + /// + /// Fired when a subscription to a SKU is updated. + /// + public event Func, SocketEntitlement, Task> EntitlementUpdated + { + add { _entitlementUpdated.Add(value); } + remove { _entitlementUpdated.Remove(value); } + } + + internal readonly AsyncEvent, SocketEntitlement, Task>> _entitlementUpdated = new(); + + + /// + /// Fired when a user's entitlement is deleted. + /// + public event Func, Task> EntitlementDeleted + { + add { _entitlementDeleted.Add(value); } + remove { _entitlementDeleted.Remove(value); } + } + + internal readonly AsyncEvent, Task>> _entitlementDeleted = new(); + + #endregion } } diff --git a/src/Discord.Net.WebSocket/ClientState.cs b/src/Discord.Net.WebSocket/ClientState.cs index c40ae3f9..745d5581 100644 --- a/src/Discord.Net.WebSocket/ClientState.cs +++ b/src/Discord.Net.WebSocket/ClientState.cs @@ -17,6 +17,7 @@ namespace Discord.WebSocket private readonly ConcurrentDictionary _users; private readonly ConcurrentHashSet _groupChannels; private readonly ConcurrentDictionary _commands; + private readonly ConcurrentDictionary _entitlements; internal IReadOnlyCollection Channels => _channels.ToReadOnlyCollection(); internal IReadOnlyCollection DMChannels => _dmChannels.ToReadOnlyCollection(); @@ -24,6 +25,7 @@ namespace Discord.WebSocket internal IReadOnlyCollection Guilds => _guilds.ToReadOnlyCollection(); internal IReadOnlyCollection Users => _users.ToReadOnlyCollection(); internal IReadOnlyCollection Commands => _commands.ToReadOnlyCollection(); + internal IReadOnlyCollection Entitlements => _entitlements.ToReadOnlyCollection(); internal IReadOnlyCollection PrivateChannels => _dmChannels.Select(x => x.Value as ISocketPrivateChannel).Concat( @@ -40,6 +42,7 @@ namespace Discord.WebSocket _users = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(estimatedUsersCount * CollectionMultiplier)); _groupChannels = new ConcurrentHashSet(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(10 * CollectionMultiplier)); _commands = new ConcurrentDictionary(); + _entitlements = new(); } internal SocketChannel GetChannel(ulong id) @@ -170,5 +173,29 @@ namespace Discord.WebSocket foreach (var id in ids) _commands.TryRemove(id, out var _); } + + internal void AddEntitlement(ulong id, SocketEntitlement entitlement) + { + _entitlements.TryAdd(id, entitlement); + } + + internal SocketEntitlement GetEntitlement(ulong id) + { + if (_entitlements.TryGetValue(id, out var entitlement)) + return entitlement; + return null; + } + + internal SocketEntitlement GetOrAddEntitlement(ulong id, Func entitlementFactory) + { + return _entitlements.GetOrAdd(id, entitlementFactory); + } + + internal SocketEntitlement RemoveEntitlement(ulong id) + { + if(_entitlements.TryRemove(id, out var entitlement)) + return entitlement; + return null; + } } } diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 2356f226..d11d86a6 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -428,6 +428,35 @@ namespace Discord.WebSocket public override SocketUser GetUser(string username, string discriminator = null) => State.Users.FirstOrDefault(x => (discriminator is null || x.Discriminator == discriminator) && x.Username == username); + /// + public Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null) + => ClientHelper.CreateTestEntitlementAsync(this, skuId, ownerId, ownerType, options); + + /// + public Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => ApiClient.DeleteEntitlementAsync(entitlementId, options); + + /// + public IAsyncEnumerable> GetEntitlementsAsync(BaseDiscordClient client, int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + => ClientHelper.ListEntitlementsAsync(this, limit, afterId, beforeId, excludeEnded, guildId, userId, skuIds, options); + + /// + public Task> GetSKUsAsync(RequestOptions options = null) + => ClientHelper.ListSKUsAsync(this, options); + + /// + /// Gets entitlements from cache. + /// + public IReadOnlyCollection Entitlements => State.Entitlements; + + /// + /// Gets an entitlement from cache. if not found. + /// + public SocketEntitlement GetEntitlement(ulong id) + => State.GetEntitlement(id); + /// /// Gets a global application command. /// @@ -2903,20 +2932,20 @@ namespace Discord.WebSocket #region Audit Logs case "GUILD_AUDIT_LOG_ENTRY_CREATE": - { - var data = (payload as JToken).ToObject(_serializer); - type = "GUILD_AUDIT_LOG_ENTRY_CREATE"; - await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_AUDIT_LOG_ENTRY_CREATE)").ConfigureAwait(false); + { + var data = (payload as JToken).ToObject(_serializer); + type = "GUILD_AUDIT_LOG_ENTRY_CREATE"; + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_AUDIT_LOG_ENTRY_CREATE)").ConfigureAwait(false); - var guild = State.GetGuild(data.GuildId); - var auditLog = SocketAuditLogEntry.Create(this, data); - guild.AddAuditLog(auditLog); + var guild = State.GetGuild(data.GuildId); + var auditLog = SocketAuditLogEntry.Create(this, data); + guild.AddAuditLog(auditLog); - await TimedInvokeAsync(_auditLogCreated, nameof(AuditLogCreated), auditLog, guild); - } - break; + await TimedInvokeAsync(_auditLogCreated, nameof(AuditLogCreated), auditLog, guild); + } + break; #endregion - + #region Auto Moderation case "AUTO_MODERATION_RULE_CREATE": @@ -3051,6 +3080,65 @@ namespace Discord.WebSocket #endregion + #region App Subscriptions + + case "ENTITLEMENT_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_CREATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = SocketEntitlement.Create(this, data); + State.AddEntitlement(data.Id, entitlement); + + await TimedInvokeAsync(_entitlementCreated, nameof(EntitlementCreated), entitlement); + } + break; + + case "ENTITLEMENT_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_UPDATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = State.GetEntitlement(data.Id); + + var cacheableBefore = new Cacheable(entitlement?.Clone(), data.Id, + entitlement is not null, () => null); + + if (entitlement is null) + { + entitlement = SocketEntitlement.Create(this, data); + State.AddEntitlement(data.Id, entitlement); + } + else + { + entitlement.Update(data); + } + + await TimedInvokeAsync(_entitlementUpdated, nameof(EntitlementUpdated), cacheableBefore, entitlement); + } + break; + + case "ENTITLEMENT_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_DELETE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = State.RemoveEntitlement(data.Id); + + if (entitlement is null) + entitlement = SocketEntitlement.Create(this, data); + else + entitlement.Update(data); + + var cacheableEntitlement = new Cacheable(entitlement, data.Id, + entitlement is not null, () => null); + + await TimedInvokeAsync(_entitlementDeleted, nameof(EntitlementDeleted), cacheableEntitlement); + } + break; + + #endregion + #region Ignored (User only) case "CHANNEL_PINS_ACK": await _gatewayLogger.DebugAsync("Ignored Dispatch (CHANNEL_PINS_ACK)").ConfigureAwait(false); @@ -3380,10 +3468,9 @@ namespace Discord.WebSocket internal int GetAudioId() => _nextAudioId++; #region IDiscordClient - /// - async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) - => await GetApplicationInfoAsync().ConfigureAwait(false); + async Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => await CreateTestEntitlementAsync(skuId, ownerId, ownerType, options).ConfigureAwait(false); /// async Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) => mode == CacheMode.AllowDownload ? await GetChannelAsync(id, options).ConfigureAwait(false) : GetChannel(id); diff --git a/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs b/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs new file mode 100644 index 00000000..0bfafe87 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs @@ -0,0 +1,91 @@ +using Discord.Rest; +using System; + +using Model = Discord.API.Entitlement; + +namespace Discord.WebSocket; + +public class SocketEntitlement : SocketEntity, IEntitlement +{ + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public ulong SkuId { get; private set; } + + /// + public Cacheable? User { get; private set; } + + /// + public Cacheable? Guild { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public EntitlementType Type { get; private set; } + + /// + public bool IsConsumed { get; private set; } + + /// + public DateTimeOffset? StartsAt { get; private set; } + + /// + public DateTimeOffset? EndsAt { get; private set; } + + internal SocketEntitlement(DiscordSocketClient discord, ulong id) : base(discord, id) + { + } + + internal static SocketEntitlement Create(DiscordSocketClient discord, Model model) + { + var entity = new SocketEntitlement(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + SkuId = model.SkuId; + + if (model.UserId.IsSpecified) + { + var user = Discord.GetUser(model.UserId.Value); + + User = new Cacheable(user, model.UserId.Value, user is not null, async () + => await Discord.Rest.GetUserAsync(model.UserId.Value)); + } + + if (model.GuildId.IsSpecified) + { + var guild = Discord.GetGuild(model.GuildId.Value); + + Guild = new Cacheable(guild, model.GuildId.Value, guild is not null, async () + => await Discord.Rest.GetGuildAsync(model.GuildId.Value)); + } + + ApplicationId = model.ApplicationId; + Type = model.Type; + IsConsumed = model.IsConsumed; + StartsAt = model.StartsAt.IsSpecified + ? model.StartsAt.Value + : null; + EndsAt = model.EndsAt.IsSpecified + ? model.EndsAt.Value + : null; + } + + internal SocketEntitlement Clone() => MemberwiseClone() as SocketEntitlement; + + #region IEntitlement + + /// + ulong? IEntitlement.GuildId => Guild?.Id; + + /// + ulong? IEntitlement.UserId => User?.Id; + + #endregion + +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs index c7c1e268..d4ee3661 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs @@ -2,7 +2,9 @@ using Discord.Net; using Discord.Rest; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Threading.Tasks; using DataModel = Discord.API.ApplicationCommandInteractionData; using Model = Discord.API.Interaction; @@ -71,6 +73,9 @@ namespace Discord.WebSocket /// public ulong ApplicationId { get; private set; } + /// + public IReadOnlyCollection Entitlements { get; private set; } + internal SocketInteraction(DiscordSocketClient client, ulong id, ISocketMessageChannel channel, SocketUser user) : base(client, id) { @@ -142,6 +147,8 @@ namespace Discord.WebSocket GuildLocale = model.GuildLocale.IsSpecified ? model.GuildLocale.Value : null; + + Entitlements = model.Entitlements.Select(x => RestEntitlement.Create(Discord, x)).ToImmutableArray(); } /// @@ -398,6 +405,11 @@ namespace Discord.WebSocket /// The request options for this request. /// A task that represents the asynchronous operation of responding to the interaction. public abstract Task RespondWithModalAsync(Modal modal, RequestOptions options = null); + + /// + public Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => InteractionHelper.RespondWithPremiumRequiredAsync(Discord, Id, Token, options); + #endregion /// @@ -423,6 +435,9 @@ namespace Discord.WebSocket } #region IDiscordInteraction + /// + IReadOnlyCollection IDiscordInteraction.Entitlements => Entitlements; + /// IUser IDiscordInteraction.User => User;