Compare commits

...

11 commits

Author SHA1 Message Date
asleepyskye
981546647a WIP: port fronters embed to CV2
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-11-07 09:57:35 -05:00
asleepyskye
83f866384a feat(bot): use discord timestamps on cv2 cards 2025-11-07 09:20:23 -05:00
asleepyskye
0983179240 fix(bot): add allowed mentions to msg info replies
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
2025-10-24 21:26:33 -04:00
asleepyskye
49ce00e675 fix(bot): check for null avatar in msg info 2025-10-24 19:55:53 -04:00
asleepyskye
83f2d33c3d feat(bot): port message info embeds to cv2
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
2025-10-24 10:23:38 -04:00
Jake Fulmine
14f11bd1e9 fix(bot): take member name privacy into account when viewing member groups
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
2025-09-20 02:46:29 +02:00
asleepyskye
39179f8e3a fix(gateway): properly check for reconnect
Some checks failed
Build and push Rust service Docker images / rust docker build (push) Has been cancelled
rust checks / cargo fmt (push) Has been cancelled
2025-09-19 09:30:20 -04:00
alyssa
24361d9d2b fix(bot): correctly check for existence of current system in embeds
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
2025-09-18 23:32:00 +00:00
asleepyskye
9c99a0bc02 chore: update faq for cv2 2025-09-18 08:06:55 -04:00
asleepyskye
ebf8a40369 fix(bot): fix utility admin command
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
2025-09-09 11:32:13 -04:00
asleepyskye
8bca02032f feat(bot): add utility admin command 2025-09-09 10:43:00 -04:00
17 changed files with 658 additions and 73 deletions

View file

@ -11,6 +11,7 @@ public record MessageComponent
public string? Url { get; init; }
public bool? Disabled { get; init; }
public uint? AccentColor { get; init; }
public int? Spacing { get; init; }
public ComponentMedia? Media { get; init; }
public ComponentMediaItem[]? Items { get; init; }

View file

@ -43,11 +43,10 @@ public class ApplicationCommandProxiedMessage
if (channel == null)
showContent = false;
var embeds = new List<Embed>();
var components = new List<MessageComponent>();
var guild = await _cache.GetGuild(ctx.GuildId);
if (msg.Member != null)
embeds.Add(await _embeds.CreateMemberEmbed(
components.AddRange(await _embeds.CreateMemberMessageComponents(
msg.System,
msg.Member,
guild,
@ -55,10 +54,12 @@ public class ApplicationCommandProxiedMessage
LookupContext.ByNonOwner,
DateTimeZone.Utc
));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent, ctx.Config));
await ctx.Reply(embeds: embeds.ToArray());
components.Add(new MessageComponent()
{
Type = ComponentType.Separator
});
components.AddRange(await _embeds.CreateMessageInfoMessageComponents(msg, showContent, ctx.Config));
await ctx.Reply(components: components.ToArray());
}
private async Task QueryCommandMessage(InteractionContext ctx)
@ -68,11 +69,7 @@ public class ApplicationCommandProxiedMessage
if (msg == null)
throw Errors.MessageNotFound(messageId);
var embeds = new List<Embed>();
embeds.Add(await _embeds.CreateCommandMessageInfoEmbed(msg, true));
await ctx.Reply(embeds: embeds.ToArray());
await ctx.Reply(components: await _embeds.CreateCommandMessageInfoMessageComponents(msg, true));
}
public async Task DeleteMessage(InteractionContext ctx)

View file

@ -181,6 +181,8 @@ public partial class CommandTree
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx));
else if (ctx.Match("sendmsg", "sendmessage"))
await ctx.Execute<Admin>(Admin, a => a.SendAdminMessage(ctx));
else if (ctx.Match("al", "abuselog"))
await HandleAdminAbuseLogCommand(ctx);
else
@ -594,6 +596,8 @@ public partial class CommandTree
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[] { "fronter", "front" }, new[] { "list" }, new[] { "format" }) || ctx.Match("fronterlistformat", "frontlistformat", "flf"))
return ctx.Execute<Config>(null, m => m.FronterListFormat(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

@ -9,6 +9,8 @@ using Myriad.Extensions;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Rest.Exceptions;
using PluralKit.Core;
@ -19,12 +21,14 @@ public class Admin
private readonly BotConfig _botConfig;
private readonly DiscordApiClient _rest;
private readonly IDiscordCache _cache;
private readonly PrivateChannelService _dmCache;
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache)
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache, PrivateChannelService dmCache)
{
_botConfig = botConfig;
_rest = rest;
_cache = cache;
_dmCache = dmCache;
}
private Task<(ulong Id, User? User)[]> GetUsers(IEnumerable<ulong> ids)
@ -496,4 +500,34 @@ public class Admin
await ctx.Repository.DeleteAbuseLog(abuseLog.Id);
await ctx.Reply($"{Emojis.Success} Successfully deleted abuse log entry.");
}
public async Task SendAdminMessage(Context ctx)
{
ctx.AssertBotAdmin();
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to send an admin message to (either ID or @mention).");
if (!ctx.HasNext())
throw new PKError("You must provide a message to send.");
var content = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var messageContent = $"## [Admin Message]\n\n{content}\n\nWe cannot read replies sent to this DM. If you wish to contact the staff team, please join the support server (<https://discord.gg/PczBt78>) or send us an email at <legal@pluralkit.me>.";
try
{
var dm = await _dmCache.GetOrCreateDmChannel(account.Id);
var msg = await ctx.Rest.CreateMessage(dm,
new MessageRequest { Content = messageContent }
);
}
catch (ForbiddenException)
{
await ctx.Reply(
$"{Emojis.Error} Error while sending DM.");
return;
}
await ctx.Reply($"{Emojis.Success} Successfully sent message.");
}
}

View file

@ -9,6 +9,7 @@ using NodaTime.TimeZones;
using PluralKit.Core;
namespace PluralKit.Bot;
public class Config
{
private record PaginatedConfigItem(string Key, string Description, string? CurrentValue, string DefaultValue);
@ -130,6 +131,13 @@ public class Config
"disabled"
));
items.Add(new(
"fronter list format",
"Whether to show the fronter list as full or short.",
ctx.Config.FronterListFormat.ToUserString(),
"short"
));
items.Add(new(
"Proxy Switch",
"Switching behavior when proxy tags are used",
@ -591,6 +599,29 @@ public class Config
await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(newVal)}.");
}
public async Task FronterListFormat(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Format of the fronter list is currently **{ctx.Config.FronterListFormat}**.";
await ctx.Reply(msg);
return;
}
var badInputError = "Valid list format settings are `short` or `full`.";
if (ctx.Match("full", "f"))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { FronterListFormat = SystemConfig.ListFormat.Full });
await ctx.Reply($"Fronter lists are now formatted as `full`");
}
else if (ctx.Match("short", "s"))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { FronterListFormat = SystemConfig.ListFormat.Short });
await ctx.Reply($"Fronter lists are now formatted as `short`");
}
else throw new PKError(badInputError);
}
public async Task ProxySwitch(Context ctx)
{
if (!ctx.HasNext())

View file

@ -57,7 +57,7 @@ public class GroupMember
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System));
opts.MemberFilter = target.Id;
var title = new StringBuilder($"Groups containing {target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
var title = new StringBuilder($"Groups containing {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`) in ");
if (ctx.Guild != null)
{
var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, targetSystem.Id);

View file

@ -426,21 +426,33 @@ public class ProxiedMessage
if (ctx.Match("author") || ctx.MatchFlag("author"))
{
var user = await _rest.GetUser(message.Message.Sender);
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(
user != null
? $"{user.Username}#{user.Discriminator}"
: $"Deleted user ${message.Message.Sender}",
IconUrl: user != null ? user.AvatarUrl() : null))
.Description(message.Message.Sender.ToString());
if (ctx.MatchFlag("show-embed", "se"))
{
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(
user != null
? $"{user.Username}#{user.Discriminator}"
: $"Deleted user ${message.Message.Sender}",
IconUrl: user != null ? user.AvatarUrl() : null))
.Description(message.Message.Sender.ToString());
await ctx.Reply(
user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*",
eb.Build());
await ctx.Reply(
user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*",
eb.Build());
return;
}
await ctx.Reply(components: await _embeds.CreateAuthorMessageComponents(user, message));
return;
}
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
return;
}
await ctx.Reply(components: await _embeds.CreateMessageInfoMessageComponents(message, showContent, ctx.Config));
}
private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete)
@ -472,6 +484,11 @@ public class ProxiedMessage
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false;
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent));
return;
}
await ctx.Reply(components: await _embeds.CreateCommandMessageInfoMessageComponents(msg, showContent));
}
}

View file

@ -23,7 +23,12 @@ public class SystemFront
var sw = await ctx.Repository.GetLatestSwitch(system.Id);
if (sw == null) throw Errors.NoRegisteredSwitches;
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system.Id)));
if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system.Id)));
return;
}
await ctx.Reply(components: await _embeds.CreateFronterMessageComponents(ctx, system, sw, ctx.LookupContextFor(system.Id)));
}
public async Task SystemFrontHistory(Context ctx, PKSystem system)

View file

@ -186,10 +186,9 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
{
var dm = await _dmCache.GetOrCreateDmChannel(evt.UserId);
var embeds = new List<Embed>();
var components = new List<MessageComponent>();
if (msg.Member != null)
embeds.Add(await _embeds.CreateMemberEmbed(
components.AddRange(await _embeds.CreateMemberMessageComponents(
msg.System,
msg.Member,
guild,
@ -197,10 +196,12 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
LookupContext.ByNonOwner,
DateTimeZone.Utc
));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, true, config));
await _rest.CreateMessage(dm, new MessageRequest { Embeds = embeds.ToArray() });
components.Add(new MessageComponent()
{
Type = ComponentType.Separator
});
components.AddRange(await _embeds.CreateMessageInfoMessageComponents(msg, true, config));
await _rest.CreateMessage(dm, new MessageRequest { Components = components.ToArray(), Flags = Message.MessageFlags.IsComponentsV2, AllowedMentions = new AllowedMentions() });
}
catch (ForbiddenException) { } // No permissions to DM, can't check for this :(

View file

@ -57,7 +57,7 @@ public class EmbedService
var countctx = LookupContext.ByNonOwner;
if (cctx.MatchFlag("a", "all"))
{
if (system.Id == cctx.System.Id)
if (system.Id == cctx.System?.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
@ -89,7 +89,7 @@ public class EmbedService
if (system.MemberListPrivacy.CanAccess(ctx))
{
headerText += $"\n**Members:** {memberCount}";
if (system.Id == cctx.System.Id)
if (system.Id == cctx.System?.Id)
if (memberCount > 0)
headerText += $" (see `{cctx.DefaultPrefix}system list`)";
else
@ -192,7 +192,7 @@ public class EmbedService
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {system.Created.FormatZoned(cctx.Zone)}",
Content = $"-# System ID: `{system.DisplayHid(cctx.Config)}`\n-# Created: {DiscordUtils.InstantToTimestampString(system.Created)}",
},
],
Accessory = new MessageComponent()
@ -215,7 +215,7 @@ public class EmbedService
var countctx = LookupContext.ByNonOwner;
if (cctx.MatchFlag("a", "all"))
{
if (system.Id == cctx.System.Id)
if (system.Id == cctx.System?.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
@ -469,7 +469,7 @@ public class EmbedService
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# System ID: `{system.DisplayHid(ccfg)}` \u2219 Member ID: `{member.DisplayHid(ccfg)}`{(member.MetadataPrivacy.CanAccess(ctx) ? $"\n-# Created: {member.Created.FormatZoned(zone)}" : "")}",
Content = $"-# System ID: `{system.DisplayHid(ccfg)}` \u2219 Member ID: `{member.DisplayHid(ccfg)}`{(member.MetadataPrivacy.CanAccess(ctx) ? $"\n-# Created: {DiscordUtils.InstantToTimestampString(member.Created)}" : "")}",
},
],
Accessory = new MessageComponent()
@ -570,7 +570,7 @@ public class EmbedService
var countctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
{
if (system.Id == ctx.System.Id)
if (system.Id == ctx.System?.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
@ -588,7 +588,7 @@ public class EmbedService
if (target.ListPrivacy.CanAccess(pctx))
{
headerText += $"\n**Members:** {memberCount}";
if (system.Id == ctx.System.Id && memberCount == 0)
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`)";
@ -659,7 +659,7 @@ public class EmbedService
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# System ID: `{system.DisplayHid(ctx.Config)}` \u2219 Group ID: `{target.DisplayHid(ctx.Config)}`{(target.MetadataPrivacy.CanAccess(pctx) ? $"\n-# Created: {target.Created.FormatZoned(ctx.Zone)}" : "")}",
Content = $"-# System ID: `{system.DisplayHid(ctx.Config)}` \u2219 Group ID: `{target.DisplayHid(ctx.Config)}`{(target.MetadataPrivacy.CanAccess(pctx) ? $"\n-# Created: {DiscordUtils.InstantToTimestampString(target.Created)}" : "")}",
},
],
Accessory = new MessageComponent()
@ -680,7 +680,7 @@ public class EmbedService
var countctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
{
if (system.Id == ctx.System.Id)
if (system.Id == ctx.System?.Id)
countctx = LookupContext.ByOwner;
else
throw Errors.LookupNotAllowed;
@ -766,6 +766,337 @@ public class EmbedService
.Build();
}
public async Task<MessageComponent[]> CreateFronterMessageComponents(Context cctx, PKSystem system, PKSwitch sw, LookupContext ctx)
{
var formatType = cctx.Config.FronterListFormat;
if (cctx.MatchFlag("short")) formatType = SystemConfig.ListFormat.Short;
if (cctx.MatchFlag("full")) formatType = SystemConfig.ListFormat.Full;
if (formatType == SystemConfig.ListFormat.Full)
{
return await CreateFronterList(cctx, system, sw, ctx);
}
return await CreateFronterListShort(cctx, system, sw, ctx);
}
public async Task<MessageComponent[]> CreateFronterListShort(Context cctx, PKSystem system, PKSwitch sw, LookupContext ctx)
{
var systemGuildSettings = await _repo.GetSystemGuild(cctx.Guild.Id, system.Id);
var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask());
var memberStr = "*(no fronter)*";
if (members.Count > 0)
{
memberStr = "";
foreach (var item in members.Select((value, i) => new { i, value }))
{
memberStr += item.i == 0 ? "" : ", ";
if (memberStr.Length < 900)
memberStr += item.value.NameFor(ctx);
else
{
memberStr += $"*({members.Count - item.i} not shown)*";
break;
}
}
}
return [
new MessageComponent(){
Type = ComponentType.Text,
Content = $"## Current fronter(s) in {systemGuildSettings.DisplayName ?? system.NameFor(ctx) ?? $"`{system.Hid}`"}"
},
new MessageComponent(){
Type = ComponentType.Container,
AccentColor = members.FirstOrDefault()?.Color?.ToDiscordColor(),
Components = [
new MessageComponent(){
Type = ComponentType.Text,
Content = $"**Current fronters:** \n{memberStr}"
},
new MessageComponent(){
Type = ComponentType.Separator,
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"**Since:** {DiscordUtils.InstantToTimestampString(sw.Timestamp)} (<t:{sw.Timestamp.ToUnixTimeSeconds()}:R>)"
}
]
},
];
}
public async Task<MessageComponent[]> CreateFronterList(Context cctx, PKSystem system, PKSwitch sw, LookupContext ctx)
{
var systemGuildSettings = await _repo.GetSystemGuild(cctx.Guild.Id, system.Id);
var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask());
var memberStr = "*(no fronter)*";
var memberList = new List<MessageComponent>();
if (members.Count > 0)
{
memberStr = "";
foreach (var item in members.Select((value, i) => new { i, value }))
{
if (item.i < 5)
{
var guildSettings = cctx.Guild != null ? await _repo.GetMemberGuild(cctx.Guild.Id, item.value.Id) : null;
var avatar = guildSettings?.AvatarUrl ?? item.value.AvatarFor(ctx) ?? systemGuildSettings.AvatarUrl ?? system.AvatarFor(ctx);
var pronouns = item.value.PronounsFor(ctx);
var displayName = (guildSettings?.DisplayName) ?? item.value.DisplayName;
var headerText = "";
if (!displayName.EmptyOrNull() && item.value.NamePrivacy.CanAccess(ctx))
{
headerText += $"**Display name:** {displayName}\n";
}
if (!pronouns.EmptyOrNull())
{
headerText += $"**Pronouns:** {item.value.PronounsFor(ctx)}\n";
}
headerText += $"[View on dashboard]({_coreConfig.DashboardBaseUrl}/profile/m/{item.value.Hid})";
var nameItem = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### {item.value.NameFor(ctx)}\n{headerText}"
};
if (!avatar.EmptyOrNull())
{
memberList.Add(new MessageComponent()
{
Type = ComponentType.Section,
Components = [nameItem],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new ComponentMedia()
{
Url = avatar.TryGetCleanCdnUrl()
}
}
});
}
else
{
memberList.Add(nameItem);
}
memberList.Add(new MessageComponent()
{
Type = ComponentType.Separator,
});
}
else
{
memberStr += item.i == 5 ? "+ " : ", ";
if (memberStr.Length < 900)
memberStr += item.value.NameFor(ctx);
else
{
memberStr += $"*({members.Count - item.i} not shown)*";
break;
}
}
}
if (memberStr.Length > 0)
{
memberList.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = $"{memberStr}"
});
memberList.Add(new MessageComponent()
{
Type = ComponentType.Separator,
});
}
}
else
{
memberList.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = "*(no fronter)*"
});
memberList.Add(new MessageComponent()
{
Type = ComponentType.Separator
});
}
memberList.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = $"**Since:** {DiscordUtils.InstantToTimestampString(sw.Timestamp)} (<t:{sw.Timestamp.ToUnixTimeSeconds()}:R>)"
});
return [
new MessageComponent(){
Type = ComponentType.Text,
Content = $"## Current fronter(s) in {systemGuildSettings.DisplayName ?? system.NameFor(ctx) ?? $"`{system.Hid}`"}"
},
new MessageComponent(){
Type = ComponentType.Container,
AccentColor = members.FirstOrDefault()?.Color?.ToDiscordColor(),
Components = [
.. memberList.ToArray()
]
}
];
}
public async Task<MessageComponent[]> CreateMessageInfoMessageComponents(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
{
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
var ctx = LookupContext.ByNonOwner;
var serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
// Need this whole dance to handle cases where:
// - the user is deleted (userInfo == null)
// - the bot's no longer in the server we're querying (channel == null)
// - the member is no longer in the server we're querying (memberInfo == null)
// TODO: optimize ordering here a bit with new cache impl; and figure what happens if bot leaves server -> channel still cached -> hits this bit and 401s?
GuildMemberPartial memberInfo = null;
User userInfo = null;
if (channel != null)
{
GuildMember member = null;
try
{
member = await _rest.GetGuildMember(channel.GuildId!.Value, msg.Message.Sender);
}
catch (ForbiddenException)
{
// no permission, couldn't fetch, oh well
}
if (member != null)
// Don't do an extra request if we already have this info from the member lookup
userInfo = member.User;
memberInfo = member;
}
if (userInfo == null)
userInfo = await _cache.GetOrFetchUser(_rest, msg.Message.Sender);
// Calculate string displayed under "Sent by"
string userStr;
if (showContent && memberInfo != null && memberInfo.Nick != null)
userStr = $"**\n Username:** {userInfo.NameAndMention()}\n** Nickname:** {memberInfo.Nick}";
else if (userInfo != null) userStr = userInfo.NameAndMention();
else userStr = $"*(deleted user {msg.Message.Sender})*";
var content = serverMsg?.Content?.NormalizeLineEndSpacing();
if (content == null || !showContent)
content = "*(message contents deleted or inaccessible)*";
var systemStr = msg.System == null
? "*(deleted or unknown system)*"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.DisplayHid(ccfg)}`)" : $"`{msg.System.DisplayHid(ccfg)}`";
var memberStr = msg.Member == null
? "*(deleted member)*"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.DisplayHid(ccfg)}`)";
var roles = memberInfo?.Roles?.ToList();
var rolesContent = "";
if (roles != null && roles.Count > 0 && showContent)
{
var guild = await _cache.GetGuild(channel.GuildId!.Value);
var rolesString = string.Join(", ", (roles
.Select(id =>
{
var role = Array.Find(guild.Roles, r => r.Id == id);
if (role != null)
return role;
return new Role { Name = "*(unknown role)*", Position = 0 };
}))
.OrderByDescending(role => role.Position)
.Select(role => role.Name));
rolesContent = $"**Account Roles ({roles.Count})**\n{rolesString}";
}
MessageComponent authorData = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"**System:** {systemStr}\n**Member:** {memberStr}\n**Sent by:** {userStr}\n\n{rolesContent}"
};
var avatarURL = msg.Member?.AvatarFor(ctx).TryGetCleanCdnUrl();
MessageComponent header = (avatarURL == "" || avatarURL == null) ? authorData : new MessageComponent()
{
Type = ComponentType.Section,
Components = [authorData],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new ComponentMedia()
{
Url = avatarURL
}
}
};
List<MessageComponent> body = [
new MessageComponent()
{
Type = ComponentType.Separator,
Spacing = 2
}
];
if (content != "")
{
body.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = content
});
}
if (showContent)
{
if (serverMsg != null)
{
var media = new List<ComponentMediaItem>();
foreach (Message.Attachment attachment in serverMsg?.Attachments)
{
var url = attachment.Url;
if (url != null && url != "")
media.Add(new ComponentMediaItem()
{
Media = new ComponentMedia()
{
Url = url
}
});
}
if (media.Count > 0)
body.Add(new MessageComponent()
{
Type = ComponentType.MediaGallery,
Items = media.ToArray()
});
}
}
MessageComponent footer = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# Original Message ID: {msg.Message.OriginalMid} · {DiscordUtils.InstantToTimestampString(DiscordUtils.SnowflakeToInstant(msg.Message.Mid))}"
};
return [
new MessageComponent()
{
Type = ComponentType.Container,
Components = [
header,
..body
]
},
footer
];
}
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
{
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
@ -852,6 +1183,106 @@ public class EmbedService
return eb.Build();
}
public async Task<MessageComponent[]> CreateAuthorMessageComponents(User? user, FullMessage msg)
{
MessageComponent authorInfo;
var author = user != null
? $"{user.Username}#{user.Discriminator}"
: $"Deleted user ${msg.Message.Sender}";
var avatarUrl = user?.AvatarUrl();
var authorString = $"{author}\n**ID: **`{msg.Message.Sender.ToString()}`";
if (user != null && avatarUrl != "")
{
authorInfo = new MessageComponent()
{
Type = ComponentType.Section,
Components = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = authorString
}
],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new ComponentMedia()
{
Url = avatarUrl
}
}
};
}
else
{
authorInfo = new MessageComponent()
{
Type = ComponentType.Text,
Content = authorString
};
}
MessageComponent container = new MessageComponent()
{
Type = ComponentType.Container,
Components = [
authorInfo,
]
};
return (
[
new MessageComponent()
{
Type = ComponentType.Text,
Content = user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {msg.Message.Sender})*"
},
container
]
);
}
public async Task<MessageComponent[]> CreateCommandMessageInfoMessageComponents(Core.CommandMessage msg, bool showContent)
{
var content = "*(command message deleted or inaccessible)*";
if (showContent)
{
var discordMessage = await _rest.GetMessageOrNull(msg.Channel, msg.OriginalMid);
if (discordMessage != null)
content = discordMessage.Content;
}
List<MessageComponent> body = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### Command response message\n**Original message:** https://discord.com/channels/{msg.Guild}/{msg.Channel}/{msg.OriginalMid}\n**Sent By:** <@{msg.Sender}>"
},
new MessageComponent()
{
Type = ComponentType.Separator,
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = content
},
];
MessageComponent footer = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# Original Message ID: {msg.OriginalMid} · {DiscordUtils.InstantToTimestampString(DiscordUtils.SnowflakeToInstant(msg.OriginalMid))}"
};
return [
new MessageComponent(){
Type = ComponentType.Container,
Components = [
..body
]
},
footer
];
}
public async Task<Embed> CreateCommandMessageInfoEmbed(Core.CommandMessage msg, bool showContent)
{
var content = "*(command message deleted or inaccessible)*";

View file

@ -39,9 +39,15 @@ public static class DiscordUtils
public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22);
public static ulong SnowflakeToTimestamp(ulong snowflake) =>
((ulong)Instant.FromUtc(2015, 1, 1, 0, 0, 0).ToUnixTimeMilliseconds() + (snowflake >> 22)) / 1000;
public static ulong InstantToSnowflake(Instant time) =>
(ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22;
public static string InstantToTimestampString(Instant time) =>
$"<t:{time.ToUnixTimeSeconds()}:f>";
public static async Task CreateReactionsBulk(this DiscordApiClient rest, Message msg, string[] reactions)
{
foreach (var reaction in reactions)

View file

@ -5,6 +5,7 @@ using Autofac;
using Myriad.Cache;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Types;
using Myriad.Types;
using PluralKit.Core;
@ -76,6 +77,17 @@ public class InteractionContext
});
}
public async Task Reply(MessageComponent[] components = null, AllowedMentions? mentions = null)
{
await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
new InteractionApplicationCommandCallbackData
{
Components = components,
Flags = Message.MessageFlags.Ephemeral | Message.MessageFlags.IsComponentsV2,
AllowedMentions = mentions ?? new AllowedMentions()
});
}
public async Task Defer()
{
await Respond(InteractionResponse.ResponseType.DeferredChannelMessageWithSource,

View file

@ -25,6 +25,7 @@ public class SystemConfigPatch: PatchObject
public Partial<bool> CardShowColorHex { get; set; }
public Partial<string?> NameFormat { get; set; }
public Partial<SystemConfig.HidPadFormat> HidListPadding { get; set; }
public Partial<SystemConfig.ListFormat> FronterListFormat { get; set; }
public Partial<SystemConfig.ProxySwitchAction> ProxySwitch { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
@ -43,6 +44,7 @@ public class SystemConfigPatch: PatchObject
.With("hid_display_caps", HidDisplayCaps)
.With("hid_list_padding", HidListPadding)
.With("card_show_color_hex", CardShowColorHex)
.With("fronter_list_format", FronterListFormat)
.With("proxy_switch", ProxySwitch)
.With("name_format", NameFormat)
);
@ -112,6 +114,9 @@ public class SystemConfigPatch: PatchObject
if (CardShowColorHex.IsPresent)
o.Add("card_show_color_hex", CardShowColorHex.Value);
if (FronterListFormat.IsPresent)
o.Add("fronter_list_format", FronterListFormat.Value.ToUserString());
if (ProxySwitch.IsPresent)
o.Add("proxy_switch", ProxySwitch.Value.ToUserString());
@ -158,6 +163,13 @@ public class SystemConfigPatch: PatchObject
if (o.ContainsKey("card_show_color_hex"))
patch.CardShowColorHex = o.Value<bool>("card_show_color_hex");
if (o.ContainsKey("fronter_list_format"))
patch.FronterListFormat = o.Value<string>("fronter_list_format") switch
{
"full" => SystemConfig.ListFormat.Full,
_ => SystemConfig.ListFormat.Short,
};
if (o.ContainsKey("proxy_switch"))
patch.ProxySwitch = o.Value<string>("proxy_switch") switch
{

View file

@ -26,6 +26,7 @@ public class SystemConfig
public bool CardShowColorHex { get; }
public HidPadFormat HidListPadding { get; }
public ProxySwitchAction ProxySwitch { get; }
public ListFormat FronterListFormat { get; }
public string NameFormat { get; }
public enum HidPadFormat
@ -40,6 +41,11 @@ public class SystemConfig
New = 1,
Add = 2,
}
public enum ListFormat
{
Short = 0,
Full = 1,
}
}
public static class SystemConfigExt
@ -62,6 +68,7 @@ public static class SystemConfigExt
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("fronter_list_format", cfg.FronterListFormat.ToUserString());
o.Add("proxy_switch", cfg.ProxySwitch.ToUserString());
o.Add("name_format", cfg.NameFormat);
@ -77,5 +84,5 @@ public static class SystemConfigExt
}
public static string ToUserString(this SystemConfig.ProxySwitchAction val) => val.ToString().ToLower();
public static string ToUserString(this SystemConfig.ListFormat val) => val.ToString().ToLower();
}

View file

@ -6,7 +6,7 @@ use std::sync::Arc;
use tokio::sync::mpsc::Sender;
use tracing::{error, info, warn};
use twilight_gateway::{
CloseFrame, ConfigBuilder, Event, EventTypeFlags, Message, Shard, ShardId, create_iterator,
ConfigBuilder, Event, EventTypeFlags, Message, Shard, ShardId, create_iterator,
};
use twilight_model::gateway::{
Intents,
@ -118,8 +118,11 @@ pub async fn runner(
Message::Close(frame) => {
let mut state_event = ShardStateEvent::Closed;
let close_code = if let Some(close) = frame {
if close == CloseFrame::RESUME {
state_event = ShardStateEvent::Reconnect;
match close.code {
4000..=4003 | 4005..=4009 => {
state_event = ShardStateEvent::Reconnect;
}
_ => {}
}
close.code.to_string()
} else {
@ -176,32 +179,45 @@ pub async fn runner(
)
.increment(1);
// update shard state and discord cache
if matches!(event, Event::Ready(_)) || matches!(event, Event::Resumed) {
if let Err(error) = tx_state.try_send((
shard.id(),
ShardStateEvent::Other,
Some(event.clone()),
None,
)) {
tracing::error!(?error, "error updating shard state");
// check for shard status events
match event {
Event::Ready(_) | Event::Resumed => {
if let Err(error) = tx_state.try_send((
shard.id(),
ShardStateEvent::Other,
Some(event.clone()),
None,
)) {
tracing::error!(?error, "error updating shard state");
}
}
}
// need to do heartbeat separately, to get the latency
let latency_num = shard
.latency()
.recent()
.first()
.map_or_else(|| 0, |d| d.as_millis()) as i32;
if let Event::GatewayHeartbeatAck = event
&& let Err(error) = tx_state.try_send((
shard.id(),
ShardStateEvent::Heartbeat,
Some(event.clone()),
Some(latency_num),
))
{
tracing::error!(?error, "error updating shard state for latency");
Event::GatewayReconnect => {
if let Err(error) = tx_state.try_send((
shard.id(),
ShardStateEvent::Reconnect,
Some(event.clone()),
None,
)) {
tracing::error!(?error, "error updating shard state for reconnect");
}
}
Event::GatewayHeartbeatAck => {
// need to do heartbeat separately, to get the latency
let latency_num = shard
.latency()
.recent()
.first()
.map_or_else(|| 0, |d| d.as_millis()) as i32;
if let Err(error) = tx_state.try_send((
shard.id(),
ShardStateEvent::Heartbeat,
Some(event.clone()),
Some(latency_num),
)) {
tracing::error!(?error, "error updating shard state for latency");
}
}
_ => {}
}
if let Event::Ready(_) = event {

View file

@ -0,0 +1,6 @@
-- database version 54
-- add short/full option for fronter list
alter table system_config add column fronter_list_format int default 0;
update info set schema_version = 54;

View file

@ -99,4 +99,9 @@ It is not possible to edit messages via ID. Please use the full link, or reply t
You cannot reply-@ a proxied messages due to their nature as webhooks. If you want to "reply-@" a proxied message, you must react to the message with 🔔, 🛎, or 🏓. This will send a message from PluralKit that reads "Psst, MEMBER (@User), you have been pinged by @You", which will ping the Discord account behind the proxied message.
### Why do most of PluralKit's messages look blank or empty?
A lot of PluralKit's command responses use Discord embeds. If you can't see them, it's likely you have embeds turned off. To change this, go into your discord settings and find the tab "Chat" under "App Settings". Find the setting "Show embeds and preview website links" and turn it on. If it's already on, try turning it off and then on again.
PluralKit now uses Discord's "Components V2" for system/member/group cards - if the cards no longer show, your Discord app is too old to show the new components, and you should update it.
A temporary workaround to show the old version of the cards exists as the -show-embed (or -se) flag to pk;system / pk;member / pk;group - however, we will be removing the old embed-based cards in the future (and as such, we will not add a config option to always use the old cards).
Please read the announcement post for more details: <https://pluralkit.me/posts/2025-09-08-components-v2/>
Some of PluralKit's command responses still use Discord embeds. If you can't see them, it's likely you have embeds turned off. To change this, go into your discord settings and find the tab "Chat" under "App Settings". Find the setting "Show embeds and preview website links" and turn it on. If it's already on, try turning it off and then on again.