Compare commits

...

19 commits

Author SHA1 Message Date
alyssa
4c24399c92 feat(api): improve logging
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-30 03:30:43 +00:00
alyssa
75b1aa3e35 add /api/v2/bulk endpoint
also, initial support for patch models in rust!
2025-09-30 01:07:02 +00: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
Iris System
4293f26b8a chore: slightly reword beta test details in CV2 post
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-08 14:24:23 +12:00
Iris System
bb0f27a70d chore: add "pk;cfg show color" info to website 2025-09-08 13:32:58 +12:00
Iris System
21ec6ce022 fix(bot): properly check description presence in CV2 cards 2025-09-08 13:30:45 +12:00
Iris System
ceedf8a327 chore: update announcement index 2025-09-08 11:08:11 +12:00
Iris System
cd42131b3f chore: update changelog, add CV2 post 2025-09-08 10:58:57 +12:00
alyssa
26208ce16c feat(bot): port help menu to components v2 2025-09-07 22:45:31 +00:00
Iris System
a55ccdceea fix(bot): resolve NRE on querying CV2 cards 2025-09-08 10:43:38 +12:00
alyssa
ccbc027729 chore: fix rust build
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-07 17:18:27 +00:00
dependabot[bot]
2ba624d127
build(deps): bump tokio from 1.44.1 to 1.44.2 (#729)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.44.1 to 1.44.2.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.44.1...tokio-1.44.2)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.44.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 13:03:17 -04:00
dependabot[bot]
443e402cdb
chore(deps): bump tracing-subscriber from 0.3.19 to 0.3.20 (#768)
Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.19 to 0.3.20.
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.19...tracing-subscriber-0.3.20)

---
updated-dependencies:
- dependency-name: tracing-subscriber
  dependency-version: 0.3.20
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 13:02:48 -04:00
dependabot[bot]
5df3191a2c
chore(deps): bump webp from 0.2.6 to 0.3.1 (#767)
Bumps [webp](https://github.com/jaredforth/webp) from 0.2.6 to 0.3.1.
- [Commits](https://github.com/jaredforth/webp/commits)

---
updated-dependencies:
- dependency-name: webp
  dependency-version: 0.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 13:02:10 -04:00
28 changed files with 1221 additions and 173 deletions

164
Cargo.lock generated
View file

@ -92,6 +92,8 @@ dependencies = [
"pluralkit_models",
"reqwest 0.12.15",
"reverse-proxy-service",
"sea-query",
"sea-query-sqlx",
"serde",
"serde_json",
"serde_urlencoded",
@ -448,6 +450,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@ -1079,7 +1087,7 @@ dependencies = [
"redis-protocol",
"semver",
"sha-1",
"socket2",
"socket2 0.5.9",
"tokio",
"tokio-stream",
"tokio-util",
@ -1581,7 +1589,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"socket2",
"socket2 0.5.9",
"tokio",
"tower-service",
"tracing",
@ -1656,7 +1664,7 @@ dependencies = [
"hyper 1.6.0",
"libc",
"pin-project-lite",
"socket2",
"socket2 0.5.9",
"tokio",
"tower-service",
"tracing",
@ -1833,18 +1841,30 @@ dependencies = [
[[package]]
name = "image"
version = "0.24.9"
version = "0.25.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
dependencies = [
"bytemuck",
"byteorder",
"byteorder-lite",
"color_quant",
"gif",
"jpeg-decoder",
"image-webp",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
@ -1868,6 +1888,17 @@ dependencies = [
"syn",
]
[[package]]
name = "io-uring"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
dependencies = [
"bitflags 2.9.0",
"cfg-if",
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@ -1980,9 +2011,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.171"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libloading"
@ -2090,11 +2121,11 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "matchers"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata 0.1.10",
"regex-automata",
]
[[package]]
@ -2252,12 +2283,11 @@ dependencies = [
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
"overload",
"winapi",
"windows-sys 0.52.0",
]
[[package]]
@ -2394,12 +2424,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking"
version = "2.2.1"
@ -2645,6 +2669,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.26.0"
@ -2668,7 +2698,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.25",
"socket2",
"socket2 0.5.9",
"thiserror 2.0.12",
"tokio",
"tracing",
@ -2704,7 +2734,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.5.9",
"tracing",
"windows-sys 0.59.0",
]
@ -2845,17 +2875,8 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.9",
"regex-syntax 0.8.5",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
"regex-automata",
"regex-syntax",
]
[[package]]
@ -2866,15 +2887,9 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.8.5",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.8.5"
@ -3345,19 +3360,20 @@ dependencies = [
[[package]]
name = "sea-query"
version = "0.32.3"
version = "1.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5a24d8b9fcd2674a6c878a3d871f4f1380c6c43cc3718728ac96864d888458e"
checksum = "ab621a8d8b03a3e513ea075f71aa26830a55c977d7b40f09e825bb91910db823"
dependencies = [
"chrono",
"inherent",
"sea-query-derive",
]
[[package]]
name = "sea-query-derive"
version = "0.4.3"
version = "1.0.0-rc.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
checksum = "217e9422de35f26c16c5f671fce3c075a65e10322068dbc66078428634af6195"
dependencies = [
"darling",
"heck 0.4.1",
@ -3367,6 +3383,17 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "sea-query-sqlx"
version = "0.8.0-rc.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5eb19495858d8ae3663387a4f5298516c6f0171a7ca5681055450f190236b8"
dependencies = [
"chrono",
"sea-query",
"sqlx",
]
[[package]]
name = "security-framework"
version = "3.2.0"
@ -3718,6 +3745,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "spin"
version = "0.5.2"
@ -4164,20 +4201,22 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.44.1"
version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"slab",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@ -4420,14 +4459,14 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.19"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
@ -4830,9 +4869,9 @@ dependencies = [
[[package]]
name = "webp"
version = "0.2.6"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb5d8e7814e92297b0e1c773ce43d290bef6c17452dafd9fc49e5edb5beba71"
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
dependencies = [
"image",
"libwebp-sys",
@ -5498,3 +5537,18 @@ dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]

View file

@ -14,13 +14,14 @@ futures = "0.3.30"
lazy_static = "1.4.0"
metrics = "0.23.0"
reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-tls", "trust-dns"]}
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", "chrono", "macros", "uuid"] }
tokio = { version = "1.36.0", features = ["full"] }
tokio = { version = "1.46.1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
uuid = { version = "1.7.0", features = ["serde"] }
axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit" }

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

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

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

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

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

@ -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;
@ -75,7 +75,7 @@ public class EmbedService
if (system.Tag != null)
headerText += $"\n**Tag:** {system.Tag.EscapeMarkdown()}";
if (cctx.Config.CardShowColorHex && !system.Color.EmptyOrNull())
if (cctx.Config != null && cctx.Config.CardShowColorHex && !system.Color.EmptyOrNull())
headerText += $"\n**Color:** #{system.Color}";
if (cctx.Guild != null)
@ -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
@ -119,7 +119,7 @@ public class EmbedService
}
List<MessageComponent> descComponents = [];
if (system.DescriptionFor(ctx) is { } desc)
if (system.DescriptionFor(ctx) is { } desc && !string.IsNullOrWhiteSpace(desc))
{
descComponents.Add(new()
{
@ -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;
@ -361,7 +361,7 @@ public class EmbedService
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())
if (ccfg != null && ccfg.CardShowColorHex && !member.Color.EmptyOrNull())
headerText += $"\n**Color:** #{member.Color}";
if (member.PronounsFor(ctx) is { } pronouns && !string.IsNullOrWhiteSpace(pronouns))
headerText += $"\n**Pronouns:** {pronouns}";
@ -405,7 +405,7 @@ public class EmbedService
}
List<MessageComponent> descComponents = [];
if (member.DescriptionFor(ctx) is { } desc)
if (member.DescriptionFor(ctx) is { } desc && !string.IsNullOrWhiteSpace(desc))
{
descComponents.Add(new()
{
@ -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;
@ -582,20 +582,20 @@ public class EmbedService
if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null)
headerText += $"\n**Display name:** {target.DisplayName}";
if (ctx.Config.CardShowColorHex && !target.Color.EmptyOrNull())
if (ctx.Config != null && 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)
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)
if (target.DescriptionFor(pctx) is { } desc && !string.IsNullOrWhiteSpace(desc))
{
descComponents.Add(new()
{
@ -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;

View file

@ -14,6 +14,7 @@ fred = { workspace = true }
lazy_static = { workspace = true }
metrics = { workspace = true }
reqwest = { workspace = true }
sea-query = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
@ -28,3 +29,4 @@ serde_urlencoded = "0.7.1"
tower = "0.4.13"
tower-http = { version = "0.5.2", features = ["catch-panic"] }
subtle = "2.6.1"
sea-query-sqlx = { version = "0.8.0-rc.8", features = ["sqlx-postgres", "with-chrono"] }

View file

@ -0,0 +1,211 @@
use axum::{
Extension, Json,
extract::{Json as ExtractJson, State},
response::IntoResponse,
};
use pk_macros::api_endpoint;
use sea_query::{Expr, ExprTrait, PostgresQueryBuilder};
use sea_query_sqlx::SqlxBinder;
use serde_json::{Value, json};
use pluralkit_models::{PKGroup, PKGroupPatch, PKMember, PKMemberPatch, PKSystem};
use crate::{
ApiContext,
auth::AuthState,
error::{
GENERIC_AUTH_ERROR, NOT_OWN_GROUP, NOT_OWN_MEMBER, PKError, TARGET_GROUP_NOT_FOUND,
TARGET_MEMBER_NOT_FOUND,
},
};
#[derive(serde::Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BulkActionRequestFilter {
All,
Ids { ids: Vec<String> },
Connection { id: String },
}
#[derive(serde::Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BulkActionRequest {
Member {
filter: BulkActionRequestFilter,
patch: PKMemberPatch,
},
Group {
filter: BulkActionRequestFilter,
patch: PKGroupPatch,
},
}
#[api_endpoint]
pub async fn bulk(
Extension(auth): Extension<AuthState>,
State(ctx): State<ApiContext>,
ExtractJson(req): ExtractJson<BulkActionRequest>,
) -> Json<Value> {
let Some(system_id) = auth.system_id() else {
return Err(GENERIC_AUTH_ERROR);
};
#[derive(sqlx::FromRow)]
struct Ider {
id: i32,
hid: String,
uuid: String,
}
#[derive(sqlx::FromRow)]
struct GroupMemberEntry {
member_id: i32,
group_id: i32,
}
#[allow(dead_code)]
#[derive(sqlx::FromRow)]
struct OnlyIder {
id: i32,
}
println!("BulkActionRequest::{req:#?}");
match req {
BulkActionRequest::Member { filter, mut patch } => {
patch.validate_bulk();
if patch.errors().len() > 0 {
return Err(PKError::from_validation_errors(patch.errors()));
}
let ids: Vec<i32> = match filter {
BulkActionRequestFilter::All => {
let ids: Vec<Ider> = sqlx::query_as("select id from members where system = $1")
.bind(system_id as i64)
.fetch_all(&ctx.db)
.await?;
ids.iter().map(|v| v.id).collect()
}
BulkActionRequestFilter::Ids { ids } => {
let members: Vec<PKMember> = sqlx::query_as(
"select * from members where hid = any($1::array) or uuid::text = any($1::array)",
)
.bind(&ids)
.fetch_all(&ctx.db)
.await?;
// todo: better errors
if members.len() != ids.len() {
return Err(TARGET_MEMBER_NOT_FOUND);
}
if members.iter().any(|m| m.system != system_id) {
return Err(NOT_OWN_MEMBER);
}
members.iter().map(|m| m.id).collect()
}
BulkActionRequestFilter::Connection { id } => {
let Some(group): Option<PKGroup> =
sqlx::query_as("select * from groups where hid = $1 or uuid::text = $1")
.bind(id)
.fetch_optional(&ctx.db)
.await?
else {
return Err(TARGET_GROUP_NOT_FOUND);
};
if group.system != system_id {
return Err(NOT_OWN_GROUP);
}
let entries: Vec<GroupMemberEntry> =
sqlx::query_as("select * from group_members where group_id = $1")
.bind(group.id)
.fetch_all(&ctx.db)
.await?;
entries.iter().map(|v| v.member_id).collect()
}
};
let (q, pms) = patch
.to_sql()
.table("members") // todo: this should be in the model definition
.and_where(Expr::col("id").is_in(ids))
.returning_col("id")
.build_sqlx(PostgresQueryBuilder);
let res: Vec<OnlyIder> = sqlx::query_as_with(&q, pms).fetch_all(&ctx.db).await?;
Ok(Json(json! {{ "updated": res.len() }}))
}
BulkActionRequest::Group { filter, mut patch } => {
patch.validate_bulk();
if patch.errors().len() > 0 {
return Err(PKError::from_validation_errors(patch.errors()));
}
let ids: Vec<i32> = match filter {
BulkActionRequestFilter::All => {
let ids: Vec<Ider> = sqlx::query_as("select id from groups where system = $1")
.bind(system_id as i64)
.fetch_all(&ctx.db)
.await?;
ids.iter().map(|v| v.id).collect()
}
BulkActionRequestFilter::Ids { ids } => {
let groups: Vec<PKGroup> = sqlx::query_as(
"select * from groups where hid = any($1) or uuid::text = any($1)",
)
.bind(&ids)
.fetch_all(&ctx.db)
.await?;
// todo: better errors
if groups.len() != ids.len() {
return Err(TARGET_GROUP_NOT_FOUND);
}
if groups.iter().any(|m| m.system != system_id) {
return Err(NOT_OWN_GROUP);
}
groups.iter().map(|m| m.id).collect()
}
BulkActionRequestFilter::Connection { id } => {
let Some(member): Option<PKMember> =
sqlx::query_as("select * from members where hid = $1 or uuid::text = $1")
.bind(id)
.fetch_optional(&ctx.db)
.await?
else {
return Err(TARGET_MEMBER_NOT_FOUND);
};
if member.system != system_id {
return Err(NOT_OWN_MEMBER);
}
let entries: Vec<GroupMemberEntry> =
sqlx::query_as("select * from group_members where member_id = $1")
.bind(member.id)
.fetch_all(&ctx.db)
.await?;
entries.iter().map(|v| v.group_id).collect()
}
};
let (q, pms) = patch
.to_sql()
.table("groups") // todo: this should be in the model definition
.and_where(Expr::col("id").is_in(ids))
.returning_col("id")
.build_sqlx(PostgresQueryBuilder);
println!("{q:#?} {pms:#?}");
let res: Vec<OnlyIder> = sqlx::query_as_with(&q, pms).fetch_all(&ctx.db).await?;
Ok(Json(json! {{ "updated": res.len() }}))
}
}
}

View file

@ -1,2 +1,3 @@
pub mod bulk;
pub mod private;
pub mod system;

View file

@ -2,6 +2,7 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use pluralkit_models::ValidationError;
use std::fmt;
// todo: model parse errors
@ -11,6 +12,8 @@ pub struct PKError {
pub json_code: i32,
pub message: &'static str,
pub errors: Vec<ValidationError>,
pub inner: Option<anyhow::Error>,
}
@ -30,6 +33,21 @@ impl Clone for PKError {
json_code: self.json_code,
message: self.message,
inner: None,
errors: self.errors.clone(),
}
}
}
// can't `impl From<Vec<ValidationError>>`
// because "upstream crate may add a new impl" >:(
impl PKError {
pub fn from_validation_errors(errs: Vec<ValidationError>) -> Self {
Self {
message: "Error parsing JSON model",
json_code: 40001,
errors: errs,
response_code: StatusCode::BAD_REQUEST,
inner: None,
}
}
}
@ -50,14 +68,19 @@ impl IntoResponse for PKError {
if let Some(inner) = self.inner {
tracing::error!(?inner, "error returned from handler");
}
crate::util::json_err(
self.response_code,
serde_json::to_string(&serde_json::json!({
let json = if self.errors.len() > 0 {
serde_json::json!({
"message": self.message,
"code": self.json_code,
}))
.unwrap(),
)
"errors": self.errors,
})
} else {
serde_json::json!({
"message": self.message,
"code": self.json_code,
})
};
crate::util::json_err(self.response_code, serde_json::to_string(&json).unwrap())
}
}
@ -78,9 +101,17 @@ macro_rules! define_error {
json_code: $json_code,
message: $message,
inner: None,
errors: vec![],
};
};
}
define_error! { GENERIC_AUTH_ERROR, StatusCode::UNAUTHORIZED, 0, "401: Missing or invalid Authorization header" }
define_error! { GENERIC_BAD_REQUEST, StatusCode::BAD_REQUEST, 0, "400: Bad Request" }
define_error! { GENERIC_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR, 0, "500: Internal Server Error" }
define_error! { NOT_OWN_MEMBER, StatusCode::FORBIDDEN, 30006, "Target member is not part of your system." }
define_error! { NOT_OWN_GROUP, StatusCode::FORBIDDEN, 30007, "Target group is not part of your system." }
define_error! { TARGET_MEMBER_NOT_FOUND, StatusCode::BAD_REQUEST, 40010, "Target member not found." }
define_error! { TARGET_GROUP_NOT_FOUND, StatusCode::BAD_REQUEST, 40011, "Target group not found." }

View file

@ -115,6 +115,8 @@ fn router(ctx: ApiContext) -> Router {
.route("/v2/messages/{message_id}", get(rproxy))
.route("/v2/bulk", post(endpoints::bulk::bulk))
.route("/private/bulk_privacy/member", post(rproxy))
.route("/private/bulk_privacy/group", post(rproxy))
.route("/private/discord/callback", post(rproxy))
@ -127,13 +129,10 @@ fn router(ctx: ApiContext) -> Router {
.route("/v2/groups/{group_id}/oembed.json", get(rproxy))
.layer(middleware::ratelimit::ratelimiter(middleware::ratelimit::do_request_ratelimited)) // this sucks
.layer(axum::middleware::from_fn(middleware::ignore_invalid_routes::ignore_invalid_routes))
.layer(axum::middleware::from_fn(middleware::logger::logger))
.layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::params::params))
.layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::auth::auth))
.layer(axum::middleware::from_fn(middleware::logger::logger))
.layer(axum::middleware::from_fn(middleware::cors::cors))
.layer(tower_http::catch_panic::CatchPanicLayer::custom(util::handle_panic))

View file

@ -76,5 +76,10 @@ pub async fn auth(State(ctx): State<ApiContext>, mut req: Request, next: Next) -
req.extensions_mut()
.insert(AuthState::new(authed_system_id, authed_app_id, internal));
next.run(req).await
let mut res = next.run(req).await;
res.extensions_mut()
.insert(AuthState::new(authed_system_id, authed_app_id, internal));
res
}

View file

@ -12,9 +12,10 @@ const MIN_LOG_TIME: u128 = 2_000;
pub async fn logger(request: Request, next: Next) -> Response {
let method = request.method().clone();
let headers = request.headers().clone();
let remote_ip = header_or_unknown(request.headers().get("X-PluralKit-Client-IP"));
let user_agent = header_or_unknown(request.headers().get("User-Agent"));
let remote_ip = header_or_unknown(headers.get("X-PluralKit-Client-IP"));
let user_agent = header_or_unknown(headers.get("User-Agent"));
let extensions = request.extensions().clone();
@ -24,10 +25,6 @@ pub async fn logger(request: Request, next: Next) -> Response {
.map(|v| v.as_str().to_string())
.unwrap_or("unknown".to_string());
let auth = extensions
.get::<AuthState>()
.expect("should always have AuthState");
let uri = request.uri().clone();
let request_span = span!(
@ -43,15 +40,24 @@ pub async fn logger(request: Request, next: Next) -> Response {
let response = next.run(request).instrument(request_span).await;
let elapsed = start.elapsed().as_millis();
let system_id = auth
.system_id()
.map(|v| v.to_string())
.unwrap_or("none".to_string());
let rext = response.extensions().clone();
let auth = rext.get::<AuthState>();
let app_id = auth
.app_id()
.map(|v| v.to_string())
.unwrap_or("none".to_string());
let system_id = if let Some(auth) = auth {
auth.system_id()
.map(|v| v.to_string())
.unwrap_or("none".to_string())
} else {
"none".to_string()
};
let app_id = if let Some(auth) = auth {
auth.app_id()
.map(|v| v.to_string())
.unwrap_or("none".to_string())
} else {
"none".to_string()
};
counter!(
"pluralkit_api_requests",
@ -73,6 +79,14 @@ pub async fn logger(request: Request, next: Next) -> Response {
.record(elapsed as f64 / 1_000_f64);
info!(
status = response.status().as_str(),
method = method.to_string(),
endpoint,
elapsed,
user_agent,
remote_ip,
system_id,
app_id,
"{} handled request for {} {} in {}ms",
response.status(),
method,

View file

@ -21,9 +21,9 @@ uuid = { workspace = true }
data-encoding = "2.5.0"
gif = "0.13.1"
image = { version = "0.24.8", default-features = false, features = ["gif", "jpeg", "png", "webp", "tiff"] }
image = { version = "0.25.6", default-features = false, features = ["gif", "jpeg", "png", "webp", "tiff"] }
form_urlencoded = "1.2.1"
rust-s3 = { version = "0.33.0", default-features = false, features = ["tokio-rustls-tls"] }
sha2 = "0.10.8"
thiserror = "1.0.56"
webp = "0.2.6"
webp = "0.3.1"

View file

@ -211,8 +211,8 @@ fn process_gif_inner(
}))
}
fn reader_for(data: &[u8]) -> image::io::Reader<Cursor<&[u8]>> {
image::io::Reader::new(Cursor::new(data))
fn reader_for(data: &[u8]) -> image::ImageReader<Cursor<&[u8]>> {
image::ImageReader::new(Cursor::new(data))
.with_guessed_format()
.expect("cursor i/o is infallible")
}

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

@ -85,8 +85,14 @@ fn parse_field(field: syn::Field) -> ModelField {
panic!("must have json name to be publicly patchable");
}
if f.json.is_some() && f.is_privacy {
panic!("cannot set custom json name for privacy field");
if f.is_privacy && f.json.is_none() {
f.json = Some(syn::Expr::Lit(syn::ExprLit {
attrs: vec![],
lit: syn::Lit::Str(syn::LitStr::new(
f.name.clone().to_string().as_str(),
proc_macro2::Span::call_site(),
)),
}))
}
f
@ -122,17 +128,17 @@ pub fn macro_impl(
let fields: Vec<ModelField> = fields
.iter()
.filter(|f| !matches!(f.patch, ElemPatchability::None))
.filter(|f| f.is_privacy || !matches!(f.patch, ElemPatchability::None))
.cloned()
.collect();
let patch_fields = mk_patch_fields(fields.clone());
let patch_from_json = mk_patch_from_json(fields.clone());
let patch_validate = mk_patch_validate(fields.clone());
let patch_validate_bulk = mk_patch_validate_bulk(fields.clone());
let patch_to_json = mk_patch_to_json(fields.clone());
let patch_to_sql = mk_patch_to_sql(fields.clone());
return quote! {
let code = quote! {
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct #tname {
#tfields
@ -146,31 +152,42 @@ pub fn macro_impl(
#to_json
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct #patchable_name {
#patch_fields
errors: Vec<crate::ValidationError>,
}
impl #patchable_name {
pub fn from_json(input: String) -> Self {
#patch_from_json
}
pub fn validate(self) -> bool {
pub fn validate(&mut self) {
#patch_validate
}
pub fn errors(&self) -> Vec<crate::ValidationError> {
self.errors.clone()
}
pub fn validate_bulk(&mut self) {
#patch_validate_bulk
}
pub fn to_sql(self) -> sea_query::UpdateStatement {
// sea_query::Query::update()
#patch_to_sql
use sea_query::types::*;
let mut patch = &mut sea_query::Query::update();
#patch_to_sql
patch.clone()
}
pub fn to_json(self) -> serde_json::Value {
#patch_to_json
}
}
}
.into();
};
// panic!("{:#?}", code.to_string());
return code.into();
}
fn mk_tfields(fields: Vec<ModelField>) -> TokenStream {
@ -225,7 +242,7 @@ fn mk_tto_json(fields: Vec<ModelField>) -> TokenStream {
.filter_map(|f| {
if f.is_privacy {
let tname = f.name.clone();
let tnamestr = f.name.clone().to_string();
let tnamestr = f.json.clone();
Some(quote! {
#tnamestr: self.#tname,
})
@ -280,13 +297,48 @@ fn mk_patch_fields(fields: Vec<ModelField>) -> TokenStream {
.collect()
}
fn mk_patch_validate(_fields: Vec<ModelField>) -> TokenStream {
quote! { true }
}
fn mk_patch_from_json(_fields: Vec<ModelField>) -> TokenStream {
quote! { unimplemented!(); }
}
fn mk_patch_to_sql(_fields: Vec<ModelField>) -> TokenStream {
quote! { unimplemented!(); }
fn mk_patch_validate_bulk(fields: Vec<ModelField>) -> TokenStream {
// iterate over all nullable patchable fields other than privacy
// add an error if any field is set to a value other than null
fields
.iter()
.map(|f| {
if let syn::Type::Path(path) = &f.ty && let Some(inner) = path.path.segments.last() && inner.ident != "Option" {
return quote! {};
}
let name = f.name.clone();
if matches!(f.patch, ElemPatchability::Public) {
let json = f.json.clone().unwrap();
quote! {
if let Some(val) = self.#name.clone() && val.is_some() {
self.errors.push(ValidationError::simple(#json, "Only null values are supported in bulk endpoint"));
}
}
} else {
quote! {}
}
})
.collect()
}
fn mk_patch_to_sql(fields: Vec<ModelField>) -> TokenStream {
fields
.iter()
.filter_map(|f| {
if !matches!(f.patch, ElemPatchability::None) || f.is_privacy {
let name = f.name.clone();
let column = f.name.to_string();
Some(quote! {
if let Some(value) = self.#name {
patch = patch.value(#column, value);
}
})
} else {
None
}
})
.collect()
}
fn mk_patch_to_json(_fields: Vec<ModelField>) -> TokenStream {
quote! { unimplemented!(); }

View file

@ -6,7 +6,7 @@ edition = "2024"
[dependencies]
chrono = { workspace = true, features = ["serde"] }
pk_macros = { path = "../macros" }
sea-query = "0.32.1"
sea-query = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
# in theory we want to default-features = false for sqlx

132
crates/models/src/group.rs Normal file
View file

@ -0,0 +1,132 @@
use pk_macros::pk_model;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
use crate::{PrivacyLevel, SystemId, ValidationError};
// todo: fix
pub type GroupId = i32;
#[pk_model]
struct Group {
id: GroupId,
#[json = "hid"]
#[private_patchable]
hid: String,
#[json = "uuid"]
uuid: Uuid,
// TODO fix
#[json = "system"]
system: SystemId,
#[json = "name"]
#[privacy = name_privacy]
#[patchable]
name: String,
#[json = "display_name"]
#[patchable]
display_name: Option<String>,
#[json = "color"]
#[patchable]
color: Option<String>,
#[json = "icon"]
#[patchable]
icon: Option<String>,
#[json = "banner_image"]
#[patchable]
banner_image: Option<String>,
#[json = "description"]
#[privacy = description_privacy]
#[patchable]
description: Option<String>,
#[json = "created"]
created: DateTime<Utc>,
#[privacy]
name_privacy: PrivacyLevel,
#[privacy]
description_privacy: PrivacyLevel,
#[privacy]
banner_privacy: PrivacyLevel,
#[privacy]
icon_privacy: PrivacyLevel,
#[privacy]
list_privacy: PrivacyLevel,
#[privacy]
metadata_privacy: PrivacyLevel,
#[privacy]
visibility: PrivacyLevel,
}
impl<'de> Deserialize<'de> for PKGroupPatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut patch: PKGroupPatch = Default::default();
let value: Value = Value::deserialize(deserializer)?;
if let Some(v) = value.get("name") {
if let Some(name) = v.as_str() {
patch.name = Some(name.to_string());
} else if v.is_null() {
patch.errors.push(ValidationError::simple(
"name",
"Group name cannot be set to null.",
));
}
}
macro_rules! parse_string_simple {
($k:expr) => {
match value.get($k) {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(s)) => Some(Some(s.clone())),
_ => {
patch.errors.push(ValidationError::new($k));
None
}
}
};
}
patch.display_name = parse_string_simple!("display_name");
patch.description = parse_string_simple!("description");
patch.icon = parse_string_simple!("icon");
patch.banner_image = parse_string_simple!("banner");
patch.color = parse_string_simple!("color").map(|v| v.map(|t| t.to_lowercase()));
if let Some(privacy) = value.get("privacy").and_then(Value::as_object) {
macro_rules! parse_privacy {
($v:expr) => {
match privacy.get($v) {
None => None,
Some(Value::Null) => Some(PrivacyLevel::Private),
Some(Value::String(s)) if s == "" || s == "private" => {
Some(PrivacyLevel::Private)
}
Some(Value::String(s)) if s == "public" => Some(PrivacyLevel::Public),
_ => {
patch.errors.push(ValidationError::new($v));
None
}
}
};
}
patch.name_privacy = parse_privacy!("name_privacy");
patch.description_privacy = parse_privacy!("description_privacy");
patch.banner_privacy = parse_privacy!("banner_privacy");
patch.icon_privacy = parse_privacy!("icon_privacy");
patch.list_privacy = parse_privacy!("list_privacy");
patch.metadata_privacy = parse_privacy!("metadata_privacy");
patch.visibility = parse_privacy!("visibility");
}
Ok(patch)
}
}

View file

@ -9,6 +9,8 @@ macro_rules! model {
model!(system);
model!(system_config);
model!(member);
model!(group);
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
@ -31,3 +33,30 @@ impl From<i32> for PrivacyLevel {
}
}
}
impl From<PrivacyLevel> for sea_query::Value {
fn from(level: PrivacyLevel) -> sea_query::Value {
match level {
PrivacyLevel::Public => sea_query::Value::Int(Some(1)),
PrivacyLevel::Private => sea_query::Value::Int(Some(2)),
}
}
}
#[derive(serde::Serialize, Debug, Clone)]
pub enum ValidationError {
Simple { key: String, value: String },
}
impl ValidationError {
fn new(key: &str) -> Self {
Self::simple(key, "is invalid")
}
fn simple(key: &str, value: &str) -> Self {
Self::Simple {
key: key.to_string(),
value: value.to_string(),
}
}
}

208
crates/models/src/member.rs Normal file
View file

@ -0,0 +1,208 @@
use pk_macros::pk_model;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{PrivacyLevel, SystemId, ValidationError};
// todo: fix
pub type MemberId = i32;
#[derive(Clone, Debug, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "proxy_tag")]
pub struct ProxyTag {
pub prefix: Option<String>,
pub suffix: Option<String>,
}
#[pk_model]
struct Member {
id: MemberId,
#[json = "hid"]
#[private_patchable]
hid: String,
#[json = "uuid"]
uuid: Uuid,
// TODO fix
#[json = "system"]
system: SystemId,
#[json = "color"]
#[patchable]
color: Option<String>,
#[json = "webhook_avatar_url"]
#[patchable]
webhook_avatar_url: Option<String>,
#[json = "avatar_url"]
#[patchable]
avatar_url: Option<String>,
#[json = "banner_image"]
#[patchable]
banner_image: Option<String>,
#[json = "name"]
#[privacy = name_privacy]
#[patchable]
name: String,
#[json = "display_name"]
#[patchable]
display_name: Option<String>,
#[json = "birthday"]
#[patchable]
birthday: Option<String>,
#[json = "pronouns"]
#[privacy = pronoun_privacy]
#[patchable]
pronouns: Option<String>,
#[json = "description"]
#[privacy = description_privacy]
#[patchable]
description: Option<String>,
#[json = "proxy_tags"]
// #[patchable]
proxy_tags: Vec<ProxyTag>,
#[json = "keep_proxy"]
#[patchable]
keep_proxy: bool,
#[json = "tts"]
#[patchable]
tts: bool,
#[json = "created"]
created: NaiveDateTime,
#[json = "message_count"]
#[private_patchable]
message_count: i32,
#[json = "last_message_timestamp"]
#[private_patchable]
last_message_timestamp: Option<NaiveDateTime>,
#[json = "allow_autoproxy"]
#[patchable]
allow_autoproxy: bool,
#[privacy]
#[json = "visibility"]
member_visibility: PrivacyLevel,
#[privacy]
description_privacy: PrivacyLevel,
#[privacy]
banner_privacy: PrivacyLevel,
#[privacy]
avatar_privacy: PrivacyLevel,
#[privacy]
name_privacy: PrivacyLevel,
#[privacy]
birthday_privacy: PrivacyLevel,
#[privacy]
pronoun_privacy: PrivacyLevel,
#[privacy]
metadata_privacy: PrivacyLevel,
#[privacy]
proxy_privacy: PrivacyLevel,
}
impl<'de> Deserialize<'de> for PKMemberPatch {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut patch: PKMemberPatch = Default::default();
let value: Value = Value::deserialize(deserializer)?;
if let Some(v) = value.get("name") {
if let Some(name) = v.as_str() {
patch.name = Some(name.to_string());
} else if v.is_null() {
patch.errors.push(ValidationError::simple(
"name",
"Member name cannot be set to null.",
));
}
}
macro_rules! parse_string_simple {
($k:expr) => {
match value.get($k) {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(s)) => Some(Some(s.clone())),
_ => {
patch.errors.push(ValidationError::new($k));
None
}
}
};
}
patch.color = parse_string_simple!("color").map(|v| v.map(|t| t.to_lowercase()));
patch.display_name = parse_string_simple!("display_name");
patch.avatar_url = parse_string_simple!("avatar_url");
patch.banner_image = parse_string_simple!("banner");
patch.birthday = parse_string_simple!("birthday"); // fix
patch.pronouns = parse_string_simple!("pronouns");
patch.description = parse_string_simple!("description");
if let Some(keep_proxy) = value.get("keep_proxy").and_then(Value::as_bool) {
patch.keep_proxy = Some(keep_proxy);
}
if let Some(tts) = value.get("tts").and_then(Value::as_bool) {
patch.tts = Some(tts);
}
// todo: legacy import handling
// todo: fix proxy_tag type in sea_query
// if let Some(proxy_tags) = value.get("proxy_tags").and_then(Value::as_array) {
// patch.proxy_tags = Some(
// proxy_tags
// .iter()
// .filter_map(|tag| {
// tag.as_object().map(|tag_obj| {
// let prefix = tag_obj
// .get("prefix")
// .and_then(Value::as_str)
// .map(|s| s.to_string());
// let suffix = tag_obj
// .get("suffix")
// .and_then(Value::as_str)
// .map(|s| s.to_string());
// ProxyTag { prefix, suffix }
// })
// })
// .collect(),
// )
// }
if let Some(privacy) = value.get("privacy").and_then(Value::as_object) {
macro_rules! parse_privacy {
($v:expr) => {
match privacy.get($v) {
None => None,
Some(Value::Null) => Some(PrivacyLevel::Private),
Some(Value::String(s)) if s == "" || s == "private" => {
Some(PrivacyLevel::Private)
}
Some(Value::String(s)) if s == "public" => Some(PrivacyLevel::Public),
_ => {
patch.errors.push(ValidationError::new($v));
None
}
}
};
}
patch.member_visibility = parse_privacy!("visibility");
patch.name_privacy = parse_privacy!("name_privacy");
patch.description_privacy = parse_privacy!("description_privacy");
patch.banner_privacy = parse_privacy!("banner_privacy");
patch.avatar_privacy = parse_privacy!("avatar_privacy");
patch.birthday_privacy = parse_privacy!("birthday_privacy");
patch.pronoun_privacy = parse_privacy!("pronoun_privacy");
patch.proxy_privacy = parse_privacy!("proxy_privacy");
patch.metadata_privacy = parse_privacy!("metadata_privacy");
}
Ok(patch)
}
}

View file

@ -9,6 +9,51 @@ the below is a lightly edited copy of the changelog messages posted on discord.
a more complete list of code changes can be found [in the git repo](https://github.com/pluralkit/pluralkit/commits/main)
## 2025-09-08
### new/updated
- **new "ComponentsV2" format for system/member/group cards and help menu** (use -show-embed or -se flag to get the old view)
- [we have written a blog post giving some context behind this change](/posts/2025-09-08-components-v2/) - please read this also!
- color codes are now hidden by default on cards, and a config option to show them again was added: `pk;config show color on`
- **new status page at <https://status.pluralkit.me>**
- replies to commands can now be deleted forever (previously 24h)
- logclean support for [Zeppelin](https://zeppelin.gg/) bot
- `pk;system` command now can be used with `pk;account` alias
- creating a new system now shows a note about the terms of service
- the announcements/changelog are now cross-posted from discord to the website
- `pk;member <member> name` command now can show the member name as well as setting it
- clarified wording and updated formatting for some messages
### fixed
- reply embeds now strip out excessive newlines
- cleaned up some error messages
- bot now correctly checks privacy in a few commands
- made a better attempt to not delete images uploaded to CDN on export/import
- importing system data with a large amount of switches should no longer throw an error
### API changes
- api no longer breaks when the redis server is restarted
- some error messages have been fixed to use the correct JSON format
- document that short IDs are accepted in any format displayable by the bot
- added public/unauthenticated partial view for `/systems/:id/settings` endpoint
- autoproxy endpoint now allows changing the currently latched member
- discord guild endpoints now try harder at checking if the system is in the guild
- many docs fixes
### Internal changes
- updated docker-compose configuration for self-hosting
- modernised the development documentation
- the Myriad Discord library has been relicensed to MIT to facilitate external use
- the C# code now correctly builds in Visual Studio 2022
- the C# services now log in JSON format; this required a fork of Serilog and thus a git submodule
- code to generate colour previews has been rewritten, since the third-party site went offline
- fixed a bug where shards would never reconnect and require manual intervention
- bumped Rust language version to 2024
- rewrote scheduled tasks and database migrations in Rust
- the Rust entrypoint macro has been rewritten as a proc macro to clean up call sites
- all (well, most) Rust errors correctly use fields for values instead of formatting them into the error string
- gateway events are now sent to the C# bot code through HTTP
## 2025-01-01
### Added

View file

@ -148,6 +148,7 @@ You can have a space after `pk;`, e.g. `pk;system` and `pk; system` will do the
- `pk;config split IDs [on|off]` - Toggles whether to display 6-letter IDs with a hyphen, to ease readability.
- `pk;config capitalize IDs [on|off]` - Toggles whether to display IDs as capital letters, to ease readability.
- `pk;config pad IDs [left|right|off]` - Toggles whether to pad (add a space) 5-character IDs in lists.
- `pk;config show color [on|off]` - Toggles whether to show color codes in system/member/group cards
- `pk;config proxy switch [new|add|off]` - Toggles whether to log a switch whenever you proxy as a different member (or add member to recent switch in add mode).
- `pk;config name format [format]` - Changes your system's username formatting.
- `pk;config server name format [format]` - Changes your system's username formatting for the current server.

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.

View file

@ -0,0 +1,127 @@
---
title: on the switch to Components V2
permalink: /posts/2025-09-08-components-v2/
---
## on the switch to Components V2
you probably will have noticed the new design of system/member/group cards
in PluralKit. we know that a lot of people will have questions as to why
we've suddenly changed the layout & design of the cards, so we hope this
post can explain some of the decisions that went into these.
### why change the cards at all?
the old cards used something Discord calls "embeds." embeds were initially
only meant for showing details about a link posted in chat, but were later
also used by Discord bots for displaying information in a structured way.
embeds are extremely limited in their layout, and bots have very little
control over how things are displayed in them. in addition, a lot of newer
Discord features (things like the Markdown headers, or small text) either
don't display in embeds at all, or only display on some platforms - making
them very inconsistent in how they display.
the new cards use Discord's "Components V2" - which has been designed by
Discord from the ground up specifically for bots to use for custom content.
Components V2 is much more flexible, and fixes a lot of the issues with the
old embeds (the Markdown headers / small text being one example), letting us
have a lot more control over the display of the cards.
Components V2 is the way forward, as far as Discord is concerned - meaning
that some of the issues with embeds will never be fixed in embeds themselves,
necessitating a move to Components V2.
### continual improvement (also: "where'd my proxy avatar go?")
currently, the only thing missing from PluralKit's new Components V2 cards
(compared to the previous embed-based cards) is member proxy avatars. on
the old member cards, proxy avatars would display as a tiny circle at the
top left. there is not currently a way to display an image of that size/
position in Components V2... but from what we understand, that is on the
list of things Discord will be adding in the future. which leads into the
next point:
unlike embeds, which have remained stagnant for years, Discord are actively
working on adding *new functionality* to Components V2, as well as fixing
whatever issues arise - in order to take advantage of new functionality as
Discord release it, we would have needed to move to Components V2 at some
point. we figured that with the state Components V2 is currently in, now
was a good time to make that switch!
### character limits
another advantage of Components V2 over embeds is the character limit for
cards. the old embeds had a hard limit of 1024 characters in a single field,
with a limit of 2000 characters for the entire embed. this is the reason that
PK descriptions are capped at 1000 characters.
Components V2, however, has a character limit of *6000* characters across
the entire card, which can be split however we like. this means that once the
old embed-based cards are removed, we will be able to raise the description
character limit!
### where'd the color codes go?
one of the design choices we made for the new system/member/group cards was
to hide the hex color codes by default - this has been a near-constantly
requested feature, and the Components V2 rework gave us a good opportunity
to implement it.
we added a configuration toggle for this - if you *do* want to see the hex
codes for colors, you can use `pk;config show color on` to re-enable them.
### other small improvements
- Components V2 allows us to use real code blocks in the card footers for
things like system/member/group IDs. on mobile Discord clients, this makes
copying IDs a lot easier - you can copy an individual code block's content
by tapping on it
- having a banner image set no longer makes the description width smaller
- on mobile clients, it is now a lot easier to view any images larger, just
by tapping on them
- some PluralKit users who use screen readers have reported that the new
Components V2 cards are read by their screen readers in a much more easily
understood way
### the new cards don't show up! / is there a way to see the old cards?
Components V2 is not supported on older Discord clients. there is nothing
we can do about this, other than encourage you to update your Discord
client.
however - for now, using the `-show-embed` (or `-se`) flag to the
`pk;system`, `pk;member`, and `pk;group` commands will show the old
embed-based cards.
the old cards will still show in some places in the bot (the most prominent
example being when querying message info with the ❓ reaction) also,
until we migrate those parts of the bot to use Components V2.
the old embed-based cards **will be removed from the bot in future** -
although we do not have any specific timeframe in mind for this yet.
because of this, we will not be supporting a way to persistently show
the old cards (such as a config option) - the `-show-embed` flag is the
only way to pull up the old cards when using a command to query info.
### in closing
we hope that this gives you a bit more context as to why we've made this
change - although there are some new design choices here, this was not
a change made just for the sake of changing. moving to Components V2 not only
gives us a lot more freedom to do things that weren't previously possible,
but means we can fix a lot of the long-standing issues with PK cards!
we know this is a significant change for PluralKit, but we hope you can
understand that this is a change for the better.
a lot of the decisions that went into the new versions of the cards were
iterated on with feedback from members of the community who help beta test
new PluralKit features - i want to thank those people immensely for their
input!
if you'd like to help beta-test new features in future, check out the pins
in the `#beta-testing` channel in [the support server](https://discord.gg/PczBt78)
for the beta testing announcement role.
if you have any questions, please let us know in the support server!

View file

@ -4,5 +4,6 @@ title: Announcements & other posts
# Announcements & other posts
- 2025-09-08: [on the switch to Components V2](/posts/2025-09-08-components-v2/)
- 2025-01-14: [january 2025 funding update](/posts/2025-01-14-funding-update/)
- 2024-12-05: [late 2024 downtime notes & funding update](/posts/2024-12-05-downtime-notes/)