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

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -7,8 +8,10 @@ using App.Metrics;
using Autofac;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using Microsoft.Extensions.Configuration;
using PluralKit.Core;
@ -61,7 +64,6 @@ namespace PluralKit.Bot
SchemaService.Initialize();
var coreConfig = services.Resolve<CoreConfig>();
var botConfig = services.Resolve<BotConfig>();
var schema = services.Resolve<SchemaService>();
using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl);
@ -71,10 +73,9 @@ namespace PluralKit.Bot
logger.Information("Connecting to Discord");
var client = services.Resolve<DiscordShardedClient>();
await client.LoginAsync(TokenType.Bot, botConfig.Token);
logger.Information("Initializing bot");
await client.StartAsync();
logger.Information("Initializing bot");
await services.Resolve<Bot>().Init();
try
@ -105,10 +106,10 @@ namespace PluralKit.Bot
private WebhookRateLimitService _webhookRateLimit;
private int _periodicUpdateCount;
public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit)
public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit)
{
_services = services;
_client = client as DiscordShardedClient;
_client = client;
_metrics = metrics;
_collector = collector;
_webhookRateLimit = webhookRateLimit;
@ -117,53 +118,51 @@ namespace PluralKit.Bot
public Task Init()
{
_client.ShardDisconnected += ShardDisconnected;
_client.ShardReady += ShardReady;
_client.Log += FrameworkLog;
// _client.ShardDisconnected += ShardDisconnected;
// _client.ShardReady += ShardReady;
_client.DebugLogger.LogMessageReceived += FrameworkLog;
_client.MessageReceived += (msg) => HandleEvent(eh => eh.HandleMessage(msg));
_client.ReactionAdded += (msg, channel, reaction) => HandleEvent(eh => eh.HandleReactionAdded(msg, channel, reaction));
_client.MessageDeleted += (msg, channel) => HandleEvent(eh => eh.HandleMessageDeleted(msg, channel));
_client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(eh => eh.HandleMessagesBulkDelete(msgs, channel));
_client.MessageUpdated += (oldMessage, newMessage, channel) => HandleEvent(eh => eh.HandleMessageEdited(oldMessage, newMessage, channel));
_client.MessageCreated += args => HandleEvent(eh => eh.HandleMessage(args));
_client.MessageReactionAdded += args => HandleEvent(eh => eh.HandleReactionAdded(args));
_client.MessageDeleted += args => HandleEvent(eh => eh.HandleMessageDeleted(args));
_client.MessagesBulkDeleted += args => HandleEvent(eh => eh.HandleMessagesBulkDelete(args));
_client.MessageUpdated += args => HandleEvent(eh => eh.HandleMessageEdited(args));
_services.Resolve<ShardInfoService>().Init(_client);
return Task.CompletedTask;
}
private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
/*private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
{
_logger.Warning(ex, $"Shard #{shard.ShardId} disconnected");
return Task.CompletedTask;
}
}*/
private Task FrameworkLog(LogMessage msg)
private void FrameworkLog(object sender, DebugLogMessageEventArgs args)
{
// Bridge D.NET logging to Serilog
// Bridge D#+ logging to Serilog
LogEventLevel level = LogEventLevel.Verbose;
if (msg.Severity == LogSeverity.Critical)
if (args.Level == LogLevel.Critical)
level = LogEventLevel.Fatal;
else if (msg.Severity == LogSeverity.Debug)
else if (args.Level == LogLevel.Debug)
level = LogEventLevel.Debug;
else if (msg.Severity == LogSeverity.Error)
else if (args.Level == LogLevel.Error)
level = LogEventLevel.Error;
else if (msg.Severity == LogSeverity.Info)
else if (args.Level == LogLevel.Info)
level = LogEventLevel.Information;
else if (msg.Severity == LogSeverity.Debug) // D.NET's lowest level is Debug and Verbose is greater, Serilog's is the other way around
level = LogEventLevel.Verbose;
else if (msg.Severity == LogSeverity.Verbose)
level = LogEventLevel.Debug;
else if (args.Level == LogLevel.Warning)
level = LogEventLevel.Warning;
_logger.Write(level, msg.Exception, "Discord.Net {Source}: {Message}", msg.Source, msg.Message);
return Task.CompletedTask;
_logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message);
}
// Method called every 60 seconds
private async Task UpdatePeriodic()
{
// Change bot status
await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers");
var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count);
await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers"));
// Run webhook rate limit GC every 10 minutes
if (_periodicUpdateCount++ % 10 == 0)
@ -177,7 +176,7 @@ namespace PluralKit.Bot
await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync());
}
private Task ShardReady(DiscordSocketClient shardClient)
/*private Task ShardReady(DiscordSocketClient shardClient)
{
_logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count);
@ -191,7 +190,7 @@ namespace PluralKit.Bot
}
return Task.CompletedTask;
}
}*/
private Task HandleEvent(Func<PKEventHandler, Task> handler)
{
@ -252,7 +251,7 @@ namespace PluralKit.Bot
// This means that the HandleMessage function will either be called once, or not at all
// The ReportError function will be called on an error, and needs to refer back to the "trigger message"
// hence, we just store it in a local variable, ignoring it entirely if it's null.
private IUserMessage _msg = null;
private DiscordMessage _currentlyHandlingMessage = null;
public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache, LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean)
{
@ -269,42 +268,44 @@ namespace PluralKit.Bot
_loggerClean = loggerClean;
}
public async Task HandleMessage(SocketMessage arg)
public async Task HandleMessage(MessageCreateEventArgs args)
{
var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild);
// TODO
/*var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild);
if (shard.ConnectionState != ConnectionState.Connected || _client.CurrentUser == null)
return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts
return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts*/
RegisterMessageMetrics(arg);
RegisterMessageMetrics(args);
// Ignore system messages (member joined, message pinned, etc)
var msg = arg as SocketUserMessage;
if (msg == null) return;
var msg = args.Message;
if (msg.MessageType != MessageType.Default) return;
// Fetch information about the guild early, as we need it for the logger cleanup
GuildConfig cachedGuild = default; // todo: is this default correct?
if (msg.Channel is ITextChannel textChannel) cachedGuild = await _cache.GetGuildDataCached(textChannel.GuildId);
GuildConfig cachedGuild = default;
if (msg.Channel.Type == ChannelType.Text) await _cache.GetGuildDataCached(msg.Channel.GuildId);
// Pass guild bot/WH messages onto the logger cleanup service, but otherwise ignore
if ((msg.Author.IsBot || msg.Author.IsWebhook) && msg.Channel is ITextChannel)
if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text)
{
await _loggerClean.HandleLoggerBotCleanup(arg, cachedGuild);
await _loggerClean.HandleLoggerBotCleanup(msg, cachedGuild);
return;
}
_currentlyHandlingMessage = msg;
// Add message info as Sentry breadcrumb
_msg = msg;
_sentryScope.AddBreadcrumb(msg.Content, "event.message", data: new Dictionary<string, string>
{
{"user", msg.Author.Id.ToString()},
{"channel", msg.Channel.Id.ToString()},
{"guild", ((msg.Channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"guild", msg.Channel.GuildId.ToString()},
{"message", msg.Id.ToString()},
});
_sentryScope.SetTag("shard", shard.ShardId.ToString());
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
// Add to last message cache
_lastMessageCache.AddMessage(arg.Channel.Id, arg.Id);
_lastMessageCache.AddMessage(msg.Channel.Id, msg.Id);
// We fetch information about the sending account from the cache
var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id);
@ -330,7 +331,7 @@ namespace PluralKit.Bot
try
{
await _tree.ExecuteCommand(new Context(_services, msg, argPos, cachedAccount?.System));
await _tree.ExecuteCommand(new Context(_services, args.Client, msg, argPos, cachedAccount?.System));
}
catch (PKError)
{
@ -345,12 +346,12 @@ namespace PluralKit.Bot
// no data = no account = no system = no proxy!
try
{
await _proxy.HandleMessageAsync(cachedGuild, cachedAccount, msg, doAutoProxy: true);
await _proxy.HandleMessageAsync(args.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true);
}
catch (PKError e)
{
if (arg.Channel.HasPermission(ChannelPermission.SendMessages))
await arg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
if (msg.Channel.Guild == null || msg.Channel.BotHasPermission(Permissions.SendMessages))
await msg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
}
}
}
@ -358,98 +359,95 @@ namespace PluralKit.Bot
public async Task ReportError(SentryEvent evt, Exception exc)
{
// If we don't have a "trigger message", bail
if (_msg == null) return;
if (_currentlyHandlingMessage == null) return;
// This function *specifically* handles reporting a command execution error to the user.
// We'll fetch the event ID and send a user-facing error message.
// ONLY IF this error's actually our problem. As for what defines an error as "our problem",
// check the extension method :)
if (exc.IsOurProblem() && _msg.Channel.HasPermission(ChannelPermission.SendMessages))
if (exc.IsOurProblem() && _currentlyHandlingMessage.Channel.BotHasPermission(Permissions.SendMessages))
{
var eid = evt.EventId;
await _msg.Channel.SendMessageAsync(
await _currentlyHandlingMessage.Channel.SendMessageAsync(
$"{Emojis.Error} Internal error occurred. Please join the support server (<https://discord.gg/PczBt78>), and send the developer this ID: `{eid}`\nBe sure to include a description of what you were doing to make the error occur.");
}
// If not, don't care. lol.
}
private void RegisterMessageMetrics(SocketMessage msg)
private void RegisterMessageMetrics(MessageCreateEventArgs msg)
{
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt;
var gatewayLatency = DateTimeOffset.Now - msg.Message.Timestamp;
_logger.Verbose("Message received with latency {Latency}", gatewayLatency);
}
public Task HandleReactionAdded(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel,
SocketReaction reaction)
public Task HandleReactionAdded(MessageReactionAddEventArgs args)
{
_sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary<string, string>()
{
{"user", reaction.UserId.ToString()},
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", message.Id.ToString()},
{"reaction", reaction.Emote.Name}
{"user", args.User.Id.ToString()},
{"channel", (args.Channel?.Id ?? 0).ToString()},
{"guild", (args.Channel?.GuildId ?? 0).ToString()},
{"message", args.Message.Id.ToString()},
{"reaction", args.Emoji.Name}
});
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
return _proxy.HandleReactionAddedAsync(message, channel, reaction);
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
return _proxy.HandleReactionAddedAsync(args);
}
public Task HandleMessageDeleted(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
public Task HandleMessageDeleted(MessageDeleteEventArgs args)
{
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
{
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", message.Id.ToString()},
{"channel", args.Channel.Id.ToString()},
{"guild", args.Channel.GuildId.ToString()},
{"message", args.Message.Id.ToString()},
});
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
return _proxy.HandleMessageDeletedAsync(message, channel);
return _proxy.HandleMessageDeletedAsync(args);
}
public Task HandleMessagesBulkDelete(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages,
IMessageChannel channel)
public Task HandleMessagesBulkDelete(MessageBulkDeleteEventArgs args)
{
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
{
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"messages", string.Join(",", messages.Select(m => m.Id))},
{"channel", args.Channel.Id.ToString()},
{"guild", args.Channel.Id.ToString()},
{"messages", string.Join(",", args.Messages.Select(m => m.Id))},
});
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
return _proxy.HandleMessageBulkDeleteAsync(messages, channel);
return _proxy.HandleMessageBulkDeleteAsync(args);
}
public async Task HandleMessageEdited(Cacheable<IMessage, ulong> oldMessage, SocketMessage newMessage, ISocketMessageChannel channel)
public async Task HandleMessageEdited(MessageUpdateEventArgs args)
{
_sentryScope.AddBreadcrumb(newMessage.Content, "event.messageEdit", data: new Dictionary<string, string>()
_sentryScope.AddBreadcrumb(args.Message.Content ?? "<unknown>", "event.messageEdit", data: new Dictionary<string, string>()
{
{"channel", channel.Id.ToString()},
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
{"message", newMessage.Id.ToString()}
{"channel", args.Channel.Id.ToString()},
{"guild", args.Channel.GuildId.ToString()},
{"message", args.Message.Id.ToString()}
});
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
// If this isn't a guild, bail
if (!(channel is IGuildChannel gc)) return;
if (args.Channel.Guild == null) return;
// If this isn't the last message in the channel, don't do anything
if (_lastMessageCache.GetLastMessage(channel.Id) != newMessage.Id) return;
if (_lastMessageCache.GetLastMessage(args.Channel.Id) != args.Message.Id) return;
// Fetch account from cache if there is any
var account = await _cache.GetAccountDataCached(newMessage.Author.Id);
var account = await _cache.GetAccountDataCached(args.Author.Id);
if (account == null) return; // Again: no cache = no account = no system = no proxy
// Also fetch guild cache
var guild = await _cache.GetGuildDataCached(gc.GuildId);
var guild = await _cache.GetGuildDataCached(args.Channel.GuildId);
// Just run the normal message handling stuff
await _proxy.HandleMessageAsync(guild, account, newMessage, doAutoProxy: false);
await _proxy.HandleMessageAsync(args.Client, guild, account, args.Message, doAutoProxy: false);
}
}
}

View File

@ -9,6 +9,9 @@ using Autofac;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
namespace PluralKit.Bot
@ -18,7 +21,8 @@ namespace PluralKit.Bot
private ILifetimeScope _provider;
private readonly DiscordShardedClient _client;
private readonly SocketUserMessage _message;
private readonly DiscordClient _shard;
private readonly DiscordMessage _message;
private readonly Parameters _parameters;
private readonly IDataStore _data;
@ -27,11 +31,12 @@ namespace PluralKit.Bot
private Command _currentCommand;
public Context(ILifetimeScope provider, SocketUserMessage message, int commandParseOffset,
public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset,
PKSystem senderSystem)
{
_client = provider.Resolve<DiscordShardedClient>();
_message = message;
_shard = shard;
_data = provider.Resolve<IDataStore>();
_senderSystem = senderSystem;
_metrics = provider.Resolve<IMetrics>();
@ -39,11 +44,11 @@ namespace PluralKit.Bot
_parameters = new Parameters(message.Content.Substring(commandParseOffset));
}
public IUser Author => _message.Author;
public IMessageChannel Channel => _message.Channel;
public IUserMessage Message => _message;
public IGuild Guild => (_message.Channel as ITextChannel)?.Guild;
public DiscordSocketClient Shard => _client.GetShardFor(Guild);
public DiscordUser Author => _message.Author;
public DiscordChannel Channel => _message.Channel;
public DiscordMessage Message => _message;
public DiscordGuild Guild => _message.Channel.Guild;
public DiscordClient Shard => _shard;
public DiscordShardedClient Client => _client;
public PKSystem System => _senderSystem;
@ -53,13 +58,13 @@ namespace PluralKit.Bot
public bool HasNext(bool skipFlags = true) => RemainderOrNull(skipFlags) != null;
public string FullCommand => _parameters.FullCommand;
public Task<IUserMessage> Reply(string text = null, Embed embed = null)
public Task<DiscordMessage> Reply(string text = null, DiscordEmbed embed = null)
{
if (!this.BotHasPermission(ChannelPermission.SendMessages))
if (!this.BotHasPermission(Permissions.SendMessages))
// Will be "swallowed" during the error handler anyway, this message is never shown.
throw new PKError("PluralKit does not have permission to send messages in this channel.");
if (embed != null && !this.BotHasPermission(ChannelPermission.EmbedLinks))
if (embed != null && !this.BotHasPermission(Permissions.EmbedLinks))
throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled.");
return Channel.SendMessageAsync(text, embed: embed);
@ -125,11 +130,11 @@ namespace PluralKit.Bot
}
}
public async Task<IUser> MatchUser()
public async Task<DiscordUser> MatchUser()
{
var text = PeekArgument();
if (MentionUtils.TryParseUser(text, out var id))
return await Shard.Rest.GetUserAsync(id); // TODO: this should properly fetch
if (text.TryParseMention(out var id))
return await Shard.GetUserAsync(id);
return null;
}
@ -138,11 +143,9 @@ namespace PluralKit.Bot
id = 0;
var text = PeekArgument();
if (MentionUtils.TryParseUser(text, out var mentionId))
if (text.TryParseMention(out var mentionId))
id = mentionId;
else if (ulong.TryParse(text, out var rawId))
id = rawId;
return id != 0;
}
@ -246,41 +249,19 @@ namespace PluralKit.Bot
return this;
}
public GuildPermissions GetGuildPermissions(IUser user)
public Context CheckAuthorPermission(Permissions neededPerms, string permissionName)
{
if (user is IGuildUser gu)
return gu.GuildPermissions;
if (Channel is SocketGuildChannel gc)
return gc.GetUser(user.Id).GuildPermissions;
return GuildPermissions.None;
}
public ChannelPermissions GetChannelPermissions(IUser user)
{
if (user is IGuildUser gu && Channel is IGuildChannel igc)
return gu.GetPermissions(igc);
if (Channel is SocketGuildChannel gc)
return gc.GetUser(user.Id).GetPermissions(gc);
return ChannelPermissions.DM;
}
public Context CheckAuthorPermission(GuildPermission permission, string permissionName)
{
if (!GetGuildPermissions(Author).Has(permission))
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
return this;
}
public Context CheckAuthorPermission(ChannelPermission permission, string permissionName)
{
if (!GetChannelPermissions(Author).Has(permission))
// TODO: can we always assume Author is a DiscordMember? I would think so, given they always come from a
// message received event...
var hasPerms = Channel.PermissionsInSync(Author);
if ((hasPerms & neededPerms) != neededPerms)
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
return this;
}
public Context CheckGuildContext()
{
if (Channel is IGuildChannel) return this;
if (Channel.Guild != null) return this;
throw new PKError("This command can not be run in a DM.");
}
@ -296,10 +277,10 @@ namespace PluralKit.Bot
throw new PKError("You do not have permission to access this information.");
}
public ITextChannel MatchChannel()
public DiscordChannel MatchChannel()
{
if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null;
if (!(_client.GetChannel(channel) is ITextChannel textChannel)) return null;
if (!(_client.GetChannelAsync(channel) is ITextChannel textChannel)) return null;
PopArgument();
return textChannel;

View File

@ -3,9 +3,7 @@ using System.Net.Http;
using Autofac;
using Discord;
using Discord.Rest;
using Discord.WebSocket;
using DSharpPlus;
using PluralKit.Core;
@ -18,18 +16,12 @@ namespace PluralKit.Bot
protected override void Load(ContainerBuilder builder)
{
// Client
builder.Register(c => new DiscordShardedClient(new DiscordSocketConfig()
{
MessageCacheSize = 0,
ConnectionTimeout = 2 * 60 * 1000,
ExclusiveBulkDelete = true,
LargeThreshold = 50,
GuildSubscriptions = false,
DefaultRetryMode = RetryMode.RetryTimeouts | RetryMode.RetryRatelimit
// Commented this out since Debug actually sends, uh, quite a lot that's not necessary in production
// but leaving it here in case I (or someone else) get[s] confused about why logging isn't working again :p
// LogLevel = LogSeverity.Debug // We filter log levels in Serilog, so just pass everything through (Debug is lower than Verbose)
})).AsSelf().As<BaseDiscordClient>().As<BaseSocketClient>().As<IDiscordClient>().SingleInstance();
builder.Register(c => new DiscordShardedClient(new DiscordConfiguration
{
Token = c.Resolve<BotConfig>().Token,
TokenType = TokenType.Bot,
MessageCacheSize = 0,
})).AsSelf().SingleInstance();
// Commands
builder.RegisterType<CommandTree>().AsSelf();

View File

@ -6,7 +6,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
</ItemGroup>

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());

View File

@ -1,58 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Discord;
using Discord.Net;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using DSharpPlus.Exceptions;
using PluralKit.Core;
namespace PluralKit.Bot {
public static class ContextUtils {
public static async Task<bool> PromptYesNo(this Context ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) {
public static async Task<bool> PromptYesNo(this Context ctx, DiscordMessage message, DiscordUser user = null, TimeSpan? timeout = null) {
// "Fork" the task adding the reactions off so we don't have to wait for them to be finished to start listening for presses
#pragma warning disable 4014
message.AddReactionsAsync(new IEmote[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)});
#pragma warning restore 4014
var reaction = await ctx.AwaitReaction(message, user ?? ctx.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1));
return reaction.Emote.Name == Emojis.Success;
var _ = message.CreateReactionsBulk(new[] {Emojis.Success, Emojis.Error});
var reaction = await ctx.AwaitReaction(message, user ?? ctx.Author, r => r.Emoji.Name == Emojis.Success || r.Emoji.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1));
return reaction.Emoji.Name == Emojis.Success;
}
public static async Task<SocketReaction> AwaitReaction(this Context ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) {
var tcs = new TaskCompletionSource<SocketReaction>();
Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) {
if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages
if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined
if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate
tcs.SetResult(reaction);
public static async Task<MessageReactionAddEventArgs> AwaitReaction(this Context ctx, DiscordMessage message, DiscordUser user = null, Func<MessageReactionAddEventArgs, bool> predicate = null, TimeSpan? timeout = null) {
var tcs = new TaskCompletionSource<MessageReactionAddEventArgs>();
Task Inner(MessageReactionAddEventArgs args) {
if (message.Id != args.Message.Id) return Task.CompletedTask; // Ignore reactions for different messages
if (user != null && user.Id != args.User.Id) return Task.CompletedTask; // Ignore messages from other users if a user was defined
if (predicate != null && !predicate.Invoke(args)) return Task.CompletedTask; // Check predicate
tcs.SetResult(args);
return Task.CompletedTask;
}
((BaseSocketClient) ctx.Shard).ReactionAdded += Inner;
ctx.Shard.MessageReactionAdded += Inner;
try {
return await (tcs.Task.TimeoutAfter(timeout));
return await tcs.Task.TimeoutAfter(timeout);
} finally {
((BaseSocketClient) ctx.Shard).ReactionAdded -= Inner;
ctx.Shard.MessageReactionAdded -= Inner;
}
}
public static async Task<IUserMessage> AwaitMessage(this Context ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) {
var tcs = new TaskCompletionSource<IUserMessage>();
Task Inner(SocketMessage msg) {
public static async Task<DiscordMessage> AwaitMessage(this Context ctx, DiscordChannel channel, DiscordUser user = null, Func<DiscordMessage, bool> predicate = null, TimeSpan? timeout = null) {
var tcs = new TaskCompletionSource<DiscordMessage>();
Task Inner(MessageCreateEventArgs args)
{
var msg = args.Message;
if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel
if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users
if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate
((BaseSocketClient) ctx.Shard).MessageReceived -= Inner;
tcs.SetResult(msg as IUserMessage);
tcs.SetResult(msg);
return Task.CompletedTask;
}
((BaseSocketClient) ctx.Shard).MessageReceived += Inner;
return await (tcs.Task.TimeoutAfter(timeout));
ctx.Shard.MessageCreated += Inner;
try
{
return await (tcs.Task.TimeoutAfter(timeout));
}
finally
{
ctx.Shard.MessageCreated -= Inner;
}
}
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply)
@ -61,20 +66,20 @@ namespace PluralKit.Bot {
return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
}
public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount, int itemsPerPage, string title, Func<EmbedBuilder, IEnumerable<T>, Task> renderer) {
public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount, int itemsPerPage, string title, Func<DiscordEmbedBuilder, IEnumerable<T>, Task> renderer) {
// TODO: make this generic enough we can use it in Choose<T> below
var buffer = new List<T>();
await using var enumerator = items.GetAsyncEnumerator();
var pageCount = (totalCount / itemsPerPage) + 1;
async Task<Embed> MakeEmbedForPage(int page)
async Task<DiscordEmbed> MakeEmbedForPage(int page)
{
var bufferedItemsNeeded = (page + 1) * itemsPerPage;
while (buffer.Count < bufferedItemsNeeded && await enumerator.MoveNextAsync())
buffer.Add(enumerator.Current);
var eb = new EmbedBuilder();
var eb = new DiscordEmbedBuilder();
eb.Title = pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title;
await renderer(eb, buffer.Skip(page*itemsPerPage).Take(itemsPerPage));
return eb.Build();
@ -84,8 +89,9 @@ namespace PluralKit.Bot {
{
var msg = await ctx.Reply(embed: await MakeEmbedForPage(0));
if (pageCount == 1) return; // If we only have one page, don't bother with the reaction/pagination logic, lol
IEmote[] botEmojis = { new Emoji("\u23EA"), new Emoji("\u2B05"), new Emoji("\u27A1"), new Emoji("\u23E9"), new Emoji(Emojis.Error) };
await msg.AddReactionsAsync(botEmojis);
string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", Emojis.Error };
var _ = msg.CreateReactionsBulk(botEmojis); // Again, "fork"
try {
var currentPage = 0;
@ -93,31 +99,30 @@ namespace PluralKit.Bot {
var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: TimeSpan.FromMinutes(5));
// Increment/decrement page counter based on which reaction was clicked
if (reaction.Emote.Name == "\u23EA") currentPage = 0; // <<
if (reaction.Emote.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // <
if (reaction.Emote.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // >
if (reaction.Emote.Name == "\u23E9") currentPage = pageCount - 1; // >>
if (reaction.Emote.Name == Emojis.Error) break; // X
if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // <<
if (reaction.Emoji.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // <
if (reaction.Emoji.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // >
if (reaction.Emoji.Name == "\u23E9") currentPage = pageCount - 1; // >>
if (reaction.Emoji.Name == Emojis.Error) break; // X
// C#'s % operator is dumb and wrong, so we fix negative numbers
if (currentPage < 0) currentPage += pageCount;
// If we can, remove the user's reaction (so they can press again quickly)
if (ctx.BotHasPermission(ChannelPermission.ManageMessages) && reaction.User.IsSpecified) await msg.RemoveReactionAsync(reaction.Emote, reaction.User.Value);
if (ctx.BotHasPermission(Permissions.ManageMessages)) await msg.DeleteReactionAsync(reaction.Emoji, reaction.User);
// Edit the embed with the new page
var embed = await MakeEmbedForPage(currentPage);
await msg.ModifyAsync((mp) => mp.Embed = embed);
await msg.ModifyAsync(embed: embed);
}
} catch (TimeoutException) {
// "escape hatch", clean up as if we hit X
}
if (ctx.BotHasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync();
else await msg.RemoveReactionsAsync(ctx.Shard.CurrentUser, botEmojis);
if (ctx.BotHasPermission(Permissions.ManageMessages)) await msg.DeleteAllReactionsAsync();
}
// If we get a "NotFound" error, the message has been deleted and thus not our problem
catch (HttpException e) when (e.HttpCode == HttpStatusCode.NotFound) { }
catch (NotFoundException) { }
}
public static async Task<T> Choose<T>(this Context ctx, string description, IList<T> items, Func<T, string> display = null)
@ -152,36 +157,35 @@ namespace PluralKit.Bot {
// Add back/forward reactions and the actual indicator emojis
async Task AddEmojis()
{
await msg.AddReactionAsync(new Emoji("\u2B05"));
await msg.AddReactionAsync(new Emoji("\u27A1"));
for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i]));
await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u2B05"));
await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u27A1"));
for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i]));
}
var _ = AddEmojis(); // Not concerned about awaiting
while (true)
{
// Wait for a reaction
var reaction = await ctx.AwaitReaction(msg, ctx.Author);
// If it's a movement reaction, inc/dec the page index
if (reaction.Emote.Name == "\u2B05") currPage -= 1; // <
if (reaction.Emote.Name == "\u27A1") currPage += 1; // >
if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // <
if (reaction.Emoji.Name == "\u27A1") currPage += 1; // >
if (currPage < 0) currPage += pageCount;
if (currPage >= pageCount) currPage -= pageCount;
// If it's an indicator emoji, return the relevant item
if (indicators.Contains(reaction.Emote.Name))
if (indicators.Contains(reaction.Emoji.Name))
{
var idx = Array.IndexOf(indicators, reaction.Emote.Name) + pageSize * currPage;
var idx = Array.IndexOf(indicators, reaction.Emoji.Name) + pageSize * currPage;
// only if it's in bounds, though
// eg. 8 items, we're on page 2, and I hit D (3 + 1*7 = index 10 on an 8-long list) = boom
if (idx < items.Count) return items[idx];
}
var __ = msg.RemoveReactionAsync(reaction.Emote, ctx.Author); // don't care about awaiting
await msg.ModifyAsync(mp => mp.Content = $"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}");
var __ = msg.DeleteReactionAsync(reaction.Emoji, ctx.Author); // don't care about awaiting
await msg.ModifyAsync($"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}");
}
}
else
@ -191,26 +195,21 @@ namespace PluralKit.Bot {
// Add the relevant reactions (we don't care too much about awaiting)
async Task AddEmojis()
{
for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i]));
for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i]));
}
var _ = AddEmojis();
// Then wait for a reaction and return whichever one we found
var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emote.Name));
return items[Array.IndexOf(indicators, reaction.Emote.Name)];
var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emoji.Name));
return items[Array.IndexOf(indicators, reaction.Emoji.Name)];
}
}
public static ChannelPermissions BotPermissions(this Context ctx) {
if (ctx.Channel is SocketGuildChannel gc) {
var gu = gc.Guild.CurrentUser;
return gu.GetPermissions(gc);
}
return ChannelPermissions.DM;
}
public static Permissions BotPermissions(this Context ctx) => ctx.Channel.BotPermissions();
public static bool BotHasPermission(this Context ctx, ChannelPermission permission) => BotPermissions(ctx).Has(permission);
public static bool BotHasPermission(this Context ctx, Permissions permission) =>
ctx.Channel.BotHasPermission(permission);
public static async Task BusyIndicator(this Context ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */)
{
@ -226,17 +225,17 @@ namespace PluralKit.Bot {
var task = f();
// If we don't have permission to add reactions, don't bother, and just await the task normally.
if (!ctx.BotHasPermission(ChannelPermission.AddReactions)) return await task;
if (!ctx.BotHasPermission(ChannelPermission.ReadMessageHistory)) return await task;
var neededPermissions = Permissions.AddReactions | Permissions.ReadMessageHistory;
if ((ctx.BotPermissions() & neededPermissions) != neededPermissions) return await task;
try
{
await Task.WhenAll(ctx.Message.AddReactionAsync(new Emoji(emoji)), task);
await Task.WhenAll(ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode(emoji)), task);
return await task;
}
finally
{
var _ = ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Shard.CurrentUser);
var _ = ctx.Message.DeleteReactionAsync(DiscordEmoji.FromUnicode(emoji), ctx.Shard.CurrentUser);
}
}
}

View File

@ -1,31 +1,76 @@
using Discord;
using Discord.WebSocket;
using System;
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.Entities;
using NodaTime;
namespace PluralKit.Bot
{
public static class DiscordUtils
{
public static string NameAndMention(this IUser user) {
public static string NameAndMention(this DiscordUser user) {
return $"{user.Username}#{user.Discriminator} ({user.Mention})";
}
public static ChannelPermissions PermissionsIn(this IChannel channel)
public static async Task<Permissions> PermissionsIn(this DiscordChannel channel, DiscordUser user)
{
switch (channel)
if (channel.Guild != null)
{
case IDMChannel _:
return ChannelPermissions.DM;
case IGroupChannel _:
return ChannelPermissions.Group;
case SocketGuildChannel gc:
var currentUser = gc.Guild.CurrentUser;
return currentUser.GetPermissions(gc);
default:
return ChannelPermissions.None;
var member = await channel.Guild.GetMemberAsync(user.Id);
return member.PermissionsIn(channel);
}
if (channel.Type == ChannelType.Private)
return (Permissions) 0b00000_1000110_1011100110000_000000;
return Permissions.None;
}
public static bool HasPermission(this IChannel channel, ChannelPermission permission) =>
PermissionsIn(channel).Has(permission);
public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user)
{
if (user is DiscordMember dm && channel.Guild != null)
return dm.PermissionsIn(channel);
if (channel.Type == ChannelType.Private)
return (Permissions) 0b00000_1000110_1011100110000_000000;
return Permissions.None;
}
public static Permissions BotPermissions(this DiscordChannel channel)
{
if (channel.Guild != null)
{
var member = channel.Guild.CurrentMember;
return channel.PermissionsFor(member);
}
if (channel.Type == ChannelType.Private)
return (Permissions) 0b00000_1000110_1011100110000_000000;
return Permissions.None;
}
public static bool BotHasPermission(this DiscordChannel channel, Permissions permissionSet) =>
(BotPermissions(channel) & permissionSet) == permissionSet;
public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake << 22);
public static ulong InstantToSnowflake(Instant time) =>
(ulong) (time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds >> 22;
public static ulong InstantToSnowflake(DateTimeOffset time) =>
(ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds >> 22;
public static async Task CreateReactionsBulk(this DiscordMessage msg, string[] reactions)
{
foreach (var reaction in reactions)
{
await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(reaction));
}
}
}
}

View File

@ -3,8 +3,6 @@ using System.Linq;
using System.Net.Sockets;
using System.Threading.Tasks;
using Discord.Net;
using Npgsql;
using PluralKit.Core;
@ -20,7 +18,8 @@ namespace PluralKit.Bot
// otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something.
// Discord server errors are *not our problem*
if (e is HttpException he && ((int) he.HttpCode) >= 500) return false;
// TODO
// if (e is DSharpPlus.Exceptions he && ((int) he.HttpCode) >= 500) return false;
// Webhook server errors are also *not our problem*
// (this includes rate limit errors, WebhookRateLimited is a subclass)

View File

@ -2,16 +2,16 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Discord;
using DSharpPlus.Entities;
namespace PluralKit.Bot
{
public static class StringUtils
{
public static Color? ToDiscordColor(this string color)
public static DiscordColor? ToDiscordColor(this string color)
{
if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt))
return new Color(colorInt);
if (int.TryParse(color, NumberStyles.HexNumber, null, out var colorInt))
return new DiscordColor(colorInt);
throw new ArgumentException($"Invalid color string '{color}'.");
}
@ -23,7 +23,7 @@ namespace PluralKit.Bot
if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@'))
return false;
int num = content.IndexOf('>');
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out mentionId))
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !TryParseMention(content.Substring(0, num + 1), out mentionId))
return false;
argPos = num + 2;
return true;
@ -32,7 +32,18 @@ namespace PluralKit.Bot
public static bool TryParseMention(this string potentialMention, out ulong id)
{
if (ulong.TryParse(potentialMention, out id)) return true;
if (MentionUtils.TryParseUser(potentialMention, out id)) return true;
// Roughly ported from Discord.MentionUtils.TryParseUser
if (potentialMention.Length >= 3 && potentialMention[0] == '<' && potentialMention[1] == '@' && potentialMention[potentialMention.Length - 1] == '>')
{
if (potentialMention.Length >= 4 && potentialMention[2] == '!')
potentialMention = potentialMention.Substring(3, potentialMention.Length - 4); //<@!123>
else
potentialMention = potentialMention.Substring(2, potentialMention.Length - 3); //<@123>
if (ulong.TryParse(potentialMention, NumberStyles.None, CultureInfo.InvariantCulture, out id))
return true;
}
return false;
}