Merge branch 'main' of https://github.com/rladenson/PluralKit into logclean_annabelle

This commit is contained in:
rladenson 2024-11-18 23:52:49 -07:00
commit 1c7f950dae
265 changed files with 10696 additions and 2964 deletions

View file

@ -0,0 +1,79 @@
using PluralKit.Core;
using System.Net;
using System.Net.Http.Json;
namespace PluralKit.Bot;
public class AvatarHostingService
{
private readonly BotConfig _config;
private readonly HttpClient _client;
public AvatarHostingService(BotConfig config)
{
_config = config;
_client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10),
};
}
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
{
try
{
var uploaded = await TryUploadAvatar(input.Url, type, userId, system);
if (uploaded != null)
{
// todo: make new image type called Cdn?
return new ParsedImage { Url = uploaded, Source = AvatarSource.HostedCdn };
}
return input;
}
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<string?> TryUploadAvatar(string? avatarUrl, RehostedImageType type, ulong userId, PKSystem? system)
{
if (!AvatarUtils.IsDiscordCdnUrl(avatarUrl))
return null;
if (_config.AvatarServiceUrl == null)
return null;
var kind = type switch
{
RehostedImageType.Avatar => "avatar",
RehostedImageType.Banner => "banner",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
};
var response = await _client.PostAsJsonAsync(_config.AvatarServiceUrl + "/pull",
new { url = avatarUrl, kind, uploaded_by = userId, system_id = system?.Uuid.ToString() });
if (response.StatusCode != HttpStatusCode.OK)
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
throw new PKError($"Error uploading image to CDN: {error.Error}");
}
var success = await response.Content.ReadFromJsonAsync<SuccessResponse>();
return success.Url;
}
public record ErrorResponse(string Error);
public record SuccessResponse(string Url, bool New);
public enum RehostedImageType
{
Avatar,
Banner,
}
}

View file

@ -18,7 +18,7 @@ public class CommandMessageService
_logger = logger.ForContext<CommandMessageService>();
}
public async Task RegisterMessage(ulong messageId, ulong channelId, ulong authorId)
public async Task RegisterMessage(ulong messageId, ulong guildId, ulong channelId, ulong authorId)
{
if (_redis.Connection == null) return;
@ -27,17 +27,19 @@ public class CommandMessageService
messageId, authorId, channelId
);
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}", expiry: CommandMessageRetention);
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}-{guildId}", expiry: CommandMessageRetention);
}
public async Task<(ulong?, ulong?)> GetCommandMessage(ulong messageId)
public async Task<CommandMessage?> GetCommandMessage(ulong messageId)
{
var str = await _redis.Connection.GetDatabase().StringGetAsync(messageId.ToString());
if (str.HasValue)
{
var split = ((string)str).Split("-");
return (ulong.Parse(split[0]), ulong.Parse(split[1]));
return new CommandMessage(ulong.Parse(split[0]), ulong.Parse(split[1]), ulong.Parse(split[2]));
}
return (null, null);
return null;
}
}
}
public record CommandMessage(ulong AuthorId, ulong ChannelId, ulong GuildId);

View file

@ -56,29 +56,18 @@ public class EmbedService
var memberCount = await _repo.GetSystemMemberCount(system.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
uint color;
try
{
color = system.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// There's no API for system colors yet, but defaulting to a blank color in advance can't be a bad idea
color = DiscordUtils.Gray;
}
var eb = new EmbedBuilder()
.Title(system.NameFor(ctx))
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(color)
$"System ID: {system.DisplayHid(cctx.Config)} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(system.Color?.ToDiscordColor())
.Url($"https://dash.pluralkit.me/profile/s/{system.Hid}");
var avatar = system.AvatarFor(ctx);
if (avatar != null)
eb.Thumbnail(new Embed.EmbedThumbnail(avatar));
if (system.DescriptionPrivacy.CanAccess(ctx))
if (system.BannerPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(system.BannerImage));
var latestSwitch = await _repo.GetLatestSwitch(system.Id);
@ -90,7 +79,7 @@ public class EmbedService
{
var memberStr = string.Join(", ", switchMembers.Select(m => m.NameFor(ctx)));
if (memberStr.Length > 200)
memberStr = $"[too many to show, see `pk;system {system.Hid} fronters`]";
memberStr = $"[too many to show, see `pk;system {system.DisplayHid(cctx.Config)} fronters`]";
eb.Field(new Embed.Field("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), memberStr));
}
}
@ -137,7 +126,7 @@ public class EmbedService
{
if (memberCount > 0)
eb.Field(new Embed.Field($"Members ({memberCount})",
$"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true));
$"(see `pk;system {system.DisplayHid(cctx.Config)} list` or `pk;system {system.DisplayHid(cctx.Config)} list full`)", true));
else
eb.Field(new Embed.Field($"Members ({memberCount})", "Add one with `pk;member new`!", true));
}
@ -175,7 +164,7 @@ public class EmbedService
return embed.Build();
}
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, LookupContext ctx, DateTimeZone zone)
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, SystemConfig? ccfg, LookupContext ctx, DateTimeZone zone)
{
// string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
@ -188,19 +177,6 @@ public class EmbedService
else
name = $"{name}";
uint color;
try
{
color = member.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// Bad API use can cause an invalid color string
// this is now fixed in the API, but might still have some remnants in the database
// so we just default to a blank color, yolo
color = DiscordUtils.Gray;
}
var guildSettings = guild != null ? await _repo.GetMemberGuild(guild.Id, member.Id) : null;
var guildDisplayName = guildSettings?.DisplayName;
var webhook_avatar = guildSettings?.AvatarUrl ?? member.WebhookAvatarFor(ctx) ?? member.AvatarFor(ctx);
@ -213,12 +189,12 @@ public class EmbedService
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
.Color(color)
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : null)
.Color(member.Color?.ToDiscordColor())
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(zone)}" : "")}"));
$"System ID: {system.DisplayHid(ccfg)} | Member ID: {member.DisplayHid(ccfg)} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(zone)}" : "")}"));
if (member.DescriptionPrivacy.CanAccess(ctx))
if (member.BannerPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(member.BannerImage));
var description = "";
@ -255,7 +231,7 @@ public class EmbedService
// More than 5 groups show in "compact" format without ID
var content = groups.Count > 5
? string.Join(", ", groups.Select(g => g.DisplayName ?? g.Name))
: string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**"));
: string.Join("\n", groups.Select(g => $"[`{g.DisplayHid(ccfg, isList: true)}`] **{g.DisplayName ?? g.Name}**"));
eb.Field(new Embed.Field($"Groups ({groups.Count})", content.Truncate(1000)));
}
@ -287,26 +263,15 @@ public class EmbedService
else if (system.NameFor(ctx) != null)
nameField = $"{nameField} ({system.NameFor(ctx)})";
else
nameField = $"{nameField} ({system.Name})";
uint color;
try
{
color = target.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// There's no API for group colors yet, but defaulting to a blank color regardless
color = DiscordUtils.Gray;
}
nameField = $"{nameField}";
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
.Color(color);
.Color(target.Color?.ToDiscordColor());
eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
eb.Footer(new Embed.EmbedFooter($"System ID: {system.DisplayHid(ctx.Config)} | Group ID: {target.DisplayHid(ctx.Config)}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
if (target.DescriptionPrivacy.CanAccess(pctx))
if (target.BannerPrivacy.CanAccess(pctx))
eb.Image(new Embed.EmbedImage(target.BannerImage));
if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null)
@ -324,7 +289,7 @@ public class EmbedService
{
var name = pctx == LookupContext.ByOwner
? target.Reference(ctx)
: target.Hid;
: target.DisplayHid(ctx.Config);
eb.Field(new Embed.Field($"Members ({memberCount})", $"(see `pk;group {name} list`)"));
}
}
@ -362,16 +327,16 @@ public class EmbedService
}
return new EmbedBuilder()
.Color(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray)
.Color(members.FirstOrDefault()?.Color?.ToDiscordColor())
.Field(new Embed.Field($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", memberStr))
.Field(new Embed.Field("Since",
$"{sw.Timestamp.FormatZoned(zone)} ({timeSinceSwitch.FormatDuration()} ago)"))
.Build();
}
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent)
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
{
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Channel);
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
var ctx = LookupContext.ByNonOwner;
var serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
@ -424,27 +389,29 @@ public class EmbedService
.Field(new Embed.Field("System",
msg.System == null
? "*(deleted or unknown system)*"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.DisplayHid(ccfg)}`)" : $"`{msg.System.DisplayHid(ccfg)}`"
, true))
.Field(new Embed.Field("Member",
msg.Member == null
? "*(deleted member)*"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.Hid}`)"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.DisplayHid(ccfg)}`)"
, true))
.Field(new Embed.Field("Sent by", userStr, true))
.Timestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset().ToString("O"));
.Timestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset().ToString("O"))
.Footer(new Embed.EmbedFooter($"Original Message ID: {msg.Message.OriginalMid}"));
var roles = memberInfo?.Roles?.ToList();
if (roles != null && roles.Count > 0 && showContent)
{
var rolesString = string.Join(", ", (await Task.WhenAll(roles
.Select(async id =>
var guild = await _cache.GetGuild(channel.GuildId!.Value);
var rolesString = string.Join(", ", (roles
.Select(id =>
{
var role = await _cache.TryGetRole(id);
var role = Array.Find(guild.Roles, r => r.Id == id);
if (role != null)
return role;
return new Role { Name = "*(unknown role)*", Position = 0 };
})))
}))
.OrderByDescending(role => role.Position)
.Select(role => role.Name));
eb.Field(new Embed.Field($"Account roles ({roles.Count})", rolesString.Truncate(1024)));
@ -460,19 +427,9 @@ public class EmbedService
var color = system.Color;
if (group != null) color = group.Color;
uint embedColor;
try
{
embedColor = color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
embedColor = DiscordUtils.Gray;
}
var eb = new EmbedBuilder()
.Title(embedTitle)
.Color(embedColor);
.Color(color?.ToDiscordColor());
var footer =
$"Since {breakdown.RangeStart.FormatZoned(tz)} ({(breakdown.RangeEnd - breakdown.RangeStart).FormatDuration()} ago)";

View file

@ -42,7 +42,7 @@ public class LogChannelService
if (logChannelId == null)
return;
var triggerChannel = await _cache.GetChannel(proxiedMessage.Channel);
var triggerChannel = await _cache.GetChannel(proxiedMessage.Guild!.Value, proxiedMessage.Channel);
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
var system = await _repo.GetSystem(member.System);
@ -63,7 +63,7 @@ public class LogChannelService
return null;
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
var rootChannel = await _cache.GetRootChannel(trigger.ChannelId);
var rootChannel = await _cache.GetRootChannel(guildId, trigger.ChannelId);
// get log channel info from the database
var guild = await _repo.GetGuild(guildId);
@ -109,7 +109,7 @@ public class LogChannelService
private async Task<Channel?> FindLogChannel(ulong guildId, ulong channelId)
{
// TODO: fetch it directly on cache miss?
if (await _cache.TryGetChannel(channelId) is Channel channel)
if (await _cache.TryGetChannel(guildId, channelId) is Channel channel)
return channel;
if (await _rest.GetChannelOrNull(channelId) is Channel restChannel)

View file

@ -23,6 +23,8 @@ public class LoggerCleanService
private static readonly Regex _basicRegex = new("(\\d{17,19})");
private static readonly Regex _dynoRegex = new("Message ID: (\\d{17,19})");
private static readonly Regex _carlRegex = new("Message ID: (\\d{17,19})");
private static readonly Regex _sapphireRegex = new("\\*\\*Message ID:\\*\\* \\[(\\d{17,19})\\]");
private static readonly Regex _makiRegex = new("Message ID: (\\d{17,19})");
private static readonly Regex _circleRegex = new("\\(`(\\d{17,19})`\\)");
private static readonly Regex _loggerARegex = new("Message = (\\d{17,19})");
private static readonly Regex _loggerBRegex = new("MessageID:(\\d{17,19})");
@ -62,6 +64,8 @@ public class LoggerCleanService
new LoggerBot("Dyno#8389", 470724017205149701, ExtractDyno), // webhook
new LoggerBot("Dyno#5714", 470723870270160917, ExtractDyno), // webhook
new LoggerBot("Dyno#1961", 347378323418251264, ExtractDyno), // webhook
new LoggerBot("Maki", 563434444321587202, ExtractMaki), // webhook
new LoggerBot("Sapphire", 678344927997853742, ExtractSapphire), // webhook
new LoggerBot("Auttaja", 242730576195354624, ExtractAuttaja), // webhook
new LoggerBot("GenericBot", 295329346590343168, ExtractGenericBot),
new LoggerBot("blargbot", 134133271750639616, ExtractBlargBot),
@ -101,10 +105,10 @@ public class LoggerCleanService
public async ValueTask HandleLoggerBotCleanup(Message msg)
{
var channel = await _cache.GetChannel(msg.ChannelId);
var channel = await _cache.GetChannel(msg.GuildId!.Value, msg.ChannelId!);
if (channel.Type != Channel.ChannelType.GuildText) return;
if (!(await _cache.PermissionsIn(channel.Id)).HasFlag(PermissionSet.ManageMessages)) return;
if (!(await _cache.BotPermissionsIn(msg.GuildId!.Value, channel.Id)).HasFlag(PermissionSet.ManageMessages)) return;
// If this message is from a *webhook*, check if the application ID matches one of the bots we know
// If it's from a *bot*, check the bot ID to see if we know it.
@ -239,6 +243,26 @@ public class LoggerCleanService
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static ulong? ExtractMaki(Message msg)
{
// Embed, Message Author Name field: "Message Deleted", footer is "Message ID: [id]"
var embed = msg.Embeds?.FirstOrDefault();
if (embed?.Author?.Name == null || embed?.Footer == null || (!embed?.Author?.Name.StartsWith("Message Deleted") ?? false)) return null;
var match = _makiRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static ulong? ExtractSapphire(Message msg)
{
// Embed, Message title field: "Message deleted", description contains "**Message ID:** [[id]]"
// Example: "**Message ID:** [1297549791927996598]"
var embed = msg.Embeds?.FirstOrDefault();
if (embed == null) return null;
if (!(embed.Title?.StartsWith("Message deleted") ?? false)) return null;
var match = _sapphireRegex.Match(embed.Description);
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static FuzzyExtractResult? ExtractCircle(Message msg)
{
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.

View file

@ -54,33 +54,6 @@ public class PeriodicStatCollector
var stopwatch = new Stopwatch();
stopwatch.Start();
// Aggregate guild/channel stats
var guildCount = 0;
var channelCount = 0;
// No LINQ today, sorry
await foreach (var guild in _cache.GetAllGuilds())
{
guildCount++;
foreach (var channel in await _cache.GetGuildChannels(guild.Id))
if (DiscordUtils.IsValidGuildChannel(channel))
channelCount++;
}
if (_config.UseRedisMetrics)
{
var db = _redis.Connection.GetDatabase();
await db.HashSetAsync("pluralkit:cluster_stats", new StackExchange.Redis.HashEntry[] {
new(_botConfig.Cluster.NodeIndex, JsonConvert.SerializeObject(new ClusterMetricInfo
{
GuildCount = guildCount,
ChannelCount = channelCount,
DatabaseConnectionCount = _countHolder.ConnectionCount,
WebhookCacheSize = _webhookCache.CacheSize,
})),
});
}
// Process info
var process = Process.GetCurrentProcess();
_metrics.Measure.Gauge.SetValue(CoreMetrics.ProcessPhysicalMemory, process.WorkingSet64);

View file

@ -94,9 +94,9 @@ public class WebhookCacheService
// We don't have one, so we gotta create a new one
// but first, make sure we haven't hit the webhook cap yet...
if (webhooks.Length >= 10)
if (webhooks.Length >= 15)
throw new PKError(
"This channel has the maximum amount of possible webhooks (10) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying.");
"This channel has the maximum amount of possible webhooks (15) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying.");
return await DoCreateWebhook(channelId);
}

View file

@ -4,6 +4,8 @@ using App.Metrics;
using Humanizer;
using NodaTime.Text;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Rest;
@ -17,7 +19,6 @@ using Newtonsoft.Json.Linq;
using Serilog;
using PluralKit.Core;
using Myriad.Utils;
namespace PluralKit.Bot;
@ -35,6 +36,7 @@ public record ProxyRequest
public ulong GuildId { get; init; }
public ulong ChannelId { get; init; }
public ulong? ThreadId { get; init; }
public ulong MessageId { get; init; }
public string Name { get; init; }
public string? AvatarUrl { get; init; }
public string? Content { get; init; }
@ -45,6 +47,7 @@ public record ProxyRequest
public bool AllowEveryone { get; init; }
public Message.MessageFlags? Flags { get; init; }
public bool Tts { get; init; }
public Message.MessagePoll? Poll { get; init; }
}
public class WebhookExecutorService
@ -83,7 +86,8 @@ public class WebhookExecutorService
return webhookMessage;
}
public async Task<Message> EditWebhookMessage(ulong channelId, ulong messageId, string newContent, bool clearEmbeds = false)
public async Task<Message> EditWebhookMessage(ulong guildId, ulong channelId, ulong messageId, string newContent,
bool clearEmbeds = false, bool clearAttachments = false)
{
var allowedMentions = newContent.ParseMentions() with
{
@ -92,7 +96,7 @@ public class WebhookExecutorService
};
ulong? threadId = null;
var channel = await _cache.GetOrFetchChannel(_rest, channelId);
var channel = await _cache.GetOrFetchChannel(_rest, guildId, channelId);
if (channel.IsThread())
{
threadId = channelId;
@ -104,7 +108,10 @@ public class WebhookExecutorService
{
Content = newContent,
AllowedMentions = allowedMentions,
Embeds = (clearEmbeds == true ? Optional<Embed[]>.Some(new Embed[] { }) : Optional<Embed[]>.None()),
Embeds = (clearEmbeds ? Optional<Embed[]>.Some(new Embed[] { }) : Optional<Embed[]>.None()),
Attachments = (clearAttachments
? Optional<Message.Attachment[]>.Some(new Message.Attachment[] { })
: Optional<Message.Attachment[]>.None())
};
return await _rest.EditWebhookMessage(webhook.Id, webhook.Token, messageId, editReq, threadId);
@ -154,6 +161,26 @@ public class WebhookExecutorService
}).ToArray();
}
if (req.Poll is Message.MessagePoll poll)
{
int? duration = null;
if (poll.Expiry is string expiry)
{
var then = OffsetDateTimePattern.ExtendedIso.Parse(expiry).Value.ToInstant();
var now = DiscordUtils.SnowflakeToInstant(req.MessageId);
// in theory .TotalHours should be exact, but just in case
duration = (int)Math.Round((then - now).TotalMinutes / 60.0);
}
webhookReq.Poll = new ExecuteWebhookRequest.WebhookPoll
{
Question = poll.Question,
Answers = poll.Answers,
Duration = duration,
AllowMultiselect = poll.AllowMultiselect,
LayoutType = poll.LayoutType
};
}
Message webhookMessage;
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
{