Port some things, still does not compile
This commit is contained in:
parent
f56c3e819f
commit
23cf06df4c
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -7,8 +8,10 @@ using App.Metrics;
|
||||
|
||||
using Autofac;
|
||||
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
using DSharpPlus.EventArgs;
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
using PluralKit.Core;
|
||||
@ -61,7 +64,6 @@ namespace PluralKit.Bot
|
||||
SchemaService.Initialize();
|
||||
|
||||
var coreConfig = services.Resolve<CoreConfig>();
|
||||
var botConfig = services.Resolve<BotConfig>();
|
||||
var schema = services.Resolve<SchemaService>();
|
||||
|
||||
using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl);
|
||||
@ -71,10 +73,9 @@ namespace PluralKit.Bot
|
||||
|
||||
logger.Information("Connecting to Discord");
|
||||
var client = services.Resolve<DiscordShardedClient>();
|
||||
await client.LoginAsync(TokenType.Bot, botConfig.Token);
|
||||
|
||||
logger.Information("Initializing bot");
|
||||
await client.StartAsync();
|
||||
|
||||
logger.Information("Initializing bot");
|
||||
await services.Resolve<Bot>().Init();
|
||||
|
||||
try
|
||||
@ -105,10 +106,10 @@ namespace PluralKit.Bot
|
||||
private WebhookRateLimitService _webhookRateLimit;
|
||||
private int _periodicUpdateCount;
|
||||
|
||||
public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit)
|
||||
public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit)
|
||||
{
|
||||
_services = services;
|
||||
_client = client as DiscordShardedClient;
|
||||
_client = client;
|
||||
_metrics = metrics;
|
||||
_collector = collector;
|
||||
_webhookRateLimit = webhookRateLimit;
|
||||
@ -117,53 +118,51 @@ namespace PluralKit.Bot
|
||||
|
||||
public Task Init()
|
||||
{
|
||||
_client.ShardDisconnected += ShardDisconnected;
|
||||
_client.ShardReady += ShardReady;
|
||||
_client.Log += FrameworkLog;
|
||||
// _client.ShardDisconnected += ShardDisconnected;
|
||||
// _client.ShardReady += ShardReady;
|
||||
_client.DebugLogger.LogMessageReceived += FrameworkLog;
|
||||
|
||||
_client.MessageReceived += (msg) => HandleEvent(eh => eh.HandleMessage(msg));
|
||||
_client.ReactionAdded += (msg, channel, reaction) => HandleEvent(eh => eh.HandleReactionAdded(msg, channel, reaction));
|
||||
_client.MessageDeleted += (msg, channel) => HandleEvent(eh => eh.HandleMessageDeleted(msg, channel));
|
||||
_client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(eh => eh.HandleMessagesBulkDelete(msgs, channel));
|
||||
_client.MessageUpdated += (oldMessage, newMessage, channel) => HandleEvent(eh => eh.HandleMessageEdited(oldMessage, newMessage, channel));
|
||||
_client.MessageCreated += args => HandleEvent(eh => eh.HandleMessage(args));
|
||||
_client.MessageReactionAdded += args => HandleEvent(eh => eh.HandleReactionAdded(args));
|
||||
_client.MessageDeleted += args => HandleEvent(eh => eh.HandleMessageDeleted(args));
|
||||
_client.MessagesBulkDeleted += args => HandleEvent(eh => eh.HandleMessagesBulkDelete(args));
|
||||
_client.MessageUpdated += args => HandleEvent(eh => eh.HandleMessageEdited(args));
|
||||
|
||||
_services.Resolve<ShardInfoService>().Init(_client);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
|
||||
/*private Task ShardDisconnected(Exception ex, DiscordSocketClient shard)
|
||||
{
|
||||
_logger.Warning(ex, $"Shard #{shard.ShardId} disconnected");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}*/
|
||||
|
||||
private Task FrameworkLog(LogMessage msg)
|
||||
private void FrameworkLog(object sender, DebugLogMessageEventArgs args)
|
||||
{
|
||||
// Bridge D.NET logging to Serilog
|
||||
// Bridge D#+ logging to Serilog
|
||||
LogEventLevel level = LogEventLevel.Verbose;
|
||||
if (msg.Severity == LogSeverity.Critical)
|
||||
if (args.Level == LogLevel.Critical)
|
||||
level = LogEventLevel.Fatal;
|
||||
else if (msg.Severity == LogSeverity.Debug)
|
||||
else if (args.Level == LogLevel.Debug)
|
||||
level = LogEventLevel.Debug;
|
||||
else if (msg.Severity == LogSeverity.Error)
|
||||
else if (args.Level == LogLevel.Error)
|
||||
level = LogEventLevel.Error;
|
||||
else if (msg.Severity == LogSeverity.Info)
|
||||
else if (args.Level == LogLevel.Info)
|
||||
level = LogEventLevel.Information;
|
||||
else if (msg.Severity == LogSeverity.Debug) // D.NET's lowest level is Debug and Verbose is greater, Serilog's is the other way around
|
||||
level = LogEventLevel.Verbose;
|
||||
else if (msg.Severity == LogSeverity.Verbose)
|
||||
level = LogEventLevel.Debug;
|
||||
else if (args.Level == LogLevel.Warning)
|
||||
level = LogEventLevel.Warning;
|
||||
|
||||
_logger.Write(level, msg.Exception, "Discord.Net {Source}: {Message}", msg.Source, msg.Message);
|
||||
return Task.CompletedTask;
|
||||
_logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message);
|
||||
}
|
||||
|
||||
// Method called every 60 seconds
|
||||
private async Task UpdatePeriodic()
|
||||
{
|
||||
// Change bot status
|
||||
await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers");
|
||||
var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count);
|
||||
await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers"));
|
||||
|
||||
// Run webhook rate limit GC every 10 minutes
|
||||
if (_periodicUpdateCount++ % 10 == 0)
|
||||
@ -177,7 +176,7 @@ namespace PluralKit.Bot
|
||||
await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync());
|
||||
}
|
||||
|
||||
private Task ShardReady(DiscordSocketClient shardClient)
|
||||
/*private Task ShardReady(DiscordSocketClient shardClient)
|
||||
{
|
||||
_logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count);
|
||||
|
||||
@ -191,7 +190,7 @@ namespace PluralKit.Bot
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}*/
|
||||
|
||||
private Task HandleEvent(Func<PKEventHandler, Task> handler)
|
||||
{
|
||||
@ -252,7 +251,7 @@ namespace PluralKit.Bot
|
||||
// This means that the HandleMessage function will either be called once, or not at all
|
||||
// The ReportError function will be called on an error, and needs to refer back to the "trigger message"
|
||||
// hence, we just store it in a local variable, ignoring it entirely if it's null.
|
||||
private IUserMessage _msg = null;
|
||||
private DiscordMessage _currentlyHandlingMessage = null;
|
||||
|
||||
public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache, LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean)
|
||||
{
|
||||
@ -269,42 +268,44 @@ namespace PluralKit.Bot
|
||||
_loggerClean = loggerClean;
|
||||
}
|
||||
|
||||
public async Task HandleMessage(SocketMessage arg)
|
||||
public async Task HandleMessage(MessageCreateEventArgs args)
|
||||
{
|
||||
var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild);
|
||||
// TODO
|
||||
/*var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild);
|
||||
if (shard.ConnectionState != ConnectionState.Connected || _client.CurrentUser == null)
|
||||
return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts
|
||||
return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts*/
|
||||
|
||||
RegisterMessageMetrics(arg);
|
||||
RegisterMessageMetrics(args);
|
||||
|
||||
// Ignore system messages (member joined, message pinned, etc)
|
||||
var msg = arg as SocketUserMessage;
|
||||
if (msg == null) return;
|
||||
var msg = args.Message;
|
||||
if (msg.MessageType != MessageType.Default) return;
|
||||
|
||||
// Fetch information about the guild early, as we need it for the logger cleanup
|
||||
GuildConfig cachedGuild = default; // todo: is this default correct?
|
||||
if (msg.Channel is ITextChannel textChannel) cachedGuild = await _cache.GetGuildDataCached(textChannel.GuildId);
|
||||
GuildConfig cachedGuild = default;
|
||||
if (msg.Channel.Type == ChannelType.Text) await _cache.GetGuildDataCached(msg.Channel.GuildId);
|
||||
|
||||
// Pass guild bot/WH messages onto the logger cleanup service, but otherwise ignore
|
||||
if ((msg.Author.IsBot || msg.Author.IsWebhook) && msg.Channel is ITextChannel)
|
||||
if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text)
|
||||
{
|
||||
await _loggerClean.HandleLoggerBotCleanup(arg, cachedGuild);
|
||||
await _loggerClean.HandleLoggerBotCleanup(msg, cachedGuild);
|
||||
return;
|
||||
}
|
||||
|
||||
_currentlyHandlingMessage = msg;
|
||||
|
||||
// Add message info as Sentry breadcrumb
|
||||
_msg = msg;
|
||||
_sentryScope.AddBreadcrumb(msg.Content, "event.message", data: new Dictionary<string, string>
|
||||
{
|
||||
{"user", msg.Author.Id.ToString()},
|
||||
{"channel", msg.Channel.Id.ToString()},
|
||||
{"guild", ((msg.Channel as IGuildChannel)?.GuildId ?? 0).ToString()},
|
||||
{"guild", msg.Channel.GuildId.ToString()},
|
||||
{"message", msg.Id.ToString()},
|
||||
});
|
||||
_sentryScope.SetTag("shard", shard.ShardId.ToString());
|
||||
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
|
||||
|
||||
// Add to last message cache
|
||||
_lastMessageCache.AddMessage(arg.Channel.Id, arg.Id);
|
||||
_lastMessageCache.AddMessage(msg.Channel.Id, msg.Id);
|
||||
|
||||
// We fetch information about the sending account from the cache
|
||||
var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id);
|
||||
@ -330,7 +331,7 @@ namespace PluralKit.Bot
|
||||
|
||||
try
|
||||
{
|
||||
await _tree.ExecuteCommand(new Context(_services, msg, argPos, cachedAccount?.System));
|
||||
await _tree.ExecuteCommand(new Context(_services, args.Client, msg, argPos, cachedAccount?.System));
|
||||
}
|
||||
catch (PKError)
|
||||
{
|
||||
@ -345,12 +346,12 @@ namespace PluralKit.Bot
|
||||
// no data = no account = no system = no proxy!
|
||||
try
|
||||
{
|
||||
await _proxy.HandleMessageAsync(cachedGuild, cachedAccount, msg, doAutoProxy: true);
|
||||
await _proxy.HandleMessageAsync(args.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true);
|
||||
}
|
||||
catch (PKError e)
|
||||
{
|
||||
if (arg.Channel.HasPermission(ChannelPermission.SendMessages))
|
||||
await arg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
|
||||
if (msg.Channel.Guild == null || msg.Channel.BotHasPermission(Permissions.SendMessages))
|
||||
await msg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -358,98 +359,95 @@ namespace PluralKit.Bot
|
||||
public async Task ReportError(SentryEvent evt, Exception exc)
|
||||
{
|
||||
// If we don't have a "trigger message", bail
|
||||
if (_msg == null) return;
|
||||
if (_currentlyHandlingMessage == null) return;
|
||||
|
||||
// This function *specifically* handles reporting a command execution error to the user.
|
||||
// We'll fetch the event ID and send a user-facing error message.
|
||||
// ONLY IF this error's actually our problem. As for what defines an error as "our problem",
|
||||
// check the extension method :)
|
||||
if (exc.IsOurProblem() && _msg.Channel.HasPermission(ChannelPermission.SendMessages))
|
||||
if (exc.IsOurProblem() && _currentlyHandlingMessage.Channel.BotHasPermission(Permissions.SendMessages))
|
||||
{
|
||||
var eid = evt.EventId;
|
||||
await _msg.Channel.SendMessageAsync(
|
||||
await _currentlyHandlingMessage.Channel.SendMessageAsync(
|
||||
$"{Emojis.Error} Internal error occurred. Please join the support server (<https://discord.gg/PczBt78>), and send the developer this ID: `{eid}`\nBe sure to include a description of what you were doing to make the error occur.");
|
||||
}
|
||||
|
||||
// If not, don't care. lol.
|
||||
}
|
||||
|
||||
private void RegisterMessageMetrics(SocketMessage msg)
|
||||
private void RegisterMessageMetrics(MessageCreateEventArgs msg)
|
||||
{
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
|
||||
|
||||
var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt;
|
||||
var gatewayLatency = DateTimeOffset.Now - msg.Message.Timestamp;
|
||||
_logger.Verbose("Message received with latency {Latency}", gatewayLatency);
|
||||
}
|
||||
|
||||
public Task HandleReactionAdded(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel,
|
||||
SocketReaction reaction)
|
||||
public Task HandleReactionAdded(MessageReactionAddEventArgs args)
|
||||
{
|
||||
_sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary<string, string>()
|
||||
{
|
||||
{"user", reaction.UserId.ToString()},
|
||||
{"channel", channel.Id.ToString()},
|
||||
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
|
||||
{"message", message.Id.ToString()},
|
||||
{"reaction", reaction.Emote.Name}
|
||||
{"user", args.User.Id.ToString()},
|
||||
{"channel", (args.Channel?.Id ?? 0).ToString()},
|
||||
{"guild", (args.Channel?.GuildId ?? 0).ToString()},
|
||||
{"message", args.Message.Id.ToString()},
|
||||
{"reaction", args.Emoji.Name}
|
||||
});
|
||||
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
|
||||
|
||||
return _proxy.HandleReactionAddedAsync(message, channel, reaction);
|
||||
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
|
||||
return _proxy.HandleReactionAddedAsync(args);
|
||||
}
|
||||
|
||||
public Task HandleMessageDeleted(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
|
||||
public Task HandleMessageDeleted(MessageDeleteEventArgs args)
|
||||
{
|
||||
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
|
||||
{
|
||||
{"channel", channel.Id.ToString()},
|
||||
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
|
||||
{"message", message.Id.ToString()},
|
||||
{"channel", args.Channel.Id.ToString()},
|
||||
{"guild", args.Channel.GuildId.ToString()},
|
||||
{"message", args.Message.Id.ToString()},
|
||||
});
|
||||
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
|
||||
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
|
||||
|
||||
return _proxy.HandleMessageDeletedAsync(message, channel);
|
||||
return _proxy.HandleMessageDeletedAsync(args);
|
||||
}
|
||||
|
||||
public Task HandleMessagesBulkDelete(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages,
|
||||
IMessageChannel channel)
|
||||
public Task HandleMessagesBulkDelete(MessageBulkDeleteEventArgs args)
|
||||
{
|
||||
_sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
|
||||
{
|
||||
{"channel", channel.Id.ToString()},
|
||||
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
|
||||
{"messages", string.Join(",", messages.Select(m => m.Id))},
|
||||
{"channel", args.Channel.Id.ToString()},
|
||||
{"guild", args.Channel.Id.ToString()},
|
||||
{"messages", string.Join(",", args.Messages.Select(m => m.Id))},
|
||||
});
|
||||
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
|
||||
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
|
||||
|
||||
return _proxy.HandleMessageBulkDeleteAsync(messages, channel);
|
||||
return _proxy.HandleMessageBulkDeleteAsync(args);
|
||||
}
|
||||
|
||||
public async Task HandleMessageEdited(Cacheable<IMessage, ulong> oldMessage, SocketMessage newMessage, ISocketMessageChannel channel)
|
||||
public async Task HandleMessageEdited(MessageUpdateEventArgs args)
|
||||
{
|
||||
_sentryScope.AddBreadcrumb(newMessage.Content, "event.messageEdit", data: new Dictionary<string, string>()
|
||||
_sentryScope.AddBreadcrumb(args.Message.Content ?? "<unknown>", "event.messageEdit", data: new Dictionary<string, string>()
|
||||
{
|
||||
{"channel", channel.Id.ToString()},
|
||||
{"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()},
|
||||
{"message", newMessage.Id.ToString()}
|
||||
{"channel", args.Channel.Id.ToString()},
|
||||
{"guild", args.Channel.GuildId.ToString()},
|
||||
{"message", args.Message.Id.ToString()}
|
||||
});
|
||||
_sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString());
|
||||
_sentryScope.SetTag("shard", args.Client.ShardId.ToString());
|
||||
|
||||
// If this isn't a guild, bail
|
||||
if (!(channel is IGuildChannel gc)) return;
|
||||
if (args.Channel.Guild == null) return;
|
||||
|
||||
// If this isn't the last message in the channel, don't do anything
|
||||
if (_lastMessageCache.GetLastMessage(channel.Id) != newMessage.Id) return;
|
||||
if (_lastMessageCache.GetLastMessage(args.Channel.Id) != args.Message.Id) return;
|
||||
|
||||
// Fetch account from cache if there is any
|
||||
var account = await _cache.GetAccountDataCached(newMessage.Author.Id);
|
||||
var account = await _cache.GetAccountDataCached(args.Author.Id);
|
||||
if (account == null) return; // Again: no cache = no account = no system = no proxy
|
||||
|
||||
// Also fetch guild cache
|
||||
var guild = await _cache.GetGuildDataCached(gc.GuildId);
|
||||
var guild = await _cache.GetGuildDataCached(args.Channel.GuildId);
|
||||
|
||||
// Just run the normal message handling stuff
|
||||
await _proxy.HandleMessageAsync(guild, account, newMessage, doAutoProxy: false);
|
||||
await _proxy.HandleMessageAsync(args.Client, guild, account, args.Message, doAutoProxy: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,9 @@ using Autofac;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
@ -18,7 +21,8 @@ namespace PluralKit.Bot
|
||||
private ILifetimeScope _provider;
|
||||
|
||||
private readonly DiscordShardedClient _client;
|
||||
private readonly SocketUserMessage _message;
|
||||
private readonly DiscordClient _shard;
|
||||
private readonly DiscordMessage _message;
|
||||
private readonly Parameters _parameters;
|
||||
|
||||
private readonly IDataStore _data;
|
||||
@ -27,11 +31,12 @@ namespace PluralKit.Bot
|
||||
|
||||
private Command _currentCommand;
|
||||
|
||||
public Context(ILifetimeScope provider, SocketUserMessage message, int commandParseOffset,
|
||||
public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset,
|
||||
PKSystem senderSystem)
|
||||
{
|
||||
_client = provider.Resolve<DiscordShardedClient>();
|
||||
_message = message;
|
||||
_shard = shard;
|
||||
_data = provider.Resolve<IDataStore>();
|
||||
_senderSystem = senderSystem;
|
||||
_metrics = provider.Resolve<IMetrics>();
|
||||
@ -39,11 +44,11 @@ namespace PluralKit.Bot
|
||||
_parameters = new Parameters(message.Content.Substring(commandParseOffset));
|
||||
}
|
||||
|
||||
public IUser Author => _message.Author;
|
||||
public IMessageChannel Channel => _message.Channel;
|
||||
public IUserMessage Message => _message;
|
||||
public IGuild Guild => (_message.Channel as ITextChannel)?.Guild;
|
||||
public DiscordSocketClient Shard => _client.GetShardFor(Guild);
|
||||
public DiscordUser Author => _message.Author;
|
||||
public DiscordChannel Channel => _message.Channel;
|
||||
public DiscordMessage Message => _message;
|
||||
public DiscordGuild Guild => _message.Channel.Guild;
|
||||
public DiscordClient Shard => _shard;
|
||||
public DiscordShardedClient Client => _client;
|
||||
public PKSystem System => _senderSystem;
|
||||
|
||||
@ -53,13 +58,13 @@ namespace PluralKit.Bot
|
||||
public bool HasNext(bool skipFlags = true) => RemainderOrNull(skipFlags) != null;
|
||||
public string FullCommand => _parameters.FullCommand;
|
||||
|
||||
public Task<IUserMessage> Reply(string text = null, Embed embed = null)
|
||||
public Task<DiscordMessage> Reply(string text = null, DiscordEmbed embed = null)
|
||||
{
|
||||
if (!this.BotHasPermission(ChannelPermission.SendMessages))
|
||||
if (!this.BotHasPermission(Permissions.SendMessages))
|
||||
// Will be "swallowed" during the error handler anyway, this message is never shown.
|
||||
throw new PKError("PluralKit does not have permission to send messages in this channel.");
|
||||
|
||||
if (embed != null && !this.BotHasPermission(ChannelPermission.EmbedLinks))
|
||||
if (embed != null && !this.BotHasPermission(Permissions.EmbedLinks))
|
||||
throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled.");
|
||||
|
||||
return Channel.SendMessageAsync(text, embed: embed);
|
||||
@ -125,11 +130,11 @@ namespace PluralKit.Bot
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IUser> MatchUser()
|
||||
public async Task<DiscordUser> MatchUser()
|
||||
{
|
||||
var text = PeekArgument();
|
||||
if (MentionUtils.TryParseUser(text, out var id))
|
||||
return await Shard.Rest.GetUserAsync(id); // TODO: this should properly fetch
|
||||
if (text.TryParseMention(out var id))
|
||||
return await Shard.GetUserAsync(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -138,11 +143,9 @@ namespace PluralKit.Bot
|
||||
id = 0;
|
||||
|
||||
var text = PeekArgument();
|
||||
if (MentionUtils.TryParseUser(text, out var mentionId))
|
||||
if (text.TryParseMention(out var mentionId))
|
||||
id = mentionId;
|
||||
else if (ulong.TryParse(text, out var rawId))
|
||||
id = rawId;
|
||||
|
||||
|
||||
return id != 0;
|
||||
}
|
||||
|
||||
@ -246,41 +249,19 @@ namespace PluralKit.Bot
|
||||
return this;
|
||||
}
|
||||
|
||||
public GuildPermissions GetGuildPermissions(IUser user)
|
||||
public Context CheckAuthorPermission(Permissions neededPerms, string permissionName)
|
||||
{
|
||||
if (user is IGuildUser gu)
|
||||
return gu.GuildPermissions;
|
||||
if (Channel is SocketGuildChannel gc)
|
||||
return gc.GetUser(user.Id).GuildPermissions;
|
||||
return GuildPermissions.None;
|
||||
}
|
||||
|
||||
public ChannelPermissions GetChannelPermissions(IUser user)
|
||||
{
|
||||
if (user is IGuildUser gu && Channel is IGuildChannel igc)
|
||||
return gu.GetPermissions(igc);
|
||||
if (Channel is SocketGuildChannel gc)
|
||||
return gc.GetUser(user.Id).GetPermissions(gc);
|
||||
return ChannelPermissions.DM;
|
||||
}
|
||||
|
||||
public Context CheckAuthorPermission(GuildPermission permission, string permissionName)
|
||||
{
|
||||
if (!GetGuildPermissions(Author).Has(permission))
|
||||
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckAuthorPermission(ChannelPermission permission, string permissionName)
|
||||
{
|
||||
if (!GetChannelPermissions(Author).Has(permission))
|
||||
// TODO: can we always assume Author is a DiscordMember? I would think so, given they always come from a
|
||||
// message received event...
|
||||
var hasPerms = Channel.PermissionsInSync(Author);
|
||||
if ((hasPerms & neededPerms) != neededPerms)
|
||||
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
|
||||
return this;
|
||||
}
|
||||
|
||||
public Context CheckGuildContext()
|
||||
{
|
||||
if (Channel is IGuildChannel) return this;
|
||||
if (Channel.Guild != null) return this;
|
||||
throw new PKError("This command can not be run in a DM.");
|
||||
}
|
||||
|
||||
@ -296,10 +277,10 @@ namespace PluralKit.Bot
|
||||
throw new PKError("You do not have permission to access this information.");
|
||||
}
|
||||
|
||||
public ITextChannel MatchChannel()
|
||||
public DiscordChannel MatchChannel()
|
||||
{
|
||||
if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null;
|
||||
if (!(_client.GetChannel(channel) is ITextChannel textChannel)) return null;
|
||||
if (!(_client.GetChannelAsync(channel) is ITextChannel textChannel)) return null;
|
||||
|
||||
PopArgument();
|
||||
return textChannel;
|
||||
|
@ -3,9 +3,7 @@ using System.Net.Http;
|
||||
|
||||
using Autofac;
|
||||
|
||||
using Discord;
|
||||
using Discord.Rest;
|
||||
using Discord.WebSocket;
|
||||
using DSharpPlus;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
@ -18,18 +16,12 @@ namespace PluralKit.Bot
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
// Client
|
||||
builder.Register(c => new DiscordShardedClient(new DiscordSocketConfig()
|
||||
{
|
||||
MessageCacheSize = 0,
|
||||
ConnectionTimeout = 2 * 60 * 1000,
|
||||
ExclusiveBulkDelete = true,
|
||||
LargeThreshold = 50,
|
||||
GuildSubscriptions = false,
|
||||
DefaultRetryMode = RetryMode.RetryTimeouts | RetryMode.RetryRatelimit
|
||||
// Commented this out since Debug actually sends, uh, quite a lot that's not necessary in production
|
||||
// but leaving it here in case I (or someone else) get[s] confused about why logging isn't working again :p
|
||||
// LogLevel = LogSeverity.Debug // We filter log levels in Serilog, so just pass everything through (Debug is lower than Verbose)
|
||||
})).AsSelf().As<BaseDiscordClient>().As<BaseSocketClient>().As<IDiscordClient>().SingleInstance();
|
||||
builder.Register(c => new DiscordShardedClient(new DiscordConfiguration
|
||||
{
|
||||
Token = c.Resolve<BotConfig>().Token,
|
||||
TokenType = TokenType.Bot,
|
||||
MessageCacheSize = 0,
|
||||
})).AsSelf().SingleInstance();
|
||||
|
||||
// Commands
|
||||
builder.RegisterType<CommandTree>().AsSelf();
|
||||
|
@ -6,7 +6,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
|
||||
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -2,8 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using Humanizer;
|
||||
using NodaTime;
|
||||
@ -22,15 +23,15 @@ namespace PluralKit.Bot {
|
||||
_data = data;
|
||||
}
|
||||
|
||||
public async Task<Embed> CreateSystemEmbed(PKSystem system, LookupContext ctx) {
|
||||
public async Task<DiscordEmbed> CreateSystemEmbed(DiscordClient client, PKSystem system, LookupContext ctx) {
|
||||
var accounts = await _data.GetSystemAccounts(system);
|
||||
|
||||
// Fetch/render info for all accounts simultaneously
|
||||
var users = await Task.WhenAll(accounts.Select(async uid => (await _client.Rest.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})"));
|
||||
var users = await Task.WhenAll(accounts.Select(async uid => (await client.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})"));
|
||||
|
||||
var memberCount = await _data.GetSystemMemberCount(system, false);
|
||||
var eb = new EmbedBuilder()
|
||||
.WithColor(Color.Blue)
|
||||
var eb = new DiscordEmbedBuilder()
|
||||
.WithColor(DiscordColor.Blue)
|
||||
.WithTitle(system.Name ?? null)
|
||||
.WithThumbnailUrl(system.AvatarUrl ?? null)
|
||||
.WithFooter($"System ID: {system.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}");
|
||||
@ -61,33 +62,33 @@ namespace PluralKit.Bot {
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IUser sender, string content, IGuildChannel channel) {
|
||||
public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) {
|
||||
// TODO: pronouns in ?-reacted response using this card
|
||||
var timestamp = SnowflakeUtils.FromSnowflake(messageId);
|
||||
return new EmbedBuilder()
|
||||
var timestamp = DiscordUtils.SnowflakeToInstant(messageId);
|
||||
return new DiscordEmbedBuilder()
|
||||
.WithAuthor($"#{channel.Name}: {member.Name}", member.AvatarUrl)
|
||||
.WithDescription(content?.NormalizeLineEndSpacing())
|
||||
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}")
|
||||
.WithTimestamp(timestamp)
|
||||
.WithTimestamp(timestamp.ToDateTimeOffset())
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild, LookupContext ctx)
|
||||
public async Task<DiscordEmbed> CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (system.Name != null) name = $"{member.Name} ({system.Name})";
|
||||
|
||||
Color color;
|
||||
DiscordColor color;
|
||||
try
|
||||
{
|
||||
color = member.Color?.ToDiscordColor() ?? Color.Default;
|
||||
color = member.Color?.ToDiscordColor() ?? DiscordColor.Gray;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Bad API use can cause an invalid color string
|
||||
// TODO: fix that in the API
|
||||
// for now we just default to a blank color, yolo
|
||||
color = Color.Default;
|
||||
color = DiscordColor.Gray;
|
||||
}
|
||||
|
||||
var messageCount = await _data.GetMemberMessageCount(member);
|
||||
@ -98,10 +99,10 @@ namespace PluralKit.Bot {
|
||||
|
||||
var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`{t.ProxyString}`"));
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
var eb = new DiscordEmbedBuilder()
|
||||
// TODO: add URL of website when that's up
|
||||
.WithAuthor(name, avatar)
|
||||
.WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : Color.Default)
|
||||
.WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordColor.Gray)
|
||||
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}");
|
||||
|
||||
var description = "";
|
||||
@ -119,7 +120,7 @@ namespace PluralKit.Bot {
|
||||
if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true);
|
||||
if (member.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true);
|
||||
if (!member.Pronouns.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true);
|
||||
if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount, true);
|
||||
if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount.ToString(), true);
|
||||
if (member.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true);
|
||||
if (!member.Color.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true);
|
||||
if (!member.Description.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false);
|
||||
@ -127,48 +128,45 @@ namespace PluralKit.Bot {
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
public async Task<Embed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
|
||||
public async Task<DiscordEmbed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
|
||||
{
|
||||
var members = await _data.GetSwitchMembers(sw).ToListAsync();
|
||||
var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
|
||||
return new EmbedBuilder()
|
||||
.WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue)
|
||||
return new DiscordEmbedBuilder()
|
||||
.WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordColor.Blue)
|
||||
.AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*")
|
||||
.AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)")
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg)
|
||||
public async Task<DiscordEmbed> CreateMessageInfoEmbed(DiscordClient client, FullMessage msg)
|
||||
{
|
||||
var channel = _client.GetChannel(msg.Message.Channel) as ITextChannel;
|
||||
var channel = await client.GetChannelAsync(msg.Message.Channel);
|
||||
var serverMsg = channel != null ? await channel.GetMessageAsync(msg.Message.Mid) : null;
|
||||
|
||||
var memberStr = $"{msg.Member.Name} (`{msg.Member.Hid}`)";
|
||||
|
||||
var userStr = $"*(deleted user {msg.Message.Sender})*";
|
||||
ICollection<IRole> roles = null;
|
||||
ICollection<DiscordRole> roles = null;
|
||||
|
||||
if (channel != null)
|
||||
{
|
||||
// Look up the user with the REST client
|
||||
// this ensures we'll still get the information even if the user's not cached,
|
||||
// even if this means an extra API request (meh, it'll be fine)
|
||||
var shard = _client.GetShardFor(channel.Guild);
|
||||
var guildUser = await shard.Rest.GetGuildUserAsync(channel.Guild.Id, msg.Message.Sender);
|
||||
var guildUser = await channel.Guild.GetMemberAsync(msg.Message.Sender);
|
||||
if (guildUser != null)
|
||||
{
|
||||
if (guildUser.RoleIds.Count > 0)
|
||||
roles = guildUser.RoleIds
|
||||
.Select(roleId => channel.Guild.GetRole(roleId))
|
||||
.Where(role => role.Name != "@everyone")
|
||||
.OrderByDescending(role => role.Position)
|
||||
.ToList();
|
||||
roles = guildUser.Roles
|
||||
.Where(role => role.Name != "@everyone")
|
||||
.OrderByDescending(role => role.Position)
|
||||
.ToList();
|
||||
|
||||
userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser?.NameAndMention();
|
||||
userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser.NameAndMention();
|
||||
}
|
||||
}
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
var eb = new DiscordEmbedBuilder()
|
||||
.WithAuthor(msg.Member.Name, msg.Member.AvatarUrl)
|
||||
.WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*")
|
||||
.WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url)
|
||||
@ -176,18 +174,18 @@ namespace PluralKit.Bot {
|
||||
msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)
|
||||
.AddField("Member", memberStr, true)
|
||||
.AddField("Sent by", userStr, inline: true)
|
||||
.WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid));
|
||||
.WithTimestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset());
|
||||
|
||||
if (roles != null && roles.Count > 0)
|
||||
eb.AddField($"Account roles ({roles.Count})", string.Join(", ", roles.Select(role => role.Name)));
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
public Task<Embed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
|
||||
public Task<DiscordEmbed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
|
||||
{
|
||||
var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart;
|
||||
var eb = new EmbedBuilder()
|
||||
.WithColor(Color.Blue)
|
||||
var eb = new DiscordEmbedBuilder()
|
||||
.WithColor(DiscordColor.Blue)
|
||||
.WithFooter($"Since {DateTimeFormats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({DateTimeFormats.DurationFormat.Format(actualPeriod)} ago)");
|
||||
|
||||
var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others"
|
||||
|
@ -7,6 +7,7 @@ namespace PluralKit.Bot
|
||||
// not particularly efficient? It allocates a dictionary *and* a queue for every single channel (500k in prod!)
|
||||
// whereas this is, worst case, one dictionary *entry* of a single ulong per channel, and one dictionary instance
|
||||
// on the whole instance, total. Yeah, much more efficient.
|
||||
// TODO: is this still needed after the D#+ migration?
|
||||
public class LastMessageCacheService
|
||||
{
|
||||
private IDictionary<ulong, ulong> _cache = new ConcurrentDictionary<ulong, ulong>();
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Discord;
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
@ -8,20 +9,18 @@ using Serilog;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
public class LogChannelService {
|
||||
private IDiscordClient _client;
|
||||
private EmbedService _embed;
|
||||
private IDataStore _data;
|
||||
private ILogger _logger;
|
||||
|
||||
public LogChannelService(IDiscordClient client, EmbedService embed, ILogger logger, IDataStore data)
|
||||
public LogChannelService(EmbedService embed, ILogger logger, IDataStore data)
|
||||
{
|
||||
_client = client;
|
||||
_embed = embed;
|
||||
_data = data;
|
||||
_logger = logger.ForContext<LogChannelService>();
|
||||
}
|
||||
|
||||
public async Task LogMessage(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IGuildChannel originalChannel, IUser sender, string content, GuildConfig? guildCfg = null)
|
||||
public async Task LogMessage(DiscordClient client, PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordChannel originalChannel, DiscordUser sender, string content, GuildConfig? guildCfg = null)
|
||||
{
|
||||
if (guildCfg == null)
|
||||
guildCfg = await _data.GetOrCreateGuildConfig(originalChannel.GuildId);
|
||||
@ -31,17 +30,19 @@ namespace PluralKit.Bot {
|
||||
if (guildCfg.Value.LogBlacklist.Contains(originalChannel.Id)) return;
|
||||
|
||||
// Bail if we can't find the channel
|
||||
if (!(await _client.GetChannelAsync(guildCfg.Value.LogChannel.Value) is ITextChannel logChannel)) return;
|
||||
var channel = await client.GetChannelAsync(guildCfg.Value.LogChannel.Value);
|
||||
if (channel == null || channel.Type != ChannelType.Text) return;
|
||||
|
||||
// Bail if we don't have permission to send stuff here
|
||||
if (!logChannel.HasPermission(ChannelPermission.SendMessages) || !logChannel.HasPermission(ChannelPermission.EmbedLinks))
|
||||
var neededPermissions = Permissions.SendMessages | Permissions.EmbedLinks;
|
||||
if ((channel.BotPermissions() & neededPermissions) != neededPermissions)
|
||||
return;
|
||||
|
||||
var embed = _embed.CreateLoggedMessageEmbed(system, member, messageId, originalMsgId, sender, content, originalChannel);
|
||||
|
||||
var url = $"https://discordapp.com/channels/{originalChannel.GuildId}/{originalChannel.Id}/{messageId}";
|
||||
|
||||
await logChannel.SendMessageAsync(text: url, embed: embed);
|
||||
await channel.SendMessageAsync(content: url, embed: embed);
|
||||
}
|
||||
}
|
||||
}
|
@ -6,8 +6,8 @@ using System.Threading.Tasks;
|
||||
|
||||
using Dapper;
|
||||
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
@ -61,18 +61,18 @@ namespace PluralKit.Bot
|
||||
|
||||
public ICollection<LoggerBot> Bots => _bots.Values;
|
||||
|
||||
public async ValueTask HandleLoggerBotCleanup(SocketMessage msg, GuildConfig cachedGuild)
|
||||
public async ValueTask HandleLoggerBotCleanup(DiscordMessage msg, GuildConfig cachedGuild)
|
||||
{
|
||||
// Bail if not enabled, or if we don't have permission here
|
||||
if (!cachedGuild.LogCleanupEnabled) return;
|
||||
if (!(msg.Channel is SocketTextChannel channel)) return;
|
||||
if (!channel.Guild.GetUser(_client.CurrentUser.Id).GetPermissions(channel).ManageMessages) return;
|
||||
if (msg.Channel.Type != ChannelType.Text) return;
|
||||
if (!msg.Channel.BotHasPermission(Permissions.ManageMessages)) return;
|
||||
|
||||
// If this message is from a *webhook*, check if the name matches one of the bots we know
|
||||
// TODO: do we need to do a deeper webhook origin check, or would that be too hard on the rate limit?
|
||||
// If it's from a *bot*, check the bot ID to see if we know it.
|
||||
LoggerBot bot = null;
|
||||
if (msg.Author.IsWebhook) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot);
|
||||
if (msg.WebhookMessage) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot);
|
||||
else if (msg.Author.IsBot) _bots.TryGetValue(msg.Author.Id, out bot);
|
||||
|
||||
// If we didn't find anything before, or what we found is an unsupported bot, bail
|
||||
@ -95,8 +95,8 @@ namespace PluralKit.Bot
|
||||
new
|
||||
{
|
||||
fuzzy.Value.User,
|
||||
Guild = (msg.Channel as ITextChannel)?.GuildId ?? 0,
|
||||
ApproxId = SnowflakeUtils.ToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3))
|
||||
Guild = msg.Channel.GuildId,
|
||||
ApproxId = DiscordUtils.InstantToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3))
|
||||
});
|
||||
if (mid == null) return; // If we didn't find a corresponding message, bail
|
||||
// Otherwise, we can *reasonably assume* that this is a logged deletion, so delete the log message.
|
||||
@ -118,7 +118,7 @@ namespace PluralKit.Bot
|
||||
} // else should not happen, but idk, it might
|
||||
}
|
||||
|
||||
private static ulong? ExtractAuttaja(SocketMessage msg)
|
||||
private static ulong? ExtractAuttaja(DiscordMessage msg)
|
||||
{
|
||||
// Auttaja has an optional "compact mode" that logs without embeds
|
||||
// That one puts the ID in the message content, non-compact puts it in the embed description.
|
||||
@ -130,16 +130,16 @@ namespace PluralKit.Bot
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractDyno(SocketMessage msg)
|
||||
private static ulong? ExtractDyno(DiscordMessage msg)
|
||||
{
|
||||
// Embed *description* contains "Message sent by [mention] deleted in [channel]", contains message ID in footer per regex
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
if (embed?.Footer == null || !(embed.Description?.Contains("deleted in") ?? false)) return null;
|
||||
var match = _dynoRegex.Match(embed.Footer.Value.Text ?? "");
|
||||
var match = _dynoRegex.Match(embed.Footer.Text ?? "");
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractLoggerA(SocketMessage msg)
|
||||
private static ulong? ExtractLoggerA(DiscordMessage msg)
|
||||
{
|
||||
// This is for Logger#6088 (298822483060981760), distinct from Logger#6278 (327424261180620801).
|
||||
// Embed contains title "Message deleted in [channel]", and an ID field containing both message and user ID (see regex).
|
||||
@ -153,26 +153,26 @@ namespace PluralKit.Bot
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractLoggerB(SocketMessage msg)
|
||||
private static ulong? ExtractLoggerB(DiscordMessage msg)
|
||||
{
|
||||
// This is for Logger#6278 (327424261180620801), distinct from Logger#6088 (298822483060981760).
|
||||
// Embed title ends with "A Message Was Deleted!", footer contains message ID as per regex.
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
if (embed?.Footer == null || !(embed.Title?.EndsWith("A Message Was Deleted!") ?? false)) return null;
|
||||
var match = _loggerBRegex.Match(embed.Footer.Value.Text ?? "");
|
||||
var match = _loggerBRegex.Match(embed.Footer.Text ?? "");
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractGenericBot(SocketMessage msg)
|
||||
private static ulong? ExtractGenericBot(DiscordMessage msg)
|
||||
{
|
||||
// Embed, title is "Message Deleted", ID plain in footer.
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
if (embed?.Footer == null || !(embed.Title?.Contains("Message Deleted") ?? false)) return null;
|
||||
var match = _basicRegex.Match(embed.Footer.Value.Text ?? "");
|
||||
var match = _basicRegex.Match(embed.Footer.Text ?? "");
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractBlargBot(SocketMessage msg)
|
||||
private static ulong? ExtractBlargBot(DiscordMessage msg)
|
||||
{
|
||||
// Embed, title ends with "Message Deleted", contains ID plain in a field.
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
@ -182,7 +182,7 @@ namespace PluralKit.Bot
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractMantaro(SocketMessage msg)
|
||||
private static ulong? ExtractMantaro(DiscordMessage msg)
|
||||
{
|
||||
// Plain message, "Message (ID: [id]) created by [user] (ID: [id]) in channel [channel] was deleted.
|
||||
if (!(msg.Content?.Contains("was deleted.") ?? false)) return null;
|
||||
@ -190,19 +190,19 @@ namespace PluralKit.Bot
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static FuzzyExtractResult? ExtractCarlBot(SocketMessage msg)
|
||||
private static FuzzyExtractResult? ExtractCarlBot(DiscordMessage msg)
|
||||
{
|
||||
// Embed, title is "Message deleted in [channel], **user** ID in the footer, timestamp as, well, timestamp in embed.
|
||||
// This is the *deletion* timestamp, which we can assume is a couple seconds at most after the message was originally sent
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
if (embed?.Footer == null || embed.Timestamp == null || !(embed.Title?.StartsWith("Message deleted in") ?? false)) return null;
|
||||
var match = _carlRegex.Match(embed.Footer.Value.Text ?? "");
|
||||
var match = _carlRegex.Match(embed.Footer.Text ?? "");
|
||||
return match.Success
|
||||
? new FuzzyExtractResult { User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = embed.Timestamp.Value }
|
||||
: (FuzzyExtractResult?) null;
|
||||
}
|
||||
|
||||
private static FuzzyExtractResult? ExtractCircle(SocketMessage msg)
|
||||
private static FuzzyExtractResult? ExtractCircle(DiscordMessage msg)
|
||||
{
|
||||
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.
|
||||
// Compact: "Message from [user] ([id]) deleted in [channel]", no timestamp (use message time)
|
||||
@ -211,7 +211,7 @@ namespace PluralKit.Bot
|
||||
if (msg.Embeds.Count > 0)
|
||||
{
|
||||
var embed = msg.Embeds.First();
|
||||
if (embed.Author?.Name == null || !embed.Author.Value.Name.StartsWith("Message Deleted in")) return null;
|
||||
if (embed.Author?.Name == null || !embed.Author.Name.StartsWith("Message Deleted in")) return null;
|
||||
var field = embed.Fields.FirstOrDefault(f => f.Name == "Message Author");
|
||||
if (field.Value == null) return null;
|
||||
stringWithId = field.Value;
|
||||
@ -224,7 +224,7 @@ namespace PluralKit.Bot
|
||||
: (FuzzyExtractResult?) null;
|
||||
}
|
||||
|
||||
private static FuzzyExtractResult? ExtractPancake(SocketMessage msg)
|
||||
private static FuzzyExtractResult? ExtractPancake(DiscordMessage msg)
|
||||
{
|
||||
// Embed, author is "Message Deleted", description includes a mention, timestamp is *message send time* (but no ID)
|
||||
// so we use the message timestamp to get somewhere *after* the message was proxied
|
||||
@ -236,16 +236,16 @@ namespace PluralKit.Bot
|
||||
: (FuzzyExtractResult?) null;
|
||||
}
|
||||
|
||||
private static ulong? ExtractUnbelievaBoat(SocketMessage msg)
|
||||
private static ulong? ExtractUnbelievaBoat(DiscordMessage msg)
|
||||
{
|
||||
// Embed author is "Message Deleted", footer contains message ID per regex
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
if (embed?.Footer == null || embed.Author?.Name != "Message Deleted") return null;
|
||||
var match = _unbelievaboatRegex.Match(embed.Footer.Value.Text ?? "");
|
||||
var match = _unbelievaboatRegex.Match(embed.Footer.Text ?? "");
|
||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null;
|
||||
}
|
||||
|
||||
private static FuzzyExtractResult? ExtractVanessa(SocketMessage msg)
|
||||
private static FuzzyExtractResult? ExtractVanessa(DiscordMessage msg)
|
||||
{
|
||||
// Title is "Message Deleted", embed description contains mention
|
||||
var embed = msg.Embeds.FirstOrDefault();
|
||||
@ -261,11 +261,11 @@ namespace PluralKit.Bot
|
||||
{
|
||||
public string Name;
|
||||
public ulong Id;
|
||||
public Func<SocketMessage, ulong?> ExtractFunc;
|
||||
public Func<SocketMessage, FuzzyExtractResult?> FuzzyExtractFunc;
|
||||
public Func<DiscordMessage, ulong?> ExtractFunc;
|
||||
public Func<DiscordMessage, FuzzyExtractResult?> FuzzyExtractFunc;
|
||||
public string WebhookName;
|
||||
|
||||
public LoggerBot(string name, ulong id, Func<SocketMessage, ulong?> extractFunc = null, Func<SocketMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
|
||||
public LoggerBot(string name, ulong id, Func<DiscordMessage, ulong?> extractFunc = null, Func<DiscordMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
|
||||
{
|
||||
Name = name;
|
||||
Id = id;
|
||||
|
@ -1,10 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using App.Metrics;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using NodaTime.Extensions;
|
||||
using PluralKit.Core;
|
||||
|
||||
@ -27,9 +30,9 @@ namespace PluralKit.Bot
|
||||
|
||||
private ILogger _logger;
|
||||
|
||||
public PeriodicStatCollector(IDiscordClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache)
|
||||
public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache)
|
||||
{
|
||||
_client = (DiscordShardedClient) client;
|
||||
_client = client;
|
||||
_metrics = metrics;
|
||||
_webhookCache = webhookCache;
|
||||
_countHolder = countHolder;
|
||||
@ -45,18 +48,31 @@ namespace PluralKit.Bot
|
||||
stopwatch.Start();
|
||||
|
||||
// Aggregate guild/channel stats
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, _client.Guilds.Count);
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.Channels, _client.Guilds.Sum(g => g.TextChannels.Count));
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.ShardsConnected, _client.Shards.Count(shard => shard.ConnectionState == ConnectionState.Connected));
|
||||
|
||||
var guildCount = 0;
|
||||
var channelCount = 0;
|
||||
// No LINQ today, sorry
|
||||
foreach (var shard in _client.ShardClients.Values)
|
||||
{
|
||||
guildCount += shard.Guilds.Count;
|
||||
foreach (var guild in shard.Guilds.Values)
|
||||
foreach (var channel in guild.Channels.Values)
|
||||
if (channel.Type == ChannelType.Text)
|
||||
channelCount++;
|
||||
}
|
||||
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, guildCount);
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.Channels, channelCount);
|
||||
|
||||
// Aggregate member stats
|
||||
var usersKnown = new HashSet<ulong>();
|
||||
var usersOnline = new HashSet<ulong>();
|
||||
foreach (var guild in _client.Guilds)
|
||||
foreach (var user in guild.Users)
|
||||
foreach (var shard in _client.ShardClients.Values)
|
||||
foreach (var guild in shard.Guilds.Values)
|
||||
foreach (var user in guild.Members.Values)
|
||||
{
|
||||
usersKnown.Add(user.Id);
|
||||
if (user.Status == UserStatus.Online) usersOnline.Add(user.Id);
|
||||
if (user.Presence.Status == UserStatus.Online) usersOnline.Add(user.Id);
|
||||
}
|
||||
|
||||
_metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count);
|
||||
|
@ -3,12 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.WebSocket;
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
using DSharpPlus.EventArgs;
|
||||
using DSharpPlus.Exceptions;
|
||||
|
||||
using NodaTime;
|
||||
using NodaTime.Extensions;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
@ -83,16 +83,16 @@ namespace PluralKit.Bot
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task HandleMessageAsync(GuildConfig guild, CachedAccount account, IMessage message, bool doAutoProxy)
|
||||
public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool doAutoProxy)
|
||||
{
|
||||
// Bail early if this isn't in a guild channel
|
||||
if (!(message.Channel is ITextChannel channel)) return;
|
||||
if (message.Channel.Guild != null) return;
|
||||
|
||||
// Find a member with proxy tags matching the message
|
||||
var match = GetProxyTagMatch(message.Content, account.System, account.Members);
|
||||
|
||||
// O(n) lookup since n is small (max ~100 in prod) and we're more constrained by memory (for a dictionary) here
|
||||
var systemSettingsForGuild = account.SettingsForGuild(channel.GuildId);
|
||||
var systemSettingsForGuild = account.SettingsForGuild(message.Channel.GuildId);
|
||||
|
||||
// If we didn't get a match by proxy tags, try to get one by autoproxy
|
||||
// Also try if we *did* get a match, but there's no inner text. This happens if someone sends a message that
|
||||
@ -102,26 +102,26 @@ namespace PluralKit.Bot
|
||||
// When a normal message is sent, autoproxy is enabled, but if this method is called from a message *edit*
|
||||
// event, then autoproxy is disabled. This is so AP doesn't "retrigger" when the original message was escaped.
|
||||
if (doAutoProxy && (match == null || (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0)))
|
||||
match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, channel);
|
||||
match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, message.Channel);
|
||||
|
||||
// If we still haven't found any, just yeet
|
||||
if (match == null) return;
|
||||
|
||||
// And make sure the channel's not blacklisted from proxying.
|
||||
if (guild.Blacklist.Contains(channel.Id)) return;
|
||||
if (guild.Blacklist.Contains(message.ChannelId)) return;
|
||||
|
||||
// Make sure the system hasn't blacklisted the guild either
|
||||
if (!systemSettingsForGuild.ProxyEnabled) return;
|
||||
|
||||
// We know message.Channel can only be ITextChannel as PK doesn't work in DMs/groups
|
||||
// Afterwards we ensure the bot has the right permissions, otherwise bail early
|
||||
if (!await EnsureBotPermissions(channel)) return;
|
||||
if (!await EnsureBotPermissions(message.Channel)) return;
|
||||
|
||||
// Can't proxy a message with no content and no attachment
|
||||
if (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0)
|
||||
return;
|
||||
|
||||
var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, channel.GuildId);
|
||||
var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, message.Channel.GuildId);
|
||||
|
||||
// Get variables in order and all
|
||||
var proxyName = match.Member.ProxyName(match.System.Tag, memberSettingsForGuild.DisplayName);
|
||||
@ -138,19 +138,17 @@ namespace PluralKit.Bot
|
||||
: match.InnerText;
|
||||
|
||||
// Sanitize @everyone, but only if the original user wouldn't have permission to
|
||||
messageContents = SanitizeEveryoneMaybe(message, messageContents);
|
||||
messageContents = await SanitizeEveryoneMaybe(message, messageContents);
|
||||
|
||||
// Execute the webhook itself
|
||||
var hookMessageId = await _webhookExecutor.ExecuteWebhook(
|
||||
channel,
|
||||
proxyName, avatarUrl,
|
||||
var hookMessageId = await _webhookExecutor.ExecuteWebhook(message.Channel, proxyName, avatarUrl,
|
||||
messageContents,
|
||||
message.Attachments
|
||||
);
|
||||
|
||||
// Store the message in the database, and log it in the log channel (if applicable)
|
||||
await _data.AddMessage(message.Author.Id, hookMessageId, channel.GuildId, message.Channel.Id, message.Id, match.Member);
|
||||
await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, guild);
|
||||
await _data.AddMessage(message.Author.Id, hookMessageId, message.Channel.GuildId, message.Channel.Id, message.Id, match.Member);
|
||||
await _logChannel.LogMessage(client, match.System, match.Member, hookMessageId, message.Id, message.Channel, message.Author, match.InnerText, guild);
|
||||
|
||||
// Wait a second or so before deleting the original message
|
||||
await Task.Delay(1000);
|
||||
@ -159,14 +157,14 @@ namespace PluralKit.Bot
|
||||
{
|
||||
await message.DeleteAsync();
|
||||
}
|
||||
catch (HttpException)
|
||||
catch (NotFoundException)
|
||||
{
|
||||
// If it's already deleted, we just log and swallow the exception
|
||||
_logger.Warning("Attempted to delete already deleted proxy trigger message {Message}", message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ProxyMatch> GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, IMessage message, IGuildChannel channel)
|
||||
private async Task<ProxyMatch> GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, DiscordMessage message, DiscordChannel channel)
|
||||
{
|
||||
// For now we use a backslash as an "escape character", subject to change later
|
||||
if ((message.Content ?? "").TrimStart().StartsWith("\\")) return null;
|
||||
@ -189,7 +187,7 @@ namespace PluralKit.Bot
|
||||
|
||||
// If the message is older than 6 hours, ignore it and force the sender to "refresh" a proxy
|
||||
// This can be revised in the future, it's a preliminary value.
|
||||
var timestamp = SnowflakeUtils.FromSnowflake(msg.Message.Mid).ToInstant();
|
||||
var timestamp = DiscordUtils.SnowflakeToInstant(msg.Message.Mid);
|
||||
var timeSince = SystemClock.Instance.GetCurrentInstant() - timestamp;
|
||||
if (timeSince > Duration.FromHours(6)) return null;
|
||||
|
||||
@ -214,23 +212,23 @@ namespace PluralKit.Bot
|
||||
};
|
||||
}
|
||||
|
||||
private static string SanitizeEveryoneMaybe(IMessage message, string messageContents)
|
||||
private static async Task<string> SanitizeEveryoneMaybe(DiscordMessage message,
|
||||
string messageContents)
|
||||
{
|
||||
var senderPermissions = ((IGuildUser) message.Author).GetPermissions(message.Channel as IGuildChannel);
|
||||
if (!senderPermissions.MentionEveryone) return messageContents.SanitizeEveryone();
|
||||
var member = await message.Channel.Guild.GetMemberAsync(message.Author.Id);
|
||||
if ((member.PermissionsIn(message.Channel) & Permissions.MentionEveryone) == 0) return messageContents.SanitizeEveryone();
|
||||
return messageContents;
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureBotPermissions(ITextChannel channel)
|
||||
private async Task<bool> EnsureBotPermissions(DiscordChannel channel)
|
||||
{
|
||||
var guildUser = await channel.Guild.GetCurrentUserAsync();
|
||||
var permissions = guildUser.GetPermissions(channel);
|
||||
var permissions = channel.BotPermissions();
|
||||
|
||||
// If we can't send messages at all, just bail immediately.
|
||||
// TODO: can you have ManageMessages and *not* SendMessages? What happens then?
|
||||
if (!permissions.SendMessages && !permissions.ManageMessages) return false;
|
||||
if ((permissions & (Permissions.SendMessages | Permissions.ManageMessages)) == 0) return false;
|
||||
|
||||
if (!permissions.ManageWebhooks)
|
||||
if ((permissions & Permissions.ManageWebhooks) == 0)
|
||||
{
|
||||
// todo: PKError-ify these
|
||||
await channel.SendMessageAsync(
|
||||
@ -238,7 +236,7 @@ namespace PluralKit.Bot
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!permissions.ManageMessages)
|
||||
if ((permissions & Permissions.ManageMessages) == 0)
|
||||
{
|
||||
await channel.SendMessageAsync(
|
||||
$"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this.");
|
||||
@ -248,121 +246,117 @@ namespace PluralKit.Bot
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction)
|
||||
public Task HandleReactionAddedAsync(MessageReactionAddEventArgs args)
|
||||
{
|
||||
// Dispatch on emoji
|
||||
switch (reaction.Emote.Name)
|
||||
switch (args.Emoji.Name)
|
||||
{
|
||||
case "\u274C": // Red X
|
||||
return HandleMessageDeletionByReaction(message, reaction.UserId);
|
||||
return HandleMessageDeletionByReaction(args);
|
||||
case "\u2753": // Red question mark
|
||||
case "\u2754": // White question mark
|
||||
return HandleMessageQueryByReaction(message, channel, reaction.UserId, reaction.Emote);
|
||||
return HandleMessageQueryByReaction(args);
|
||||
case "\U0001F514": // Bell
|
||||
case "\U0001F6CE": // Bellhop bell
|
||||
case "\U0001F3D3": // Ping pong paddle (lol)
|
||||
case "\u23F0": // Alarm clock
|
||||
case "\u2757": // Exclamation mark
|
||||
return HandleMessagePingByReaction(message, channel, reaction.UserId, reaction.Emote);
|
||||
return HandleMessagePingByReaction(args);
|
||||
default:
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessagePingByReaction(Cacheable<IUserMessage, ulong> message,
|
||||
ISocketMessageChannel channel, ulong userWhoReacted,
|
||||
IEmote reactedEmote)
|
||||
private async Task HandleMessagePingByReaction(MessageReactionAddEventArgs args)
|
||||
{
|
||||
// Bail in DMs
|
||||
if (!(channel is SocketGuildChannel gc)) return;
|
||||
if (args.Channel.Type != ChannelType.Text) return;
|
||||
|
||||
// Find the message in the DB
|
||||
var msg = await _data.GetMessage(message.Id);
|
||||
var msg = await _data.GetMessage(args.Message.Id);
|
||||
if (msg == null) return;
|
||||
|
||||
// Check if the pinger has permission to ping in this channel
|
||||
var guildUser = await _client.Rest.GetGuildUserAsync(gc.Guild.Id, userWhoReacted);
|
||||
var permissions = guildUser.GetPermissions(gc);
|
||||
var guildUser = await args.Guild.GetMemberAsync(args.User.Id);
|
||||
var permissions = guildUser.PermissionsIn(args.Channel);
|
||||
|
||||
var realMessage = await message.GetOrDownloadAsync();
|
||||
|
||||
// If they don't have Send Messages permission, bail (since PK shouldn't send anything on their behalf)
|
||||
if (!permissions.SendMessages || !permissions.ViewChannel) return;
|
||||
|
||||
var embed = new EmbedBuilder().WithDescription($"[Jump to pinged message]({realMessage.GetJumpUrl()})");
|
||||
await channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{userWhoReacted}>.", embed: embed.Build());
|
||||
var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages;
|
||||
if ((permissions & requiredPerms) != requiredPerms) return;
|
||||
|
||||
var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({args.Message.JumpLink})");
|
||||
await args.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{args.User.Id}>.", embed: embed.Build());
|
||||
|
||||
// Finally remove the original reaction (if we can)
|
||||
var user = await _client.Rest.GetUserAsync(userWhoReacted);
|
||||
if (user != null && realMessage.Channel.HasPermission(ChannelPermission.ManageMessages))
|
||||
await realMessage.RemoveReactionAsync(reactedEmote, user);
|
||||
if (args.Channel.BotHasPermission(Permissions.ManageMessages))
|
||||
await args.Message.DeleteReactionAsync(args.Emoji, args.User);
|
||||
}
|
||||
|
||||
private async Task HandleMessageQueryByReaction(Cacheable<IUserMessage, ulong> message,
|
||||
ISocketMessageChannel channel, ulong userWhoReacted,
|
||||
IEmote reactedEmote)
|
||||
private async Task HandleMessageQueryByReaction(MessageReactionAddEventArgs args)
|
||||
{
|
||||
// Find the user who sent the reaction, so we can DM them
|
||||
var user = await _client.Rest.GetUserAsync(userWhoReacted);
|
||||
if (user == null) return;
|
||||
|
||||
// Bail if not in guild
|
||||
if (args.Guild == null) return;
|
||||
|
||||
// Find the message in the DB
|
||||
var msg = await _data.GetMessage(message.Id);
|
||||
var msg = await _data.GetMessage(args.Message.Id);
|
||||
if (msg == null) return;
|
||||
|
||||
// Get guild member so we can DM
|
||||
var member = await args.Guild.GetMemberAsync(args.User.Id);
|
||||
|
||||
// DM them the message card
|
||||
try
|
||||
{
|
||||
await user.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, (channel as IGuildChannel)?.Guild, LookupContext.ByNonOwner));
|
||||
await user.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(msg));
|
||||
await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, args.Guild, LookupContext.ByNonOwner));
|
||||
await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(args.Client, msg));
|
||||
}
|
||||
catch (HttpException e) when (e.DiscordCode == 50007)
|
||||
catch (BadRequestException)
|
||||
{
|
||||
// TODO: is this the correct exception
|
||||
// Ignore exception if it means we don't have DM permission to this user
|
||||
// not much else we can do here :/
|
||||
}
|
||||
|
||||
// And finally remove the original reaction (if we can)
|
||||
var msgObj = await message.GetOrDownloadAsync();
|
||||
if (msgObj.Channel.HasPermission(ChannelPermission.ManageMessages))
|
||||
await msgObj.RemoveReactionAsync(reactedEmote, user);
|
||||
await args.Message.DeleteReactionAsync(args.Emoji, args.User);
|
||||
}
|
||||
|
||||
public async Task HandleMessageDeletionByReaction(Cacheable<IUserMessage, ulong> message, ulong userWhoReacted)
|
||||
public async Task HandleMessageDeletionByReaction(MessageReactionAddEventArgs args)
|
||||
{
|
||||
// Bail if we don't have permission to delete
|
||||
if (!args.Channel.BotHasPermission(Permissions.ManageMessages)) return;
|
||||
|
||||
// Find the message in the database
|
||||
var storedMessage = await _data.GetMessage(message.Id);
|
||||
var storedMessage = await _data.GetMessage(args.Message.Id);
|
||||
if (storedMessage == null) return; // (if we can't, that's ok, no worries)
|
||||
|
||||
// Make sure it's the actual sender of that message deleting the message
|
||||
if (storedMessage.Message.Sender != userWhoReacted) return;
|
||||
if (storedMessage.Message.Sender != args.User.Id) return;
|
||||
|
||||
try {
|
||||
// Then, fetch the Discord message and delete that
|
||||
// TODO: this could be faster if we didn't bother fetching it and just deleted it directly
|
||||
// somehow through REST?
|
||||
await (await message.GetOrDownloadAsync()).DeleteAsync();
|
||||
try
|
||||
{
|
||||
await args.Message.DeleteAsync();
|
||||
} catch (NullReferenceException) {
|
||||
// Message was deleted before we got to it... cool, no problem, lmao
|
||||
}
|
||||
|
||||
// Finally, delete it from our database.
|
||||
await _data.DeleteMessage(message.Id);
|
||||
await _data.DeleteMessage(args.Message.Id);
|
||||
}
|
||||
|
||||
public async Task HandleMessageDeletedAsync(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
|
||||
public async Task HandleMessageDeletedAsync(MessageDeleteEventArgs args)
|
||||
{
|
||||
// Don't delete messages from the store if they aren't webhooks
|
||||
// Non-webhook messages will never be stored anyway.
|
||||
// If we're not sure (eg. message outside of cache), delete just to be sure.
|
||||
if (message.HasValue && !message.Value.Author.IsWebhook) return;
|
||||
await _data.DeleteMessage(message.Id);
|
||||
if (!args.Message.WebhookMessage) return;
|
||||
await _data.DeleteMessage(args.Message.Id);
|
||||
}
|
||||
|
||||
public async Task HandleMessageBulkDeleteAsync(IReadOnlyCollection<Cacheable<IMessage, ulong>> messages, IMessageChannel channel)
|
||||
public async Task HandleMessageBulkDeleteAsync(MessageBulkDeleteEventArgs args)
|
||||
{
|
||||
_logger.Information("Bulk deleting {Count} messages in channel {Channel}", messages.Count, channel.Id);
|
||||
await _data.DeleteMessagesBulk(messages.Select(m => m.Id).ToList());
|
||||
_logger.Information("Bulk deleting {Count} messages in channel {Channel}", args.Messages.Count, args.Channel.Id);
|
||||
await _data.DeleteMessagesBulk(args.Messages.Select(m => m.Id).ToList());
|
||||
}
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Discord.WebSocket;
|
||||
using DSharpPlus;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
@ -21,35 +21,36 @@ namespace PluralKit.Bot
|
||||
|
||||
public void Init(DiscordShardedClient client)
|
||||
{
|
||||
for (var i = 0; i < client.Shards.Count; i++)
|
||||
foreach (var i in client.ShardClients.Keys)
|
||||
_shardInfo[i] = new ShardInfo();
|
||||
|
||||
client.ShardConnected += ShardConnected;
|
||||
client.ShardDisconnected += ShardDisconnected;
|
||||
client.ShardReady += ShardReady;
|
||||
client.ShardLatencyUpdated += ShardLatencyUpdated;
|
||||
|
||||
// TODO
|
||||
// client.ShardConnected += ShardConnected;
|
||||
// client.ShardDisconnected += ShardDisconnected;
|
||||
// client.ShardReady += ShardReady;
|
||||
// client.ShardLatencyUpdated += ShardLatencyUpdated;
|
||||
}
|
||||
|
||||
public ShardInfo GetShardInfo(DiscordSocketClient shard) => _shardInfo[shard.ShardId];
|
||||
public ShardInfo GetShardInfo(DiscordClient shard) => _shardInfo[shard.ShardId];
|
||||
|
||||
private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordSocketClient shard)
|
||||
private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordClient shard)
|
||||
{
|
||||
_shardInfo[shard.ShardId].ShardLatency = newLatency;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task ShardReady(DiscordSocketClient shard)
|
||||
private Task ShardReady(DiscordClient shard)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task ShardDisconnected(Exception e, DiscordSocketClient shard)
|
||||
private Task ShardDisconnected(Exception e, DiscordClient shard)
|
||||
{
|
||||
_shardInfo[shard.ShardId].DisconnectionCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task ShardConnected(DiscordSocketClient shard)
|
||||
private Task ShardConnected(DiscordClient shard)
|
||||
{
|
||||
_shardInfo[shard.ShardId].LastConnectionTime = SystemClock.Instance.GetCurrentInstant();
|
||||
return Task.CompletedTask;
|
||||
|
@ -3,8 +3,9 @@ using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using Serilog;
|
||||
|
||||
@ -15,54 +16,55 @@ namespace PluralKit.Bot
|
||||
public static readonly string WebhookName = "PluralKit Proxy Webhook";
|
||||
|
||||
private DiscordShardedClient _client;
|
||||
private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks;
|
||||
private ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>> _webhooks;
|
||||
|
||||
private ILogger _logger;
|
||||
|
||||
public WebhookCacheService(IDiscordClient client, ILogger logger)
|
||||
public WebhookCacheService(DiscordShardedClient client, ILogger logger)
|
||||
{
|
||||
_client = client as DiscordShardedClient;
|
||||
_client = client;
|
||||
_logger = logger.ForContext<WebhookCacheService>();
|
||||
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>();
|
||||
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>>();
|
||||
}
|
||||
|
||||
public async Task<IWebhook> GetWebhook(ulong channelId)
|
||||
public async Task<DiscordWebhook> GetWebhook(DiscordClient client, ulong channelId)
|
||||
{
|
||||
var channel = _client.GetChannel(channelId) as ITextChannel;
|
||||
var channel = await client.GetChannelAsync(channelId);
|
||||
if (channel == null) return null;
|
||||
if (channel.Type == ChannelType.Text) return null;
|
||||
return await GetWebhook(channel);
|
||||
}
|
||||
|
||||
public async Task<IWebhook> GetWebhook(ITextChannel channel)
|
||||
public async Task<DiscordWebhook> GetWebhook(DiscordChannel channel)
|
||||
{
|
||||
// We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel
|
||||
// If the webhook is requested twice before it's actually been found, the Lazy<T> wrapper will stop the
|
||||
// webhook from being created twice.
|
||||
var lazyWebhookValue =
|
||||
_webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => GetOrCreateWebhook(channel)));
|
||||
_webhooks.GetOrAdd(channel.Id, new Lazy<Task<DiscordWebhook>>(() => GetOrCreateWebhook(channel)));
|
||||
|
||||
// It's possible to "move" a webhook to a different channel after creation
|
||||
// Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one.
|
||||
var webhook = await lazyWebhookValue.Value;
|
||||
if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(webhook);
|
||||
if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(channel, webhook);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public async Task<IWebhook> InvalidateAndRefreshWebhook(IWebhook webhook)
|
||||
public async Task<DiscordWebhook> InvalidateAndRefreshWebhook(DiscordChannel channel, DiscordWebhook webhook)
|
||||
{
|
||||
_logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId);
|
||||
|
||||
_webhooks.TryRemove(webhook.ChannelId, out _);
|
||||
return await GetWebhook(webhook.Channel);
|
||||
return await GetWebhook(channel);
|
||||
}
|
||||
|
||||
private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel)
|
||||
private async Task<DiscordWebhook> GetOrCreateWebhook(DiscordChannel channel)
|
||||
{
|
||||
_logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id);
|
||||
return await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel);
|
||||
}
|
||||
|
||||
private async Task<IWebhook> FindExistingWebhook(ITextChannel channel)
|
||||
private async Task<DiscordWebhook> FindExistingWebhook(DiscordChannel channel)
|
||||
{
|
||||
_logger.Debug("Finding webhook for channel {Channel}", channel.Id);
|
||||
try
|
||||
@ -78,13 +80,13 @@ namespace PluralKit.Bot
|
||||
}
|
||||
}
|
||||
|
||||
private Task<IWebhook> DoCreateWebhook(ITextChannel channel)
|
||||
private Task<DiscordWebhook> DoCreateWebhook(DiscordChannel channel)
|
||||
{
|
||||
_logger.Information("Creating new webhook for channel {Channel}", channel.Id);
|
||||
return channel.CreateWebhookAsync(WebhookName);
|
||||
}
|
||||
|
||||
private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.GetShardFor(arg.Guild).CurrentUser.Id && arg.Name == WebhookName;
|
||||
private bool IsWebhookMine(DiscordWebhook arg) => arg.User.Id == _client.CurrentUser.Id && arg.Name == WebhookName;
|
||||
|
||||
public int CacheSize => _webhooks.Count;
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using App.Metrics;
|
||||
|
||||
using Discord;
|
||||
using DSharpPlus.Entities;
|
||||
using DSharpPlus.Exceptions;
|
||||
|
||||
using Humanizer;
|
||||
|
||||
@ -44,13 +45,13 @@ namespace PluralKit.Bot
|
||||
_logger = logger.ForContext<WebhookExecutorService>();
|
||||
}
|
||||
|
||||
public async Task<ulong> ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection<IAttachment> attachments)
|
||||
public async Task<ulong> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> attachments)
|
||||
{
|
||||
_logger.Verbose("Invoking webhook in channel {Channel}", channel.Id);
|
||||
|
||||
// Get a webhook, execute it
|
||||
var webhook = await _webhookCache.GetWebhook(channel);
|
||||
var id = await ExecuteWebhookInner(webhook, name, avatarUrl, content, attachments);
|
||||
var id = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments);
|
||||
|
||||
// Log the relevant metrics
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
|
||||
@ -60,112 +61,93 @@ namespace PluralKit.Bot
|
||||
return id;
|
||||
}
|
||||
|
||||
private async Task<ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content,
|
||||
IReadOnlyCollection<IAttachment> attachments, bool hasRetried = false)
|
||||
private async Task<ulong> ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
|
||||
IReadOnlyList<DiscordAttachment> attachments, bool hasRetried = false)
|
||||
{
|
||||
using var mfd = new MultipartFormDataContent
|
||||
{
|
||||
{new StringContent(content.Truncate(2000)), "content"},
|
||||
{new StringContent(FixClyde(name).Truncate(80)), "username"}
|
||||
};
|
||||
if (avatarUrl != null) mfd.Add(new StringContent(avatarUrl), "avatar_url");
|
||||
|
||||
var dwb = new DiscordWebhookBuilder();
|
||||
dwb.WithUsername(FixClyde(name).Truncate(80));
|
||||
dwb.WithContent(content.Truncate(2000));
|
||||
if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl);
|
||||
|
||||
var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024);
|
||||
if (attachmentChunks.Count > 0)
|
||||
{
|
||||
_logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.Size).Sum() / 1024 / 1024, attachmentChunks.Count);
|
||||
await AddAttachmentsToMultipart(mfd, attachmentChunks.First());
|
||||
_logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count);
|
||||
await AddAttachmentsToBuilder(dwb, attachmentChunks[0]);
|
||||
}
|
||||
|
||||
mfd.Headers.Add("X-RateLimit-Precision", "millisecond"); // Need this for better rate limit support
|
||||
|
||||
// Adding this check as close to the actual send call as possible to prevent potential race conditions (unlikely, but y'know)
|
||||
if (!_rateLimit.TryExecuteWebhook(webhook))
|
||||
throw new WebhookRateLimited();
|
||||
|
||||
var timerCtx = _metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime);
|
||||
using var response = await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}?wait=true", mfd);
|
||||
timerCtx.Dispose();
|
||||
|
||||
_rateLimit.UpdateRateLimitInfo(webhook, response);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
// Rate limits should be respected, we bail early (already updated the limit info so we hopefully won't hit this again)
|
||||
throw new WebhookRateLimited();
|
||||
|
||||
var responseString = await response.Content.ReadAsStringAsync();
|
||||
|
||||
JObject responseJson;
|
||||
DiscordMessage response;
|
||||
try
|
||||
{
|
||||
responseJson = JsonConvert.DeserializeObject<JObject>(responseString);
|
||||
response = await webhook.ExecuteAsync(dwb);
|
||||
}
|
||||
catch (JsonReaderException)
|
||||
catch (NotFoundException e)
|
||||
{
|
||||
// Sometimes we get invalid JSON from the server, just ignore all of it
|
||||
throw new WebhookExecutionErrorOnDiscordsEnd();
|
||||
}
|
||||
|
||||
if (responseJson.ContainsKey("code"))
|
||||
{
|
||||
var errorCode = responseJson["code"].Value<int>();
|
||||
if (errorCode == 10015 && !hasRetried)
|
||||
if (e.JsonMessage.Contains("10015") && !hasRetried)
|
||||
{
|
||||
// Error 10015 = "Unknown Webhook" - this likely means the webhook was deleted
|
||||
// but is still in our cache. Invalidate, refresh, try again
|
||||
_logger.Warning("Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId);
|
||||
return await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachments, hasRetried: true);
|
||||
}
|
||||
|
||||
if (errorCode == 40005)
|
||||
throw Errors.AttachmentTooLarge; // should be caught by the check above but just makin' sure
|
||||
|
||||
// TODO: look into what this actually throws, and if this is the correct handling
|
||||
if ((int) response.StatusCode >= 500)
|
||||
// If it's a 5xx error code, this is on Discord's end, so we throw an execution exception
|
||||
throw new WebhookExecutionErrorOnDiscordsEnd();
|
||||
|
||||
// Otherwise, this is going to throw on 4xx, and bubble up to our Sentry handler
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// If we have any leftover attachment chunks, send those
|
||||
if (attachmentChunks.Count > 1)
|
||||
{
|
||||
// Deliberately not adding a content, just the remaining files
|
||||
foreach (var chunk in attachmentChunks.Skip(1))
|
||||
{
|
||||
using var mfd2 = new MultipartFormDataContent();
|
||||
mfd2.Add(new StringContent(FixClyde(name).Truncate(80)), "username");
|
||||
if (avatarUrl != null) mfd2.Add(new StringContent(avatarUrl), "avatar_url");
|
||||
await AddAttachmentsToMultipart(mfd2, chunk);
|
||||
|
||||
// Don't bother with ?wait, we're just kinda firehosing this stuff
|
||||
// also don't error check, the real message itself is already sent
|
||||
await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}", mfd2);
|
||||
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook);
|
||||
return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, hasRetried: true);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
timerCtx.Dispose();
|
||||
|
||||
// We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off
|
||||
var _ = TrySendRemainingAttachments(webhook, name, avatarUrl, attachmentChunks);
|
||||
|
||||
return response.Id;
|
||||
}
|
||||
|
||||
private async Task TrySendRemainingAttachments(DiscordWebhook webhook, string name, string avatarUrl, IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> attachmentChunks)
|
||||
{
|
||||
if (attachmentChunks.Count <= 1) return;
|
||||
|
||||
for (var i = 1; i < attachmentChunks.Count; i++)
|
||||
{
|
||||
var dwb = new DiscordWebhookBuilder();
|
||||
if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl);
|
||||
dwb.WithUsername(name);
|
||||
await AddAttachmentsToBuilder(dwb, attachmentChunks[i]);
|
||||
await webhook.ExecuteAsync(dwb);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddAttachmentsToBuilder(DiscordWebhookBuilder dwb, IReadOnlyCollection<DiscordAttachment> attachments)
|
||||
{
|
||||
async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment)
|
||||
{
|
||||
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
|
||||
return (attachment, await attachmentResponse.Content.ReadAsStreamAsync());
|
||||
}
|
||||
|
||||
// At this point we're sure we have a 2xx status code, so just assume success
|
||||
// TODO: can we do this without a round-trip to a string?
|
||||
return responseJson["id"].Value<ulong>();
|
||||
foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
|
||||
dwb.AddFile(attachment.FileName, attachmentStream);
|
||||
}
|
||||
private IReadOnlyCollection<IReadOnlyCollection<IAttachment>> ChunkAttachmentsOrThrow(
|
||||
IReadOnlyCollection<IAttachment> attachments, int sizeThreshold)
|
||||
|
||||
private IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> ChunkAttachmentsOrThrow(
|
||||
IReadOnlyList<DiscordAttachment> attachments, int sizeThreshold)
|
||||
{
|
||||
// Splits a list of attachments into "chunks" of at most 8MB each
|
||||
// If any individual attachment is larger than 8MB, will throw an error
|
||||
var chunks = new List<List<IAttachment>>();
|
||||
var list = new List<IAttachment>();
|
||||
var chunks = new List<List<DiscordAttachment>>();
|
||||
var list = new List<DiscordAttachment>();
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge;
|
||||
if (attachment.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge;
|
||||
|
||||
if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold)
|
||||
if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold)
|
||||
{
|
||||
chunks.Add(list);
|
||||
list = new List<IAttachment>();
|
||||
list = new List<DiscordAttachment>();
|
||||
}
|
||||
|
||||
list.Add(attachment);
|
||||
@ -175,20 +157,6 @@ namespace PluralKit.Bot
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private async Task AddAttachmentsToMultipart(MultipartFormDataContent content,
|
||||
IReadOnlyCollection<IAttachment> attachments)
|
||||
{
|
||||
async Task<(IAttachment, Stream)> GetStream(IAttachment attachment)
|
||||
{
|
||||
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
|
||||
return (attachment, await attachmentResponse.Content.ReadAsStreamAsync());
|
||||
}
|
||||
|
||||
var attachmentId = 0;
|
||||
foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
|
||||
content.Add(new StreamContent(attachmentStream), $"file{attachmentId++}", attachment.Filename);
|
||||
}
|
||||
|
||||
private string FixClyde(string name)
|
||||
{
|
||||
// Check if the name contains "Clyde" - if not, do nothing
|
||||
|
@ -5,7 +5,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
using Discord;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
@ -26,7 +26,7 @@ namespace PluralKit.Bot
|
||||
|
||||
public int CacheSize => _info.Count;
|
||||
|
||||
public bool TryExecuteWebhook(IWebhook webhook)
|
||||
public bool TryExecuteWebhook(DiscordWebhook webhook)
|
||||
{
|
||||
// If we have nothing saved, just allow it (we'll save something once the response returns)
|
||||
if (!_info.TryGetValue(webhook.Id, out var info)) return true;
|
||||
@ -57,7 +57,7 @@ namespace PluralKit.Bot
|
||||
return true;
|
||||
}
|
||||
|
||||
public void UpdateRateLimitInfo(IWebhook webhook, HttpResponseMessage response)
|
||||
public void UpdateRateLimitInfo(DiscordWebhook webhook, HttpResponseMessage response)
|
||||
{
|
||||
var info = _info.GetOrAdd(webhook.Id, _ => new WebhookRateLimitInfo());
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,31 +1,76 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public static class DiscordUtils
|
||||
{
|
||||
public static string NameAndMention(this IUser user) {
|
||||
public static string NameAndMention(this DiscordUser user) {
|
||||
return $"{user.Username}#{user.Discriminator} ({user.Mention})";
|
||||
}
|
||||
|
||||
public static ChannelPermissions PermissionsIn(this IChannel channel)
|
||||
|
||||
public static async Task<Permissions> PermissionsIn(this DiscordChannel channel, DiscordUser user)
|
||||
{
|
||||
switch (channel)
|
||||
if (channel.Guild != null)
|
||||
{
|
||||
case IDMChannel _:
|
||||
return ChannelPermissions.DM;
|
||||
case IGroupChannel _:
|
||||
return ChannelPermissions.Group;
|
||||
case SocketGuildChannel gc:
|
||||
var currentUser = gc.Guild.CurrentUser;
|
||||
return currentUser.GetPermissions(gc);
|
||||
default:
|
||||
return ChannelPermissions.None;
|
||||
var member = await channel.Guild.GetMemberAsync(user.Id);
|
||||
return member.PermissionsIn(channel);
|
||||
}
|
||||
|
||||
if (channel.Type == ChannelType.Private)
|
||||
return (Permissions) 0b00000_1000110_1011100110000_000000;
|
||||
|
||||
return Permissions.None;
|
||||
}
|
||||
|
||||
public static bool HasPermission(this IChannel channel, ChannelPermission permission) =>
|
||||
PermissionsIn(channel).Has(permission);
|
||||
public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user)
|
||||
{
|
||||
if (user is DiscordMember dm && channel.Guild != null)
|
||||
return dm.PermissionsIn(channel);
|
||||
|
||||
if (channel.Type == ChannelType.Private)
|
||||
return (Permissions) 0b00000_1000110_1011100110000_000000;
|
||||
|
||||
return Permissions.None;
|
||||
}
|
||||
|
||||
public static Permissions BotPermissions(this DiscordChannel channel)
|
||||
{
|
||||
if (channel.Guild != null)
|
||||
{
|
||||
var member = channel.Guild.CurrentMember;
|
||||
return channel.PermissionsFor(member);
|
||||
}
|
||||
|
||||
if (channel.Type == ChannelType.Private)
|
||||
return (Permissions) 0b00000_1000110_1011100110000_000000;
|
||||
|
||||
return Permissions.None;
|
||||
}
|
||||
|
||||
public static bool BotHasPermission(this DiscordChannel channel, Permissions permissionSet) =>
|
||||
(BotPermissions(channel) & permissionSet) == permissionSet;
|
||||
|
||||
public static Instant SnowflakeToInstant(ulong snowflake) =>
|
||||
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake << 22);
|
||||
|
||||
public static ulong InstantToSnowflake(Instant time) =>
|
||||
(ulong) (time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds >> 22;
|
||||
|
||||
public static ulong InstantToSnowflake(DateTimeOffset time) =>
|
||||
(ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds >> 22;
|
||||
|
||||
public static async Task CreateReactionsBulk(this DiscordMessage msg, string[] reactions)
|
||||
{
|
||||
foreach (var reaction in reactions)
|
||||
{
|
||||
await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(reaction));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user