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 bool IsBetaBot { get; set; } = false!;
public string BetaBotAPIUrl { get; set; } public string BetaBotAPIUrl { get; set; }
public String? PremiumSubscriberEmoji { get; set; }
public String? PremiumLifetimeEmoji { get; set; }
public String? PremiumDashboardUrl { get; set; }
public record ClusterSettings public record ClusterSettings
{ {
// this is zero-indexed // 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 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 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 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 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 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"); 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 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 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 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 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 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"); 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 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 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 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 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 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"); 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("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("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("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")) if (ctx.Match("permcheck"))
return ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx)); return ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx));
if (ctx.Match("proxycheck")) if (ctx.Match("proxycheck"))
@ -181,6 +182,10 @@ public partial class CommandTree
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx)); await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete")) else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx)); 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")) else if (ctx.Match("sendmsg", "sendmessage"))
await ctx.Execute<Admin>(Admin, a => a.SendAdminMessage(ctx)); await ctx.Execute<Admin>(Admin, a => a.SendAdminMessage(ctx));
else if (ctx.Match("al", "abuselog")) 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)); await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx, target));
else if (ctx.Match("id")) else if (ctx.Match("id"))
await ctx.CheckSystem(target).Execute<System>(SystemId, m => m.DisplayId(ctx, target)); 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")) else if (ctx.Match("random", "rand", "r"))
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
await ctx.CheckSystem(target).Execute<Random>(GroupRandom, r => r.Group(ctx, target)); 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)); await ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ServerKeepProxy(ctx, target));
else if (ctx.Match("id")) else if (ctx.Match("id"))
await ctx.Execute<Member>(MemberId, m => m.DisplayId(ctx, target)); 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")) else if (ctx.Match("privacy"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null)); await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null));
else if (ctx.Match("private", "hidden", "hide")) 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)); await ctx.Execute<Groups>(GroupColor, g => g.GroupColor(ctx, target));
else if (ctx.Match("id")) else if (ctx.Match("id"))
await ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, target)); 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()) else if (!ctx.HasNext())
await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target)); await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target));
else else

View file

@ -28,6 +28,8 @@ public class Context
private Command? _currentCommand; private Command? _currentCommand;
private BotConfig _botConfig;
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
int commandParseOffset, PKSystem senderSystem, SystemConfig config, int commandParseOffset, PKSystem senderSystem, SystemConfig config,
GuildConfig? guildConfig, string[] prefixes) GuildConfig? guildConfig, string[] prefixes)
@ -46,6 +48,7 @@ public class Context
_metrics = provider.Resolve<IMetrics>(); _metrics = provider.Resolve<IMetrics>();
_provider = provider; _provider = provider;
_commandMessageService = provider.Resolve<CommandMessageService>(); _commandMessageService = provider.Resolve<CommandMessageService>();
_botConfig = provider.Resolve<BotConfig>();
CommandPrefix = message.Content?.Substring(0, commandParseOffset); CommandPrefix = message.Content?.Substring(0, commandParseOffset);
DefaultPrefix = prefixes[0]; DefaultPrefix = prefixes[0];
Parameters = new Parameters(message.Content?.Substring(commandParseOffset)); Parameters = new Parameters(message.Content?.Substring(commandParseOffset));
@ -74,6 +77,23 @@ public class Context
public readonly SystemConfig Config; public readonly SystemConfig Config;
public DateTimeZone Zone => Config?.Zone ?? DateTimeZone.Utc; 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 CommandPrefix;
public readonly string DefaultPrefix; public readonly string DefaultPrefix;
public readonly Parameters Parameters; public readonly Parameters Parameters;

View file

@ -2,6 +2,8 @@ using System.Text.RegularExpressions;
using Humanizer; using Humanizer;
using Dapper; using Dapper;
using NodaTime;
using NodaTime.Text;
using SqlKata; using SqlKata;
using Myriad.Builders; using Myriad.Builders;
@ -79,6 +81,16 @@ public class Admin
var groupCount = await ctx.Repository.GetSystemGroupCount(system.Id); var groupCount = await ctx.Repository.GetSystemGroupCount(system.Id);
eb.Field(new Embed.Field("Group limit", $"{groupLimit} {UntilLimit(groupCount, groupLimit)}", true)); 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(); return eb.Build();
} }
@ -396,6 +408,111 @@ public class Admin
await ctx.Reply($"{Emojis.Success} System deletion succesful."); 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) public async Task AbuseLogCreate(Context ctx)
{ {
var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage"); var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage");

View file

@ -4,6 +4,8 @@ using System.Text.RegularExpressions;
using Myriad.Builders; using Myriad.Builders;
using Myriad.Types; using Myriad.Types;
using NodaTime;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PluralKit.Core; using PluralKit.Core;
@ -99,6 +101,38 @@ public class Groups
await ctx.Reply(replyStr, eb.Build()); 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) public async Task RenameGroup(Context ctx, PKGroup target)
{ {
ctx.CheckOwnGroup(target); ctx.CheckOwnGroup(target);

View file

@ -21,6 +21,38 @@ public class MemberEdit
_avatarHosting = avatarHosting; _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) public async Task Name(Context ctx, PKMember target)
{ {
var format = ctx.MatchFormat(); var format = ctx.MatchFormat();

View file

@ -31,6 +31,82 @@ public class Misc
_shards = shards; _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) public async Task Invite(Context ctx)
{ {
var permissions = var permissions =

View file

@ -9,6 +9,8 @@ using Myriad.Types;
using Newtonsoft.Json; using Newtonsoft.Json;
using NodaTime;
using PluralKit.Core; using PluralKit.Core;
using SqlKata.Compilers; using SqlKata.Compilers;
@ -20,13 +22,47 @@ public class SystemEdit
private readonly DataFileService _dataFiles; private readonly DataFileService _dataFiles;
private readonly PrivateChannelService _dmCache; private readonly PrivateChannelService _dmCache;
private readonly AvatarHostingService _avatarHosting; 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; _dataFiles = dataFiles;
_client = client; _client = client;
_dmCache = dmCache; _dmCache = dmCache;
_avatarHosting = avatarHosting; _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) 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."); 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 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 IDatabase _db;
private readonly ModelRepository _repo; private readonly ModelRepository _repo;
private readonly DiscordApiClient _rest; private readonly DiscordApiClient _rest;
private readonly BotConfig _config;
private readonly CoreConfig _coreConfig; 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; _db = db;
_repo = repo; _repo = repo;
_cache = cache; _cache = cache;
_rest = rest; _rest = rest;
_config = config;
_coreConfig = coreConfig; _coreConfig = coreConfig;
} }
@ -43,6 +45,17 @@ public class EmbedService
return Task.WhenAll(ids.Select(Inner)); 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) public async Task<MessageComponent[]> CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx)
{ {
// Fetch/render info for all accounts simultaneously // 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 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 = [ List<MessageComponent> header = [
new MessageComponent() new MessageComponent()
{ {
@ -455,6 +470,7 @@ public class EmbedService
}, },
]; ];
var systemPremium = await SystemHasPremium(system);
return [ return [
new MessageComponent() new MessageComponent()
{ {
@ -469,7 +485,7 @@ public class EmbedService
new MessageComponent() new MessageComponent()
{ {
Type = ComponentType.Text, 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() Accessory = new MessageComponent()
@ -645,6 +661,7 @@ public class EmbedService
}, },
]; ];
var systemPremium = await SystemHasPremium(system);
return [ return [
new MessageComponent() new MessageComponent()
{ {
@ -659,7 +676,7 @@ public class EmbedService
new MessageComponent() new MessageComponent()
{ {
Type = ComponentType.Text, 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() 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 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 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 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) => private static string HidTransform(string hid, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) =>
HidUtils.HidTransform( HidUtils.HidTransform(
hid, 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 SqlKata;
using Npgsql;
namespace PluralKit.Core; namespace PluralKit.Core;
@ -20,4 +21,27 @@ public partial class ModelRepository
return config; 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<string?> NameFormat { get; set; }
public Partial<SystemConfig.HidPadFormat> HidListPadding { get; set; } public Partial<SystemConfig.HidPadFormat> HidListPadding { get; set; }
public Partial<SystemConfig.ProxySwitchAction> ProxySwitch { 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 public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("ui_tz", UiTz) .With("ui_tz", UiTz)
@ -45,6 +48,9 @@ public class SystemConfigPatch: PatchObject
.With("card_show_color_hex", CardShowColorHex) .With("card_show_color_hex", CardShowColorHex)
.With("proxy_switch", ProxySwitch) .With("proxy_switch", ProxySwitch)
.With("name_format", NameFormat) .With("name_format", NameFormat)
.With("premium_lifetime", PremiumLifetime)
.With("premium_until", PremiumUntil)
.With("premium_id_changes_remaining", PremiumIdChangesRemaining)
); );
public new void AssertIsValid() public new void AssertIsValid()
@ -118,6 +124,15 @@ public class SystemConfigPatch: PatchObject
if (NameFormat.IsPresent) if (NameFormat.IsPresent)
o.Add("name_format", NameFormat.Value); 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; return o;
} }

View file

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

View file

@ -22,4 +22,6 @@ public static class Limits
public static readonly long AvatarFileSizeLimit = 1024 * 1024; public static readonly long AvatarFileSizeLimit = 1024 * 1024;
public static readonly int AvatarDimensionLimit = 1000; 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 pk_macros::pk_model;
use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type}; use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type};
@ -87,4 +88,8 @@ struct SystemConfig {
name_format: Option<String>, name_format: Option<String>,
#[json = "description_templates"] #[json = "description_templates"]
description_templates: Vec<String>, description_templates: Vec<String>,
#[json = "premium_until"]
premium_until: Option<NaiveDateTime>,
#[json = "premium_lifetime"]
premium_lifetime: bool
} }