From 0a474c43ebccdf59e7b9923afa83e89eaeb8c196 Mon Sep 17 00:00:00 2001 From: alyssa Date: Sun, 21 Dec 2025 01:19:02 -0500 Subject: [PATCH 1/4] feat: add basic premium scaffolding --- PluralKit.Bot/BotConfig.cs | 4 +++ PluralKit.Bot/CommandMeta/CommandTree.cs | 1 + .../CommandSystem/Context/Context.cs | 20 ++++++++++++++ PluralKit.Bot/Commands/Misc.cs | 26 +++++++++++++++++++ PluralKit.Bot/Services/EmbedService.cs | 6 +++-- PluralKit.Core/Models/SystemConfig.cs | 3 +++ crates/migrate/data/migrations/54.sql | 7 +++++ crates/models/src/system_config.rs | 5 ++++ 8 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 crates/migrate/data/migrations/54.sql diff --git a/PluralKit.Bot/BotConfig.cs b/PluralKit.Bot/BotConfig.cs index 1e6e0f0b..cb3d2dfd 100644 --- a/PluralKit.Bot/BotConfig.cs +++ b/PluralKit.Bot/BotConfig.cs @@ -36,6 +36,10 @@ public class BotConfig public bool IsBetaBot { get; set; } = false!; public string BetaBotAPIUrl { get; set; } + public String? PremiumSubscriberEmoji { get; set; } + public String? PremiumLifetimeEmoji { get; set; } + public String? PremiumDashboardUrl { get; set; } + public record ClusterSettings { // this is zero-indexed diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index d1ab6da0..b64e603a 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -92,6 +92,7 @@ public partial class CommandTree if (ctx.Match("sus")) return ctx.Execute(null, m => m.Sus(ctx)); if (ctx.Match("error")) return ctx.Execute(null, m => m.Error(ctx)); if (ctx.Match("stats", "status")) return ctx.Execute(null, m => m.Stats(ctx)); + if (ctx.Match("premium")) return ctx.Execute(null, m => m.Premium(ctx)); if (ctx.Match("permcheck")) return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); if (ctx.Match("proxycheck")) diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs index f155c8dc..5e52a141 100644 --- a/PluralKit.Bot/CommandSystem/Context/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -28,6 +28,8 @@ public class Context private Command? _currentCommand; + private BotConfig _botConfig; + public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, PKSystem senderSystem, SystemConfig config, GuildConfig? guildConfig, string[] prefixes) @@ -46,6 +48,7 @@ public class Context _metrics = provider.Resolve(); _provider = provider; _commandMessageService = provider.Resolve(); + _botConfig = provider.Resolve(); CommandPrefix = message.Content?.Substring(0, commandParseOffset); DefaultPrefix = prefixes[0]; Parameters = new Parameters(message.Content?.Substring(commandParseOffset)); @@ -74,6 +77,23 @@ public class Context public readonly SystemConfig Config; public DateTimeZone Zone => Config?.Zone ?? DateTimeZone.Utc; + public bool Premium + { + get + { + if (Config?.PremiumLifetime ?? false) return true; + // generate _this_ current instant _before_ the check, otherwise it will always be true... + var premiumUntil = Config?.PremiumUntil ?? SystemClock.Instance.GetCurrentInstant(); + return SystemClock.Instance.GetCurrentInstant() < premiumUntil; + } + } + + public string PremiumEmoji => (Config?.PremiumLifetime ?? false) + ? ($"<:lifetime_premium:{_botConfig.PremiumLifetimeEmoji}>" ?? "\u2729") + : Premium + ? ($"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}>" ?? "\u2729") + : ""; + public readonly string CommandPrefix; public readonly string DefaultPrefix; public readonly Parameters Parameters; diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 514c8999..ae2b9200 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -31,6 +31,32 @@ public class Misc _shards = shards; } + public async Task Premium(Context ctx) + { + ctx.CheckSystem(); + + String message; + + if (ctx.Config?.PremiumLifetime ?? false) + { + message = $"Your system has lifetime PluralKit Premium. {ctx.PremiumEmoji} Thanks for the support!"; + } + else if (ctx.Premium) + { + message = $"Your system has PluralKit Premium active until . {ctx.PremiumEmoji} Thanks for the support!"; + } + else + { + message = "PluralKit Premium is not currently active for your system."; + if (ctx.Config?.PremiumUntil != null) + { + message += $" The subscription expired at ()"; + } + } + + await ctx.Reply(message + $"\n\nManage your subscription at <{_botConfig.PremiumDashboardUrl}>"); + } + public async Task Invite(Context ctx) { var permissions = diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index f33d56f8..2dd08abf 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -21,14 +21,16 @@ public class EmbedService private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly DiscordApiClient _rest; + private readonly BotConfig _config; private readonly CoreConfig _coreConfig; - public EmbedService(IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, CoreConfig coreConfig) + public EmbedService(IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, BotConfig config, CoreConfig coreConfig) { _db = db; _repo = repo; _cache = cache; _rest = rest; + _config = config; _coreConfig = coreConfig; } @@ -192,7 +194,7 @@ public class EmbedService new MessageComponent() { Type = ComponentType.Text, - Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {system.Created.FormatZoned(cctx.Zone)}", + Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`{cctx.PremiumEmoji}\n-# Created: {system.Created.FormatZoned(cctx.Zone)}", }, ], Accessory = new MessageComponent() diff --git a/PluralKit.Core/Models/SystemConfig.cs b/PluralKit.Core/Models/SystemConfig.cs index dac7965f..39706042 100644 --- a/PluralKit.Core/Models/SystemConfig.cs +++ b/PluralKit.Core/Models/SystemConfig.cs @@ -28,6 +28,9 @@ public class SystemConfig public ProxySwitchAction ProxySwitch { get; } public string NameFormat { get; } + public bool PremiumLifetime { get; } + public Instant? PremiumUntil { get; } + public enum HidPadFormat { None = 0, diff --git a/crates/migrate/data/migrations/54.sql b/crates/migrate/data/migrations/54.sql new file mode 100644 index 00000000..6b2a70cc --- /dev/null +++ b/crates/migrate/data/migrations/54.sql @@ -0,0 +1,7 @@ +-- database version 54 +-- initial support for premium + +alter table system_config add column premium_until timestamp; +alter table system_config add column premium_lifetime bool default false; + +update info set schema_version = 54; \ No newline at end of file diff --git a/crates/models/src/system_config.rs b/crates/models/src/system_config.rs index 772d231d..08302d2e 100644 --- a/crates/models/src/system_config.rs +++ b/crates/models/src/system_config.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDateTime; use pk_macros::pk_model; use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type}; @@ -87,4 +88,8 @@ struct SystemConfig { name_format: Option, #[json = "description_templates"] description_templates: Vec, + #[json = "premium_until"] + premium_until: Option, + #[json = "premium_lifetime"] + premium_lifetime: bool } From 59b9b6f6ec2c2ec19eddf8d43a5708ff660c3f47 Mon Sep 17 00:00:00 2001 From: Iris System Date: Sat, 27 Dec 2025 15:04:31 +1300 Subject: [PATCH 2/4] feat: add premium management admin commands --- PluralKit.Bot/CommandMeta/CommandTree.cs | 2 + PluralKit.Bot/Commands/Admin.cs | 81 +++++++++++++++++++ .../Models/Patch/SystemConfigPatch.cs | 10 +++ 3 files changed, 93 insertions(+) diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index b64e603a..84bc1041 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -182,6 +182,8 @@ public partial class CommandTree await ctx.Execute(Admin, a => a.SystemRecover(ctx)); else if (ctx.Match("sd", "systemdelete")) await ctx.Execute(Admin, a => a.SystemDelete(ctx)); + else if (ctx.Match("pe", "premiumexpiry", "premium")) + await ctx.Execute(Admin, a => a.PremiumExpiry(ctx)); else if (ctx.Match("sendmsg", "sendmessage")) await ctx.Execute(Admin, a => a.SendAdminMessage(ctx)); else if (ctx.Match("al", "abuselog")) diff --git a/PluralKit.Bot/Commands/Admin.cs b/PluralKit.Bot/Commands/Admin.cs index 44171345..51317502 100644 --- a/PluralKit.Bot/Commands/Admin.cs +++ b/PluralKit.Bot/Commands/Admin.cs @@ -2,6 +2,8 @@ using System.Text.RegularExpressions; using Humanizer; using Dapper; +using NodaTime; +using NodaTime.Text; using SqlKata; using Myriad.Builders; @@ -79,6 +81,16 @@ public class Admin var groupCount = await ctx.Repository.GetSystemGroupCount(system.Id); eb.Field(new Embed.Field("Group limit", $"{groupLimit} {UntilLimit(groupCount, groupLimit)}", true)); + var premiumEntitlement = "none"; + if (config.PremiumLifetime) + premiumEntitlement = $"<:lifetime_premium:{_botConfig.PremiumLifetimeEmoji}> **lifetime**"; + else if (config.PremiumUntil != null) + if (SystemClock.Instance.GetCurrentInstant() < config.PremiumUntil!) + premiumEntitlement = $"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}> "; + else + premiumEntitlement = $"Expired! "; + eb.Field(new Embed.Field("Premium entitlement", premiumEntitlement, false)); + return eb.Build(); } @@ -396,6 +408,75 @@ public class Admin await ctx.Reply($"{Emojis.Success} System deletion succesful."); } + public async Task PremiumExpiry(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchSystem(); + if (target == null) + throw new PKError("Unknown system."); + + await ctx.Reply(null, await CreateEmbed(ctx, target)); + if (!ctx.HasNext()) + return; + + if (ctx.Match("lifetime", "staff")) + { + if (!await ctx.PromptYesNo($"Grant system `{target.Hid}` lifetime premium?", "Grant")) + throw new PKError("Premium entitlement change cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch + { + PremiumLifetime = true, + PremiumUntil = null, + }); + await ctx.Reply($"{Emojis.Success} Premium entitlement changed."); + } + else if (ctx.Match("none", "clear")) + { + if (!await ctx.PromptYesNo($"Clear premium entitlements for system `{target.Hid}`?", "Clear")) + throw new PKError("Premium entitlement change cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch + { + PremiumLifetime = false, + PremiumUntil = null, + }); + await ctx.Reply($"{Emojis.Success} Premium entitlement changed."); + } + else + { + var timeToMove = ctx.RemainderOrNull() ?? + throw new PKSyntaxError("Must pass a date/time to set premium expiry to."); + + Instant? time = null; + + // DateUtils.ParseDateTime expects periods to be in the past, so we have to do + // this explicitly here... + var duration = DateUtils.ParsePeriod(timeToMove); + if (duration != null) + { + time = SystemClock.Instance.GetCurrentInstant() + duration; + } + else + { + var result = DateUtils.ParseDateTime(timeToMove, false); + if (result == null) throw Errors.InvalidDateTime(timeToMove); + time = result.Value.ToInstant(); + } + + if (!await ctx.PromptYesNo($"Change premium expiry for system `{target.Hid}` to ?", "Change")) + throw new PKError("Premium entitlement change cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch + { + PremiumLifetime = false, + PremiumUntil = time, + }); + await ctx.Reply($"{Emojis.Success} Premium entitlement changed."); + } + } + public async Task AbuseLogCreate(Context ctx) { var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage"); diff --git a/PluralKit.Core/Models/Patch/SystemConfigPatch.cs b/PluralKit.Core/Models/Patch/SystemConfigPatch.cs index c29cfae8..cc7cae99 100644 --- a/PluralKit.Core/Models/Patch/SystemConfigPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemConfigPatch.cs @@ -26,6 +26,8 @@ public class SystemConfigPatch: PatchObject public Partial NameFormat { get; set; } public Partial HidListPadding { get; set; } public Partial ProxySwitch { get; set; } + public Partial PremiumLifetime { get; set; } + public Partial PremiumUntil { get; set; } public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper .With("ui_tz", UiTz) @@ -45,6 +47,8 @@ public class SystemConfigPatch: PatchObject .With("card_show_color_hex", CardShowColorHex) .With("proxy_switch", ProxySwitch) .With("name_format", NameFormat) + .With("premium_lifetime", PremiumLifetime) + .With("premium_until", PremiumUntil) ); public new void AssertIsValid() @@ -118,6 +122,12 @@ public class SystemConfigPatch: PatchObject if (NameFormat.IsPresent) o.Add("name_format", NameFormat.Value); + if (PremiumLifetime.IsPresent) + o.Add("premium_lifetime", PremiumLifetime.Value); + + if (PremiumUntil.IsPresent) + o.Add("premium_until", PremiumUntil.Value?.FormatExport()); + return o; } From 41f8beb2aa3a6ee71124f4348a2f55001877c467 Mon Sep 17 00:00:00 2001 From: Iris System Date: Sat, 27 Dec 2025 16:47:51 +1300 Subject: [PATCH 3/4] feat: show premium badge on system/member/group cards --- .../CommandSystem/Context/Context.cs | 4 ++-- PluralKit.Bot/Services/EmbedService.cs | 23 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs index 5e52a141..412f8cd4 100644 --- a/PluralKit.Bot/CommandSystem/Context/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -89,9 +89,9 @@ public class Context } public string PremiumEmoji => (Config?.PremiumLifetime ?? false) - ? ($"<:lifetime_premium:{_botConfig.PremiumLifetimeEmoji}>" ?? "\u2729") + ? (_botConfig.PremiumLifetimeEmoji != null ? $"<:lifetime_premium:{_botConfig.PremiumLifetimeEmoji}>" : "\u2729") : Premium - ? ($"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}>" ?? "\u2729") + ? (_botConfig.PremiumSubscriberEmoji != null ? $"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}>" : "\u2729") : ""; public readonly string CommandPrefix; diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 2dd08abf..21dd04b8 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -45,6 +45,17 @@ public class EmbedService return Task.WhenAll(ids.Select(Inner)); } + private async Task<(bool Premium, string? Emoji)> SystemHasPremium(PKSystem system) + { + var config = await _repo.GetSystemConfig(system.Id); + if (config.PremiumLifetime) + return (true, (_config.PremiumLifetimeEmoji != null ? $"<:lifetime_premium:{_config.PremiumLifetimeEmoji}>" : "\u2729")); + else if (config.PremiumUntil != null && SystemClock.Instance.GetCurrentInstant() < config.PremiumUntil!) + return (true, (_config.PremiumSubscriberEmoji != null ? $"<:premium_subscriber:{_config.PremiumSubscriberEmoji}>" : "\u2729")); + + return (false, null); + } + public async Task CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx) { // Fetch/render info for all accounts simultaneously @@ -143,7 +154,9 @@ public class EmbedService }); var systemName = (cctx.Guild != null && guildSettings?.DisplayName != null) ? guildSettings?.DisplayName! : system.NameFor(ctx); - var premiumText = ""; // TODO(iris): "\n\U0001F31F *PluralKit Premium supporter!*"; + + var systemPremium = await SystemHasPremium(system); + var premiumText = systemPremium.Premium ? $"\n{systemPremium.Emoji} *PluralKit Premium supporter*" : ""; List header = [ new MessageComponent() { @@ -194,7 +207,7 @@ public class EmbedService new MessageComponent() { Type = ComponentType.Text, - Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`{cctx.PremiumEmoji}\n-# Created: {system.Created.FormatZoned(cctx.Zone)}", + Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {system.Created.FormatZoned(cctx.Zone)}", }, ], Accessory = new MessageComponent() @@ -457,6 +470,7 @@ public class EmbedService }, ]; + var systemPremium = await SystemHasPremium(system); return [ new MessageComponent() { @@ -471,7 +485,7 @@ public class EmbedService new MessageComponent() { Type = ComponentType.Text, - Content = $"-# System ID: `{system.DisplayHid(ccfg)}` \u2219 Member ID: `{member.DisplayHid(ccfg)}`{(member.MetadataPrivacy.CanAccess(ctx) ? $"\n-# Created: {member.Created.FormatZoned(zone)}" : "")}", + Content = $"-# System ID: `{system.DisplayHid(ccfg)}`{(systemPremium.Premium ? $" {systemPremium.Emoji}" : "")} \u2219 Member ID: `{member.DisplayHid(ccfg)}`{(member.MetadataPrivacy.CanAccess(ctx) ? $"\n-# Created: {member.Created.FormatZoned(zone)}" : "")}", }, ], Accessory = new MessageComponent() @@ -647,6 +661,7 @@ public class EmbedService }, ]; + var systemPremium = await SystemHasPremium(system); return [ new MessageComponent() { @@ -661,7 +676,7 @@ public class EmbedService new MessageComponent() { Type = ComponentType.Text, - Content = $"-# System ID: `{system.DisplayHid(ctx.Config)}` \u2219 Group ID: `{target.DisplayHid(ctx.Config)}`{(target.MetadataPrivacy.CanAccess(pctx) ? $"\n-# Created: {target.Created.FormatZoned(ctx.Zone)}" : "")}", + Content = $"-# System ID: `{system.DisplayHid(ctx.Config)}`{(systemPremium.Premium ? $" {systemPremium.Emoji}" : "")} \u2219 Group ID: `{target.DisplayHid(ctx.Config)}`{(target.MetadataPrivacy.CanAccess(pctx) ? $"\n-# Created: {target.Created.FormatZoned(ctx.Zone)}" : "")}", }, ], Accessory = new MessageComponent() From 24b6b0d455212b4dcaa84ade5e7e2cc4792e0db2 Mon Sep 17 00:00:00 2001 From: Iris System Date: Sun, 28 Dec 2025 00:06:51 +1300 Subject: [PATCH 4/4] feat: premium ID changes --- PluralKit.Bot/CommandMeta/CommandHelp.cs | 3 ++ PluralKit.Bot/CommandMeta/CommandTree.cs | 8 +++ PluralKit.Bot/Commands/Admin.cs | 38 +++++++++++++- PluralKit.Bot/Commands/Groups.cs | 34 ++++++++++++ PluralKit.Bot/Commands/MemberEdit.cs | 32 ++++++++++++ PluralKit.Bot/Commands/Misc.cs | 52 ++++++++++++++++++- PluralKit.Bot/Commands/SystemEdit.cs | 38 +++++++++++++- PluralKit.Bot/Errors.cs | 2 + PluralKit.Bot/Utils/ModelUtils.cs | 1 + .../ModelRepository.HidChangelog.cs | 34 ++++++++++++ .../ModelRepository.SystemConfig.cs | 24 +++++++++ PluralKit.Core/Models/HidChangelog.cs | 15 ++++++ .../Models/Patch/SystemConfigPatch.cs | 5 ++ PluralKit.Core/Models/SystemConfig.cs | 1 + PluralKit.Core/Utils/Limits.cs | 2 + crates/migrate/data/migrations/55.sql | 20 +++++++ 16 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 PluralKit.Core/Database/Repository/ModelRepository.HidChangelog.cs create mode 100644 PluralKit.Core/Models/HidChangelog.cs create mode 100644 crates/migrate/data/migrations/55.sql diff --git a/PluralKit.Bot/CommandMeta/CommandHelp.cs b/PluralKit.Bot/CommandMeta/CommandHelp.cs index 65fa79b6..be4c89a6 100644 --- a/PluralKit.Bot/CommandMeta/CommandHelp.cs +++ b/PluralKit.Bot/CommandMeta/CommandHelp.cs @@ -22,6 +22,7 @@ public partial class CommandTree public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history"); public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown"); public static Command SystemId = new Command("system id", "system [system] id", "Prints your system's id."); + public static Command SystemIdChange = new Command("system changeid", "system [system] changeid ", "PREMIUM: Changes your system's ID."); public static Command SystemPrivacy = new Command("system privacy", "system [system] privacy ", "Changes your system's privacy settings"); public static Command ConfigTimezone = new Command("config timezone", "config timezone [timezone]", "Changes your system's time zone"); public static Command ConfigPing = new Command("config ping", "config ping [on|off]", "Changes your system's ping preferences"); @@ -61,6 +62,7 @@ public partial class CommandTree public static Command MemberServerKeepProxy = new Command("member server keepproxy", "member serverkeepproxy [on|off|clear]", "Sets whether to include a member's proxy tags when proxying in the current server."); public static Command MemberRandom = new Command("system random", "system [system] random", "Shows the info card of a randomly selected member in a system."); public static Command MemberId = new Command("member id", "member [member] id", "Prints a member's id."); + public static Command MemberIdChange = new Command("member changeid", "member [member] changeid ", "PREMIUM: Changes a member's ID."); public static Command MemberPrivacy = new Command("member privacy", "member privacy ", "Changes a members's privacy settings"); public static Command GroupInfo = new Command("group", "group ", "Looks up information about a group"); public static Command GroupNew = new Command("group new", "group new ", "Creates a new group"); @@ -73,6 +75,7 @@ public partial class CommandTree public static Command GroupAdd = new Command("group add", "group add [member 2] [member 3...]", "Adds one or more members to a group"); public static Command GroupRemove = new Command("group remove", "group remove [member 2] [member 3...]", "Removes one or more members from a group"); public static Command GroupId = new Command("group id", "group [group] id", "Prints a group's id."); + public static Command GroupIdChange = new Command("group changeid", "group [group] changeid ", "PREMIUM: Changes a group's ID."); public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); public static Command GroupBannerImage = new Command("group banner", "group banner [url]", "Set the group's banner image"); public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 84bc1041..4c10df55 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -184,6 +184,8 @@ public partial class CommandTree await ctx.Execute(Admin, a => a.SystemDelete(ctx)); else if (ctx.Match("pe", "premiumexpiry", "premium")) await ctx.Execute(Admin, a => a.PremiumExpiry(ctx)); + else if (ctx.Match("pid", "premiumidchange", "premiumid")) + await ctx.Execute(Admin, a => a.PremiumIdChangeAllowance(ctx)); else if (ctx.Match("sendmsg", "sendmessage")) await ctx.Execute(Admin, a => a.SendAdminMessage(ctx)); else if (ctx.Match("al", "abuselog")) @@ -320,6 +322,8 @@ public partial class CommandTree await ctx.CheckSystem(target).Execute(SystemDelete, m => m.Delete(ctx, target)); else if (ctx.Match("id")) await ctx.CheckSystem(target).Execute(SystemId, m => m.DisplayId(ctx, target)); + else if (ctx.Match("changeid", "updateid")) + await ctx.CheckSystem(target).Execute(SystemIdChange, m => m.ChangeId(ctx, target)); else if (ctx.Match("random", "rand", "r")) if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) await ctx.CheckSystem(target).Execute(GroupRandom, r => r.Group(ctx, target)); @@ -395,6 +399,8 @@ public partial class CommandTree await ctx.Execute(MemberServerKeepProxy, m => m.ServerKeepProxy(ctx, target)); else if (ctx.Match("id")) await ctx.Execute(MemberId, m => m.DisplayId(ctx, target)); + else if (ctx.Match("changeid", "updateid")) + await ctx.Execute(MemberIdChange, m => m.ChangeId(ctx, target)); else if (ctx.Match("privacy")) await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, null)); else if (ctx.Match("private", "hidden", "hide")) @@ -457,6 +463,8 @@ public partial class CommandTree await ctx.Execute(GroupColor, g => g.GroupColor(ctx, target)); else if (ctx.Match("id")) await ctx.Execute(GroupId, g => g.DisplayId(ctx, target)); + else if (ctx.Match("changeid", "updateid")) + await ctx.Execute(GroupIdChange, g => g.ChangeId(ctx, target)); else if (!ctx.HasNext()) await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); else diff --git a/PluralKit.Bot/Commands/Admin.cs b/PluralKit.Bot/Commands/Admin.cs index 51317502..c5981c65 100644 --- a/PluralKit.Bot/Commands/Admin.cs +++ b/PluralKit.Bot/Commands/Admin.cs @@ -416,12 +416,15 @@ public class Admin if (target == null) throw new PKError("Unknown system."); - await ctx.Reply(null, await CreateEmbed(ctx, target)); if (!ctx.HasNext()) + { + await ctx.Reply(null, await CreateEmbed(ctx, target)); return; + } if (ctx.Match("lifetime", "staff")) { + await ctx.Reply(null, await CreateEmbed(ctx, target)); if (!await ctx.PromptYesNo($"Grant system `{target.Hid}` lifetime premium?", "Grant")) throw new PKError("Premium entitlement change cancelled."); @@ -434,6 +437,7 @@ public class Admin } else if (ctx.Match("none", "clear")) { + await ctx.Reply(null, await CreateEmbed(ctx, target)); if (!await ctx.PromptYesNo($"Clear premium entitlements for system `{target.Hid}`?", "Clear")) throw new PKError("Premium entitlement change cancelled."); @@ -465,6 +469,7 @@ public class Admin time = result.Value.ToInstant(); } + await ctx.Reply(null, await CreateEmbed(ctx, target)); if (!await ctx.PromptYesNo($"Change premium expiry for system `{target.Hid}` to ?", "Change")) throw new PKError("Premium entitlement change cancelled."); @@ -477,6 +482,37 @@ public class Admin } } + public async Task PremiumIdChangeAllowance(Context ctx) + { + ctx.AssertBotAdmin(); + + var target = await ctx.MatchSystem(); + if (target == null) + throw new PKError("Unknown system."); + + if (!ctx.HasNext()) + { + await ctx.Reply(null, await CreateEmbed(ctx, target)); + return; + } + + var config = await ctx.Repository.GetSystemConfig(target.Id); + var newAllowanceStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000"); + if (!int.TryParse(newAllowanceStr, out var newAllowance)) + throw new PKError($"Couldn't parse `{newAllowanceStr}` as number."); + + await ctx.Reply(null, await CreateEmbed(ctx, target)); + if (!await ctx.PromptYesNo($"Update premium ID change allowance from **{config.PremiumIdChangesRemaining}** to **{newAllowance}**?", "Update")) + throw new PKError("ID change allowance cancelled."); + + await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch + { + PremiumIdChangesRemaining = newAllowance, + }); + + await ctx.Reply($"{Emojis.Success} Premium entitlement changed."); + } + public async Task AbuseLogCreate(Context ctx) { var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage"); diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index b18764d2..e5fc6335 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -4,6 +4,8 @@ using System.Text.RegularExpressions; using Myriad.Builders; using Myriad.Types; +using NodaTime; + using Newtonsoft.Json.Linq; using PluralKit.Core; @@ -99,6 +101,38 @@ public class Groups await ctx.Reply(replyStr, eb.Build()); } + public async Task ChangeId(Context ctx, PKGroup target) + { + ctx.CheckSystem().CheckOwnGroup(target); + if (!ctx.Premium) + throw Errors.PremiumExclusiveCommand(); + + var input = ctx.PopArgument(); + if (!input.TryParseHid(out var newHid)) + throw new PKError($"Invalid new member ID `{input}`."); + + var existingGroup = await ctx.Repository.GetGroupByHid(newHid); + if (existingGroup != null) + throw new PKError($"Another group already exists with ID `{newHid.DisplayHid(ctx.Config)}`."); + + if (ctx.Config.PremiumIdChangesRemaining < 1) + throw new PKError("You do not have enough available ID changes to do this."); + if ((await ctx.Repository.GetHidChangelogCountForDate(ctx.System.Id, SystemClock.Instance.GetCurrentInstant().InUtc().Date)) >= Limits.PremiumDailyHidChanges) + throw new PKError($"You have already changed {Limits.PremiumDailyHidChanges} IDs today. Please try again tomorrow."); + + if (!await ctx.PromptYesNo($"Change ID for group **{target.NameFor(ctx)}** (`{target.DisplayHid(ctx.Config)}`) to `{newHid.DisplayHid(ctx.Config)}`?", "Change")) + throw new PKError("ID change cancelled."); + + if (!await ctx.Repository.TryUpdateSystemConfigForIdChange(ctx.System.Id)) + throw new PKError("You do not have enough available ID changes to do this."); + + await ctx.Repository.CreateHidChangelog(ctx.System.Id, ctx.Message.Author.Id, "group", target.Hid, newHid); + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Hid = newHid }); + + var newConfig = await ctx.Repository.GetSystemConfig(ctx.System.Id); + await ctx.Reply($"{Emojis.Success} Group ID changed to `{newHid.DisplayHid(ctx.Config)}`. You have **{newConfig.PremiumIdChangesRemaining}** ID changes remaining."); + } + public async Task RenameGroup(Context ctx, PKGroup target) { ctx.CheckOwnGroup(target); diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 525d60ab..76c65cf1 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -21,6 +21,38 @@ public class MemberEdit _avatarHosting = avatarHosting; } + public async Task ChangeId(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); + if (!ctx.Premium) + throw Errors.PremiumExclusiveCommand(); + + var input = ctx.PopArgument(); + if (!input.TryParseHid(out var newHid)) + throw new PKError($"Invalid new member ID `{input}`."); + + var existingMember = await ctx.Repository.GetMemberByHid(newHid); + if (existingMember != null) + throw new PKError($"Another member already exists with ID `{newHid.DisplayHid(ctx.Config)}`."); + + if (ctx.Config.PremiumIdChangesRemaining < 1) + throw new PKError("You do not have enough available ID changes to do this."); + if ((await ctx.Repository.GetHidChangelogCountForDate(ctx.System.Id, SystemClock.Instance.GetCurrentInstant().InUtc().Date)) >= Limits.PremiumDailyHidChanges) + throw new PKError($"You have already changed {Limits.PremiumDailyHidChanges} IDs today. Please try again tomorrow."); + + if (!await ctx.PromptYesNo($"Change ID for member **{target.NameFor(ctx)}** (`{target.DisplayHid(ctx.Config)}`) to `{newHid.DisplayHid(ctx.Config)}`?", "Change")) + throw new PKError("ID change cancelled."); + + if (!await ctx.Repository.TryUpdateSystemConfigForIdChange(ctx.System.Id)) + throw new PKError("You do not have enough available ID changes to do this."); + + await ctx.Repository.CreateHidChangelog(ctx.System.Id, ctx.Message.Author.Id, "member", target.Hid, newHid); + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { Hid = newHid }); + + var newConfig = await ctx.Repository.GetSystemConfig(ctx.System.Id); + await ctx.Reply($"{Emojis.Success} Member ID changed to `{newHid.DisplayHid(ctx.Config)}`. You have **{newConfig.PremiumIdChangesRemaining}** ID changes remaining."); + } + public async Task Name(Context ctx, PKMember target) { var format = ctx.MatchFormat(); diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index ae2b9200..1f36776e 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -54,7 +54,57 @@ public class Misc } } - await ctx.Reply(message + $"\n\nManage your subscription at <{_botConfig.PremiumDashboardUrl}>"); + List components = [ + new MessageComponent() + { + Type = ComponentType.Text, + Content = message, + }, + ]; + + if (ctx.Premium) + { + var hidChangesLeftToday = Limits.PremiumDailyHidChanges - await ctx.Repository.GetHidChangelogCountForDate(ctx.System.Id, SystemClock.Instance.GetCurrentInstant().InUtc().Date); + var limitMessage = $"You have **{ctx.Config.PremiumIdChangesRemaining}** ID changes available, of which you can use **{hidChangesLeftToday}** today."; + + components.Add(new() + { + Type = ComponentType.Separator, + }); + components.Add(new() + { + Type = ComponentType.Text, + Content = limitMessage, + }); + } + + await ctx.Reply(components: [ + new() + { + Type = ComponentType.Container, + Components = [ + new() + { + Type = ComponentType.Text, + Content = $"## {(_botConfig.PremiumSubscriberEmoji != null ? $"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}>" : "\u2729")} PluralKit Premium", + }, + ..components, + ], + }, + new() + { + Type = ComponentType.ActionRow, + Components = [ + new() + { + Type = ComponentType.Button, + Style = ButtonStyle.Link, + Label = "Manage your subscription", + Url = _botConfig.PremiumDashboardUrl, + }, + ], + }, + ]); } public async Task Invite(Context ctx) diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 3af4a639..b1035b5d 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -9,6 +9,8 @@ using Myriad.Types; using Newtonsoft.Json; +using NodaTime; + using PluralKit.Core; using SqlKata.Compilers; @@ -20,13 +22,47 @@ public class SystemEdit private readonly DataFileService _dataFiles; private readonly PrivateChannelService _dmCache; private readonly AvatarHostingService _avatarHosting; + private readonly BotConfig _botConfig; - public SystemEdit(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache, AvatarHostingService avatarHosting) + public SystemEdit(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache, AvatarHostingService avatarHosting, BotConfig botConfig) { _dataFiles = dataFiles; _client = client; _dmCache = dmCache; _avatarHosting = avatarHosting; + _botConfig = botConfig; + } + + public async Task ChangeId(Context ctx, PKSystem target) + { + ctx.CheckSystem().CheckOwnSystem(target); + if (!ctx.Premium) + throw Errors.PremiumExclusiveCommand(); + + var input = ctx.PopArgument(); + if (!input.TryParseHid(out var newHid)) + throw new PKError($"Invalid new system ID `{input}`."); + + var existingSystem = await ctx.Repository.GetSystemByHid(newHid); + if (existingSystem != null) + throw new PKError($"Another system already exists with ID `{newHid.DisplayHid(ctx.Config)}`."); + + if (ctx.Config.PremiumIdChangesRemaining < 1) + throw new PKError("You do not have enough available ID changes to do this."); + if ((await ctx.Repository.GetHidChangelogCountForDate(target.Id, SystemClock.Instance.GetCurrentInstant().InUtc().Date)) >= Limits.PremiumDailyHidChanges) + throw new PKError($"You have already changed {Limits.PremiumDailyHidChanges} IDs today. Please try again tomorrow."); + + if (!await ctx.PromptYesNo($"Change your system ID to `{newHid.DisplayHid(ctx.Config)}`?", "Change")) + throw new PKError("ID change cancelled."); + + if (!await ctx.Repository.TryUpdateSystemConfigForIdChange(target.Id)) + throw new PKError("You do not have enough available ID changes to do this."); + + await ctx.Repository.CreateHidChangelog(target.Id, ctx.Message.Author.Id, "system", target.Hid, newHid); + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Hid = newHid }); + + var newConfig = await ctx.Repository.GetSystemConfig(target.Id); + await ctx.Reply($"{Emojis.Success} System ID changed to `{newHid.DisplayHid(ctx.Config)}`. You have **{newConfig.PremiumIdChangesRemaining}** ID changes remaining."); } public async Task Name(Context ctx, PKSystem target) diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 9b496da0..7a1260fa 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -185,4 +185,6 @@ public static class Errors new($"Channel \"{channelString}\" not found or is not in this server."); public static PKError InteractionWrongAccount(ulong user) => new($"This prompt is only available for <@{user}>"); + + public static PKError PremiumExclusiveCommand() => new("This command is only available for PluralKit Premium subscribers."); } \ No newline at end of file diff --git a/PluralKit.Bot/Utils/ModelUtils.cs b/PluralKit.Bot/Utils/ModelUtils.cs index ddeedfee..c9c77c23 100644 --- a/PluralKit.Bot/Utils/ModelUtils.cs +++ b/PluralKit.Bot/Utils/ModelUtils.cs @@ -31,6 +31,7 @@ public static class ModelUtils public static string DisplayHid(this PKSystem system, SystemConfig? cfg = null, bool isList = false) => HidTransform(system.Hid, cfg, isList); public static string DisplayHid(this PKGroup group, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidTransform(group.Hid, cfg, isList, shouldPad); public static string DisplayHid(this PKMember member, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidTransform(member.Hid, cfg, isList, shouldPad); + public static string DisplayHid(this string hid, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidTransform(hid, cfg, isList, shouldPad); private static string HidTransform(string hid, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidUtils.HidTransform( hid, diff --git a/PluralKit.Core/Database/Repository/ModelRepository.HidChangelog.cs b/PluralKit.Core/Database/Repository/ModelRepository.HidChangelog.cs new file mode 100644 index 00000000..e8426b3e --- /dev/null +++ b/PluralKit.Core/Database/Repository/ModelRepository.HidChangelog.cs @@ -0,0 +1,34 @@ +using Dapper; + +using SqlKata; + +using NodaTime; + +namespace PluralKit.Core; + +public partial class ModelRepository +{ + public Task GetHidChangelogById(int id) + { + var query = new Query("hid_changelog").Where("id", id); + return _db.QueryFirst(query); + } + + public async Task CreateHidChangelog(SystemId system, ulong discord_uid, string hid_type, string hid_old, string hid_new, IPKConnection? conn = null) + { + var query = new Query("hid_changelog").AsInsert(new { system, discord_uid, hid_type, hid_old, hid_new, }); + var changelog = await _db.QueryFirst(conn, query, "returning *"); + _logger.Information("Created HidChangelog {HidChangelogId} for system {SystemId}: {HidType} {OldHid} -> {NewHid}", changelog.Id, system, hid_type, hid_old, hid_new); + return changelog; + } + + public Task GetHidChangelogCountForDate(SystemId system, LocalDate date) + { + var query = new Query("hid_changelog") + .SelectRaw("count(*)") + .Where("system", system) + .WhereDate("created", date); + + return _db.QueryFirst(query); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.SystemConfig.cs b/PluralKit.Core/Database/Repository/ModelRepository.SystemConfig.cs index 8cc83333..6febdd8f 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.SystemConfig.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.SystemConfig.cs @@ -1,4 +1,5 @@ using SqlKata; +using Npgsql; namespace PluralKit.Core; @@ -20,4 +21,27 @@ public partial class ModelRepository return config; } + + public async Task TryUpdateSystemConfigForIdChange(SystemId system, IPKConnection conn = null) + { + var query = new Query("system_config") + .AsUpdate(new + { + premium_id_changes_remaining = new UnsafeLiteral("premium_id_changes_remaining - 1") + }) + .Where("system", system); + + try + { + await _db.ExecuteQuery(conn, query); + } + catch (PostgresException pe) + { + if (!pe.Message.Contains("violates check constraint")) + throw; + return false; + } + + return true; + } } \ No newline at end of file diff --git a/PluralKit.Core/Models/HidChangelog.cs b/PluralKit.Core/Models/HidChangelog.cs new file mode 100644 index 00000000..05f21e58 --- /dev/null +++ b/PluralKit.Core/Models/HidChangelog.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json.Linq; +using NodaTime; + +namespace PluralKit.Core; + +public class HidChangelog +{ + public int Id { get; } + public SystemId System { get; } + public ulong DiscordUid { get; } + public string HidType { get; } + public string HidOld { get; } + public string HidNew { get; } + public Instant Created { get; } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemConfigPatch.cs b/PluralKit.Core/Models/Patch/SystemConfigPatch.cs index cc7cae99..a0901da9 100644 --- a/PluralKit.Core/Models/Patch/SystemConfigPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemConfigPatch.cs @@ -28,6 +28,7 @@ public class SystemConfigPatch: PatchObject public Partial ProxySwitch { get; set; } public Partial PremiumLifetime { get; set; } public Partial PremiumUntil { get; set; } + public Partial PremiumIdChangesRemaining { get; set; } public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper .With("ui_tz", UiTz) @@ -49,6 +50,7 @@ public class SystemConfigPatch: PatchObject .With("name_format", NameFormat) .With("premium_lifetime", PremiumLifetime) .With("premium_until", PremiumUntil) + .With("premium_id_changes_remaining", PremiumIdChangesRemaining) ); public new void AssertIsValid() @@ -128,6 +130,9 @@ public class SystemConfigPatch: PatchObject if (PremiumUntil.IsPresent) o.Add("premium_until", PremiumUntil.Value?.FormatExport()); + if (PremiumIdChangesRemaining.IsPresent) + o.Add("premium_id_changes_remaining", PremiumIdChangesRemaining.Value); + return o; } diff --git a/PluralKit.Core/Models/SystemConfig.cs b/PluralKit.Core/Models/SystemConfig.cs index 39706042..c12d1ab1 100644 --- a/PluralKit.Core/Models/SystemConfig.cs +++ b/PluralKit.Core/Models/SystemConfig.cs @@ -30,6 +30,7 @@ public class SystemConfig public bool PremiumLifetime { get; } public Instant? PremiumUntil { get; } + public int? PremiumIdChangesRemaining { get; } public enum HidPadFormat { diff --git a/PluralKit.Core/Utils/Limits.cs b/PluralKit.Core/Utils/Limits.cs index 4e484664..23bf0e63 100644 --- a/PluralKit.Core/Utils/Limits.cs +++ b/PluralKit.Core/Utils/Limits.cs @@ -22,4 +22,6 @@ public static class Limits public static readonly long AvatarFileSizeLimit = 1024 * 1024; public static readonly int AvatarDimensionLimit = 1000; + + public static readonly int PremiumDailyHidChanges = 3; } \ No newline at end of file diff --git a/crates/migrate/data/migrations/55.sql b/crates/migrate/data/migrations/55.sql new file mode 100644 index 00000000..6452a16f --- /dev/null +++ b/crates/migrate/data/migrations/55.sql @@ -0,0 +1,20 @@ +-- database version 55 +-- add premium ID change allowances + +alter table system_config + add column premium_id_changes_remaining int not null default 0, + add constraint premium_id_changes_nonzero check (premium_id_changes_remaining >= 0); + +create table hid_changelog ( + id serial primary key, + system integer references systems (id) on delete set null, + discord_uid bigint not null, + hid_type text not null, + hid_old char(6) not null, + hid_new char(6) not null, + created timestamp not null default (current_timestamp at time zone 'utc') +); + +create index hid_changelog_system_idx on hid_changelog (system); + +update info set schema_version = 55; \ No newline at end of file