Compare commits

..

3 commits

Author SHA1 Message Date
alyssa
79da083a74 feat(bot): port help menu to components v2
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-09-07 03:20:57 +00:00
alyssa
2f5cabc8e0 add /api/v2/bulk endpoint
also, initial support for patch models in rust!
2025-09-06 22:53:03 +00:00
Iris System
fbf51b41c1 feat(bot): ComponentsV2 system/member/group cards
Some checks are pending
Build and push Docker image / .net docker build (push) Waiting to run
.net checks / run .net tests (push) Waiting to run
.net checks / dotnet-format (push) Waiting to run
Build and push Rust service Docker images / rust docker build (push) Waiting to run
rust checks / cargo fmt (push) Waiting to run
2025-09-07 10:16:50 +12:00
22 changed files with 700 additions and 40 deletions

View file

@ -18,7 +18,7 @@ sea-query = { version = "1.0.0-rc.10", features = ["with-chrono"] }
sentry = { version = "0.36.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "reqwest", "rustls"] } # replace native-tls with rustls
serde = { version = "1.0.196", features = ["derive"] }
serde_json = "1.0.117"
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "macros", "uuid"] }
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid"] }
tokio = { version = "1.36.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }

View file

@ -9,6 +9,7 @@ public record MessageRequest
public bool Tts { get; set; }
public AllowedMentions? AllowedMentions { get; set; }
public Embed[]? Embeds { get; set; }
public Message.MessageFlags Flags { get; set; }
public MessageComponent[]? Components { get; set; }
public Message.Reference? MessageReference { get; set; }
}

View file

@ -0,0 +1,13 @@
namespace Myriad.Types;
public record ComponentMedia
{
public string? Url { get; init; }
}
public record ComponentMediaItem
{
public ComponentMedia Media { get; init; }
public string? Description { get; init; }
public bool Spoiler { get; init; } = false;
}

View file

@ -3,5 +3,12 @@ namespace Myriad.Types;
public enum ComponentType
{
ActionRow = 1,
Button = 2
Button = 2,
StringSelect = 3,
Section = 9,
Text = 10,
Thumbnail = 11,
MediaGallery = 12,
Separator = 14,
Container = 17,
}

View file

@ -5,9 +5,15 @@ public record MessageComponent
public ComponentType Type { get; init; }
public ButtonStyle? Style { get; set; }
public string? Label { get; init; }
public string? Content { get; init; }
public Emoji? Emoji { get; init; }
public string? CustomId { get; init; }
public string? Url { get; init; }
public bool? Disabled { get; init; }
public uint? AccentColor { get; init; }
public ComponentMedia? Media { get; init; }
public ComponentMediaItem[]? Items { get; init; }
public MessageComponent? Accessory { get; init; }
public MessageComponent[]? Components { get; init; }
}

View file

@ -17,6 +17,7 @@ public record Message
Ephemeral = 1 << 6,
SuppressNotifications = 1 << 12,
VoiceMessage = 1 << 13,
IsComponentsV2 = 1 << 15,
}
public enum MessageType
@ -73,8 +74,6 @@ public record Message
public MessagePoll? Poll { get; init; }
// public MessageComponent[]? Components { get; init; }
public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId);
public record MessageActivity(int Type, string PartyId);

View file

@ -139,7 +139,8 @@ public class ApplicationCommandProxiedMessage
if (member == null || !(await _cache.PermissionsForMemberInChannel(ctx.GuildId, ctx.ChannelId, member)).HasFlag(requiredPerms))
{
throw new PKError("You do not have permission to send messages in this channel.");
};
}
;
var config = await _repo.GetSystemConfig(msg.System.Id);

View file

@ -592,6 +592,8 @@ public partial class CommandTree
return ctx.Execute<Config>(null, m => m.HidDisplayCaps(ctx));
if (ctx.MatchMultiple(new[] { "pad" }, new[] { "id", "ids" }) || ctx.MatchMultiple(new[] { "id" }, new[] { "pad", "padding" }) || ctx.Match("idpad", "padid", "padids"))
return ctx.Execute<Config>(null, m => m.HidListPadding(ctx));
if (ctx.MatchMultiple(new[] { "show" }, new[] { "color", "colour", "colors", "colours" }) || ctx.Match("showcolor", "showcolour", "showcolors", "showcolours", "colorcode", "colorhex"))
return ctx.Execute<Config>(null, m => m.CardShowColorHex(ctx));
if (ctx.MatchMultiple(new[] { "name" }, new[] { "format" }) || ctx.Match("nameformat", "nf"))
return ctx.Execute<Config>(null, m => m.NameFormat(ctx));
if (ctx.MatchMultiple(new[] { "member", "group" }, new[] { "limit" }) || ctx.Match("limit"))

View file

@ -119,6 +119,41 @@ public class Context
return msg;
}
public async Task<Message> Reply(MessageComponent[] components = null, AllowedMentions? mentions = null, MultipartFile[]? files = null)
{
var botPerms = await BotPermissions;
if (!botPerms.HasFlag(PermissionSet.SendMessages))
// Will be "swallowed" during the error handler anyway, this message is never shown.
throw new PKError("PluralKit does not have permission to send messages in this channel.");
if (files != null && !botPerms.HasFlag(PermissionSet.AttachFiles))
throw new PKError("PluralKit does not have permission to attach files in this channel. Please ensure I have the **Attach Files** permission enabled.");
var msg = await Rest.CreateMessage(Channel.Id, new MessageRequest
{
Components = components,
Flags = Message.MessageFlags.IsComponentsV2,
// Default to an empty allowed mentions object instead of null (which means no mentions allowed)
AllowedMentions = mentions ?? new AllowedMentions()
}, files: files);
// store log of sent message, so it can be queried or deleted later
// skip DMs as DM messages can always be deleted
if (Guild != null)
await Repository.AddCommandMessage(new Core.CommandMessage
{
Mid = msg.Id,
Guild = Guild!.Id,
Channel = Channel.Id,
Sender = Author.Id,
OriginalMid = Message.Id,
});
return msg;
}
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler, bool deprecated = false)
{
_currentCommand = commandDef;

View file

@ -123,6 +123,13 @@ public class Config
"off"
));
items.Add(new(
"show color",
"Whether to show color codes in system/member/group cards",
EnabledDisabled(ctx.Config.CardShowColorHex),
"disabled"
));
items.Add(new(
"Proxy Switch",
"Switching behavior when proxy tags are used",
@ -570,6 +577,20 @@ public class Config
else throw new PKError(badInputError);
}
public async Task CardShowColorHex(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Showing color codes on system/member/group cards is currently **{EnabledDisabled(ctx.Config.CardShowColorHex)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CardShowColorHex = newVal });
await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(newVal)}.");
}
public async Task ProxySwitch(Context ctx)
{
if (!ctx.HasNext())

View file

@ -520,7 +520,13 @@ public class Groups
public async Task ShowGroupCard(Context ctx, PKGroup target)
{
var system = await GetGroupSystem(ctx, target);
await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateGroupEmbed(ctx, system, target));
return;
}
await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target));
}
public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand)

View file

@ -7,12 +7,94 @@ namespace PluralKit.Bot;
public class Help
{
public Task HelpRoot(Context ctx)
{
if (ctx.MatchFlag("show-embed", "se"))
return HelpRootOld(ctx);
return ctx.Reply(BuildComponents(ctx.Author.Id, Help.Description.Replace("{prefix}", ctx.DefaultPrefix), -1));
}
public static Task ButtonClick(InteractionContext ctx, string prefix)
{
if (!ctx.CustomId.Contains(ctx.User.Id.ToString()))
return ctx.Ignore();
if (ctx.CustomId.StartsWith("new-"))
{
Console.WriteLine($"{ctx.Event.Message.Components.First().Components.Length}");
if (ctx.Event.Message.Components.First().Components[1].Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Components = BuildComponents(ctx.User.Id, Help.Description.Replace("{prefix}", prefix), -1),
Flags = Message.MessageFlags.IsComponentsV2,
});
var text = helpEmbedPages.GetValueOrDefault(ctx.CustomId.Split("-")[3]).Select(
(item, index) => $"### {item.Name.Replace("{prefix}", prefix)}\n{item.Value.Replace("{prefix}", prefix)}"
).ToArray();
var index = Array.FindIndex(ctx.Event.Message.Components.First().Components[1].Components, x => x.CustomId == ctx.CustomId);
var components = BuildComponents(ctx.User.Id, Help.Description.Replace("{prefix}", prefix), index);
components.First().Components[ctx.Event.Message.Components.First().Components.Length - 1] = new MessageComponent()
{
Type = ComponentType.Text,
Content = String.Join("\n", text),
};
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Components = components,
Flags = Message.MessageFlags.IsComponentsV2,
});
}
return ButtonClickOld(ctx, prefix);
}
private static MessageComponent[] BuildComponents(ulong userId, string textContent, int menuIndex)
{
return [
new MessageComponent()
{
Type = ComponentType.Container,
AccentColor = DiscordUtils.Blue,
Components = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = "# PluralKit\n-# Use the buttons below to see more info!"
},
helpPageButtons(userId, "new-", menuIndex),
new MessageComponent()
{
Type = ComponentType.Separator,
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = textContent,
},
],
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = EmbedFooter("\n-# "),
},
];
}
///
private static string Description = "PluralKit is a bot designed for plural communities on Discord, and is open for anyone to use. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.\n\n" +
"**System recovery:** in the case of your Discord account getting lost or deleted, the PluralKit staff can help you recover your system, **only if you save the system token from `{prefix}token`**. See [this FAQ entry](https://pluralkit.me/faq/#can-i-recover-my-system-if-i-lose-access-to-my-discord-account) for more details.\n\n" +
"If PluralKit is useful to you, please consider donating on [Patreon](https://patreon.com/pluralkit) or [Buy Me A Coffee](https://buymeacoffee.com/pluralkit).\n" +
"## Use the buttons below to see more info!";
"If PluralKit is useful to you, please consider donating on [Patreon](https://patreon.com/pluralkit) or [Buy Me A Coffee](https://buymeacoffee.com/pluralkit).";
public static string EmbedFooter = "-# PluralKit by @ske and contributors | Myriad design by @layl, icon by @tedkalashnikov, banner by @fulmine | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/";
private static string DescriptionOld = $"{Description}\n## Use the buttons below to see more info!";
public static string EmbedFooter(string linkSeparator) => $"-# PluralKit by @ske and contributors | Myriad design by @layl, icon by @tedkalashnikov, banner by @fulmine{linkSeparator}GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/";
public static Embed helpEmbed = new()
{
@ -98,7 +180,7 @@ public class Help
}
};
private static MessageComponent helpPageButtons(ulong userId) => new MessageComponent
private static MessageComponent helpPageButtons(ulong userId, string pfx = "", int menuIndex = -1) => new MessageComponent
{
Type = ComponentType.ActionRow,
Components = new[]
@ -106,58 +188,54 @@ public class Help
new MessageComponent
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Style = menuIndex == 0 ? ButtonStyle.Primary : ButtonStyle.Secondary,
Label = "Basic Info",
CustomId = $"help-menu-basicinfo-{userId}",
CustomId = $"{pfx}help-menu-basicinfo-{userId}",
Emoji = new() { Name = "\u2139" },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Style = menuIndex == 1 ? ButtonStyle.Primary : ButtonStyle.Secondary,
Label = "Getting Started",
CustomId = $"help-menu-gettingstarted-{userId}",
CustomId = $"{pfx}help-menu-gettingstarted-{userId}",
Emoji = new() { Name = "\u2753", },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Style = menuIndex == 2 ? ButtonStyle.Primary : ButtonStyle.Secondary,
Label = "Useful Tips",
CustomId = $"help-menu-usefultips-{userId}",
CustomId = $"{pfx}help-menu-usefultips-{userId}",
Emoji = new() { Name = "\U0001f4a1", },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Style = menuIndex == 3 ? ButtonStyle.Primary : ButtonStyle.Secondary,
Label = "More Info",
CustomId = $"help-menu-moreinfo-{userId}",
CustomId = $"{pfx}help-menu-moreinfo-{userId}",
Emoji = new() { Id = 986379675066593330, },
}
}
};
public Task HelpRoot(Context ctx)
public Task HelpRootOld(Context ctx)
=> ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
{
Content = $"{Emojis.Warn} If you cannot see the rest of this message see [the FAQ](<https://pluralkit.me/faq/#why-do-most-of-pluralkit-s-messages-look-blank-or-empty>)",
Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", ctx.DefaultPrefix), Fields = new Embed.Field[] { new("", EmbedFooter) } } },
Embeds = new[] { helpEmbed with { Description = Help.DescriptionOld.Replace("{prefix}", ctx.DefaultPrefix), Fields = new Embed.Field[] { new("", EmbedFooter(" | ")) } } },
Components = new[] { helpPageButtons(ctx.Author.Id) },
});
public static Task ButtonClick(InteractionContext ctx, string prefix)
public static Task ButtonClickOld(InteractionContext ctx, string prefix)
{
if (!ctx.CustomId.Contains(ctx.User.Id.ToString()))
return ctx.Ignore();
var buttons = helpPageButtons(ctx.User.Id);
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", prefix), Fields = new Embed.Field[] { new("", EmbedFooter) } } },
Embeds = new[] { helpEmbed with { Description = Help.DescriptionOld.Replace("{prefix}", prefix), Fields = new Embed.Field[] { new("", EmbedFooter(" | ")) } } },
Components = new[] { buttons }
});
@ -167,7 +245,7 @@ public class Help
{
Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault(ctx.CustomId.Split("-")[2]).Select(
(item, index) => new Embed.Field(item.Name.Replace("{prefix}", prefix), item.Value.Replace("{prefix}", prefix))
).Append(new("", EmbedFooter)).ToArray() } },
).Append(new("", EmbedFooter(" | "))).ToArray() } },
Components = new[] { buttons }
});
}

View file

@ -122,8 +122,16 @@ public class Member
public async Task ViewMember(Context ctx, PKMember target)
{
var system = await ctx.Repository.GetSystem(target.System);
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
return;
}
await ctx.Reply(
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
components: await _embeds.CreateMemberMessageComponents(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
}
public async Task Soulscream(Context ctx, PKMember target)

View file

@ -92,7 +92,7 @@ public class Misc
+ $"**{stats.db.switches:N0}** switches, **{stats.db.messages:N0}** messages\n" +
$"**{stats.db.guilds:N0}** servers with **{stats.db.channels:N0}** channels"));
embed.Field(new("", Help.EmbedFooter));
embed.Field(new("", Help.EmbedFooter(" | ")));
var uptime = ((DateTimeOffset)process.StartTime).ToUnixTimeSeconds();
embed.Description($"### PluralKit [{BuildInfoService.Version}](https://github.com/pluralkit/pluralkit/commit/{BuildInfoService.FullVersion})\n" +

View file

@ -36,8 +36,17 @@ public class Random
"This system has no members!");
var randInt = randGen.Next(members.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(target, members[randInt], ctx.Guild,
ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
embed: await _embeds.CreateMemberEmbed(target, members[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone));
return;
}
await ctx.Reply(
components: await _embeds.CreateMemberMessageComponents(target, members[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone));
}
public async Task Group(Context ctx, PKSystem target)
@ -60,7 +69,17 @@ public class Random
$"This system has no groups!");
var randInt = randGen.Next(groups.Count());
await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt]));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt]));
return;
}
await ctx.Reply(
components: await _embeds.CreateGroupMessageComponents(ctx, target, groups.ToArray()[randInt]));
}
public async Task GroupMember(Context ctx, PKGroup group)
@ -92,7 +111,16 @@ public class Random
system = await ctx.Repository.GetSystem(group.System);
var randInt = randGen.Next(ms.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, ms[randInt], ctx.Guild,
ctx.Config, ctx.LookupContextFor(group.System), ctx.Zone));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
embed: await _embeds.CreateMemberEmbed(system, ms[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
return;
}
await ctx.Reply(
components: await _embeds.CreateMemberMessageComponents(system, ms[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
}
}

View file

@ -17,8 +17,13 @@ public class System
public async Task Query(Context ctx, PKSystem system)
{
if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id)));
return;
}
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id)));
await ctx.Reply(components: await _embeds.CreateSystemMessageComponents(ctx, system, ctx.LookupContextFor(system.Id)));
}
public async Task New(Context ctx)

View file

@ -61,6 +61,7 @@ public class InteractionCreated: IEventHandler<InteractionCreateEvent>
// got some unhandled command, log and ignore
_logger.Warning(@"Unhandled ApplicationCommand interaction: {EventId} {CommandName}", evt.Id, evt.Data?.Name);
break;
};
}
;
}
}

View file

@ -15,17 +15,21 @@ namespace PluralKit.Bot;
public class EmbedService
{
public const string LEGACY_EMBED_WARNING = "\u26A0\uFE0F The \"legacy\" embeds for system/member/group cards are deprecated, and will be removed in future.";
private readonly IDiscordCache _cache;
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly DiscordApiClient _rest;
private readonly CoreConfig _coreConfig;
public EmbedService(IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest)
public EmbedService(IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, CoreConfig coreConfig)
{
_db = db;
_repo = repo;
_cache = cache;
_rest = rest;
_coreConfig = coreConfig;
}
private Task<(ulong Id, User? User)[]> GetUsers(IEnumerable<ulong> ids)
@ -39,6 +43,169 @@ public class EmbedService
return Task.WhenAll(ids.Select(Inner));
}
public async Task<MessageComponent[]> CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx)
{
// Fetch/render info for all accounts simultaneously
var accounts = await _repo.GetSystemAccounts(system.Id);
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted account {x.Id})");
var linkedAccounts = new MessageComponent()
{
Type = ComponentType.Text,
Content = "**Linked accounts:**\n" + string.Join("\n", users).Truncate(1000),
};
var countctx = LookupContext.ByNonOwner;
if (cctx.MatchFlag("a", "all"))
{
if (system.Id == cctx.System.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
}
var memberCount = await _repo.GetSystemMemberCount(system.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
var guildSettings = cctx.Guild != null ? await _repo.GetSystemGuild(cctx.Guild.Id, system.Id) : null;
var avatar = system.AvatarFor(ctx);
var headerText = "";
if (system.PronounPrivacy.CanAccess(ctx) && system.Pronouns != null)
headerText += $"\n**Pronouns:** {system.Pronouns}";
if (system.Tag != null)
headerText += $"\n**Tag:** {system.Tag.EscapeMarkdown()}";
if (cctx.Config.CardShowColorHex && !system.Color.EmptyOrNull())
headerText += $"\n**Color:** #{system.Color}";
if (cctx.Guild != null)
{
if (guildSettings.Tag != null && guildSettings.TagEnabled)
headerText += $"\n**Tag (in server '{cctx.Guild.Name}'):** {guildSettings.Tag.EscapeMarkdown()}";
if (!guildSettings.TagEnabled)
headerText += $"\n**Tag (in server '{cctx.Guild.Name}'):** *(tag is disabled in this server)*";
}
if (system.MemberListPrivacy.CanAccess(ctx))
{
headerText += $"\n**Members:** {memberCount}";
if (system.Id == cctx.System.Id)
if (memberCount > 0)
headerText += $" (see `{cctx.DefaultPrefix}system list`)";
else
headerText += $" (add one with `{cctx.DefaultPrefix}member new`!)";
else if (memberCount > 0)
headerText += $" (see `{cctx.DefaultPrefix}system {system.DisplayHid(cctx.Config)} list`)";
}
List<MessageComponent> switchComponent = [];
var latestSwitch = await _repo.GetLatestSwitch(system.Id);
if (latestSwitch != null && system.FrontPrivacy.CanAccess(ctx))
{
var switchMembers =
await _db.Execute(conn => _repo.GetSwitchMembers(conn, latestSwitch.Id)).ToListAsync();
if (switchMembers.Count > 0)
{
var memberStr = string.Join(", ", switchMembers.Select(m => m.NameFor(ctx)));
if (memberStr.Length > 200)
memberStr = $"(too many to show, see `{cctx.DefaultPrefix}system {system.DisplayHid(cctx.Config)} fronters`)";
switchComponent.Add(new()
{
Type = ComponentType.Text,
Content = $"**{"Current fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None)}:** {memberStr}",
});
}
}
List<MessageComponent> descComponents = [];
if (system.DescriptionFor(ctx) is { } desc)
{
descComponents.Add(new()
{
Type = ComponentType.Separator,
});
descComponents.Add(new()
{
Type = ComponentType.Text,
Content = desc.NormalizeLineEndSpacing().Truncate(1024),
});
}
if (system.BannerPrivacy.CanAccess(ctx) && !string.IsNullOrWhiteSpace(system.BannerImage))
descComponents.Add(new()
{
Type = ComponentType.MediaGallery,
Items = [new() { Media = new() { Url = system.BannerImage } }],
});
var systemName = (cctx.Guild != null && guildSettings?.DisplayName != null) ? guildSettings?.DisplayName! : system.NameFor(ctx);
var premiumText = ""; // TODO(iris): "\n\U0001F31F *PluralKit Premium supporter!*";
List<MessageComponent> header = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### {systemName ?? $"`{system.DisplayHid(cctx.Config)}`"}{premiumText}",
},
];
if (!string.IsNullOrWhiteSpace(headerText))
header.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = headerText,
});
if (cctx.Guild != null)
{
var guildAvatar = guildSettings.AvatarUrl.TryGetCleanCdnUrl();
if (!string.IsNullOrWhiteSpace(guildAvatar))
avatar = guildAvatar;
}
if (!string.IsNullOrWhiteSpace(avatar))
header = [
new MessageComponent()
{
Type = ComponentType.Section,
Components = [.. header],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new() { Url = avatar },
},
},
];
return [
new MessageComponent()
{
Type = ComponentType.Container,
AccentColor = system.Color?.ToDiscordColor(),
Components = [ ..header, ..switchComponent, linkedAccounts, ..descComponents ],
},
new MessageComponent()
{
Type = ComponentType.Section,
Components = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {system.Created.FormatZoned(cctx.Zone)}",
},
],
Accessory = new MessageComponent()
{
Type = ComponentType.Button,
Style = ButtonStyle.Link,
Label = "View on dashboard",
Url = $"{_coreConfig.DashboardBaseUrl}/profile/s/{system.Hid}",
},
},
];
}
public async Task<Embed> CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx)
{
// Fetch/render info for all accounts simultaneously
@ -61,7 +228,7 @@ public class EmbedService
.Footer(new Embed.EmbedFooter(
$"System ID: {system.DisplayHid(cctx.Config)} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(system.Color?.ToDiscordColor())
.Url($"https://dash.pluralkit.me/profile/s/{system.Hid}");
.Url($"{_coreConfig.DashboardBaseUrl}/profile/s/{system.Hid}");
var avatar = system.AvatarFor(ctx);
if (avatar != null)
@ -164,6 +331,158 @@ public class EmbedService
return embed.Build();
}
public async Task<MessageComponent[]> CreateMemberMessageComponents(PKSystem system, PKMember member, Guild guild, SystemConfig? ccfg, LookupContext ctx, DateTimeZone zone)
{
var name = member.NameFor(ctx);
var systemGuildSettings = guild != null ? await _repo.GetSystemGuild(guild.Id, system.Id) : null;
var systemName = (guild != null && systemGuildSettings?.DisplayName != null) ? systemGuildSettings?.DisplayName! : system.NameFor(ctx);
var guildSettings = guild != null ? await _repo.GetMemberGuild(guild.Id, member.Id) : null;
var guildDisplayName = guildSettings?.DisplayName;
var webhook_avatar = guildSettings?.AvatarUrl ?? member.WebhookAvatarFor(ctx) ?? member.AvatarFor(ctx);
var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx);
var groups = await _repo.GetMemberGroups(member.Id)
.Where(g => g.Visibility.CanAccess(ctx))
.OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase)
.ToListAsync();
var headerText = "";
if (member.MemberVisibility == PrivacyLevel.Private)
headerText += "*(this member is hidden)*\n";
if (guildSettings?.AvatarUrl != null)
if (member.AvatarFor(ctx) != null)
headerText +=
$"*(this member has a server-specific avatar set; [click here]({member.AvatarUrl.TryGetCleanCdnUrl()}) to see the global avatar)*\n";
else
headerText += "*(this member has a server-specific avatar set)*\n";
if (!member.DisplayName.EmptyOrNull() && member.NamePrivacy.CanAccess(ctx))
headerText += $"\n**Display name:** {member.DisplayName.Truncate(1024)}";
if (guild != null && guildDisplayName != null)
headerText += $"\n**Server nickname (for '{guild.Name}'):** {guildDisplayName.Truncate(1024)}";
if (ccfg.CardShowColorHex && !member.Color.EmptyOrNull())
headerText += $"\n**Color:** #{member.Color}";
if (member.PronounsFor(ctx) is { } pronouns && !string.IsNullOrWhiteSpace(pronouns))
headerText += $"\n**Pronouns:** {pronouns}";
if (member.BirthdayFor(ctx) != null)
headerText += $"\n**Birthday:** {member.BirthdayString}";
if (member.MessageCountFor(ctx) is { } count && count > 0)
headerText += $"\n**Message count:** {member.MessageCount}";
List<MessageComponent> extraData = [];
if (member.HasProxyTags && member.ProxyPrivacy.CanAccess(ctx))
{
extraData.Add(new MessageComponent
{
Type = ComponentType.Separator,
});
extraData.Add(new MessageComponent
{
Type = ComponentType.Text,
Content = $"**Proxy tags:**\n{member.ProxyTagsString("\n").Truncate(1024)}",
});
}
if (groups.Count > 0)
{
// More than 5 groups show in "compact" format without ID
var content = groups.Count > 5
? string.Join(", ", groups.Select(g => g.DisplayName ?? g.Name))
: string.Join("\n", groups.Select(g => $"[`{g.DisplayHid(ccfg, isList: true)}`] **{g.DisplayName ?? g.Name}**"));
extraData.Add(new MessageComponent
{
Type = ComponentType.Separator,
});
extraData.Add(new MessageComponent
{
Type = ComponentType.Text,
Content = $"**Groups ({groups.Count}):**\n{content.Truncate(1000)}",
});
}
List<MessageComponent> descComponents = [];
if (member.DescriptionFor(ctx) is { } desc)
{
descComponents.Add(new()
{
Type = ComponentType.Separator,
});
descComponents.Add(new()
{
Type = ComponentType.Text,
Content = desc.NormalizeLineEndSpacing().Truncate(1024),
});
}
if (member.BannerPrivacy.CanAccess(ctx) && !string.IsNullOrWhiteSpace(member.BannerImage))
descComponents.Add(new()
{
Type = ComponentType.MediaGallery,
Items = [new() { Media = new() { Url = member.BannerImage } }],
});
List<MessageComponent> header = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### {name}{(systemName != null ? $" ({systemName})" : "")}",
},
];
if (!string.IsNullOrWhiteSpace(headerText))
header.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = headerText,
});
if (!string.IsNullOrWhiteSpace(avatar))
header = [
new MessageComponent()
{
Type = ComponentType.Section,
Components = [.. header],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new() { Url = avatar },
},
},
];
return [
new MessageComponent()
{
Type = ComponentType.Container,
AccentColor = member.Color?.ToDiscordColor(),
Components = [ ..header, ..extraData, ..descComponents ],
},
new MessageComponent()
{
Type = ComponentType.Section,
Components = [
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)}" : "")}",
},
],
Accessory = new MessageComponent()
{
Type = ComponentType.Button,
Style = ButtonStyle.Link,
Label = "View on dashboard",
Url = $"{_coreConfig.DashboardBaseUrl}/profile/m/{member.Hid}",
},
},
];
}
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, SystemConfig? ccfg, LookupContext ctx, DateTimeZone zone)
{
// string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
@ -188,7 +507,7 @@ public class EmbedService
.ToListAsync();
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
.Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"{_coreConfig.DashboardBaseUrl}/profile/m/{member.Hid}"))
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : null)
.Color(member.Color?.ToDiscordColor())
.Footer(new Embed.EmbedFooter(
@ -241,6 +560,119 @@ public class EmbedService
return eb.Build();
}
public async Task<MessageComponent[]> CreateGroupMessageComponents(Context ctx, PKSystem system, PKGroup target)
{
var pctx = ctx.LookupContextFor(system.Id);
var name = target.NameFor(ctx);
var systemGuildSettings = ctx.Guild != null ? await _repo.GetSystemGuild(ctx.Guild.Id, system.Id) : null;
var systemName = (ctx.Guild != null && systemGuildSettings?.DisplayName != null) ? systemGuildSettings?.DisplayName! : system.NameFor(ctx);
var countctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
{
if (system.Id == ctx.System.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
}
var memberCount = await _repo.GetGroupMemberCount(target.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
var headerText = "";
if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null)
headerText += $"\n**Display name:** {target.DisplayName}";
if (ctx.Config.CardShowColorHex && !target.Color.EmptyOrNull())
headerText += $"\n**Color:** #{target.Color}";
if (target.ListPrivacy.CanAccess(pctx))
{
headerText += $"\n**Members:** {memberCount}";
if (system.Id == ctx.System.Id && memberCount == 0)
headerText += $" (add one with `{ctx.DefaultPrefix}group {target.Reference(ctx)} add <member>`!)";
else if (memberCount > 0)
headerText += $" (see `{ctx.DefaultPrefix}group {target.Reference(ctx)} list`)";
}
List<MessageComponent> descComponents = [];
if (target.DescriptionFor(pctx) is { } desc)
{
descComponents.Add(new()
{
Type = ComponentType.Separator,
});
descComponents.Add(new()
{
Type = ComponentType.Text,
Content = desc.NormalizeLineEndSpacing().Truncate(1024),
});
}
if (target.BannerPrivacy.CanAccess(pctx) && !string.IsNullOrWhiteSpace(target.BannerImage))
descComponents.Add(new()
{
Type = ComponentType.MediaGallery,
Items = [new() { Media = new() { Url = target.BannerImage } }],
});
List<MessageComponent> header = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### {name}{(systemName != null ? $" ({systemName})" : "")}",
},
];
if (!string.IsNullOrWhiteSpace(headerText))
header.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = headerText,
});
if (target.IconFor(pctx) is { } icon)
header = [
new MessageComponent()
{
Type = ComponentType.Section,
Components = [.. header],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new() { Url = icon.TryGetCleanCdnUrl() },
},
},
];
return [
new MessageComponent()
{
Type = ComponentType.Container,
AccentColor = target.Color?.ToDiscordColor(),
Components = [ ..header, ..descComponents ],
},
new MessageComponent()
{
Type = ComponentType.Section,
Components = [
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)}" : "")}",
},
],
Accessory = new MessageComponent()
{
Type = ComponentType.Button,
Style = ButtonStyle.Link,
Label = "View on dashboard",
Url = $"{_coreConfig.DashboardBaseUrl}/profile/g/{target.Hid}",
},
},
];
}
public async Task<Embed> CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target)
{
var pctx = ctx.LookupContextFor(system.Id);
@ -266,7 +698,7 @@ public class EmbedService
nameField = $"{nameField}";
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"{_coreConfig.DashboardBaseUrl}/profile/g/{target.Hid}"))
.Color(target.Color?.ToDiscordColor());
eb.Footer(new Embed.EmbedFooter($"System ID: {system.DisplayHid(ctx.Config)} | Group ID: {target.DisplayHid(ctx.Config)}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));

View file

@ -16,6 +16,7 @@ public class CoreConfig
public string? SeqLogUrl { get; set; }
public string? DispatchProxyUrl { get; set; }
public string? DispatchProxyToken { get; set; }
public string DashboardBaseUrl { get; set; } = "https://dash.pluralkit.local";
public LogEventLevel ConsoleLogLevel { get; set; } = LogEventLevel.Debug;
public LogEventLevel ElasticLogLevel { get; set; } = LogEventLevel.Information;

View file

@ -22,6 +22,7 @@ public class SystemConfigPatch: PatchObject
public Partial<bool> ProxyErrorMessageEnabled { get; set; }
public Partial<bool> HidDisplaySplit { get; set; }
public Partial<bool> HidDisplayCaps { get; set; }
public Partial<bool> CardShowColorHex { get; set; }
public Partial<string?> NameFormat { get; set; }
public Partial<SystemConfig.HidPadFormat> HidListPadding { get; set; }
public Partial<SystemConfig.ProxySwitchAction> ProxySwitch { get; set; }
@ -41,6 +42,7 @@ public class SystemConfigPatch: PatchObject
.With("hid_display_split", HidDisplaySplit)
.With("hid_display_caps", HidDisplayCaps)
.With("hid_list_padding", HidListPadding)
.With("card_show_color_hex", CardShowColorHex)
.With("proxy_switch", ProxySwitch)
.With("name_format", NameFormat)
);
@ -107,6 +109,9 @@ public class SystemConfigPatch: PatchObject
if (HidListPadding.IsPresent)
o.Add("hid_list_padding", HidListPadding.Value.ToUserString());
if (CardShowColorHex.IsPresent)
o.Add("card_show_color_hex", CardShowColorHex.Value);
if (ProxySwitch.IsPresent)
o.Add("proxy_switch", ProxySwitch.Value.ToUserString());
@ -150,6 +155,9 @@ public class SystemConfigPatch: PatchObject
if (o.ContainsKey("hid_display_caps"))
patch.HidDisplayCaps = o.Value<bool>("hid_display_caps");
if (o.ContainsKey("card_show_color_hex"))
patch.CardShowColorHex = o.Value<bool>("card_show_color_hex");
if (o.ContainsKey("proxy_switch"))
patch.ProxySwitch = o.Value<string>("proxy_switch") switch
{

View file

@ -23,6 +23,7 @@ public class SystemConfig
public bool ProxyErrorMessageEnabled { get; }
public bool HidDisplaySplit { get; }
public bool HidDisplayCaps { get; }
public bool CardShowColorHex { get; }
public HidPadFormat HidListPadding { get; }
public ProxySwitchAction ProxySwitch { get; }
public string NameFormat { get; }
@ -60,6 +61,7 @@ public static class SystemConfigExt
o.Add("hid_display_split", cfg.HidDisplaySplit);
o.Add("hid_display_caps", cfg.HidDisplayCaps);
o.Add("hid_list_padding", cfg.HidListPadding.ToUserString());
o.Add("card_show_color_hex", cfg.CardShowColorHex);
o.Add("proxy_switch", cfg.ProxySwitch.ToUserString());
o.Add("name_format", cfg.NameFormat);

View file

@ -0,0 +1,6 @@
-- database version 53
-- add toggle for showing color codes on cv2 cards
alter table system_config add column card_show_color_hex bool default false;
update info set schema_version = 53;