Port some things, still does not compile
This commit is contained in:
@@ -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"
|
||||
|
@@ -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>();
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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);
|
||||
|
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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());
|
||||
|
||||
|
Reference in New Issue
Block a user