Merge remote-tracking branch 'upstream/main' into rust-command-parser

This commit is contained in:
dusk 2025-03-29 07:36:43 +03:00
commit e8f8e5f0a3
No known key found for this signature in database
37 changed files with 316 additions and 201 deletions

61
Cargo.lock generated
View file

@ -1648,7 +1648,7 @@ dependencies = [
"http 1.1.0", "http 1.1.0",
"hyper 1.5.0", "hyper 1.5.0",
"hyper-util", "hyper-util",
"rustls 0.23.10", "rustls 0.23.23",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls 0.26.0", "tokio-rustls 0.26.0",
@ -1856,6 +1856,7 @@ dependencies = [
"metrics", "metrics",
"metrics-exporter-prometheus", "metrics-exporter-prometheus",
"sentry", "sentry",
"sentry-tracing",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@ -2538,7 +2539,7 @@ dependencies = [
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls 0.23.10", "rustls 0.23.23",
"socket2 0.5.7", "socket2 0.5.7",
"thiserror", "thiserror",
"tokio", "tokio",
@ -2555,7 +2556,7 @@ dependencies = [
"rand", "rand",
"ring 0.17.8", "ring 0.17.8",
"rustc-hash", "rustc-hash",
"rustls 0.23.10", "rustls 0.23.23",
"slab", "slab",
"thiserror", "thiserror",
"tinyvec", "tinyvec",
@ -2788,7 +2789,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls 0.23.10", "rustls 0.23.23",
"rustls-pemfile 2.1.2", "rustls-pemfile 2.1.2",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
@ -2998,25 +2999,24 @@ version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [ dependencies = [
"log",
"ring 0.17.8", "ring 0.17.8",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki 0.102.4", "rustls-webpki 0.102.8",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.10" version = "0.23.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
"ring 0.17.8", "ring 0.17.8",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki 0.102.4", "rustls-webpki 0.102.8",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -3071,9 +3071,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.102.4" version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [ dependencies = [
"ring 0.17.8", "ring 0.17.8",
"rustls-pki-types", "rustls-pki-types",
@ -3118,6 +3118,7 @@ dependencies = [
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
"twilight-http",
] ]
[[package]] [[package]]
@ -3214,13 +3215,13 @@ dependencies = [
[[package]] [[package]]
name = "sentry" name = "sentry"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" checksum = "3a7332159e544e34db06b251b1eda5e546bd90285c3f58d9c8ff8450b484e0da"
dependencies = [ dependencies = [
"httpdate", "httpdate",
"reqwest 0.12.8", "reqwest 0.12.8",
"rustls 0.22.4", "rustls 0.23.23",
"sentry-backtrace", "sentry-backtrace",
"sentry-contexts", "sentry-contexts",
"sentry-core", "sentry-core",
@ -3234,9 +3235,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-backtrace" name = "sentry-backtrace"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" checksum = "565ec31ad37bab8e6d9f289f34913ed8768347b133706192f10606dabd5c6bc4"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"once_cell", "once_cell",
@ -3246,9 +3247,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-contexts" name = "sentry-contexts"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" checksum = "e860275f25f27e8c0c7726ce116c7d5c928c5bba2ee73306e52b20a752298ea6"
dependencies = [ dependencies = [
"hostname", "hostname",
"libc", "libc",
@ -3260,9 +3261,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-core" name = "sentry-core"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" checksum = "653942e6141f16651273159f4b8b1eaeedf37a7554c00cd798953e64b8a9bf72"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"rand", "rand",
@ -3273,9 +3274,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-debug-images" name = "sentry-debug-images"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" checksum = "2a60bc2154e6df59beed0ac13d58f8dfaf5ad20a88548a53e29e4d92e8e835c2"
dependencies = [ dependencies = [
"findshlibs", "findshlibs",
"once_cell", "once_cell",
@ -3284,9 +3285,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-panic" name = "sentry-panic"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" checksum = "105e3a956c8aa9dab1e4087b1657b03271bfc49d838c6ae9bfc7c58c802fd0ef"
dependencies = [ dependencies = [
"sentry-backtrace", "sentry-backtrace",
"sentry-core", "sentry-core",
@ -3294,9 +3295,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-tracing" name = "sentry-tracing"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" checksum = "64e75c831b4d8b34a5aec1f65f67c5d46a26c7c5d3c7abd8b5ef430796900cf8"
dependencies = [ dependencies = [
"sentry-backtrace", "sentry-backtrace",
"sentry-core", "sentry-core",
@ -3306,9 +3307,9 @@ dependencies = [
[[package]] [[package]]
name = "sentry-types" name = "sentry-types"
version = "0.34.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" checksum = "2d4203359e60724aa05cf2385aaf5d4f147e837185d7dd2b9ccf1ee77f4420c8"
dependencies = [ dependencies = [
"debugid", "debugid",
"hex", "hex",
@ -4053,7 +4054,7 @@ version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [ dependencies = [
"rustls 0.23.10", "rustls 0.23.23",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
] ]
@ -4610,7 +4611,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"log", "log",
"once_cell", "once_cell",
"rustls 0.23.10", "rustls 0.23.23",
"rustls-pki-types", "rustls-pki-types",
"url", "url",
"webpki-roots 0.26.6", "webpki-roots 0.26.6",

View file

@ -15,13 +15,13 @@ futures = "0.3.30"
lazy_static = "1.4.0" lazy_static = "1.4.0"
metrics = "0.23.0" metrics = "0.23.0"
reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-tls", "trust-dns"]} reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-tls", "trust-dns"]}
sentry = { version = "0.34.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "reqwest", "rustls"] } # replace native-tls with rustls 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 = { version = "1.0.196", features = ["derive"] }
serde_json = "1.0.117" serde_json = "1.0.117"
signal-hook = "0.3.17" signal-hook = "0.3.17"
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid"] } sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid"] }
tokio = { version = "1.36.0", features = ["full"] } tokio = { version = "1.36.0", features = ["full"] }
tracing = "0.1.40" tracing = "0.1"
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
uuid = { version = "1.7.0", features = ["serde"] } uuid = { version = "1.7.0", features = ["serde"] }

View file

@ -9,14 +9,14 @@ public static class CacheExtensions
public static async Task<Guild> GetGuild(this IDiscordCache cache, ulong guildId) public static async Task<Guild> GetGuild(this IDiscordCache cache, ulong guildId)
{ {
if (!(await cache.TryGetGuild(guildId) is Guild guild)) if (!(await cache.TryGetGuild(guildId) is Guild guild))
throw new KeyNotFoundException($"Guild {guildId} not found in cache"); throw new NotFoundInCacheException(guildId, "guild");
return guild; return guild;
} }
public static async Task<Channel> GetChannel(this IDiscordCache cache, ulong guildId, ulong channelId) public static async Task<Channel> GetChannel(this IDiscordCache cache, ulong guildId, ulong channelId)
{ {
if (!(await cache.TryGetChannel(guildId, channelId) is Channel channel)) if (!(await cache.TryGetChannel(guildId, channelId) is Channel channel))
throw new KeyNotFoundException($"Channel {channelId} not found in cache"); throw new NotFoundInCacheException(channelId, "channel");
return channel; return channel;
} }
@ -54,4 +54,16 @@ public static class CacheExtensions
if (parent == null) throw new Exception($"failed to find parent channel for thread {channelOrThread} in cache"); if (parent == null) throw new Exception($"failed to find parent channel for thread {channelOrThread} in cache");
return parent; return parent;
} }
}
public class NotFoundInCacheException: Exception
{
public ulong EntityId { get; init; }
public string EntityType { get; init; }
public NotFoundInCacheException(ulong id, string type) : base("expected entity in discord cache but was not found")
{
EntityId = id;
EntityType = type;
}
} }

View file

@ -31,6 +31,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.1.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.1.0" />
<PackageReference Include="Sentry" Version="4.13.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -14,6 +14,16 @@ public class Program
await BuildInfoService.LoadVersion(); await BuildInfoService.LoadVersion();
var host = CreateHostBuilder(args).Build(); var host = CreateHostBuilder(args).Build();
var config = host.Services.GetRequiredService<CoreConfig>(); var config = host.Services.GetRequiredService<CoreConfig>();
// Initialize Sentry SDK, and make sure it gets dropped at the end
using var _ = SentrySdk.Init(opts =>
{
opts.Dsn = config.SentryUrl ?? "";
opts.Release = BuildInfoService.FullVersion;
opts.AutoSessionTracking = true;
// opts.DisableTaskUnobservedTaskExceptionCapture();
});
await host.Services.GetRequiredService<RedisService>().InitAsync(config); await host.Services.GetRequiredService<RedisService>().InitAsync(config);
await host.RunAsync(); await host.RunAsync();
} }

View file

@ -62,8 +62,13 @@ public class Startup
await ctx.Response.WriteJSON(400, "{\"message\":\"400: Bad Request\",\"code\":0}"); await ctx.Response.WriteJSON(400, "{\"message\":\"400: Bad Request\",\"code\":0}");
else if (exc.Error is not PKError) else if (exc.Error is not PKError)
{
await ctx.Response.WriteJSON(500, "{\"message\":\"500: Internal Server Error\",\"code\":0}"); await ctx.Response.WriteJSON(500, "{\"message\":\"500: Internal Server Error\",\"code\":0}");
var sentryEvent = new SentryEvent(exc.Error);
SentrySdk.CaptureEvent(sentryEvent);
}
// for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method // for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method
else if (exc.Error is ModelParseError fe) else if (exc.Error is ModelParseError fe)
await ctx.Response.WriteJSON(fe.ResponseCode, JsonConvert.SerializeObject(fe.ToJson())); await ctx.Response.WriteJSON(fe.ResponseCode, JsonConvert.SerializeObject(fe.ToJson()));

View file

@ -28,6 +28,12 @@
"Microsoft.AspNetCore.Mvc.Versioning": "5.1.0" "Microsoft.AspNetCore.Mvc.Versioning": "5.1.0"
} }
}, },
"Sentry": {
"type": "Direct",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg=="
},
"Serilog.AspNetCore": { "Serilog.AspNetCore": {
"type": "Direct", "type": "Direct",
"requested": "[9.0.0, )", "requested": "[9.0.0, )",

View file

@ -213,6 +213,13 @@ public class Bot
{ {
_metrics.Measure.Meter.Mark(BotMetrics.BotErrors, exc.GetType().FullName); _metrics.Measure.Meter.Mark(BotMetrics.BotErrors, exc.GetType().FullName);
if (exc is Myriad.Extensions.NotFoundInCacheException ce)
{
var scope = serviceScope.Resolve<Scope>();
scope.SetTag("entity.id", ce.EntityId.ToString());
scope.SetTag("entity.type", ce.EntityType);
}
// Make this beforehand so we can access the event ID for logging // Make this beforehand so we can access the event ID for logging
var sentryEvent = new SentryEvent(exc); var sentryEvent = new SentryEvent(exc);

View file

@ -291,7 +291,7 @@ public class Groups
ctx.CheckOwnGroup(target); ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
@ -354,8 +354,8 @@ public class Groups
{ {
async Task ClearBannerImage() async Task ClearBannerImage()
{ {
await ctx.ConfirmClear("this group's banner image");
ctx.CheckOwnGroup(target); ctx.CheckOwnGroup(target);
await ctx.ConfirmClear("this group's banner image");
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null }); await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null });
await ctx.Reply($"{Emojis.Success} Group banner image cleared."); await ctx.Reply($"{Emojis.Success} Group banner image cleared.");
@ -366,7 +366,7 @@ public class Groups
ctx.CheckOwnGroup(target); ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
@ -391,7 +391,7 @@ public class Groups
{ {
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy); ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.Icon?.Trim() ?? "").Length > 0) if ((target.BannerImage?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat()) switch (ctx.MatchFormat())
{ {
case ReplyFormat.Raw: case ReplyFormat.Raw:

View file

@ -12,10 +12,11 @@ public class Help
"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" + "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!"; "## Use the buttons below to see more info!";
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/";
public static Embed helpEmbed = new() public static Embed helpEmbed = new()
{ {
Title = "PluralKit", Title = "PluralKit",
Footer = new("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/"),
Color = DiscordUtils.Blue, Color = DiscordUtils.Blue,
}; };
@ -142,7 +143,7 @@ public class Help
=> ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest => 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>)", 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) } }, Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", ctx.DefaultPrefix), Fields = new Embed.Field[] { new("", EmbedFooter) } } },
Components = new[] { helpPageButtons(ctx.Author.Id) }, Components = new[] { helpPageButtons(ctx.Author.Id) },
}); });
@ -156,7 +157,7 @@ public class Help
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary) if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new() return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{ {
Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", prefix) } }, Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", prefix), Fields = new Embed.Field[] { new("", EmbedFooter) } } },
Components = new[] { buttons } Components = new[] { buttons }
}); });
@ -164,8 +165,9 @@ public class Help
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new() return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{ {
Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault(ctx.CustomId.Split("-")[2]).Select((item, index) => Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault(ctx.CustomId.Split("-")[2]).Select(
new Embed.Field(item.Name.Replace("{prefix}", prefix), item.Value.Replace("{prefix}", prefix))).ToArray() } }, (item, index) => new Embed.Field(item.Name.Replace("{prefix}", prefix), item.Value.Replace("{prefix}", prefix))
).Append(new("", EmbedFooter)).ToArray() } },
Components = new[] { buttons } Components = new[] { buttons }
}); });
} }

View file

@ -84,7 +84,7 @@ public class Member
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = img.CleanUrl ?? img.Url }, conn); await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = img.CleanUrl ?? img.Url }, conn);
dispatchData.Add("avatar_url", img.CleanUrl); dispatchData.Add("avatar_url", img.CleanUrl);

View file

@ -159,7 +159,7 @@ public class MemberAvatar
ctx.CheckSystem().CheckOwnMember(target); ctx.CheckSystem().CheckOwnMember(target);
avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Value.Url); await _avatarHosting.VerifyAvatarOrThrow(avatarArg.Value.Url);
await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url); await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url);
await PrintResponse(location, ctx, target, avatarArg.Value, guildData); await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
} }

View file

@ -231,7 +231,7 @@ public class MemberEdit
{ {
ctx.CheckOwnMember(target); ctx.CheckOwnMember(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url });
@ -254,6 +254,8 @@ public class MemberEdit
async Task ShowBannerImage() async Task ShowBannerImage()
{ {
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.BannerImage?.Trim() ?? "").Length > 0) if ((target.BannerImage?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat()) switch (ctx.MatchFormat())
{ {

View file

@ -241,6 +241,12 @@ public class ProxiedMessage
{ {
throw new PKError("Could not edit message."); throw new PKError("Could not edit message.");
} }
catch (BadRequestException e)
{
if (e.Message == "Voice messages cannot be edited")
throw new PKError($"{e.Message}.");
throw;
}
} }
private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, Duration timeout, bool isReproxy) private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, Duration timeout, bool isReproxy)

View file

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

View file

@ -572,7 +572,7 @@ public class SystemEdit
ctx.CheckOwnSystem(target); ctx.CheckOwnSystem(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url });
@ -659,7 +659,7 @@ public class SystemEdit
ctx.CheckOwnSystem(target); ctx.CheckOwnSystem(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url); await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url });
@ -781,7 +781,7 @@ public class SystemEdit
else if (await ctx.MatchImage() is { } img) else if (await ctx.MatchImage() is { } img)
{ {
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true); await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url }); await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url });

View file

@ -25,6 +25,5 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Sentry" Version="4.13.0" /> <PackageReference Include="Sentry" Version="4.13.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -238,25 +238,33 @@ public class ProxyService
if (trigger.Flags.HasFlag(Message.MessageFlags.VoiceMessage)) if (trigger.Flags.HasFlag(Message.MessageFlags.VoiceMessage))
flags |= Message.MessageFlags.VoiceMessage; flags |= Message.MessageFlags.VoiceMessage;
var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest try
{ {
GuildId = trigger.GuildId!.Value, var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest
ChannelId = rootChannel.Id, {
ThreadId = threadId, GuildId = trigger.GuildId!.Value,
MessageId = trigger.Id, ChannelId = rootChannel.Id,
Name = await FixSameName(messageChannel.Id, ctx, match.Member), ThreadId = threadId,
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)), MessageId = trigger.Id,
Content = content, Name = await FixSameName(messageChannel.Id, ctx, match.Member),
Attachments = trigger.Attachments, AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
FileSizeLimit = guild.FileSizeLimit(), Content = content,
Embeds = embeds.ToArray(), Attachments = trigger.Attachments,
Stickers = trigger.StickerItems, FileSizeLimit = guild.FileSizeLimit(),
AllowEveryone = allowEveryone, Embeds = embeds.ToArray(),
Flags = flags, Stickers = trigger.StickerItems,
Tts = tts, AllowEveryone = allowEveryone,
Poll = trigger.Poll, Flags = flags,
}); Tts = tts,
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match); Poll = trigger.Poll,
});
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
}
catch (PKError)
{
if (ctx.ProxyErrorMessageEnabled)
throw;
}
} }
public async Task ExecuteReproxy(Message trigger, PKMessage msg, List<ProxyMember> members, ProxyMember member, string prefix) public async Task ExecuteReproxy(Message trigger, PKMessage msg, List<ProxyMember> members, ProxyMember member, string prefix)
@ -391,6 +399,10 @@ public class ProxyService
if (hasContent) if (hasContent)
{ {
var msg = repliedTo.Content; var msg = repliedTo.Content;
// strip out overly excessive line breaks
msg = Regex.Replace(msg, @"(?:(?:([_\*]) \1)?\n){2,}", "\n");
if (msg.Length > 100) if (msg.Length > 100)
{ {
msg = repliedTo.Content.Substring(0, 100); msg = repliedTo.Content.Substring(0, 100);

View file

@ -18,6 +18,44 @@ public class AvatarHostingService
}; };
} }
public async Task VerifyAvatarOrThrow(string url, bool isBanner = false)
{
if (url.Length > Limits.MaxUriLength)
throw Errors.UrlTooLong(url);
if (!PluralKit.Core.MiscUtils.TryMatchUri(url, out var uri))
throw Errors.InvalidUrl;
if (uri.Host.Contains("toyhou.se"))
throw new PKError("Due to server issues, PluralKit is unable to read images hosted on toyhou.se.");
if (uri.Host == "cdn.pluralkit.me") return;
if (_config.AvatarServiceUrl == null)
return;
var kind = isBanner ? "banner" : "avatar";
try
{
var response = await _client.PostAsJsonAsync(_config.AvatarServiceUrl + "/verify",
new { url, kind });
if (response.StatusCode != HttpStatusCode.OK)
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
throw new PKError($"{error.Error}");
}
}
catch (TaskCanceledException e)
{
// don't show an internal error to users
if (e.Message.Contains("HttpClient.Timeout"))
throw new PKError("Temporary error setting image, please try again later");
throw;
}
}
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system) public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
{ {
try try

View file

@ -116,7 +116,7 @@ public class ErrorMessageService
return new EmbedBuilder() return new EmbedBuilder()
.Color(0xE74C3C) .Color(0xE74C3C)
.Title("Internal error occurred") .Title("Internal error occurred")
.Description($"For support, please send the error code above in {channelInfo} with a description of what you were doing at the time.") .Description($"For support, please send the error code above as text in {channelInfo} with a description of what you were doing at the time.")
.Footer(new Embed.EmbedFooter(errorId)) .Footer(new Embed.EmbedFooter(errorId))
.Timestamp(now.ToDateTimeOffset().ToString("O")) .Timestamp(now.ToDateTimeOffset().ToString("O"))
.Build(); .Build();

View file

@ -191,6 +191,9 @@ public class WebhookExecutorService
} }
catch (BadRequestException e) catch (BadRequestException e)
{ {
if (e.Message == "Cannot use one or more emoji included with this poll")
throw new PKError($"Discord rejected proxy message: {e.Message}");
// explanation for hacky: I don't care if this code fails, it just means it wasn't a username error // explanation for hacky: I don't care if this code fails, it just means it wasn't a username error
try try
{ {

View file

@ -2,63 +2,10 @@ using System.Text.RegularExpressions;
using PluralKit.Core; using PluralKit.Core;
using SixLabors.ImageSharp;
namespace PluralKit.Bot; namespace PluralKit.Bot;
public static class AvatarUtils public static class AvatarUtils
{ {
public static async Task VerifyAvatarOrThrow(HttpClient client, string url, bool isFullSizeImage = false)
{
if (url.Length > Limits.MaxUriLength)
throw Errors.UrlTooLong(url);
// List of MIME types we consider acceptable
var acceptableMimeTypes = new[]
{
"image/jpeg", "image/gif", "image/png", "image/webp"
};
if (!PluralKit.Core.MiscUtils.TryMatchUri(url, out var uri))
throw Errors.InvalidUrl;
if (uri.Host.Contains("toyhou.se"))
throw new PKError("Due to server issues, PluralKit is unable to read images hosted on toyhou.se.");
url = TryRewriteCdnUrl(url);
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode) // Check status code
throw Errors.AvatarServerError(response.StatusCode);
if (response.Content.Headers.ContentLength == null) // Check presence of content length
throw Errors.AvatarNotAnImage(null);
try
{
if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type
throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType);
}
catch (NullReferenceException)
{
throw new PKError("Could not verify avatar is an image. This can happen when the server sends a malformed response."
+ "\nPlease join the support server for help: <https://discord.gg/PczBt78>");
}
if (isFullSizeImage)
// no need to do size checking on banners
return;
if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length
throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value);
// Parse the image header in a worker
var stream = await response.Content.ReadAsStreamAsync();
var image = await Task.Run(() => Image.Identify(stream));
if (image == null) throw Errors.AvatarInvalid;
if (image.Width > Limits.AvatarDimensionLimit ||
image.Height > Limits.AvatarDimensionLimit) // Check image size
throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height);
}
// Rewrite cdn.discordapp.com URLs to media.discordapp.net for jpg/png files // Rewrite cdn.discordapp.com URLs to media.discordapp.net for jpg/png files
// This lets us add resizing parameters to "borrow" their media proxy server to downsize the image // This lets us add resizing parameters to "borrow" their media proxy server to downsize the image
// which in turn makes it more likely to be underneath the size limit! // which in turn makes it more likely to be underneath the size limit!

View file

@ -14,12 +14,6 @@
"resolved": "4.13.0", "resolved": "4.13.0",
"contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg==" "contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg=="
}, },
"SixLabors.ImageSharp": {
"type": "Direct",
"requested": "[3.1.6, )",
"resolved": "3.1.6",
"contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA=="
},
"App.Metrics": { "App.Metrics": {
"type": "Transitive", "type": "Transitive",
"resolved": "4.3.0", "resolved": "4.3.0",

View file

@ -654,11 +654,6 @@
"Serilog.Sinks.File": "5.0.0" "Serilog.Sinks.File": "5.0.0"
} }
}, },
"SixLabors.ImageSharp": {
"type": "Transitive",
"resolved": "3.1.6",
"contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA=="
},
"SqlKata": { "SqlKata": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.4.0", "resolved": "2.4.0",
@ -949,6 +944,7 @@
"Microsoft.AspNetCore.Mvc.Versioning": "[5.1.0, )", "Microsoft.AspNetCore.Mvc.Versioning": "[5.1.0, )",
"Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer": "[5.1.0, )", "Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer": "[5.1.0, )",
"PluralKit.Core": "[1.0.0, )", "PluralKit.Core": "[1.0.0, )",
"Sentry": "[4.13.0, )",
"Serilog.AspNetCore": "[9.0.0, )" "Serilog.AspNetCore": "[9.0.0, )"
} }
}, },
@ -958,8 +954,7 @@
"Humanizer.Core": "[2.14.1, )", "Humanizer.Core": "[2.14.1, )",
"Myriad": "[1.0.0, )", "Myriad": "[1.0.0, )",
"PluralKit.Core": "[1.0.0, )", "PluralKit.Core": "[1.0.0, )",
"Sentry": "[4.13.0, )", "Sentry": "[4.13.0, )"
"SixLabors.ImageSharp": "[3.1.6, )"
} }
}, },
"pluralkit.core": { "pluralkit.core": {

View file

@ -16,6 +16,7 @@ use axum::{
use libpk::_config::AvatarsConfig; use libpk::_config::AvatarsConfig;
use libpk::db::repository::avatars as db; use libpk::db::repository::avatars as db;
use libpk::db::types::avatars::*; use libpk::db::types::avatars::*;
use pull::ParsedUrl;
use reqwest::{Client, ClientBuilder}; use reqwest::{Client, ClientBuilder};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
@ -23,7 +24,7 @@ use std::error::Error;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use thiserror::Error; use thiserror::Error;
use tracing::{error, info}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -35,9 +36,15 @@ pub enum PKAvatarError {
#[error("discord cdn responded with status code: {0}")] #[error("discord cdn responded with status code: {0}")]
BadCdnResponse(reqwest::StatusCode), BadCdnResponse(reqwest::StatusCode),
#[error("server responded with status code: {0}")]
BadServerResponse(reqwest::StatusCode),
#[error("network error: {0}")] #[error("network error: {0}")]
NetworkError(reqwest::Error), NetworkError(reqwest::Error),
#[error("network error: {0}")]
NetworkErrorString(String),
#[error("response is missing header: {0}")] #[error("response is missing header: {0}")]
MissingHeader(&'static str), MissingHeader(&'static str),
@ -86,7 +93,6 @@ async fn pull(
) -> Result<Json<PullResponse>, PKAvatarError> { ) -> Result<Json<PullResponse>, PKAvatarError> {
let parsed = pull::parse_url(&req.url) // parsing beforehand to "normalize" let parsed = pull::parse_url(&req.url) // parsing beforehand to "normalize"
.map_err(|_| PKAvatarError::InvalidCdnUrl)?; .map_err(|_| PKAvatarError::InvalidCdnUrl)?;
if !req.force { if !req.force {
if let Some(existing) = db::get_by_attachment_id(&state.pool, parsed.attachment_id).await? { if let Some(existing) = db::get_by_attachment_id(&state.pool, parsed.attachment_id).await? {
// remove any pending image cleanup // remove any pending image cleanup
@ -132,6 +138,26 @@ async fn pull(
})) }))
} }
async fn verify(
State(state): State<AppState>,
Json(req): Json<PullRequest>,
) -> Result<(), PKAvatarError> {
let result = crate::pull::pull(
state.pull_client,
&ParsedUrl {
full_url: req.url.clone(),
channel_id: 0,
attachment_id: 0,
filename: "".to_string(),
},
)
.await?;
let encoded = process::process_async(result.data, req.kind).await?;
Ok(())
}
pub async fn stats(State(state): State<AppState>) -> Result<Json<Stats>, PKAvatarError> { pub async fn stats(State(state): State<AppState>) -> Result<Json<Stats>, PKAvatarError> {
Ok(Json(db::get_stats(&state.pool).await?)) Ok(Json(db::get_stats(&state.pool).await?))
} }
@ -193,6 +219,7 @@ async fn real_main() -> anyhow::Result<()> {
// migrate::spawn_migrate_workers(Arc::new(state.clone()), state.config.migrate_worker_count); // migrate::spawn_migrate_workers(Arc::new(state.clone()), state.config.migrate_worker_count);
let app = Router::new() let app = Router::new()
.route("/verify", post(verify))
.route("/pull", post(pull)) .route("/pull", post(pull))
.route("/stats", get(stats)) .route("/stats", get(stats))
.with_state(state); .with_state(state);
@ -235,7 +262,12 @@ impl IntoResponse for PKAvatarError {
}; };
// print inner error if otherwise hidden // print inner error if otherwise hidden
error!("error: {}", self.source().unwrap_or(&self)); // `error!` calls go to sentry, so only use that if it's our error
if matches!(self, PKAvatarError::InternalError(_)) {
error!("error: {}", self.source().unwrap_or(&self));
} else {
warn!("error: {}", self.source().unwrap_or(&self));
}
( (
status_code, status_code,

View file

@ -3,6 +3,8 @@ use std::{str::FromStr, sync::Arc};
use crate::PKAvatarError; use crate::PKAvatarError;
use anyhow::Context; use anyhow::Context;
use reqwest::{Client, StatusCode, Url}; use reqwest::{Client, StatusCode, Url};
use std::error::Error;
use std::fmt::Write;
use std::time::Instant; use std::time::Instant;
use tracing::{error, instrument}; use tracing::{error, instrument};
@ -28,14 +30,29 @@ pub async fn pull(
.expect("set_host should not fail"); .expect("set_host should not fail");
} }
let response = client.get(trimmed_url.clone()).send().await.map_err(|e| { let response = client.get(trimmed_url.clone()).send().await.map_err(|e| {
error!("network error for {}: {}", parsed_url.full_url, e); // terrible
PKAvatarError::NetworkError(e) let mut s = format!("{}", e);
if let Some(src) = e.source() {
let _ = write!(s, ": {}", src);
let mut err = src;
while let Some(src) = err.source() {
let _ = write!(s, ": {}", src);
err = src;
}
}
error!("network error for {}: {}", parsed_url.full_url, s);
PKAvatarError::NetworkErrorString(s)
})?; })?;
let time_after_headers = Instant::now(); let time_after_headers = Instant::now();
let status = response.status(); let status = response.status();
if status != StatusCode::OK { if status != StatusCode::OK {
return Err(PKAvatarError::BadCdnResponse(status)); if trimmed_url.host_str() == Some("cdn.discordapp.com") {
return Err(PKAvatarError::BadCdnResponse(status));
} else {
return Err(PKAvatarError::BadServerResponse(status));
}
} }
let size = match response.content_length() { let size = match response.content_length() {

View file

@ -105,7 +105,7 @@ pub fn new() -> DiscordCache {
.api_base_url .api_base_url
.clone() .clone()
{ {
client_builder = client_builder.proxy(base_url, true); client_builder = client_builder.proxy(base_url, true).ratelimiter(None);
} }
let client = Arc::new(client_builder.build()); let client = Arc::new(client_builder.build());

View file

@ -83,29 +83,38 @@ pub async fn runner(
cache: Arc<DiscordCache>, cache: Arc<DiscordCache>,
) { ) {
// let _span = info_span!("shard_runner", shard_id = shard.id().number()).entered(); // let _span = info_span!("shard_runner", shard_id = shard.id().number()).entered();
let shard_id = shard.id().number();
info!("waiting for events"); info!("waiting for events");
while let Some(item) = shard.next().await { while let Some(item) = shard.next().await {
let raw_event = match item { let raw_event = match item {
Ok(evt) => match evt { Ok(evt) => match evt {
Message::Close(frame) => { Message::Close(frame) => {
info!( let close_code = if let Some(close) = frame {
"shard {} closed: {}", close.code.to_string()
shard.id().number(), } else {
if let Some(close) = frame { "unknown".to_string()
format!("{} ({})", close.code, close.reason) };
} else {
"unknown".to_string() info!("shard {shard_id} closed: {close_code}");
}
); counter!(
if let Err(error) = shard_state.socket_closed(shard.id().number()).await { "pluralkit_gateway_shard_closed",
"shard_id" => shard_id.to_string(),
"close_code" => close_code,
)
.increment(1);
if let Err(error) = shard_state.socket_closed(shard_id).await {
error!("failed to update shard state for socket closure: {error}"); error!("failed to update shard state for socket closure: {error}");
} }
continue; continue;
} }
Message::Text(text) => text, Message::Text(text) => text,
}, },
Err(error) => { Err(error) => {
tracing::warn!(?error, "error receiving event from shard {}", shard.id()); tracing::warn!(?error, "error receiving event from shard {shard_id}");
continue; continue;
} }
}; };
@ -118,11 +127,7 @@ pub async fn runner(
continue; continue;
} }
Err(error) => { Err(error) => {
error!( error!("shard {shard_id} failed to parse gateway event: {error}");
"shard {} failed to parse gateway event: {}",
shard.id().number(),
error
);
continue; continue;
} }
}; };
@ -137,29 +142,24 @@ pub async fn runner(
.increment(1); .increment(1);
counter!( counter!(
"pluralkit_gateway_events_shard", "pluralkit_gateway_events_shard",
"shard_id" => shard.id().number().to_string(), "shard_id" => shard_id.to_string(),
) )
.increment(1); .increment(1);
// update shard state and discord cache // update shard state and discord cache
if let Err(error) = shard_state if let Err(error) = shard_state.handle_event(shard_id, event.clone()).await {
.handle_event(shard.id().number(), event.clone()) tracing::error!(?error, "error updating redis state");
.await
{
tracing::warn!(?error, "error updating redis state");
} }
// need to do heartbeat separately, to get the latency // need to do heartbeat separately, to get the latency
if let Event::GatewayHeartbeatAck = event if let Event::GatewayHeartbeatAck = event
&& let Err(error) = shard_state && let Err(error) = shard_state.heartbeated(shard_id, shard.latency()).await
.heartbeated(shard.id().number(), shard.latency())
.await
{ {
tracing::warn!(?error, "error updating redis state for latency"); tracing::error!(?error, "error updating redis state for latency");
} }
if let Event::Ready(_) = event { if let Event::Ready(_) = event {
if !cache.2.read().await.contains(&shard.id().number()) { if !cache.2.read().await.contains(&shard_id) {
cache.2.write().await.push(shard.id().number()); cache.2.write().await.push(shard_id);
} }
} }
cache.0.update(&event); cache.0.update(&event);

View file

@ -3,7 +3,7 @@ use metrics::{counter, gauge};
use tracing::info; use tracing::info;
use twilight_gateway::{Event, Latency}; use twilight_gateway::{Event, Latency};
use libpk::{state::*, util::redis::*}; use libpk::state::ShardState;
#[derive(Clone)] #[derive(Clone)]
pub struct ShardStateManager { pub struct ShardStateManager {
@ -24,11 +24,7 @@ impl ShardStateManager {
} }
async fn get_shard(&self, shard_id: u32) -> anyhow::Result<ShardState> { async fn get_shard(&self, shard_id: u32) -> anyhow::Result<ShardState> {
let data: Option<String> = self let data: Option<String> = self.redis.hget("pluralkit:shardstatus", shard_id).await?;
.redis
.hget("pluralkit:shardstatus", shard_id)
.await
.to_option_or_error()?;
match data { match data {
Some(buf) => Ok(serde_json::from_str(&buf).expect("could not decode shard data!")), Some(buf) => Ok(serde_json::from_str(&buf).expect("could not decode shard data!")),
None => Ok(ShardState::default()), None => Ok(ShardState::default()),

View file

@ -21,3 +21,4 @@ uuid = { workspace = true }
config = "0.14.0" config = "0.14.0"
json-subscriber = { version = "0.2.2", features = ["env-filter"] } json-subscriber = { version = "0.2.2", features = ["env-filter"] }
metrics-exporter-prometheus = { version = "0.15.3", default-features = false, features = ["tokio", "http-listener", "tracing"] } metrics-exporter-prometheus = { version = "0.15.3", default-features = false, features = ["tokio", "http-listener", "tracing"] }
sentry-tracing = "0.36.0"

View file

@ -5,16 +5,28 @@ use metrics_exporter_prometheus::PrometheusBuilder;
use sentry::IntoDsn; use sentry::IntoDsn;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use sentry_tracing::event_from_event;
pub mod db; pub mod db;
pub mod state; pub mod state;
pub mod util;
pub mod _config; pub mod _config;
pub use crate::_config::CONFIG as config; pub use crate::_config::CONFIG as config;
// functions in this file are only used by the main function below // functions in this file are only used by the main function below
pub fn init_logging(component: &str) -> anyhow::Result<()> { pub fn init_logging(component: &str) {
let sentry_layer =
sentry_tracing::layer().event_mapper(|md, ctx| match md.metadata().level() {
&tracing::Level::ERROR => {
// for some reason this works, but letting the library handle it doesn't
let event = event_from_event(md, ctx);
sentry::capture_event(event);
sentry_tracing::EventMapping::Ignore
}
_ => sentry_tracing::EventMapping::Ignore,
});
if config.json_log { if config.json_log {
let mut layer = json_subscriber::layer(); let mut layer = json_subscriber::layer();
layer.inner_layer_mut().add_static_field( layer.inner_layer_mut().add_static_field(
@ -22,16 +34,16 @@ pub fn init_logging(component: &str) -> anyhow::Result<()> {
serde_json::Value::String(component.to_string()), serde_json::Value::String(component.to_string()),
); );
tracing_subscriber::registry() tracing_subscriber::registry()
.with(sentry_layer)
.with(layer) .with(layer)
.with(EnvFilter::from_default_env()) .with(EnvFilter::from_default_env())
.init(); .init();
} else { } else {
tracing_subscriber::fmt() tracing_subscriber::registry()
.with_env_filter(EnvFilter::from_default_env()) .with(sentry_layer)
.with(tracing_subscriber::fmt::layer())
.init(); .init();
} }
Ok(())
} }
pub fn init_metrics() -> anyhow::Result<()> { pub fn init_metrics() -> anyhow::Result<()> {
@ -61,7 +73,7 @@ macro_rules! main {
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let _sentry_guard = libpk::init_sentry(); let _sentry_guard = libpk::init_sentry();
// we might also be able to use env!("CARGO_CRATE_NAME") here // we might also be able to use env!("CARGO_CRATE_NAME") here
libpk::init_logging($component)?; libpk::init_logging($component);
tokio::runtime::Builder::new_multi_thread() tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build() .build()

View file

@ -1 +0,0 @@
pub mod redis;

View file

@ -1,15 +0,0 @@
use fred::error::RedisError;
pub trait RedisErrorExt<T> {
fn to_option_or_error(self) -> Result<Option<T>, RedisError>;
}
impl<T> RedisErrorExt<T> for Result<T, RedisError> {
fn to_option_or_error(self) -> Result<Option<T>, RedisError> {
match self {
Ok(v) => Ok(Some(v)),
Err(error) if error.is_not_found() => Ok(None),
Err(error) => Err(error),
}
}
}

View file

@ -16,6 +16,7 @@ serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
twilight-http = { workspace = true }
croner = "2.1.0" croner = "2.1.0"
num-format = "0.4.4" num-format = "0.4.4"

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use croner::Cron; use croner::Cron;
use fred::prelude::RedisPool; use fred::prelude::RedisPool;
@ -14,15 +16,38 @@ pub struct AppCtx {
pub messages: PgPool, pub messages: PgPool,
pub stats: PgPool, pub stats: PgPool,
pub redis: RedisPool, pub redis: RedisPool,
pub discord: Arc<twilight_http::Client>,
} }
libpk::main!("scheduled_tasks"); libpk::main!("scheduled_tasks");
async fn real_main() -> anyhow::Result<()> { async fn real_main() -> anyhow::Result<()> {
let mut client_builder = twilight_http::Client::builder().token(
libpk::config
.discord
.as_ref()
.expect("missing discord config")
.bot_token
.clone(),
);
if let Some(base_url) = libpk::config
.discord
.as_ref()
.expect("missing discord config")
.api_base_url
.clone()
{
client_builder = client_builder.proxy(base_url, true).ratelimiter(None);
}
let ctx = AppCtx { let ctx = AppCtx {
data: libpk::db::init_data_db().await?, data: libpk::db::init_data_db().await?,
messages: libpk::db::init_messages_db().await?, messages: libpk::db::init_messages_db().await?,
stats: libpk::db::init_stats_db().await?, stats: libpk::db::init_stats_db().await?,
redis: libpk::db::init_redis().await?, redis: libpk::db::init_redis().await?,
discord: Arc::new(client_builder.build()),
}; };
info!("starting scheduled tasks runner"); info!("starting scheduled tasks runner");

View file

@ -25,7 +25,13 @@ pub async fn update_prometheus(ctx: AppCtx) -> anyhow::Result<()> {
gauge!("pluralkit_image_cleanup_queue_length").set(count.count as f64); gauge!("pluralkit_image_cleanup_queue_length").set(count.count as f64);
// todo: remaining shard session_start_limit let gateway = ctx.discord.gateway().authed().await?.model().await?;
gauge!("pluralkit_gateway_sessions_remaining")
.set(gateway.session_start_limit.remaining as f64);
gauge!("pluralkit_gateway_sessions_reset_after")
.set(gateway.session_start_limit.reset_after as f64);
Ok(()) Ok(())
} }

View file

@ -27,6 +27,7 @@ PluralKit has a couple of useful command shorthands to reduce the typing:
|pk;switch|pk;sw| |pk;switch|pk;sw|
|pk;message|pk;msg| |pk;message|pk;msg|
|pk;autoproxy|pk;ap| |pk;autoproxy|pk;ap|
|pk;reproxy|pk;rp|
|pk;edit|pk;e| |pk;edit|pk;e|
|pk;edit -regex|pk;x| |pk;edit -regex|pk;x|