mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-16 10:40:12 +00:00
Compare commits
19 commits
79da083a74
...
4c24399c92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c24399c92 | ||
|
|
75b1aa3e35 | ||
|
|
14f11bd1e9 | ||
|
|
39179f8e3a | ||
|
|
24361d9d2b | ||
|
|
9c99a0bc02 | ||
|
|
ebf8a40369 | ||
|
|
8bca02032f | ||
|
|
4293f26b8a | ||
|
|
bb0f27a70d | ||
|
|
21ec6ce022 | ||
|
|
ceedf8a327 | ||
|
|
cd42131b3f | ||
|
|
26208ce16c | ||
|
|
a55ccdceea | ||
|
|
ccbc027729 | ||
|
|
2ba624d127 | ||
|
|
443e402cdb | ||
|
|
5df3191a2c |
28 changed files with 1221 additions and 173 deletions
164
Cargo.lock
generated
164
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
211
crates/api/src/endpoints/bulk.rs
Normal file
211
crates/api/src/endpoints/bulk.rs
Normal 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() }}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod bulk;
|
||||
pub mod private;
|
||||
pub mod system;
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!(); }
|
||||
|
|
|
|||
|
|
@ -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
132
crates/models/src/group.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
208
crates/models/src/member.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
127
docs/content/posts/2025-09-08-components-v2.md
Normal file
127
docs/content/posts/2025-09-08-components-v2.md
Normal 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!
|
||||
|
|
@ -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/)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue