feat(bot): initial ComponentsV2 implementation

This commit is contained in:
Iris System 2025-08-25 11:30:46 +12:00
parent 695d1debf2
commit 517d4d9b56
12 changed files with 401 additions and 67 deletions

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

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

@ -520,7 +520,7 @@ 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));
await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target));
}
public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand)

View file

@ -123,7 +123,7 @@ public class Member
{
var system = await ctx.Repository.GetSystem(target.System);
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

@ -18,7 +18,7 @@ public class System
{
if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
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

@ -39,11 +39,16 @@ public class EmbedService
return Task.WhenAll(ids.Select(Inner));
}
public async Task<Embed> CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx)
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"))
@ -55,21 +60,38 @@ public class EmbedService
}
var memberCount = await _repo.GetSystemMemberCount(system.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
var eb = new EmbedBuilder()
.Title(system.NameFor(ctx))
.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}");
var guildSettings = cctx.Guild != null ? await _repo.GetSystemGuild(cctx.Guild.Id, system.Id) : null;
var avatar = system.AvatarFor(ctx);
if (avatar != null)
eb.Thumbnail(new Embed.EmbedThumbnail(avatar));
var headerText = "";
if (system.BannerPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(system.BannerImage));
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.Guild != null)
{
if (guildSettings.Tag != null && guildSettings.TagEnabled)
headerText += $"**Tag (in server '{cctx.Guild.Name}'):** {guildSettings.Tag.EscapeMarkdown()}";
if (!guildSettings.TagEnabled)
headerText += $"**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))
{
@ -79,62 +101,87 @@ public class EmbedService
{
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`]";
eb.Field(new Embed.Field("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), memberStr));
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}",
});
}
}
if (system.Tag != null)
eb.Field(new Embed.Field("Tag", system.Tag.EscapeMarkdown(), true));
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))
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}](https://dash.pluralkit.me/profile/s/{system.Hid}){premiumText}",
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = headerText,
},
];
if (cctx.Guild != null)
{
var guildSettings = await _repo.GetSystemGuild(cctx.Guild.Id, system.Id);
if (guildSettings.Tag != null && guildSettings.TagEnabled)
eb.Field(new Embed.Field($"Tag (in server '{cctx.Guild.Name}')", guildSettings.Tag
.EscapeMarkdown(), true));
if (!guildSettings.TagEnabled)
eb.Field(new Embed.Field($"Tag (in server '{cctx.Guild.Name}')",
"*(tag is disabled in this server)*"));
if (guildSettings.DisplayName != null)
eb.Title(guildSettings.DisplayName);
var guildAvatar = guildSettings.AvatarUrl.TryGetCleanCdnUrl();
if (guildAvatar != null)
avatar = guildAvatar;
}
if (avatar != null)
header = [
new MessageComponent()
{
Type = ComponentType.Section,
Components = [.. header],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new() { Url = avatar },
},
},
];
return [
new MessageComponent()
{
eb.Thumbnail(new Embed.EmbedThumbnail(guildAvatar));
var sysDesc = "*(this system has a server-specific avatar set";
if (avatar != null)
sysDesc += $"; [click here]({system.AvatarUrl.TryGetCleanCdnUrl()}) to see their global avatar)*";
else
sysDesc += ")*";
eb.Description(sysDesc);
}
}
if (system.PronounPrivacy.CanAccess(ctx) && system.Pronouns != null)
eb.Field(new Embed.Field("Pronouns", system.Pronouns, true));
if (!system.Color.EmptyOrNull()) eb.Field(new Embed.Field("Color", $"#{system.Color}", true));
eb.Field(new Embed.Field("Linked accounts", string.Join("\n", users).Truncate(1000), true));
if (system.MemberListPrivacy.CanAccess(ctx))
{
if (memberCount > 0)
eb.Field(new Embed.Field($"Members ({memberCount})",
$"(see `{cctx.DefaultPrefix}system {system.DisplayHid(cctx.Config)} list` or `{cctx.DefaultPrefix}system {system.DisplayHid(cctx.Config)} list full`)", true));
else
eb.Field(new Embed.Field($"Members ({memberCount})", $"Add one with `{cctx.DefaultPrefix}member new`!", true));
}
if (system.DescriptionFor(ctx) is { } desc)
eb.Field(new Embed.Field("Description", desc.NormalizeLineEndSpacing().Truncate(1024)));
return eb.Build();
Type = ComponentType.Container,
AccentColor = system.Color?.ToDiscordColor(),
Components = [ ..header, ..switchComponent, linkedAccounts, ..descComponents ],
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {system.Created.FormatZoned(cctx.Zone)}",
},
];
}
public Embed CreateLoggedMessageEmbed(Message triggerMessage, Message proxiedMessage, string systemHid,
@ -164,6 +211,135 @@ 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 (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.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.Text,
Content = $"**Groups ({groups.Count}):**\n{content.Truncate(1000)}",
});
}
if (extraData.Count > 0)
extraData.Insert(0, new MessageComponent
{
Type = ComponentType.Separator,
});
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}](https://dash.pluralkit.me/profile/m/{member.Hid}){(systemName != null ? $" ({systemName})" : "")}",
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = headerText,
},
];
if (avatar != null)
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.Text,
Content = $"-# System ID: `{system.DisplayHid(ccfg)}` \u2219 Member ID: `{member.DisplayHid(ccfg)}`{(member.MetadataPrivacy.CanAccess(ctx) ? $"\n-# Created: {member.Created.FormatZoned(zone)}" : "")}",
},
];
}
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));
@ -241,6 +417,101 @@ 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 (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}](https://dash.pluralkit.me/profile/g/{target.Hid}){(systemName != null ? $" ({systemName})" : "")}",
},
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.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)}" : "")}",
},
];
}
public async Task<Embed> CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target)
{
var pctx = ctx.LookupContextFor(system.Id);