Merge branch 'refactor/dsharpplus'

It's heeeeeeeere~
This commit is contained in:
Ske 2020-05-01 15:21:55 +02:00
commit e4ebd2a5fe
39 changed files with 926 additions and 867 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "Discord.Net"]
path = Discord.Net
url = https://github.com/xSke/Discord.Net

@ -1 +0,0 @@
Subproject commit 23567d17a64d1f4c8bd92cb7b3c7b69f8897a759

View File

@ -1,16 +1,22 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using App.Metrics;
using Autofac;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using Microsoft.Extensions.Configuration;
using NodaTime;
using PluralKit.Core;
using Sentry;
@ -61,7 +67,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 +76,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
@ -98,98 +102,92 @@ namespace PluralKit.Bot
{
private ILifetimeScope _services;
private DiscordShardedClient _client;
private Timer _updateTimer;
private IMetrics _metrics;
private PeriodicStatCollector _collector;
private ILogger _logger;
private WebhookRateLimitService _webhookRateLimit;
private int _periodicUpdateCount;
public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit)
private Task _periodicWorker;
public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
{
_services = services;
_client = client as DiscordShardedClient;
_client = client;
_metrics = metrics;
_collector = collector;
_webhookRateLimit = webhookRateLimit;
_logger = logger.ForContext<Bot>();
}
public Task Init()
{
_client.ShardDisconnected += ShardDisconnected;
_client.ShardReady += ShardReady;
_client.Log += FrameworkLog;
// DiscordShardedClient SocketErrored/Ready events also fire whenever an individual shard's respective events fire
_client.SocketErrored += ShardDisconnected;
_client.Ready += 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);
// Will not be awaited, just runs in the background
_periodicWorker = UpdatePeriodic();
return Task.CompletedTask;
}
private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
private Task ShardDisconnected(SocketErrorEventArgs e)
{
_logger.Warning(ex, $"Shard #{shard.ShardId} disconnected");
_logger.Warning(e.Exception, $"Shard #{e.Client.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");
// Run webhook rate limit GC every 10 minutes
if (_periodicUpdateCount++ % 10 == 0)
while (true)
{
var _ = Task.Run(() => _webhookRateLimit.GarbageCollect());
// Run at every whole minute (:00), mostly because I feel like it
var timeNow = SystemClock.Instance.GetCurrentInstant();
var timeTillNextWholeMinute = 60000 - (timeNow.ToUnixTimeMilliseconds() % 60000);
await Task.Delay((int) timeTillNextWholeMinute);
// Change bot status
var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count);
try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received)
{
await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers"));
}
catch (WebSocketException) { }
await _collector.CollectStats();
_logger.Information("Submitted metrics to backend");
await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync());
}
await _collector.CollectStats();
_logger.Information("Submitted metrics to backend");
await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync());
}
private Task ShardReady(DiscordSocketClient shardClient)
private Task ShardReady(ReadyEventArgs e)
{
_logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count);
if (shardClient.ShardId == 0)
{
_updateTimer = new Timer((_) => {
HandleEvent(_ => UpdatePeriodic());
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
_logger.Information("PluralKit started as {Username}#{Discriminator} ({Id})", _client.CurrentUser.Username, _client.CurrentUser.Discriminator, _client.CurrentUser.Id);
}
_logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", e.Client.ShardId, e.Client.Guilds.Sum(g => g.Value.Channels.Count), e.Client.Guilds.Count);
return Task.CompletedTask;
}
@ -252,7 +250,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 +267,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) cachedGuild = 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 +330,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 +345,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 +358,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

@ -6,9 +6,11 @@ using App.Metrics;
using Autofac;
using Discord;
using Discord.WebSocket;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Bot.Utils;
using PluralKit.Core;
namespace PluralKit.Bot
@ -17,8 +19,10 @@ namespace PluralKit.Bot
{
private ILifetimeScope _provider;
private readonly DiscordRestClient _rest;
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,13 @@ 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)
{
_rest = provider.Resolve<DiscordRestClient>();
_client = provider.Resolve<DiscordShardedClient>();
_message = message;
_shard = shard;
_data = provider.Resolve<IDataStore>();
_senderSystem = senderSystem;
_metrics = provider.Resolve<IMetrics>();
@ -39,12 +45,15 @@ 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 DiscordRestClient Rest => _rest;
public PKSystem System => _senderSystem;
public string PopArgument() => _parameters.Pop();
@ -53,13 +62,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 +134,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 +147,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 +253,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,13 +281,14 @@ 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;
var discordChannel = _rest.GetChannelAsync(channel).GetAwaiter().GetResult();
if (discordChannel.Type != ChannelType.Text) return null;
PopArgument();
return textChannel;
return discordChannel;
}
}
}

View File

@ -1,8 +1,8 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -96,12 +96,12 @@ namespace PluralKit.Bot
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.Name}** in this server.");
}
private async Task<Embed> CreateAutoproxyStatusEmbed(Context ctx)
private async Task<DiscordEmbed> CreateAutoproxyStatusEmbed(Context ctx)
{
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member\n**pk;autoproxy front** - Autoproxies as current (first) fronter\n**pk;autoproxy <member>** - Autoproxies as a specific member";
var eb = new EmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
var eb = new DiscordEmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
switch (settings.AutoproxyMode) {
case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}");

View File

@ -1,7 +1,7 @@
using System.Linq;
using System.Threading.Tasks;
using Discord.WebSocket;
using DSharpPlus;
using PluralKit.Core;
@ -81,6 +81,7 @@ namespace PluralKit.Bot
public CommandTree(DiscordShardedClient client)
{
_client = client;
}
@ -345,7 +346,7 @@ namespace PluralKit.Bot
{
// Try to resolve the user ID to find the associated account,
// so we can print their username.
var user = await _client.Rest.GetUserAsync(id);
var user = await ctx.Rest.GetUserAsync(id);
// Print descriptive errors based on whether we found the user or not.
if (user == null)

View File

@ -1,6 +1,6 @@
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -10,7 +10,7 @@ namespace PluralKit.Bot
{
public async Task HelpRoot(Context ctx)
{
await ctx.Reply(embed: new EmbedBuilder()
await ctx.Reply(embed: new DiscordEmbedBuilder()
.WithTitle("PluralKit")
.WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.")
.AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.")
@ -20,7 +20,7 @@ namespace PluralKit.Bot
.AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.")
.AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78")
.WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/")
.WithColor(Color.Blue)
.WithColor(DiscordUtils.Blue)
.Build());
}
}

View File

@ -5,10 +5,9 @@ using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Discord;
using Discord.Net;
using Newtonsoft.Json;
using DSharpPlus.Exceptions;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -134,13 +133,14 @@ namespace PluralKit.Bot
try
{
await ctx.Author.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!");
var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id);
await dm.SendFileAsync("system.json", stream, $"{Emojis.Success} Here you go!");
// If the original message wasn't posted in DMs, send a public reminder
if (!(ctx.Channel is IDMChannel))
if (!(ctx.Channel is DiscordDmChannel))
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
catch (HttpException)
catch (UnauthorizedException)
{
// If user has DMs closed, tell 'em to open them
await ctx.Reply(

View File

@ -1,7 +1,8 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -39,7 +40,7 @@ namespace PluralKit.Bot
{
if ((target.AvatarUrl?.Trim() ?? "").Length > 0)
{
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle($"{target.Name.SanitizeMentions()}'s avatar")
.WithImageUrl(target.AvatarUrl);
if (target.System == ctx.System?.Id)
@ -55,18 +56,17 @@ namespace PluralKit.Bot
return;
}
var user = await ctx.MatchUser();
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
else if (await ctx.MatchUser() is IUser user)
else if (user != null)
{
if (user.AvatarId == null) throw Errors.UserHasNoAvatar;
if (!user.HasAvatar()) throw Errors.UserHasNoAvatar; //TODO: is this necessary?
target.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256);
await _data.SaveMember(target);
var embed = new EmbedBuilder().WithImageUrl(target.AvatarUrl).Build();
var embed = new DiscordEmbedBuilder().WithImageUrl(target.AvatarUrl).Build();
await ctx.Reply(
$"{Emojis.Success} Member avatar changed to {user.Username}'s avatar! {Emojis.Warn} Please note that if {user.Username} changes their avatar, the member's avatar will need to be re-set.", embed: embed);
}
@ -76,10 +76,10 @@ namespace PluralKit.Bot
target.AvatarUrl = url;
await _data.SaveMember(target);
var embed = new EmbedBuilder().WithImageUrl(url).Build();
var embed = new DiscordEmbedBuilder().WithImageUrl(url).Build();
await ctx.Reply($"{Emojis.Success} Member avatar changed.", embed: embed);
}
else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment)
else if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment attachment)
{
await AvatarUtils.VerifyAvatarOrThrow(attachment.Url);
target.AvatarUrl = attachment.Url;
@ -113,7 +113,7 @@ namespace PluralKit.Bot
{
if ((guildData.AvatarUrl?.Trim() ?? "").Length > 0)
{
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle($"{target.Name.SanitizeMentions()}'s server avatar (for {ctx.Guild.Name})")
.WithImageUrl(guildData.AvatarUrl);
if (target.System == ctx.System?.Id)
@ -125,17 +125,17 @@ namespace PluralKit.Bot
return;
}
var user = await ctx.MatchUser();
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
if (await ctx.MatchUser() is IUser user)
if (user != null)
{
if (user.AvatarId == null) throw Errors.UserHasNoAvatar;
if (!user.HasAvatar()) throw Errors.UserHasNoAvatar;
guildData.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256);
await _data.SetMemberGuildSettings(target, ctx.Guild.Id, guildData);
var embed = new EmbedBuilder().WithImageUrl(guildData.AvatarUrl).Build();
var embed = new DiscordEmbedBuilder().WithImageUrl(guildData.AvatarUrl).Build();
await ctx.Reply(
$"{Emojis.Success} Member server avatar changed to {user.Username}'s avatar! This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**). {Emojis.Warn} Please note that if {user.Username} changes their avatar, the member's server avatar will need to be re-set.", embed: embed);
}
@ -145,10 +145,10 @@ namespace PluralKit.Bot
guildData.AvatarUrl = url;
await _data.SetMemberGuildSettings(target, ctx.Guild.Id, guildData);
var embed = new EmbedBuilder().WithImageUrl(url).Build();
var embed = new DiscordEmbedBuilder().WithImageUrl(url).Build();
await ctx.Reply($"{Emojis.Success} Member server avatar changed. This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", embed: embed);
}
else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment)
else if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment attachment)
{
await AvatarUtils.VerifyAvatarOrThrow(attachment.Url);
guildData.AvatarUrl = attachment.Url;

View File

@ -1,8 +1,8 @@
using System;
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using NodaTime;
@ -86,7 +86,7 @@ namespace PluralKit.Bot
else if (ctx.MatchFlag("r", "raw"))
await ctx.Reply($"```\n{target.Description.SanitizeMentions()}\n```");
else
await ctx.Reply(embed: new EmbedBuilder()
await ctx.Reply(embed: new DiscordEmbedBuilder()
.WithTitle("Member description")
.WithDescription(target.Description)
.AddField("\u200B", $"To print the description with formatting, type `pk;member {target.Hid} description -raw`."
@ -163,7 +163,7 @@ namespace PluralKit.Bot
else
await ctx.Reply("This member does not have a color set.");
else
await ctx.Reply(embed: new EmbedBuilder()
await ctx.Reply(embed: new DiscordEmbedBuilder()
.WithTitle("Member color")
.WithColor(target.Color.ToDiscordColor().Value)
.WithThumbnailUrl($"https://fakeimg.pl/256x256/{target.Color}/?text=%20")
@ -180,7 +180,7 @@ namespace PluralKit.Bot
target.Color = color.ToLower();
await _data.SaveMember(target);
await ctx.Reply(embed: new EmbedBuilder()
await ctx.Reply(embed: new DiscordEmbedBuilder()
.WithTitle($"{Emojis.Success} Member color changed.")
.WithColor(target.Color.ToDiscordColor().Value)
.WithThumbnailUrl($"https://fakeimg.pl/256x256/{target.Color}/?text=%20")
@ -220,13 +220,13 @@ namespace PluralKit.Bot
}
}
private async Task<EmbedBuilder> CreateMemberNameInfoEmbed(Context ctx, PKMember target)
private async Task<DiscordEmbedBuilder> CreateMemberNameInfoEmbed(Context ctx, PKMember target)
{
MemberGuildSettings memberGuildConfig = null;
if (ctx.Guild != null)
memberGuildConfig = await _data.GetMemberGuildSettings(target, ctx.Guild.Id);
var eb = new EmbedBuilder().WithTitle($"Member names")
var eb = new DiscordEmbedBuilder().WithTitle($"Member names")
.WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name.");
if (target.DisplayName == null && memberGuildConfig?.DisplayName == null)

View File

@ -6,13 +6,14 @@ using System.Threading.Tasks;
using App.Metrics;
using Discord;
using DSharpPlus;
using Humanizer;
using NodaTime;
using PluralKit.Core;
using DSharpPlus.Entities;
namespace PluralKit.Bot {
public class Misc
@ -36,18 +37,16 @@ namespace PluralKit.Bot {
public async Task Invite(Context ctx)
{
var clientId = _botConfig.ClientId ?? (await ctx.Client.GetApplicationInfoAsync()).Id;
var permissions = new GuildPermissions(
addReactions: true,
attachFiles: true,
embedLinks: true,
manageMessages: true,
manageWebhooks: true,
readMessageHistory: true,
sendMessages: true
);
var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}";
var clientId = _botConfig.ClientId ?? ctx.Client.CurrentApplication.Id;
var permissions = new Permissions()
.Grant(Permissions.AddReactions)
.Grant(Permissions.AttachFiles)
.Grant(Permissions.EmbedLinks)
.Grant(Permissions.ManageMessages)
.Grant(Permissions.ManageWebhooks)
.Grant(Permissions.ReadMessageHistory)
.Grant(Permissions.SendMessages);
var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={(long)permissions}";
await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>");
}
@ -65,8 +64,8 @@ namespace PluralKit.Bot {
var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.First(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name).Value;
var shardId = ctx.Shard.ShardId;
var shardTotal = ctx.Client.Shards.Count;
var shardUpTotal = ctx.Client.Shards.Select(s => s.ConnectionState == ConnectionState.Connected).Count();
var shardTotal = ctx.Client.ShardClients.Count;
var shardUpTotal = ctx.Client.ShardClients.Where(x => x.Value.IsConnected()).Count();
var shardInfo = _shards.GetShardInfo(ctx.Shard);
var process = Process.GetCurrentProcess();
@ -74,7 +73,7 @@ namespace PluralKit.Bot {
var shardUptime = SystemClock.Instance.GetCurrentInstant() - shardInfo.LastConnectionTime;
var embed = new EmbedBuilder()
var embed = new DiscordEmbedBuilder()
.AddField("Messages processed", $"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true)
.AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true)
.AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true)
@ -84,17 +83,12 @@ namespace PluralKit.Bot {
.AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true)
.AddField("Latency", $"API: {(msg.Timestamp - ctx.Message.Timestamp).TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency} ms", true)
.AddField("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalSwitches:N0} switches, {totalMessages:N0} messages");
await msg.ModifyAsync(f =>
{
f.Content = "";
f.Embed = embed.Build();
});
await msg.ModifyAsync("", embed.Build());
}
public async Task PermCheckGuild(Context ctx)
{
IGuild guild;
DiscordGuild guild;
if (ctx.Guild != null && !ctx.HasNext())
{
@ -107,37 +101,36 @@ namespace PluralKit.Bot {
throw new PKSyntaxError($"Could not parse `{guildIdStr.SanitizeMentions()}` as an ID.");
// TODO: will this call break for sharding if you try to request a guild on a different bot instance?
guild = ctx.Client.GetGuild(guildId);
guild = await ctx.Rest.GetGuildAsync(guildId);
if (guild == null)
throw Errors.GuildNotFound(guildId);
}
// Ensure people can't query guilds they're not in + get their own permissions (for view access checking)
var senderGuildUser = await guild.GetUserAsync(ctx.Author.Id);
var senderGuildUser = await guild.GetMemberAsync(ctx.Author.Id);
if (senderGuildUser == null)
throw new PKError("You must be a member of the guild you are querying.");
var requiredPermissions = new []
{
ChannelPermission.ViewChannel,
ChannelPermission.SendMessages,
ChannelPermission.AddReactions,
ChannelPermission.AttachFiles,
ChannelPermission.EmbedLinks,
ChannelPermission.ManageMessages,
ChannelPermission.ManageWebhooks
Permissions.AccessChannels,
Permissions.SendMessages,
Permissions.AddReactions,
Permissions.AttachFiles,
Permissions.EmbedLinks,
Permissions.ManageMessages,
Permissions.ManageWebhooks
};
// Loop through every channel and group them by sets of permissions missing
var permissionsMissing = new Dictionary<ulong, List<ITextChannel>>();
var permissionsMissing = new Dictionary<ulong, List<DiscordChannel>>();
var hiddenChannels = 0;
foreach (var channel in await guild.GetTextChannelsAsync())
foreach (var channel in await guild.GetChannelsAsync())
{
var botPermissions = channel.PermissionsIn();
var botPermissions = channel.BotPermissions();
var userGuildPermissions = senderGuildUser.GuildPermissions;
var userPermissions = senderGuildUser.GetPermissions(channel);
if (!userPermissions.ViewChannel && !userGuildPermissions.Administrator)
var userPermissions = senderGuildUser.PermissionsIn(channel);
if ((userPermissions & Permissions.AccessChannels) == 0)
{
// If the user can't see this channel, don't calculate permissions for it
// (to prevent info-leaking, mostly)
@ -147,27 +140,28 @@ namespace PluralKit.Bot {
}
// We use a bitfield so we can set individual permission bits in the loop
// TODO: Rewrite with proper bitfield math
ulong missingPermissionField = 0;
foreach (var requiredPermission in requiredPermissions)
if (!botPermissions.Has(requiredPermission))
if ((botPermissions & requiredPermission) == 0)
missingPermissionField |= (ulong) requiredPermission;
// If we're not missing any permissions, don't bother adding it to the dict
// This means we can check if the dict is empty to see if all channels are proxyable
if (missingPermissionField != 0)
{
permissionsMissing.TryAdd(missingPermissionField, new List<ITextChannel>());
permissionsMissing.TryAdd(missingPermissionField, new List<DiscordChannel>());
permissionsMissing[missingPermissionField].Add(channel);
}
}
// Generate the output embed
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle($"Permission check for **{guild.Name.SanitizeMentions()}**");
if (permissionsMissing.Count == 0)
{
eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(Color.Green);
eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordUtils.Green);
}
else
{
@ -175,15 +169,13 @@ namespace PluralKit.Bot {
{
// Each missing permission field can have multiple missing channels
// so we extract them all and generate a comma-separated list
var missingPermissionNames = string.Join(", ", new ChannelPermissions(missingPermissionField)
.ToList()
.Select(perm => perm.Humanize().Transform(To.TitleCase)));
var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString();
var channelsList = string.Join("\n", channels
.OrderBy(c => c.Position)
.Select(c => $"#{c.Name}"));
eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000));
eb.WithColor(Color.Red);
eb.WithColor(DiscordUtils.Red);
}
}
@ -208,7 +200,7 @@ namespace PluralKit.Bot {
var message = await _data.GetMessage(messageId);
if (message == null) throw Errors.MessageNotFound(messageId);
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message));
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message));
}
}
}

View File

@ -2,7 +2,8 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -20,12 +21,12 @@ namespace PluralKit.Bot
public async Task SetLogChannel(Context ctx)
{
ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server");
ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server");
ITextChannel channel = null;
DiscordChannel channel = null;
if (ctx.HasNext())
channel = ctx.MatchChannel() ?? throw new PKSyntaxError("You must pass a #channel to set.");
if (channel != null && channel.GuildId != ctx.Guild.Id) throw new PKError("That channel is not in this server!");
if (channel != null && channel.GuildId != ctx.Guild.Id) throw new PKError("That channel is not in this server!");
var cfg = await _data.GetOrCreateGuildConfig(ctx.Guild.Id);
cfg.LogChannel = channel?.Id;
@ -39,16 +40,15 @@ namespace PluralKit.Bot
public async Task SetLogEnabled(Context ctx, bool enable)
{
ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server");
ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server");
var affectedChannels = new List<ITextChannel>();
var affectedChannels = new List<DiscordChannel>();
if (ctx.Match("all"))
affectedChannels = (await ctx.Guild.GetChannelsAsync()).OfType<ITextChannel>().ToList();
affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList();
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
else while (ctx.HasNext())
{
if (!(ctx.MatchChannel() is ITextChannel channel))
throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found.");
var channel = ctx.MatchChannel() ?? throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found.");
if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server.");
affectedChannels.Add(channel);
}
@ -65,16 +65,15 @@ namespace PluralKit.Bot
public async Task SetBlacklisted(Context ctx, bool onBlacklist)
{
ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server");
ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server");
var affectedChannels = new List<ITextChannel>();
var affectedChannels = new List<DiscordChannel>();
if (ctx.Match("all"))
affectedChannels = (await ctx.Guild.GetChannelsAsync()).OfType<ITextChannel>().ToList();
affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList();
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
else while (ctx.HasNext())
{
if (!(ctx.MatchChannel() is ITextChannel channel))
throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found.");
var channel = ctx.MatchChannel() ?? throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found.");
if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server.");
affectedChannels.Add(channel);
}
@ -89,7 +88,7 @@ namespace PluralKit.Bot
public async Task SetLogCleanup(Context ctx)
{
ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server");
ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server");
var guildCfg = await _data.GetOrCreateGuildConfig(ctx.Guild.Id);
var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
@ -108,7 +107,7 @@ namespace PluralKit.Bot
}
else
{
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle("Log cleanup settings")
.AddField("Supported bots", botList);

View File

@ -2,7 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using NodaTime;
using NodaTime.TimeZones;
@ -140,7 +140,7 @@ namespace PluralKit.Bot
var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync());
var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp);
IUserMessage msg;
DiscordMessage msg;
if (lastTwoSwitches.Count == 1)
{
msg = await ctx.Reply(

View File

@ -18,9 +18,9 @@ namespace PluralKit.Bot
public async Task Query(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError;
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(system, ctx.LookupContextFor(system)));
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx.Shard, system, ctx.LookupContextFor(system)));
}
public async Task New(Context ctx)
{
ctx.CheckNoSystem();

View File

@ -1,8 +1,9 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus;
using DSharpPlus.Entities;
using NodaTime;
using NodaTime.Text;
@ -70,7 +71,7 @@ namespace PluralKit.Bot
else if (ctx.MatchFlag("r", "raw"))
await ctx.Reply($"```\n{ctx.System.Description.SanitizeMentions()}\n```");
else
await ctx.Reply(embed: new EmbedBuilder()
await ctx.Reply(embed: new DiscordEmbedBuilder()
.WithTitle("System description")
.WithDescription(ctx.System.Description)
.WithFooter("To print the description with formatting, type `pk;s description -raw`. To clear it, type `pk;s description -clear`. To change it, type `pk;s description <new description>`.")
@ -128,7 +129,7 @@ namespace PluralKit.Bot
{
if ((ctx.System.AvatarUrl?.Trim() ?? "").Length > 0)
{
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle($"System avatar")
.WithImageUrl(ctx.System.AvatarUrl)
.WithDescription($"To clear, use `pk;system avatar clear`.");
@ -143,11 +144,11 @@ namespace PluralKit.Bot
var member = await ctx.MatchUser();
if (member != null)
{
if (member.AvatarId == null) throw Errors.UserHasNoAvatar;
if (!member.HasAvatar()) throw Errors.UserHasNoAvatar;
ctx.System.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256);
await _data.SaveSystem(ctx.System);
var embed = new EmbedBuilder().WithImageUrl(ctx.System.AvatarUrl).Build();
var embed = new DiscordEmbedBuilder().WithImageUrl(ctx.System.AvatarUrl).Build();
await ctx.Reply(
$"{Emojis.Success} System avatar changed to {member.Username}'s avatar! {Emojis.Warn} Please note that if {member.Username} changes their avatar, the system's avatar will need to be re-set.", embed: embed);
}
@ -160,7 +161,7 @@ namespace PluralKit.Bot
ctx.System.AvatarUrl = url;
await _data.SaveSystem(ctx.System);
var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null;
var embed = url != null ? new DiscordEmbedBuilder().WithImageUrl(url).Build() : null;
await ctx.Reply($"{Emojis.Success} System avatar changed.", embed: embed);
}
}
@ -249,7 +250,7 @@ namespace PluralKit.Bot
_ => throw new ArgumentOutOfRangeException(nameof(level), level, null)
};
var eb = new EmbedBuilder()
var eb = new DiscordEmbedBuilder()
.WithTitle("Current privacy settings for your system")
.AddField("Description", PrivacyLevelString(ctx.System.DescriptionPrivacy))
.AddField("Member list", PrivacyLevelString(ctx.System.MemberListPrivacy))

View File

@ -1,7 +1,8 @@
using System.Linq;
using System;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using NodaTime;
@ -62,7 +63,6 @@ namespace PluralKit.Bot
embedTitle,
async (builder, switches) =>
{
var outputStr = "";
foreach (var entry in switches)
{
var lastSw = entry.LastTime;
@ -88,12 +88,15 @@ namespace PluralKit.Bot
stringToAdd =
$"**{membersStr}** ({DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {DateTimeFormats.DurationFormat.Format(switchSince)} ago)\n";
}
if (outputStr.Length + stringToAdd.Length > EmbedBuilder.MaxDescriptionLength) break;
outputStr += stringToAdd;
try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this
{
builder.Description += stringToAdd;
}
catch (ArgumentException)
{
break;
}// TODO: Make sure this works
}
builder.Description = outputStr;
}
);
}

View File

@ -1,9 +1,9 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using DSharpPlus.Entities;
using Humanizer;
@ -21,7 +21,7 @@ namespace PluralKit.Bot
}
private async Task RenderMemberList(Context ctx, PKSystem system, bool canShowPrivate, int membersPerPage, string embedTitle, Func<PKMember, bool> filter,
Func<EmbedBuilder, IEnumerable<PKMember>, Task>
Func<DiscordEmbedBuilder, IEnumerable<PKMember>, Task>
renderer)
{
var authCtx = ctx.LookupContextFor(system);
@ -54,7 +54,7 @@ namespace PluralKit.Bot
});
}
private Task ShortRenderer(EmbedBuilder eb, IEnumerable<PKMember> members)
private Task ShortRenderer(DiscordEmbedBuilder eb, IEnumerable<PKMember> members)
{
eb.Description = string.Join("\n", members.Select((m) =>
{
@ -73,7 +73,7 @@ namespace PluralKit.Bot
return Task.CompletedTask;
}
private Task LongRenderer(EmbedBuilder eb, IEnumerable<PKMember> members)
private Task LongRenderer(DiscordEmbedBuilder eb, IEnumerable<PKMember> members)
{
foreach (var m in members)
{

View File

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using Discord;
using DSharpPlus;
using DSharpPlus.Entities;
using PluralKit.Core;
@ -22,14 +23,15 @@ namespace PluralKit.Bot
var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System);
// If we're not already in a DM, reply with a reminder to check
if (!(ctx.Channel is IDMChannel))
if (!(ctx.Channel is DiscordDmChannel))
{
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
// DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile)
await ctx.Author.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:");
await ctx.Author.SendMessageAsync(token);
var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id);
await dm.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:");
await dm.SendMessageAsync(token);
}
private async Task<string> MakeAndSetNewToken(PKSystem system)
@ -55,14 +57,15 @@ namespace PluralKit.Bot
var token = await MakeAndSetNewToken(ctx.System);
// If we're not already in a DM, reply with a reminder to check
if (!(ctx.Channel is IDMChannel))
if (!(ctx.Channel is DiscordDmChannel))
{
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
// DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile)
await ctx.Author.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:");
await ctx.Author.SendMessageAsync(token);
var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id);
await dm.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:");
await dm.SendMessageAsync(token);
}
}
}

View File

@ -0,0 +1,29 @@
using DSharpPlus;
using DSharpPlus.Entities;
using System.Net.WebSockets;
namespace PluralKit.Bot
{
static class Extensions
{
//Unfortunately D#+ doesn't expose the connection state of the client, so we have to test for it instead
public static bool IsConnected(this DiscordClient client)
{
try
{
client.GetConnectionsAsync().GetAwaiter().GetResult();
}
catch(WebSocketException)
{
return false;
}
return true;
}
public static bool HasAvatar(this DiscordUser user)
{
return user.AvatarUrl != user.DefaultAvatarUrl;
}
}
}

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;
@ -17,20 +15,17 @@ namespace PluralKit.Bot
{
protected override void Load(ContainerBuilder builder)
{
// Client
builder.Register(c => new DiscordShardedClient(new DiscordSocketConfig()
// Clients
builder.Register(c => new DiscordConfiguration
{
Token = c.Resolve<BotConfig>().Token,
TokenType = TokenType.Bot,
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();
LargeThreshold = 50
}).AsSelf();
builder.Register(c => new DiscordShardedClient(c.Resolve<DiscordConfiguration>())).AsSelf().SingleInstance();
builder.Register(c => new DiscordRestClient(c.Resolve<DiscordConfiguration>())).AsSelf().SingleInstance();
// Commands
builder.RegisterType<CommandTree>().AsSelf();
builder.RegisterType<Autoproxy>().AsSelf();
@ -60,7 +55,6 @@ namespace PluralKit.Bot
builder.RegisterType<ProxyService>().AsSelf().SingleInstance();
builder.RegisterType<LogChannelService>().AsSelf().SingleInstance();
builder.RegisterType<DataFileService>().AsSelf().SingleInstance();
builder.RegisterType<WebhookRateLimitService>().AsSelf().SingleInstance();
builder.RegisterType<WebhookExecutorService>().AsSelf().SingleInstance();
builder.RegisterType<WebhookCacheService>().AsSelf().SingleInstance();
builder.RegisterType<ShardInfoService>().AsSelf().SingleInstance();
@ -71,8 +65,7 @@ namespace PluralKit.Bot
// Sentry stuff
builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope();
// Utils
builder.Register(c => new HttpClient
{

View File

@ -6,11 +6,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.0.0-nightly-00686" />
<PackageReference Include="DSharpPlus.Rest" Version="4.0.0-nightly-00686" />
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
<PackageReference Include="Sentry" Version="2.0.0-beta7" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.0-beta0007" />

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(DiscordUtils.Gray)
.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() ?? DiscordUtils.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 = DiscordUtils.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 : DiscordUtils.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() ?? DiscordUtils.Gray)
.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(DiscordUtils.Gray)
.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;
@ -21,21 +24,19 @@ namespace PluralKit.Bot
private IDataStore _data;
private WebhookCacheService _webhookCache;
private WebhookRateLimitService _webhookRateLimitCache;
private DbConnectionCountHolder _countHolder;
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)
{
_client = (DiscordShardedClient) client;
_client = client;
_metrics = metrics;
_webhookCache = webhookCache;
_countHolder = countHolder;
_data = data;
_cpu = cpu;
_webhookRateLimitCache = webhookRateLimitCache;
_logger = logger.ForContext<PeriodicStatCollector>();
}
@ -45,18 +46,32 @@ 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);
@ -82,7 +97,6 @@ namespace PluralKit.Bot
// Other shiz
_metrics.Measure.Gauge.SetValue(BotMetrics.WebhookCacheSize, _webhookCache.CacheSize);
_metrics.Measure.Gauge.SetValue(BotMetrics.WebhookRateLimitCacheSize, _webhookRateLimitCache.CacheSize);
stopwatch.Stop();
_logger.Information("Updated metrics in {Time}", stopwatch.ElapsedDuration());

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.
// 2020-04-22: Manage Messages does *not* override a lack of Send Messages.
if (!permissions.SendMessages) return false;
if ((permissions & Permissions.SendMessages) == 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;
@ -30,27 +31,25 @@ namespace PluralKit.Bot
public class WebhookExecutorService
{
private WebhookCacheService _webhookCache;
private WebhookRateLimitService _rateLimit;
private ILogger _logger;
private IMetrics _metrics;
private HttpClient _client;
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client, WebhookRateLimitService rateLimit)
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client)
{
_metrics = metrics;
_webhookCache = webhookCache;
_client = client;
_rateLimit = rateLimit;
_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 +59,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 +155,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

@ -1,128 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using Discord;
using NodaTime;
using Serilog;
namespace PluralKit.Bot
{
// Simplified rate limit handler for webhooks only, disregards most bucket functionality since scope is limited and just denies requests if too fast.
public class WebhookRateLimitService
{
private ILogger _logger;
private ConcurrentDictionary<ulong, WebhookRateLimitInfo> _info = new ConcurrentDictionary<ulong, WebhookRateLimitInfo>();
public WebhookRateLimitService(ILogger logger)
{
_logger = logger.ForContext<WebhookRateLimitService>();
}
public int CacheSize => _info.Count;
public bool TryExecuteWebhook(IWebhook 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;
// If we're past the reset time, allow the request and update the bucket limit
if (SystemClock.Instance.GetCurrentInstant() > info.resetTime)
{
if (!info.hasResetTimeExpired)
info.remaining = info.maxLimit;
info.hasResetTimeExpired = true;
// We can hit this multiple times if many requests are in flight before a real one gets "back", so we still
// decrement the remaining request count, this basically "blacklists" the channel given continuous spam until *one* of the requests come back with new rate limit headers
info.remaining--;
return true;
}
// If we don't have any more requests left, deny the request
if (info.remaining == 0)
{
_logger.Debug("Rate limit bucket for {Webhook} out of requests, denying request", webhook.Id);
return false;
}
// Otherwise, decrement the request count and allow the request
info.remaining--;
return true;
}
public void UpdateRateLimitInfo(IWebhook webhook, HttpResponseMessage response)
{
var info = _info.GetOrAdd(webhook.Id, _ => new WebhookRateLimitInfo());
if (int.TryParse(GetHeader(response, "X-RateLimit-Limit"), out var limit))
info.maxLimit = limit;
// Max "safe" is way above UNIX timestamp values, and we get fractional seconds, hence the double
// but need culture/format specifiers to get around Some Locales (cough, my local PC) having different settings for decimal point...
// We also use Reset-After to avoid issues with clock desync between us and Discord's server, this way it's all relative (plus latency errors work in our favor)
if (double.TryParse(GetHeader(response, "X-RateLimit-Reset-After"), NumberStyles.Float, CultureInfo.InvariantCulture, out var resetTimestampDelta))
{
var resetTime = SystemClock.Instance.GetCurrentInstant() + Duration.FromSeconds(resetTimestampDelta);
if (resetTime > info.resetTime)
{
// Set to the *latest* reset value we have (for safety), since we rely on relative times this can jitter a bit
info.resetTime = resetTime;
info.hasResetTimeExpired = false;
}
}
if (int.TryParse(GetHeader(response, "X-RateLimit-Remaining"), out var remainingRequests))
// Overwrite a negative "we don't know" value with whatever we just got
// Otherwise, *lower* remaining requests takes precedence
if (info.remaining < 0 || remainingRequests < info.remaining)
info.remaining = remainingRequests;
_logger.Debug("Updated rate limit information for {Webhook}, bucket has {RequestsRemaining} requests remaining, reset in {ResetTime}", webhook.Id, info.remaining, info.resetTime - SystemClock.Instance.GetCurrentInstant());
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
// 429, we're *definitely* out of requests
info.remaining = 0;
_logger.Warning("Got 429 Too Many Requests when invoking webhook {Webhook}, next bucket reset in {ResetTime}", webhook.Id, info.resetTime - SystemClock.Instance.GetCurrentInstant());
}
}
public void GarbageCollect()
{
_logger.Information("Garbage-collecting webhook rate limit buckets...");
var collected = 0;
foreach (var channel in _info.Keys)
{
if (!_info.TryGetValue(channel, out var info)) continue;
// Remove all keys that expired more than an hour ago (and of course, haven't been reset)
if (info.resetTime < SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1))
if (_info.TryRemove(channel, out _)) collected++;
}
_logger.Information("Garbage-collected {ChannelCount} channels from the webhook rate limit buckets.", collected);
}
private string GetHeader(HttpResponseMessage response, string key)
{
var firstPair = response.Headers.FirstOrDefault(pair => pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase));
return firstPair.Value?.FirstOrDefault(); // If key is missing, default value is null
}
private class WebhookRateLimitInfo
{
public Instant resetTime;
public bool hasResetTimeExpired;
public int remaining = -1;
public int maxLimit = 0;
}
}
}

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,81 @@
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 DiscordColor Blue = new DiscordColor(0x1f99d8);
public static DiscordColor Green = new DiscordColor(0x00cc78);
public static DiscordColor Red = new DiscordColor(0xef4b3d);
public static DiscordColor Gray = new DiscordColor(0x979c9f);
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

@ -0,0 +1,123 @@
using System;
using System.Globalization;
namespace PluralKit.Bot.Utils
{
// PK note: class is wholesale copied from Discord.NET (MIT-licensed)
// https://github.com/discord-net/Discord.Net/blob/ff0fea98a65d907fbce07856f1a9ef4aebb9108b/src/Discord.Net.Core/Utils/MentionUtils.cs
/// <summary>
/// Provides a series of helper methods for parsing mentions.
/// </summary>
public static class MentionUtils
{
private const char SanitizeChar = '\x200b';
//If the system can't be positive a user doesn't have a nickname, assume useNickname = true (source: Jake)
internal static string MentionUser(string id, bool useNickname = true) => useNickname ? $"<@!{id}>" : $"<@{id}>";
/// <summary>
/// Returns a mention string based on the user ID.
/// </summary>
/// <returns>
/// A user mention string (e.g. &lt;@80351110224678912&gt;).
/// </returns>
public static string MentionUser(ulong id) => MentionUser(id.ToString(), true);
internal static string MentionChannel(string id) => $"<#{id}>";
/// <summary>
/// Returns a mention string based on the channel ID.
/// </summary>
/// <returns>
/// A channel mention string (e.g. &lt;#103735883630395392&gt;).
/// </returns>
public static string MentionChannel(ulong id) => MentionChannel(id.ToString());
internal static string MentionRole(string id) => $"<@&{id}>";
/// <summary>
/// Returns a mention string based on the role ID.
/// </summary>
/// <returns>
/// A role mention string (e.g. &lt;@&amp;165511591545143296&gt;).
/// </returns>
public static string MentionRole(ulong id) => MentionRole(id.ToString());
/// <summary>
/// Parses a provided user mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseUser(string text)
{
if (TryParseUser(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided user mention string.
/// </summary>
public static bool TryParseUser(string text, out ulong userId)
{
if (text.Length >= 3 && text[0] == '<' && text[1] == '@' && text[text.Length - 1] == '>')
{
if (text.Length >= 4 && text[2] == '!')
text = text.Substring(3, text.Length - 4); //<@!123>
else
text = text.Substring(2, text.Length - 3); //<@123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out userId))
return true;
}
userId = 0;
return false;
}
/// <summary>
/// Parses a provided channel mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseChannel(string text)
{
if (TryParseChannel(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided channel mention string.
/// </summary>
public static bool TryParseChannel(string text, out ulong channelId)
{
if (text.Length >= 3 && text[0] == '<' && text[1] == '#' && text[text.Length - 1] == '>')
{
text = text.Substring(2, text.Length - 3); //<#123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out channelId))
return true;
}
channelId = 0;
return false;
}
/// <summary>
/// Parses a provided role mention string.
/// </summary>
/// <exception cref="ArgumentException">Invalid mention format.</exception>
public static ulong ParseRole(string text)
{
if (TryParseRole(text, out ulong id))
return id;
throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text));
}
/// <summary>
/// Tries to parse a provided role mention string.
/// </summary>
public static bool TryParseRole(string text, out ulong roleId)
{
if (text.Length >= 4 && text[0] == '<' && text[1] == '@' && text[2] == '&' && text[text.Length - 1] == '>')
{
text = text.Substring(3, text.Length - 4); //<@&123>
if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out roleId))
return true;
}
roleId = 0;
return false;
}
}
}

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;
}

View File

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using App.Metrics;
@ -95,20 +96,58 @@ namespace PluralKit.Core
private ILogger InitLogger(CoreConfig config)
{
var outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.ffffff}] {Level:u3} {Message:lj}{NewLine}{Exception}";
return new LoggerConfiguration()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)
.MinimumLevel.Debug()
.WriteTo.Async(a =>
{
// Both the same output, except one is raw compact JSON and one is plain text.
// Output simultaneously. May remove the JSON formatter later, keeping it just in cast.
// Flush interval is 250ms (down from 10s) to make "tail -f" easier. May be too low?
a.File(
(config.LogDir ?? "logs") + $"/pluralkit.{_component}.json",
outputTemplate: outputTemplate,
rollingInterval: RollingInterval.Day,
flushToDiskInterval: TimeSpan.FromMilliseconds(250),
restrictedToMinimumLevel: LogEventLevel.Information,
formatProvider: new UTCTimestampFormatProvider(),
buffered: true);
a.File(
new RenderedCompactJsonFormatter(),
(config.LogDir ?? "logs") + $"/pluralkit.{_component}.log",
(config.LogDir ?? "logs") + $"/pluralkit.{_component}.json",
rollingInterval: RollingInterval.Day,
flushToDiskInterval: TimeSpan.FromSeconds(10),
flushToDiskInterval: TimeSpan.FromMilliseconds(250),
restrictedToMinimumLevel: LogEventLevel.Information,
buffered: true))
buffered: true);
})
// TODO: render as UTC in the console, too? or just in log files
.WriteTo.Async(a =>
a.Console(theme: AnsiConsoleTheme.Code, outputTemplate:"[{Timestamp:HH:mm:ss}] {Level:u3} {Message:lj}{NewLine}{Exception}"))
a.Console(theme: AnsiConsoleTheme.Code, outputTemplate: outputTemplate, formatProvider: new UTCTimestampFormatProvider()))
.CreateLogger();
}
}
// Serilog why is this necessary for such a simple thing >.>
public class UTCTimestampFormatProvider: IFormatProvider
{
public object GetFormat(Type formatType) => new UTCTimestampFormatter();
}
public class UTCTimestampFormatter: ICustomFormatter
{
public string Format(string format, object arg, IFormatProvider formatProvider)
{
// Convert offset to UTC and then print
// FormatProvider defaults to locale-specific stuff so we force-default to invariant culture
// If we pass the given formatProvider it'll conveniently ignore it, for some reason >.>
if (arg is DateTimeOffset dto)
return dto.ToUniversalTime().ToString(format, CultureInfo.InvariantCulture);
if (arg is IFormattable f)
return f.ToString(format, CultureInfo.InvariantCulture);
return arg.ToString();
}
}
}

View File

@ -6,12 +6,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.API", "PluralKit.API\PluralKit.API.csproj", "{3420F8A9-125C-4F7F-A444-10DD16945754}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.WebSocket", "Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj", "{5FE544F8-310F-410A-8590-7B78469B5B9C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Core", "Discord.Net\src\Discord.Net.Core\Discord.Net.Core.csproj", "{D6376AC6-9BE4-49CE-99CD-061879B400D4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Rest", "Discord.Net\src\Discord.Net.Rest\Discord.Net.Rest.csproj", "{4723EEE6-322B-41A1-BD89-03219E181E38}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -30,17 +24,5 @@ Global
{3420F8A9-125C-4F7F-A444-10DD16945754}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.Build.0 = Release|Any CPU
{5FE544F8-310F-410A-8590-7B78469B5B9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5FE544F8-310F-410A-8590-7B78469B5B9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FE544F8-310F-410A-8590-7B78469B5B9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FE544F8-310F-410A-8590-7B78469B5B9C}.Release|Any CPU.Build.0 = Release|Any CPU
{D6376AC6-9BE4-49CE-99CD-061879B400D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6376AC6-9BE4-49CE-99CD-061879B400D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6376AC6-9BE4-49CE-99CD-061879B400D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6376AC6-9BE4-49CE-99CD-061879B400D4}.Release|Any CPU.Build.0 = Release|Any CPU
{4723EEE6-322B-41A1-BD89-03219E181E38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4723EEE6-322B-41A1-BD89-03219E181E38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4723EEE6-322B-41A1-BD89-03219E181E38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4723EEE6-322B-41A1-BD89-03219E181E38}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

6
nuget.config Normal file
View File

@ -0,0 +1,6 @@
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="SlimGet" value="https://nuget.emzi0767.com/api/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>