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

View file

@ -15,13 +15,13 @@ 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"]}
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_json = "1.0.117"
signal-hook = "0.3.17"
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "chrono", "macros", "uuid"] }
tokio = { version = "1.36.0", features = ["full"] }
tracing = "0.1.40"
tracing = "0.1"
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
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)
{
if (!(await cache.TryGetGuild(guildId) is Guild guild))
throw new KeyNotFoundException($"Guild {guildId} not found in cache");
throw new NotFoundInCacheException(guildId, "guild");
return guild;
}
public static async Task<Channel> GetChannel(this IDiscordCache cache, ulong guildId, ulong channelId)
{
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;
}
@ -54,4 +54,16 @@ public static class CacheExtensions
if (parent == null) throw new Exception($"failed to find parent channel for thread {channelOrThread} in cache");
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.Versioning" 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" />
</ItemGroup>
</Project>

View file

@ -14,6 +14,16 @@ public class Program
await BuildInfoService.LoadVersion();
var host = CreateHostBuilder(args).Build();
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.RunAsync();
}

View file

@ -62,8 +62,13 @@ public class Startup
await ctx.Response.WriteJSON(400, "{\"message\":\"400: Bad Request\",\"code\":0}");
else if (exc.Error is not PKError)
{
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
else if (exc.Error is ModelParseError fe)
await ctx.Response.WriteJSON(fe.ResponseCode, JsonConvert.SerializeObject(fe.ToJson()));

View file

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

View file

@ -213,6 +213,13 @@ public class Bot
{
_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
var sentryEvent = new SentryEvent(exc);

View file

@ -291,7 +291,7 @@ public class Groups
ctx.CheckOwnGroup(target);
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 });
@ -354,8 +354,8 @@ public class Groups
{
async Task ClearBannerImage()
{
await ctx.ConfirmClear("this group's banner image");
ctx.CheckOwnGroup(target);
await ctx.ConfirmClear("this group's banner image");
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null });
await ctx.Reply($"{Emojis.Success} Group banner image cleared.");
@ -366,7 +366,7 @@ public class Groups
ctx.CheckOwnGroup(target);
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 });
@ -391,7 +391,7 @@ public class Groups
{
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.Icon?.Trim() ?? "").Length > 0)
if ((target.BannerImage?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat())
{
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" +
"## 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()
{
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,
};
@ -142,7 +143,7 @@ public class Help
=> 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) } },
Embeds = new[] { helpEmbed with { Description = Help.Description.Replace("{prefix}", ctx.DefaultPrefix), Fields = new Embed.Field[] { new("", EmbedFooter) } } },
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)
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 }
});
@ -164,8 +165,9 @@ public class Help
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
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))).ToArray() } },
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() } },
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);
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);
dispatchData.Add("avatar_url", img.CleanUrl);

View file

@ -159,7 +159,7 @@ public class MemberAvatar
ctx.CheckSystem().CheckOwnMember(target);
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 PrintResponse(location, ctx, target, avatarArg.Value, guildData);
}

View file

@ -231,7 +231,7 @@ public class MemberEdit
{
ctx.CheckOwnMember(target);
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 });
@ -254,6 +254,8 @@ public class MemberEdit
async Task ShowBannerImage()
{
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.BannerImage?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat())
{

View file

@ -241,6 +241,12 @@ public class ProxiedMessage
{
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)

View file

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

View file

@ -572,7 +572,7 @@ public class SystemEdit
ctx.CheckOwnSystem(target);
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 });
@ -659,7 +659,7 @@ public class SystemEdit
ctx.CheckOwnSystem(target);
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 });
@ -781,7 +781,7 @@ public class SystemEdit
else if (await ctx.MatchImage() is { } img)
{
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 });

View file

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

View file

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

View file

@ -116,7 +116,7 @@ public class ErrorMessageService
return new EmbedBuilder()
.Color(0xE74C3C)
.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))
.Timestamp(now.ToDateTimeOffset().ToString("O"))
.Build();

View file

@ -191,6 +191,9 @@ public class WebhookExecutorService
}
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
try
{

View file

@ -2,63 +2,10 @@ using System.Text.RegularExpressions;
using PluralKit.Core;
using SixLabors.ImageSharp;
namespace PluralKit.Bot;
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
// 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!

View file

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

View file

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

View file

@ -16,6 +16,7 @@ use axum::{
use libpk::_config::AvatarsConfig;
use libpk::db::repository::avatars as db;
use libpk::db::types::avatars::*;
use pull::ParsedUrl;
use reqwest::{Client, ClientBuilder};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@ -23,7 +24,7 @@ use std::error::Error;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use tracing::{error, info};
use tracing::{error, info, warn};
use uuid::Uuid;
#[derive(Error, Debug)]
@ -35,9 +36,15 @@ pub enum PKAvatarError {
#[error("discord cdn responded with status code: {0}")]
BadCdnResponse(reqwest::StatusCode),
#[error("server responded with status code: {0}")]
BadServerResponse(reqwest::StatusCode),
#[error("network error: {0}")]
NetworkError(reqwest::Error),
#[error("network error: {0}")]
NetworkErrorString(String),
#[error("response is missing header: {0}")]
MissingHeader(&'static str),
@ -86,7 +93,6 @@ async fn pull(
) -> Result<Json<PullResponse>, PKAvatarError> {
let parsed = pull::parse_url(&req.url) // parsing beforehand to "normalize"
.map_err(|_| PKAvatarError::InvalidCdnUrl)?;
if !req.force {
if let Some(existing) = db::get_by_attachment_id(&state.pool, parsed.attachment_id).await? {
// 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> {
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);
let app = Router::new()
.route("/verify", post(verify))
.route("/pull", post(pull))
.route("/stats", get(stats))
.with_state(state);
@ -235,7 +262,12 @@ impl IntoResponse for PKAvatarError {
};
// 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,

View file

@ -3,6 +3,8 @@ use std::{str::FromStr, sync::Arc};
use crate::PKAvatarError;
use anyhow::Context;
use reqwest::{Client, StatusCode, Url};
use std::error::Error;
use std::fmt::Write;
use std::time::Instant;
use tracing::{error, instrument};
@ -28,14 +30,29 @@ pub async fn pull(
.expect("set_host should not fail");
}
let response = client.get(trimmed_url.clone()).send().await.map_err(|e| {
error!("network error for {}: {}", parsed_url.full_url, e);
PKAvatarError::NetworkError(e)
// terrible
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 status = response.status();
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() {

View file

@ -105,7 +105,7 @@ pub fn new() -> DiscordCache {
.api_base_url
.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());

View file

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

View file

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

View file

@ -21,3 +21,4 @@ uuid = { workspace = true }
config = "0.14.0"
json-subscriber = { version = "0.2.2", features = ["env-filter"] }
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 tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use sentry_tracing::event_from_event;
pub mod db;
pub mod state;
pub mod util;
pub mod _config;
pub use crate::_config::CONFIG as config;
// 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 {
let mut layer = json_subscriber::layer();
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()),
);
tracing_subscriber::registry()
.with(sentry_layer)
.with(layer)
.with(EnvFilter::from_default_env())
.init();
} else {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
tracing_subscriber::registry()
.with(sentry_layer)
.with(tracing_subscriber::fmt::layer())
.init();
}
Ok(())
}
pub fn init_metrics() -> anyhow::Result<()> {
@ -61,7 +73,7 @@ macro_rules! main {
fn main() -> anyhow::Result<()> {
let _sentry_guard = libpk::init_sentry();
// 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()
.enable_all()
.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 }
tokio = { workspace = true }
tracing = { workspace = true }
twilight-http = { workspace = true }
croner = "2.1.0"
num-format = "0.4.4"

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use chrono::Utc;
use croner::Cron;
use fred::prelude::RedisPool;
@ -14,15 +16,38 @@ pub struct AppCtx {
pub messages: PgPool,
pub stats: PgPool,
pub redis: RedisPool,
pub discord: Arc<twilight_http::Client>,
}
libpk::main!("scheduled_tasks");
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 {
data: libpk::db::init_data_db().await?,
messages: libpk::db::init_messages_db().await?,
stats: libpk::db::init_stats_db().await?,
redis: libpk::db::init_redis().await?,
discord: Arc::new(client_builder.build()),
};
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);
// 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(())
}

View file

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