Port some things, still does not compile

This commit is contained in:
Ske
2020-04-17 23:10:01 +02:00
parent f56c3e819f
commit 23cf06df4c
18 changed files with 543 additions and 538 deletions

View File

@@ -2,8 +2,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using Humanizer;
using NodaTime;
@@ -22,15 +23,15 @@ namespace PluralKit.Bot {
_data = data;
}
public async Task<Embed> CreateSystemEmbed(PKSystem system, LookupContext ctx) {
public async Task<DiscordEmbed> CreateSystemEmbed(DiscordClient client, PKSystem system, LookupContext ctx) {
var accounts = await _data.GetSystemAccounts(system);
// Fetch/render info for all accounts simultaneously
var users = await Task.WhenAll(accounts.Select(async uid => (await _client.Rest.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})"));
var users = await Task.WhenAll(accounts.Select(async uid => (await client.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})"));
var memberCount = await _data.GetSystemMemberCount(system, false);
var eb = new EmbedBuilder()
.WithColor(Color.Blue)
var eb = new DiscordEmbedBuilder()
.WithColor(DiscordColor.Blue)
.WithTitle(system.Name ?? null)
.WithThumbnailUrl(system.AvatarUrl ?? null)
.WithFooter($"System ID: {system.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}");
@@ -61,33 +62,33 @@ namespace PluralKit.Bot {
return eb.Build();
}
public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IUser sender, string content, IGuildChannel channel) {
public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) {
// TODO: pronouns in ?-reacted response using this card
var timestamp = SnowflakeUtils.FromSnowflake(messageId);
return new EmbedBuilder()
var timestamp = DiscordUtils.SnowflakeToInstant(messageId);
return new DiscordEmbedBuilder()
.WithAuthor($"#{channel.Name}: {member.Name}", member.AvatarUrl)
.WithDescription(content?.NormalizeLineEndSpacing())
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}")
.WithTimestamp(timestamp)
.WithTimestamp(timestamp.ToDateTimeOffset())
.Build();
}
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild, LookupContext ctx)
public async Task<DiscordEmbed> CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx)
{
var name = member.Name;
if (system.Name != null) name = $"{member.Name} ({system.Name})";
Color color;
DiscordColor color;
try
{
color = member.Color?.ToDiscordColor() ?? Color.Default;
color = member.Color?.ToDiscordColor() ?? DiscordColor.Gray;
}
catch (ArgumentException)
{
// Bad API use can cause an invalid color string
// TODO: fix that in the API
// for now we just default to a blank color, yolo
color = Color.Default;
color = DiscordColor.Gray;
}
var messageCount = await _data.GetMemberMessageCount(member);
@@ -98,10 +99,10 @@ namespace PluralKit.Bot {
var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`{t.ProxyString}`"));
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
// TODO: add URL of website when that's up
.WithAuthor(name, avatar)
.WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : Color.Default)
.WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordColor.Gray)
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}");
var description = "";
@@ -119,7 +120,7 @@ namespace PluralKit.Bot {
if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true);
if (member.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true);
if (!member.Pronouns.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true);
if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount, true);
if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount.ToString(), true);
if (member.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true);
if (!member.Color.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
if (!member.Description.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false);
@@ -127,48 +128,45 @@ namespace PluralKit.Bot {
return eb.Build();
}
public async Task<Embed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
public async Task<DiscordEmbed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
{
var members = await _data.GetSwitchMembers(sw).ToListAsync();
var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
return new EmbedBuilder()
.WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue)
return new DiscordEmbedBuilder()
.WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordColor.Blue)
.AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*")
.AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)")
.Build();
}
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg)
public async Task<DiscordEmbed> CreateMessageInfoEmbed(DiscordClient client, FullMessage msg)
{
var channel = _client.GetChannel(msg.Message.Channel) as ITextChannel;
var channel = await client.GetChannelAsync(msg.Message.Channel);
var serverMsg = channel != null ? await channel.GetMessageAsync(msg.Message.Mid) : null;
var memberStr = $"{msg.Member.Name} (`{msg.Member.Hid}`)";
var userStr = $"*(deleted user {msg.Message.Sender})*";
ICollection<IRole> roles = null;
ICollection<DiscordRole> roles = null;
if (channel != null)
{
// Look up the user with the REST client
// this ensures we'll still get the information even if the user's not cached,
// even if this means an extra API request (meh, it'll be fine)
var shard = _client.GetShardFor(channel.Guild);
var guildUser = await shard.Rest.GetGuildUserAsync(channel.Guild.Id, msg.Message.Sender);
var guildUser = await channel.Guild.GetMemberAsync(msg.Message.Sender);
if (guildUser != null)
{
if (guildUser.RoleIds.Count > 0)
roles = guildUser.RoleIds
.Select(roleId => channel.Guild.GetRole(roleId))
.Where(role => role.Name != "@everyone")
.OrderByDescending(role => role.Position)
.ToList();
roles = guildUser.Roles
.Where(role => role.Name != "@everyone")
.OrderByDescending(role => role.Position)
.ToList();
userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser?.NameAndMention();
userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser.NameAndMention();
}
}
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithAuthor(msg.Member.Name, msg.Member.AvatarUrl)
.WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*")
.WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url)
@@ -176,18 +174,18 @@ namespace PluralKit.Bot {
msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)
.AddField("Member", memberStr, true)
.AddField("Sent by", userStr, inline: true)
.WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid));
.WithTimestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset());
if (roles != null && roles.Count > 0)
eb.AddField($"Account roles ({roles.Count})", string.Join(", ", roles.Select(role => role.Name)));
return eb.Build();
}
public Task<Embed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
public Task<DiscordEmbed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
{
var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart;
var eb = new EmbedBuilder()
.WithColor(Color.Blue)
var eb = new DiscordEmbedBuilder()
.WithColor(DiscordColor.Blue)
.WithFooter($"Since {DateTimeFormats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({DateTimeFormats.DurationFormat.Format(actualPeriod)} ago)");
var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others"

View File

@@ -7,6 +7,7 @@ namespace PluralKit.Bot
// not particularly efficient? It allocates a dictionary *and* a queue for every single channel (500k in prod!)
// whereas this is, worst case, one dictionary *entry* of a single ulong per channel, and one dictionary instance
// on the whole instance, total. Yeah, much more efficient.
// TODO: is this still needed after the D#+ migration?
public class LastMessageCacheService
{
private IDictionary<ulong, ulong> _cache = new ConcurrentDictionary<ulong, ulong>();

View File

@@ -1,6 +1,7 @@
using System.Threading.Tasks;
using Discord;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
@@ -8,20 +9,18 @@ using Serilog;
namespace PluralKit.Bot {
public class LogChannelService {
private IDiscordClient _client;
private EmbedService _embed;
private IDataStore _data;
private ILogger _logger;
public LogChannelService(IDiscordClient client, EmbedService embed, ILogger logger, IDataStore data)
public LogChannelService(EmbedService embed, ILogger logger, IDataStore data)
{
_client = client;
_embed = embed;
_data = data;
_logger = logger.ForContext<LogChannelService>();
}
public async Task LogMessage(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IGuildChannel originalChannel, IUser sender, string content, GuildConfig? guildCfg = null)
public async Task LogMessage(DiscordClient client, PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordChannel originalChannel, DiscordUser sender, string content, GuildConfig? guildCfg = null)
{
if (guildCfg == null)
guildCfg = await _data.GetOrCreateGuildConfig(originalChannel.GuildId);
@@ -31,17 +30,19 @@ namespace PluralKit.Bot {
if (guildCfg.Value.LogBlacklist.Contains(originalChannel.Id)) return;
// Bail if we can't find the channel
if (!(await _client.GetChannelAsync(guildCfg.Value.LogChannel.Value) is ITextChannel logChannel)) return;
var channel = await client.GetChannelAsync(guildCfg.Value.LogChannel.Value);
if (channel == null || channel.Type != ChannelType.Text) return;
// Bail if we don't have permission to send stuff here
if (!logChannel.HasPermission(ChannelPermission.SendMessages) || !logChannel.HasPermission(ChannelPermission.EmbedLinks))
var neededPermissions = Permissions.SendMessages | Permissions.EmbedLinks;
if ((channel.BotPermissions() & neededPermissions) != neededPermissions)
return;
var embed = _embed.CreateLoggedMessageEmbed(system, member, messageId, originalMsgId, sender, content, originalChannel);
var url = $"https://discordapp.com/channels/{originalChannel.GuildId}/{originalChannel.Id}/{messageId}";
await logChannel.SendMessageAsync(text: url, embed: embed);
await channel.SendMessageAsync(content: url, embed: embed);
}
}
}

View File

@@ -6,8 +6,8 @@ using System.Threading.Tasks;
using Dapper;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
@@ -61,18 +61,18 @@ namespace PluralKit.Bot
public ICollection<LoggerBot> Bots => _bots.Values;
public async ValueTask HandleLoggerBotCleanup(SocketMessage msg, GuildConfig cachedGuild)
public async ValueTask HandleLoggerBotCleanup(DiscordMessage msg, GuildConfig cachedGuild)
{
// Bail if not enabled, or if we don't have permission here
if (!cachedGuild.LogCleanupEnabled) return;
if (!(msg.Channel is SocketTextChannel channel)) return;
if (!channel.Guild.GetUser(_client.CurrentUser.Id).GetPermissions(channel).ManageMessages) return;
if (msg.Channel.Type != ChannelType.Text) return;
if (!msg.Channel.BotHasPermission(Permissions.ManageMessages)) return;
// If this message is from a *webhook*, check if the name matches one of the bots we know
// TODO: do we need to do a deeper webhook origin check, or would that be too hard on the rate limit?
// If it's from a *bot*, check the bot ID to see if we know it.
LoggerBot bot = null;
if (msg.Author.IsWebhook) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot);
if (msg.WebhookMessage) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot);
else if (msg.Author.IsBot) _bots.TryGetValue(msg.Author.Id, out bot);
// If we didn't find anything before, or what we found is an unsupported bot, bail
@@ -95,8 +95,8 @@ namespace PluralKit.Bot
new
{
fuzzy.Value.User,
Guild = (msg.Channel as ITextChannel)?.GuildId ?? 0,
ApproxId = SnowflakeUtils.ToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3))
Guild = msg.Channel.GuildId,
ApproxId = DiscordUtils.InstantToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3))
});
if (mid == null) return; // If we didn't find a corresponding message, bail
// Otherwise, we can *reasonably assume* that this is a logged deletion, so delete the log message.
@@ -118,7 +118,7 @@ namespace PluralKit.Bot
} // else should not happen, but idk, it might
}
private static ulong? ExtractAuttaja(SocketMessage msg)
private static ulong? ExtractAuttaja(DiscordMessage msg)
{
// Auttaja has an optional "compact mode" that logs without embeds
// That one puts the ID in the message content, non-compact puts it in the embed description.
@@ -130,16 +130,16 @@ namespace PluralKit.Bot
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractDyno(SocketMessage msg)
private static ulong? ExtractDyno(DiscordMessage msg)
{
// Embed *description* contains "Message sent by [mention] deleted in [channel]", contains message ID in footer per regex
var embed = msg.Embeds.FirstOrDefault();
if (embed?.Footer == null || !(embed.Description?.Contains("deleted in") ?? false)) return null;
var match = _dynoRegex.Match(embed.Footer.Value.Text ?? "");
var match = _dynoRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractLoggerA(SocketMessage msg)
private static ulong? ExtractLoggerA(DiscordMessage msg)
{
// This is for Logger#6088 (298822483060981760), distinct from Logger#6278 (327424261180620801).
// Embed contains title "Message deleted in [channel]", and an ID field containing both message and user ID (see regex).
@@ -153,26 +153,26 @@ namespace PluralKit.Bot
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractLoggerB(SocketMessage msg)
private static ulong? ExtractLoggerB(DiscordMessage msg)
{
// This is for Logger#6278 (327424261180620801), distinct from Logger#6088 (298822483060981760).
// Embed title ends with "A Message Was Deleted!", footer contains message ID as per regex.
var embed = msg.Embeds.FirstOrDefault();
if (embed?.Footer == null || !(embed.Title?.EndsWith("A Message Was Deleted!") ?? false)) return null;
var match = _loggerBRegex.Match(embed.Footer.Value.Text ?? "");
var match = _loggerBRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractGenericBot(SocketMessage msg)
private static ulong? ExtractGenericBot(DiscordMessage msg)
{
// Embed, title is "Message Deleted", ID plain in footer.
var embed = msg.Embeds.FirstOrDefault();
if (embed?.Footer == null || !(embed.Title?.Contains("Message Deleted") ?? false)) return null;
var match = _basicRegex.Match(embed.Footer.Value.Text ?? "");
var match = _basicRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractBlargBot(SocketMessage msg)
private static ulong? ExtractBlargBot(DiscordMessage msg)
{
// Embed, title ends with "Message Deleted", contains ID plain in a field.
var embed = msg.Embeds.FirstOrDefault();
@@ -182,7 +182,7 @@ namespace PluralKit.Bot
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static ulong? ExtractMantaro(SocketMessage msg)
private static ulong? ExtractMantaro(DiscordMessage msg)
{
// Plain message, "Message (ID: [id]) created by [user] (ID: [id]) in channel [channel] was deleted.
if (!(msg.Content?.Contains("was deleted.") ?? false)) return null;
@@ -190,19 +190,19 @@ namespace PluralKit.Bot
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static FuzzyExtractResult? ExtractCarlBot(SocketMessage msg)
private static FuzzyExtractResult? ExtractCarlBot(DiscordMessage msg)
{
// Embed, title is "Message deleted in [channel], **user** ID in the footer, timestamp as, well, timestamp in embed.
// This is the *deletion* timestamp, which we can assume is a couple seconds at most after the message was originally sent
var embed = msg.Embeds.FirstOrDefault();
if (embed?.Footer == null || embed.Timestamp == null || !(embed.Title?.StartsWith("Message deleted in") ?? false)) return null;
var match = _carlRegex.Match(embed.Footer.Value.Text ?? "");
var match = _carlRegex.Match(embed.Footer.Text ?? "");
return match.Success
? new FuzzyExtractResult { User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = embed.Timestamp.Value }
: (FuzzyExtractResult?) null;
}
private static FuzzyExtractResult? ExtractCircle(SocketMessage msg)
private static FuzzyExtractResult? ExtractCircle(DiscordMessage msg)
{
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.
// Compact: "Message from [user] ([id]) deleted in [channel]", no timestamp (use message time)
@@ -211,7 +211,7 @@ namespace PluralKit.Bot
if (msg.Embeds.Count > 0)
{
var embed = msg.Embeds.First();
if (embed.Author?.Name == null || !embed.Author.Value.Name.StartsWith("Message Deleted in")) return null;
if (embed.Author?.Name == null || !embed.Author.Name.StartsWith("Message Deleted in")) return null;
var field = embed.Fields.FirstOrDefault(f => f.Name == "Message Author");
if (field.Value == null) return null;
stringWithId = field.Value;
@@ -224,7 +224,7 @@ namespace PluralKit.Bot
: (FuzzyExtractResult?) null;
}
private static FuzzyExtractResult? ExtractPancake(SocketMessage msg)
private static FuzzyExtractResult? ExtractPancake(DiscordMessage msg)
{
// Embed, author is "Message Deleted", description includes a mention, timestamp is *message send time* (but no ID)
// so we use the message timestamp to get somewhere *after* the message was proxied
@@ -236,16 +236,16 @@ namespace PluralKit.Bot
: (FuzzyExtractResult?) null;
}
private static ulong? ExtractUnbelievaBoat(SocketMessage msg)
private static ulong? ExtractUnbelievaBoat(DiscordMessage msg)
{
// Embed author is "Message Deleted", footer contains message ID per regex
var embed = msg.Embeds.FirstOrDefault();
if (embed?.Footer == null || embed.Author?.Name != "Message Deleted") return null;
var match = _unbelievaboatRegex.Match(embed.Footer.Value.Text ?? "");
var match = _unbelievaboatRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
}
private static FuzzyExtractResult? ExtractVanessa(SocketMessage msg)
private static FuzzyExtractResult? ExtractVanessa(DiscordMessage msg)
{
// Title is "Message Deleted", embed description contains mention
var embed = msg.Embeds.FirstOrDefault();
@@ -261,11 +261,11 @@ namespace PluralKit.Bot
{
public string Name;
public ulong Id;
public Func<SocketMessage, ulong?> ExtractFunc;
public Func<SocketMessage, FuzzyExtractResult?> FuzzyExtractFunc;
public Func<DiscordMessage, ulong?> ExtractFunc;
public Func<DiscordMessage, FuzzyExtractResult?> FuzzyExtractFunc;
public string WebhookName;
public LoggerBot(string name, ulong id, Func<SocketMessage, ulong?> extractFunc = null, Func<SocketMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
public LoggerBot(string name, ulong id, Func<DiscordMessage, ulong?> extractFunc = null, Func<DiscordMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
{
Name = name;
Id = id;

View File

@@ -1,10 +1,13 @@
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using App.Metrics;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using NodaTime.Extensions;
using PluralKit.Core;
@@ -27,9 +30,9 @@ namespace PluralKit.Bot
private ILogger _logger;
public PeriodicStatCollector(IDiscordClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache)
public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache)
{
_client = (DiscordShardedClient) client;
_client = client;
_metrics = metrics;
_webhookCache = webhookCache;
_countHolder = countHolder;
@@ -45,18 +48,31 @@ namespace PluralKit.Bot
stopwatch.Start();
// Aggregate guild/channel stats
_metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, _client.Guilds.Count);
_metrics.Measure.Gauge.SetValue(BotMetrics.Channels, _client.Guilds.Sum(g => g.TextChannels.Count));
_metrics.Measure.Gauge.SetValue(BotMetrics.ShardsConnected, _client.Shards.Count(shard => shard.ConnectionState == ConnectionState.Connected));
var guildCount = 0;
var channelCount = 0;
// No LINQ today, sorry
foreach (var shard in _client.ShardClients.Values)
{
guildCount += shard.Guilds.Count;
foreach (var guild in shard.Guilds.Values)
foreach (var channel in guild.Channels.Values)
if (channel.Type == ChannelType.Text)
channelCount++;
}
_metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, guildCount);
_metrics.Measure.Gauge.SetValue(BotMetrics.Channels, channelCount);
// Aggregate member stats
var usersKnown = new HashSet<ulong>();
var usersOnline = new HashSet<ulong>();
foreach (var guild in _client.Guilds)
foreach (var user in guild.Users)
foreach (var shard in _client.ShardClients.Values)
foreach (var guild in shard.Guilds.Values)
foreach (var user in guild.Members.Values)
{
usersKnown.Add(user.Id);
if (user.Status == UserStatus.Online) usersOnline.Add(user.Id);
if (user.Presence.Status == UserStatus.Online) usersOnline.Add(user.Id);
}
_metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count);

View File

@@ -3,12 +3,12 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.Net;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using DSharpPlus.Exceptions;
using NodaTime;
using NodaTime.Extensions;
using PluralKit.Core;
@@ -83,16 +83,16 @@ namespace PluralKit.Bot
return null;
}
public async Task HandleMessageAsync(GuildConfig guild, CachedAccount account, IMessage message, bool doAutoProxy)
public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool doAutoProxy)
{
// Bail early if this isn't in a guild channel
if (!(message.Channel is ITextChannel channel)) return;
if (message.Channel.Guild != null) return;
// Find a member with proxy tags matching the message
var match = GetProxyTagMatch(message.Content, account.System, account.Members);
// O(n) lookup since n is small (max ~100 in prod) and we're more constrained by memory (for a dictionary) here
var systemSettingsForGuild = account.SettingsForGuild(channel.GuildId);
var systemSettingsForGuild = account.SettingsForGuild(message.Channel.GuildId);
// If we didn't get a match by proxy tags, try to get one by autoproxy
// Also try if we *did* get a match, but there's no inner text. This happens if someone sends a message that
@@ -102,26 +102,26 @@ namespace PluralKit.Bot
// When a normal message is sent, autoproxy is enabled, but if this method is called from a message *edit*
// event, then autoproxy is disabled. This is so AP doesn't "retrigger" when the original message was escaped.
if (doAutoProxy && (match == null || (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0)))
match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, channel);
match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, message.Channel);
// If we still haven't found any, just yeet
if (match == null) return;
// And make sure the channel's not blacklisted from proxying.
if (guild.Blacklist.Contains(channel.Id)) return;
if (guild.Blacklist.Contains(message.ChannelId)) return;
// Make sure the system hasn't blacklisted the guild either
if (!systemSettingsForGuild.ProxyEnabled) return;
// We know message.Channel can only be ITextChannel as PK doesn't work in DMs/groups
// Afterwards we ensure the bot has the right permissions, otherwise bail early
if (!await EnsureBotPermissions(channel)) return;
if (!await EnsureBotPermissions(message.Channel)) return;
// Can't proxy a message with no content and no attachment
if (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0)
return;
var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, channel.GuildId);
var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, message.Channel.GuildId);
// Get variables in order and all
var proxyName = match.Member.ProxyName(match.System.Tag, memberSettingsForGuild.DisplayName);
@@ -138,19 +138,17 @@ namespace PluralKit.Bot
: match.InnerText;
// Sanitize @everyone, but only if the original user wouldn't have permission to
messageContents = SanitizeEveryoneMaybe(message, messageContents);
messageContents = await SanitizeEveryoneMaybe(message, messageContents);
// Execute the webhook itself
var hookMessageId = await _webhookExecutor.ExecuteWebhook(
channel,
proxyName, avatarUrl,
var hookMessageId = await _webhookExecutor.ExecuteWebhook(message.Channel, proxyName, avatarUrl,
messageContents,
message.Attachments
);
// Store the message in the database, and log it in the log channel (if applicable)
await _data.AddMessage(message.Author.Id, hookMessageId, channel.GuildId, message.Channel.Id, message.Id, match.Member);
await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, guild);
await _data.AddMessage(message.Author.Id, hookMessageId, message.Channel.GuildId, message.Channel.Id, message.Id, match.Member);
await _logChannel.LogMessage(client, match.System, match.Member, hookMessageId, message.Id, message.Channel, message.Author, match.InnerText, guild);
// Wait a second or so before deleting the original message
await Task.Delay(1000);
@@ -159,14 +157,14 @@ namespace PluralKit.Bot
{
await message.DeleteAsync();
}
catch (HttpException)
catch (NotFoundException)
{
// If it's already deleted, we just log and swallow the exception
_logger.Warning("Attempted to delete already deleted proxy trigger message {Message}", message.Id);
}
}
private async Task<ProxyMatch> GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, IMessage message, IGuildChannel channel)
private async Task<ProxyMatch> GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, DiscordMessage message, DiscordChannel channel)
{
// For now we use a backslash as an "escape character", subject to change later
if ((message.Content ?? "").TrimStart().StartsWith("\\")) return null;
@@ -189,7 +187,7 @@ namespace PluralKit.Bot
// If the message is older than 6 hours, ignore it and force the sender to "refresh" a proxy
// This can be revised in the future, it's a preliminary value.
var timestamp = SnowflakeUtils.FromSnowflake(msg.Message.Mid).ToInstant();
var timestamp = DiscordUtils.SnowflakeToInstant(msg.Message.Mid);
var timeSince = SystemClock.Instance.GetCurrentInstant() - timestamp;
if (timeSince > Duration.FromHours(6)) return null;
@@ -214,23 +212,23 @@ namespace PluralKit.Bot
};
}
private static string SanitizeEveryoneMaybe(IMessage message, string messageContents)
private static async Task<string> SanitizeEveryoneMaybe(DiscordMessage message,
string messageContents)
{
var senderPermissions = ((IGuildUser) message.Author).GetPermissions(message.Channel as IGuildChannel);
if (!senderPermissions.MentionEveryone) return messageContents.SanitizeEveryone();
var member = await message.Channel.Guild.GetMemberAsync(message.Author.Id);
if ((member.PermissionsIn(message.Channel) & Permissions.MentionEveryone) == 0) return messageContents.SanitizeEveryone();
return messageContents;
}
private async Task<bool> EnsureBotPermissions(ITextChannel channel)
private async Task<bool> EnsureBotPermissions(DiscordChannel channel)
{
var guildUser = await channel.Guild.GetCurrentUserAsync();
var permissions = guildUser.GetPermissions(channel);
var permissions = channel.BotPermissions();
// If we can't send messages at all, just bail immediately.
// TODO: can you have ManageMessages and *not* SendMessages? What happens then?
if (!permissions.SendMessages && !permissions.ManageMessages) return false;
if ((permissions & (Permissions.SendMessages | Permissions.ManageMessages)) == 0) return false;
if (!permissions.ManageWebhooks)
if ((permissions & Permissions.ManageWebhooks) == 0)
{
// todo: PKError-ify these
await channel.SendMessageAsync(
@@ -238,7 +236,7 @@ namespace PluralKit.Bot
return false;
}
if (!permissions.ManageMessages)
if ((permissions & Permissions.ManageMessages) == 0)
{
await channel.SendMessageAsync(
$"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this.");
@@ -248,121 +246,117 @@ namespace PluralKit.Bot
return true;
}
public Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction)
public Task HandleReactionAddedAsync(MessageReactionAddEventArgs args)
{
// Dispatch on emoji
switch (reaction.Emote.Name)
switch (args.Emoji.Name)
{
case "\u274C": // Red X
return HandleMessageDeletionByReaction(message, reaction.UserId);
return HandleMessageDeletionByReaction(args);
case "\u2753": // Red question mark
case "\u2754": // White question mark
return HandleMessageQueryByReaction(message, channel, reaction.UserId, reaction.Emote);
return HandleMessageQueryByReaction(args);
case "\U0001F514": // Bell
case "\U0001F6CE": // Bellhop bell
case "\U0001F3D3": // Ping pong paddle (lol)
case "\u23F0": // Alarm clock
case "\u2757": // Exclamation mark
return HandleMessagePingByReaction(message, channel, reaction.UserId, reaction.Emote);
return HandleMessagePingByReaction(args);
default:
return Task.CompletedTask;
}
}
private async Task HandleMessagePingByReaction(Cacheable<IUserMessage, ulong> message,
ISocketMessageChannel channel, ulong userWhoReacted,
IEmote reactedEmote)
private async Task HandleMessagePingByReaction(MessageReactionAddEventArgs args)
{
// Bail in DMs
if (!(channel is SocketGuildChannel gc)) return;
if (args.Channel.Type != ChannelType.Text) return;
// Find the message in the DB
var msg = await _data.GetMessage(message.Id);
var msg = await _data.GetMessage(args.Message.Id);
if (msg == null) return;
// Check if the pinger has permission to ping in this channel
var guildUser = await _client.Rest.GetGuildUserAsync(gc.Guild.Id, userWhoReacted);
var permissions = guildUser.GetPermissions(gc);
var guildUser = await args.Guild.GetMemberAsync(args.User.Id);
var permissions = guildUser.PermissionsIn(args.Channel);
var realMessage = await message.GetOrDownloadAsync();
// If they don't have Send Messages permission, bail (since PK shouldn't send anything on their behalf)
if (!permissions.SendMessages || !permissions.ViewChannel) return;
var embed = new EmbedBuilder().WithDescription($"[Jump to pinged message]({realMessage.GetJumpUrl()})");
await channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{userWhoReacted}>.", embed: embed.Build());
var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages;
if ((permissions & requiredPerms) != requiredPerms) return;
var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({args.Message.JumpLink})");
await args.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{args.User.Id}>.", embed: embed.Build());
// Finally remove the original reaction (if we can)
var user = await _client.Rest.GetUserAsync(userWhoReacted);
if (user != null && realMessage.Channel.HasPermission(ChannelPermission.ManageMessages))
await realMessage.RemoveReactionAsync(reactedEmote, user);
if (args.Channel.BotHasPermission(Permissions.ManageMessages))
await args.Message.DeleteReactionAsync(args.Emoji, args.User);
}
private async Task HandleMessageQueryByReaction(Cacheable<IUserMessage, ulong> message,
ISocketMessageChannel channel, ulong userWhoReacted,
IEmote reactedEmote)
private async Task HandleMessageQueryByReaction(MessageReactionAddEventArgs args)
{
// Find the user who sent the reaction, so we can DM them
var user = await _client.Rest.GetUserAsync(userWhoReacted);
if (user == null) return;
// Bail if not in guild
if (args.Guild == null) return;
// Find the message in the DB
var msg = await _data.GetMessage(message.Id);
var msg = await _data.GetMessage(args.Message.Id);
if (msg == null) return;
// Get guild member so we can DM
var member = await args.Guild.GetMemberAsync(args.User.Id);
// DM them the message card
try
{
await user.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, (channel as IGuildChannel)?.Guild, LookupContext.ByNonOwner));
await user.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(msg));
await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, args.Guild, LookupContext.ByNonOwner));
await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(args.Client, msg));
}
catch (HttpException e) when (e.DiscordCode == 50007)
catch (BadRequestException)
{
// TODO: is this the correct exception
// Ignore exception if it means we don't have DM permission to this user
// not much else we can do here :/
}
// And finally remove the original reaction (if we can)
var msgObj = await message.GetOrDownloadAsync();
if (msgObj.Channel.HasPermission(ChannelPermission.ManageMessages))
await msgObj.RemoveReactionAsync(reactedEmote, user);
await args.Message.DeleteReactionAsync(args.Emoji, args.User);
}
public async Task HandleMessageDeletionByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted)
public async Task HandleMessageDeletionByReaction(MessageReactionAddEventArgs args)
{
// Bail if we don't have permission to delete
if (!args.Channel.BotHasPermission(Permissions.ManageMessages)) return;
// Find the message in the database
var storedMessage = await _data.GetMessage(message.Id);
var storedMessage = await _data.GetMessage(args.Message.Id);
if (storedMessage == null) return; // (if we can't, that's ok, no worries)
// Make sure it's the actual sender of that message deleting the message
if (storedMessage.Message.Sender != userWhoReacted) return;
if (storedMessage.Message.Sender != args.User.Id) return;
try {
// Then, fetch the Discord message and delete that
// TODO: this could be faster if we didn't bother fetching it and just deleted it directly
// somehow through REST?
await (await message.GetOrDownloadAsync()).DeleteAsync();
try
{
await args.Message.DeleteAsync();
} catch (NullReferenceException) {
// Message was deleted before we got to it... cool, no problem, lmao
}
// Finally, delete it from our database.
await _data.DeleteMessage(message.Id);
await _data.DeleteMessage(args.Message.Id);
}
public async Task HandleMessageDeletedAsync(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
public async Task HandleMessageDeletedAsync(MessageDeleteEventArgs args)
{
// Don't delete messages from the store if they aren't webhooks
// Non-webhook messages will never be stored anyway.
// If we're not sure (eg. message outside of cache), delete just to be sure.
if (message.HasValue && !message.Value.Author.IsWebhook) return;
await _data.DeleteMessage(message.Id);
if (!args.Message.WebhookMessage) return;
await _data.DeleteMessage(args.Message.Id);
}
public async Task HandleMessageBulkDeleteAsync(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages, IMessageChannel channel)
public async Task HandleMessageBulkDeleteAsync(MessageBulkDeleteEventArgs args)
{
_logger.Information("Bulk deleting {Count} messages in channel {Channel}", messages.Count, channel.Id);
await _data.DeleteMessagesBulk(messages.Select(m => m.Id).ToList());
_logger.Information("Bulk deleting {Count} messages in channel {Channel}", args.Messages.Count, args.Channel.Id);
await _data.DeleteMessagesBulk(args.Messages.Select(m => m.Id).ToList());
}
}
}

View File

@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Discord.WebSocket;
using DSharpPlus;
using NodaTime;
@@ -21,35 +21,36 @@ namespace PluralKit.Bot
public void Init(DiscordShardedClient client)
{
for (var i = 0; i < client.Shards.Count; i++)
foreach (var i in client.ShardClients.Keys)
_shardInfo[i] = new ShardInfo();
client.ShardConnected += ShardConnected;
client.ShardDisconnected += ShardDisconnected;
client.ShardReady += ShardReady;
client.ShardLatencyUpdated += ShardLatencyUpdated;
// TODO
// client.ShardConnected += ShardConnected;
// client.ShardDisconnected += ShardDisconnected;
// client.ShardReady += ShardReady;
// client.ShardLatencyUpdated += ShardLatencyUpdated;
}
public ShardInfo GetShardInfo(DiscordSocketClient shard) => _shardInfo[shard.ShardId];
public ShardInfo GetShardInfo(DiscordClient shard) => _shardInfo[shard.ShardId];
private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordSocketClient shard)
private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordClient shard)
{
_shardInfo[shard.ShardId].ShardLatency = newLatency;
return Task.CompletedTask;
}
private Task ShardReady(DiscordSocketClient shard)
private Task ShardReady(DiscordClient shard)
{
return Task.CompletedTask;
}
private Task ShardDisconnected(Exception e, DiscordSocketClient shard)
private Task ShardDisconnected(Exception e, DiscordClient shard)
{
_shardInfo[shard.ShardId].DisconnectionCount++;
return Task.CompletedTask;
}
private Task ShardConnected(DiscordSocketClient shard)
private Task ShardConnected(DiscordClient shard)
{
_shardInfo[shard.ShardId].LastConnectionTime = SystemClock.Instance.GetCurrentInstant();
return Task.CompletedTask;

View File

@@ -3,8 +3,9 @@ using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using Serilog;
@@ -15,54 +16,55 @@ namespace PluralKit.Bot
public static readonly string WebhookName = "PluralKit Proxy Webhook";
private DiscordShardedClient _client;
private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks;
private ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>> _webhooks;
private ILogger _logger;
public WebhookCacheService(IDiscordClient client, ILogger logger)
public WebhookCacheService(DiscordShardedClient client, ILogger logger)
{
_client = client as DiscordShardedClient;
_client = client;
_logger = logger.ForContext<WebhookCacheService>();
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>();
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>>();
}
public async Task<IWebhook> GetWebhook(ulong channelId)
public async Task<DiscordWebhook> GetWebhook(DiscordClient client, ulong channelId)
{
var channel = _client.GetChannel(channelId) as ITextChannel;
var channel = await client.GetChannelAsync(channelId);
if (channel == null) return null;
if (channel.Type == ChannelType.Text) return null;
return await GetWebhook(channel);
}
public async Task<IWebhook> GetWebhook(ITextChannel channel)
public async Task<DiscordWebhook> GetWebhook(DiscordChannel channel)
{
// We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel
// If the webhook is requested twice before it's actually been found, the Lazy<T> wrapper will stop the
// webhook from being created twice.
var lazyWebhookValue =
_webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => GetOrCreateWebhook(channel)));
_webhooks.GetOrAdd(channel.Id, new Lazy<Task<DiscordWebhook>>(() => GetOrCreateWebhook(channel)));
// It's possible to "move" a webhook to a different channel after creation
// Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one.
var webhook = await lazyWebhookValue.Value;
if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(webhook);
if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(channel, webhook);
return webhook;
}
public async Task<IWebhook> InvalidateAndRefreshWebhook(IWebhook webhook)
public async Task<DiscordWebhook> InvalidateAndRefreshWebhook(DiscordChannel channel, DiscordWebhook webhook)
{
_logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId);
_webhooks.TryRemove(webhook.ChannelId, out _);
return await GetWebhook(webhook.Channel);
return await GetWebhook(channel);
}
private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel)
private async Task<DiscordWebhook> GetOrCreateWebhook(DiscordChannel channel)
{
_logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id);
return await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel);
}
private async Task<IWebhook> FindExistingWebhook(ITextChannel channel)
private async Task<DiscordWebhook> FindExistingWebhook(DiscordChannel channel)
{
_logger.Debug("Finding webhook for channel {Channel}", channel.Id);
try
@@ -78,13 +80,13 @@ namespace PluralKit.Bot
}
}
private Task<IWebhook> DoCreateWebhook(ITextChannel channel)
private Task<DiscordWebhook> DoCreateWebhook(DiscordChannel channel)
{
_logger.Information("Creating new webhook for channel {Channel}", channel.Id);
return channel.CreateWebhookAsync(WebhookName);
}
private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.GetShardFor(arg.Guild).CurrentUser.Id && arg.Name == WebhookName;
private bool IsWebhookMine(DiscordWebhook arg) => arg.User.Id == _client.CurrentUser.Id && arg.Name == WebhookName;
public int CacheSize => _webhooks.Count;
}

View File

@@ -8,7 +8,8 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using App.Metrics;
using Discord;
using DSharpPlus.Entities;
using DSharpPlus.Exceptions;
using Humanizer;
@@ -44,13 +45,13 @@ namespace PluralKit.Bot
_logger = logger.ForContext<WebhookExecutorService>();
}
public async Task<ulong> ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection<IAttachment> attachments)
public async Task<ulong> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> attachments)
{
_logger.Verbose("Invoking webhook in channel {Channel}", channel.Id);
// Get a webhook, execute it
var webhook = await _webhookCache.GetWebhook(channel);
var id = await ExecuteWebhookInner(webhook, name, avatarUrl, content, attachments);
var id = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments);
// Log the relevant metrics
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
@@ -60,112 +61,93 @@ namespace PluralKit.Bot
return id;
}
private async Task<ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content,
IReadOnlyCollection<IAttachment> attachments, bool hasRetried = false)
private async Task<ulong> ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
IReadOnlyList<DiscordAttachment> attachments, bool hasRetried = false)
{
using var mfd = new MultipartFormDataContent
{
{new StringContent(content.Truncate(2000)), "content"},
{new StringContent(FixClyde(name).Truncate(80)), "username"}
};
if (avatarUrl != null) mfd.Add(new StringContent(avatarUrl), "avatar_url");
var dwb = new DiscordWebhookBuilder();
dwb.WithUsername(FixClyde(name).Truncate(80));
dwb.WithContent(content.Truncate(2000));
if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl);
var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024);
if (attachmentChunks.Count > 0)
{
_logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.Size).Sum() / 1024 / 1024, attachmentChunks.Count);
await AddAttachmentsToMultipart(mfd, attachmentChunks.First());
_logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count);
await AddAttachmentsToBuilder(dwb, attachmentChunks[0]);
}
mfd.Headers.Add("X-RateLimit-Precision", "millisecond"); // Need this for better rate limit support
// Adding this check as close to the actual send call as possible to prevent potential race conditions (unlikely, but y'know)
if (!_rateLimit.TryExecuteWebhook(webhook))
throw new WebhookRateLimited();
var timerCtx = _metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime);
using var response = await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}?wait=true", mfd);
timerCtx.Dispose();
_rateLimit.UpdateRateLimitInfo(webhook, response);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
// Rate limits should be respected, we bail early (already updated the limit info so we hopefully won't hit this again)
throw new WebhookRateLimited();
var responseString = await response.Content.ReadAsStringAsync();
JObject responseJson;
DiscordMessage response;
try
{
responseJson = JsonConvert.DeserializeObject<JObject>(responseString);
response = await webhook.ExecuteAsync(dwb);
}
catch (JsonReaderException)
catch (NotFoundException e)
{
// Sometimes we get invalid JSON from the server, just ignore all of it
throw new WebhookExecutionErrorOnDiscordsEnd();
}
if (responseJson.ContainsKey("code"))
{
var errorCode = responseJson["code"].Value<int>();
if (errorCode == 10015 && !hasRetried)
if (e.JsonMessage.Contains("10015") && !hasRetried)
{
// Error 10015 = "Unknown Webhook" - this likely means the webhook was deleted
// but is still in our cache. Invalidate, refresh, try again
_logger.Warning("Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId);
return await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachments, hasRetried: true);
}
if (errorCode == 40005)
throw Errors.AttachmentTooLarge; // should be caught by the check above but just makin' sure
// TODO: look into what this actually throws, and if this is the correct handling
if ((int) response.StatusCode >= 500)
// If it's a 5xx error code, this is on Discord's end, so we throw an execution exception
throw new WebhookExecutionErrorOnDiscordsEnd();
// Otherwise, this is going to throw on 4xx, and bubble up to our Sentry handler
response.EnsureSuccessStatusCode();
}
// If we have any leftover attachment chunks, send those
if (attachmentChunks.Count > 1)
{
// Deliberately not adding a content, just the remaining files
foreach (var chunk in attachmentChunks.Skip(1))
{
using var mfd2 = new MultipartFormDataContent();
mfd2.Add(new StringContent(FixClyde(name).Truncate(80)), "username");
if (avatarUrl != null) mfd2.Add(new StringContent(avatarUrl), "avatar_url");
await AddAttachmentsToMultipart(mfd2, chunk);
// Don't bother with ?wait, we're just kinda firehosing this stuff
// also don't error check, the real message itself is already sent
await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}", mfd2);
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook);
return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, hasRetried: true);
}
throw;
}
timerCtx.Dispose();
// We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off
var _ = TrySendRemainingAttachments(webhook, name, avatarUrl, attachmentChunks);
return response.Id;
}
private async Task TrySendRemainingAttachments(DiscordWebhook webhook, string name, string avatarUrl, IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> attachmentChunks)
{
if (attachmentChunks.Count <= 1) return;
for (var i = 1; i < attachmentChunks.Count; i++)
{
var dwb = new DiscordWebhookBuilder();
if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl);
dwb.WithUsername(name);
await AddAttachmentsToBuilder(dwb, attachmentChunks[i]);
await webhook.ExecuteAsync(dwb);
}
}
private async Task AddAttachmentsToBuilder(DiscordWebhookBuilder dwb, IReadOnlyCollection<DiscordAttachment> attachments)
{
async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment)
{
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
return (attachment, await attachmentResponse.Content.ReadAsStreamAsync());
}
// At this point we're sure we have a 2xx status code, so just assume success
// TODO: can we do this without a round-trip to a string?
return responseJson["id"].Value<ulong>();
foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
dwb.AddFile(attachment.FileName, attachmentStream);
}
private IReadOnlyCollection<IReadOnlyCollection<IAttachment>> ChunkAttachmentsOrThrow(
IReadOnlyCollection<IAttachment> attachments, int sizeThreshold)
private IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> ChunkAttachmentsOrThrow(
IReadOnlyList<DiscordAttachment> attachments, int sizeThreshold)
{
// Splits a list of attachments into "chunks" of at most 8MB each
// If any individual attachment is larger than 8MB, will throw an error
var chunks = new List<List<IAttachment>>();
var list = new List<IAttachment>();
var chunks = new List<List<DiscordAttachment>>();
var list = new List<DiscordAttachment>();
foreach (var attachment in attachments)
{
if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge;
if (attachment.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge;
if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold)
if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold)
{
chunks.Add(list);
list = new List<IAttachment>();
list = new List<DiscordAttachment>();
}
list.Add(attachment);
@@ -175,20 +157,6 @@ namespace PluralKit.Bot
return chunks;
}
private async Task AddAttachmentsToMultipart(MultipartFormDataContent content,
IReadOnlyCollection<IAttachment> attachments)
{
async Task<(IAttachment, Stream)> GetStream(IAttachment attachment)
{
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
return (attachment, await attachmentResponse.Content.ReadAsStreamAsync());
}
var attachmentId = 0;
foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
content.Add(new StreamContent(attachmentStream), $"file{attachmentId++}", attachment.Filename);
}
private string FixClyde(string name)
{
// Check if the name contains "Clyde" - if not, do nothing

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using Discord;
using DSharpPlus.Entities;
using NodaTime;
@@ -26,7 +26,7 @@ namespace PluralKit.Bot
public int CacheSize => _info.Count;
public bool TryExecuteWebhook(IWebhook webhook)
public bool TryExecuteWebhook(DiscordWebhook webhook)
{
// If we have nothing saved, just allow it (we'll save something once the response returns)
if (!_info.TryGetValue(webhook.Id, out var info)) return true;
@@ -57,7 +57,7 @@ namespace PluralKit.Bot
return true;
}
public void UpdateRateLimitInfo(IWebhook webhook, HttpResponseMessage response)
public void UpdateRateLimitInfo(DiscordWebhook webhook, HttpResponseMessage response)
{
var info = _info.GetOrAdd(webhook.Id, _ => new WebhookRateLimitInfo());