Compare commits

...

4 commits

Author SHA1 Message Date
Iris System
24b6b0d455 feat: premium ID changes
Some checks failed
Build and push Docker image / .net docker build (push) Has been cancelled
.net checks / run .net tests (push) Has been cancelled
.net checks / dotnet-format (push) Has been cancelled
Build and push Rust service Docker images / rust docker build (push) Has been cancelled
rust checks / cargo fmt (push) Has been cancelled
2025-12-28 00:06:51 +13:00
Iris System
41f8beb2aa feat: show premium badge on system/member/group cards 2025-12-27 16:47:51 +13:00
Iris System
59b9b6f6ec feat: add premium management admin commands 2025-12-27 15:09:59 +13:00
alyssa
0a474c43eb feat: add basic premium scaffolding 2025-12-25 12:32:16 -05:00
21 changed files with 484 additions and 5 deletions

View file

@ -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

View file

@ -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 <id>", "PREMIUM: Changes your system's ID.");
public static Command SystemPrivacy = new Command("system privacy", "system [system] privacy <name|avatar|description|members|fronter|fronthistory|all> <public|private>", "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 <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 <id>", "PREMIUM: Changes a member's ID.");
public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|proxy|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
public static Command GroupInfo = new Command("group", "group <name>", "Looks up information about a group");
public static Command GroupNew = new Command("group new", "group new <name>", "Creates a new group");
@ -73,6 +75,7 @@ public partial class CommandTree
public static Command GroupAdd = new Command("group add", "group <group> add <member> [member 2] [member 3...]", "Adds one or more members to a group");
public static Command GroupRemove = new Command("group remove", "group <group> remove <member> [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 <id>", "PREMIUM: Changes a group's ID.");
public static Command GroupPrivacy = new Command("group privacy", "group <group> privacy <name|description|icon|metadata|visibility|all> <public|private>", "Changes a group's privacy settings");
public static Command GroupBannerImage = new Command("group banner", "group <group> banner [url]", "Set the group's banner image");
public static Command GroupIcon = new Command("group icon", "group <group> icon [url|@mention]", "Changes a group's icon");

View file

@ -92,6 +92,7 @@ public partial class CommandTree
if (ctx.Match("sus")) return ctx.Execute<Fun>(null, m => m.Sus(ctx));
if (ctx.Match("error")) return ctx.Execute<Fun>(null, m => m.Error(ctx));
if (ctx.Match("stats", "status")) return ctx.Execute<Misc>(null, m => m.Stats(ctx));
if (ctx.Match("premium")) return ctx.Execute<Misc>(null, m => m.Premium(ctx));
if (ctx.Match("permcheck"))
return ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx));
if (ctx.Match("proxycheck"))
@ -181,6 +182,10 @@ public partial class CommandTree
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx));
else if (ctx.Match("pe", "premiumexpiry", "premium"))
await ctx.Execute<Admin>(Admin, a => a.PremiumExpiry(ctx));
else if (ctx.Match("pid", "premiumidchange", "premiumid"))
await ctx.Execute<Admin>(Admin, a => a.PremiumIdChangeAllowance(ctx));
else if (ctx.Match("sendmsg", "sendmessage"))
await ctx.Execute<Admin>(Admin, a => a.SendAdminMessage(ctx));
else if (ctx.Match("al", "abuselog"))
@ -317,6 +322,8 @@ public partial class CommandTree
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx, target));
else if (ctx.Match("id"))
await ctx.CheckSystem(target).Execute<System>(SystemId, m => m.DisplayId(ctx, target));
else if (ctx.Match("changeid", "updateid"))
await ctx.CheckSystem(target).Execute<SystemEdit>(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<Random>(GroupRandom, r => r.Group(ctx, target));
@ -392,6 +399,8 @@ public partial class CommandTree
await ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ServerKeepProxy(ctx, target));
else if (ctx.Match("id"))
await ctx.Execute<Member>(MemberId, m => m.DisplayId(ctx, target));
else if (ctx.Match("changeid", "updateid"))
await ctx.Execute<MemberEdit>(MemberIdChange, m => m.ChangeId(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null));
else if (ctx.Match("private", "hidden", "hide"))
@ -454,6 +463,8 @@ public partial class CommandTree
await ctx.Execute<Groups>(GroupColor, g => g.GroupColor(ctx, target));
else if (ctx.Match("id"))
await ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, target));
else if (ctx.Match("changeid", "updateid"))
await ctx.Execute<Groups>(GroupIdChange, g => g.ChangeId(ctx, target));
else if (!ctx.HasNext())
await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target));
else

View file

@ -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<IMetrics>();
_provider = provider;
_commandMessageService = provider.Resolve<CommandMessageService>();
_botConfig = provider.Resolve<BotConfig>();
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)
? (_botConfig.PremiumLifetimeEmoji != null ? $"<:lifetime_premium:{_botConfig.PremiumLifetimeEmoji}>" : "\u2729")
: Premium
? (_botConfig.PremiumSubscriberEmoji != null ? $"<:premium_subscriber:{_botConfig.PremiumSubscriberEmoji}>" : "\u2729")
: "";
public readonly string CommandPrefix;
public readonly string DefaultPrefix;
public readonly Parameters Parameters;

View file

@ -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}> <t:{config.PremiumUntil?.ToUnixTimeSeconds()}> <t:{config.PremiumUntil?.ToUnixTimeSeconds()}:R>";
else
premiumEntitlement = $"Expired! <t:{config.PremiumUntil?.ToUnixTimeSeconds()}> <t:{config.PremiumUntil?.ToUnixTimeSeconds()}:R>";
eb.Field(new Embed.Field("Premium entitlement", premiumEntitlement, false));
return eb.Build();
}
@ -396,6 +408,111 @@ 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.");
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.");
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"))
{
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.");
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();
}
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Change premium expiry for system `{target.Hid}` to <t:{time?.ToUnixTimeSeconds()}>?", "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 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");

View file

@ -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);

View file

@ -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();

View file

@ -31,6 +31,82 @@ 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 <t:{ctx.Config.PremiumUntil?.ToUnixTimeSeconds()}>. {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 <t:{ctx.Config.PremiumUntil?.ToUnixTimeSeconds()}> (<t:{ctx.Config.PremiumUntil?.ToUnixTimeSeconds()}:R>)";
}
}
List<MessageComponent> 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)
{
var permissions =

View file

@ -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)

View file

@ -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.");
}

View file

@ -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;
}
@ -43,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<MessageComponent[]> CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx)
{
// Fetch/render info for all accounts simultaneously
@ -141,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<MessageComponent> header = [
new MessageComponent()
{
@ -455,6 +470,7 @@ public class EmbedService
},
];
var systemPremium = await SystemHasPremium(system);
return [
new MessageComponent()
{
@ -469,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()
@ -645,6 +661,7 @@ public class EmbedService
},
];
var systemPremium = await SystemHasPremium(system);
return [
new MessageComponent()
{
@ -659,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()

View file

@ -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,

View file

@ -0,0 +1,34 @@
using Dapper;
using SqlKata;
using NodaTime;
namespace PluralKit.Core;
public partial class ModelRepository
{
public Task<HidChangelog?> GetHidChangelogById(int id)
{
var query = new Query("hid_changelog").Where("id", id);
return _db.QueryFirst<HidChangelog?>(query);
}
public async Task<HidChangelog> 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<HidChangelog>(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<int> GetHidChangelogCountForDate(SystemId system, LocalDate date)
{
var query = new Query("hid_changelog")
.SelectRaw("count(*)")
.Where("system", system)
.WhereDate("created", date);
return _db.QueryFirst<int>(query);
}
}

View file

@ -1,4 +1,5 @@
using SqlKata;
using Npgsql;
namespace PluralKit.Core;
@ -20,4 +21,27 @@ public partial class ModelRepository
return config;
}
public async Task<bool> 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;
}
}

View file

@ -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; }
}

View file

@ -26,6 +26,9 @@ public class SystemConfigPatch: PatchObject
public Partial<string?> NameFormat { get; set; }
public Partial<SystemConfig.HidPadFormat> HidListPadding { get; set; }
public Partial<SystemConfig.ProxySwitchAction> ProxySwitch { get; set; }
public Partial<bool> PremiumLifetime { get; set; }
public Partial<Instant?> PremiumUntil { get; set; }
public Partial<int?> PremiumIdChangesRemaining { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("ui_tz", UiTz)
@ -45,6 +48,9 @@ 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)
.With("premium_id_changes_remaining", PremiumIdChangesRemaining)
);
public new void AssertIsValid()
@ -118,6 +124,15 @@ 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());
if (PremiumIdChangesRemaining.IsPresent)
o.Add("premium_id_changes_remaining", PremiumIdChangesRemaining.Value);
return o;
}

View file

@ -28,6 +28,10 @@ public class SystemConfig
public ProxySwitchAction ProxySwitch { get; }
public string NameFormat { get; }
public bool PremiumLifetime { get; }
public Instant? PremiumUntil { get; }
public int? PremiumIdChangesRemaining { get; }
public enum HidPadFormat
{
None = 0,

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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<String>,
#[json = "description_templates"]
description_templates: Vec<String>,
#[json = "premium_until"]
premium_until: Option<NaiveDateTime>,
#[json = "premium_lifetime"]
premium_lifetime: bool
}