Initial commit, basic proxying working

This commit is contained in:
Ske 2020-12-22 13:15:26 +01:00
parent c3f6becea4
commit a6fbd869be
109 changed files with 3539 additions and 359 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
@ -9,10 +10,10 @@ using App.Metrics;
using Autofac;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using DSharpPlus.Exceptions;
using Myriad.Cache;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Types;
using NodaTime;
@ -27,47 +28,38 @@ namespace PluralKit.Bot
{
public class Bot
{
private readonly DiscordShardedClient _client;
private readonly ConcurrentDictionary<ulong, GuildMemberPartial> _guildMembers = new();
private readonly Cluster _cluster;
private readonly DiscordApiClient _rest;
private readonly ILogger _logger;
private readonly ILifetimeScope _services;
private readonly PeriodicStatCollector _collector;
private readonly IMetrics _metrics;
private readonly ErrorMessageService _errorMessageService;
private readonly CommandMessageService _commandMessageService;
private readonly IDiscordCache _cache;
private bool _hasReceivedReady = false;
private Timer _periodicTask; // Never read, just kept here for GC reasons
public Bot(DiscordShardedClient client, ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics,
ErrorMessageService errorMessageService, CommandMessageService commandMessageService)
public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics,
ErrorMessageService errorMessageService, CommandMessageService commandMessageService, Cluster cluster, DiscordApiClient rest, IDiscordCache cache)
{
_client = client;
_logger = logger.ForContext<Bot>();
_services = services;
_collector = collector;
_metrics = metrics;
_errorMessageService = errorMessageService;
_commandMessageService = commandMessageService;
_cluster = cluster;
_rest = rest;
_cache = cache;
}
public void Init()
{
// HandleEvent takes a type parameter, automatically inferred by the event type
// It will then look up an IEventHandler<TypeOfEvent> in the DI container and call that object's handler method
// For registering new ones, see Modules.cs
_client.MessageCreated += HandleEvent;
_client.MessageDeleted += HandleEvent;
_client.MessageUpdated += HandleEvent;
_client.MessagesBulkDeleted += HandleEvent;
_client.MessageReactionAdded += HandleEvent;
// Update shard status for shards immediately on connect
_client.Ready += (client, _) =>
{
_hasReceivedReady = true;
return UpdateBotStatus(client);
};
_client.Resumed += (client, _) => UpdateBotStatus(client);
_cluster.EventReceived += OnEventReceived;
// Init the shard stuff
_services.Resolve<ShardInfoService>().Init();
@ -83,6 +75,58 @@ namespace PluralKit.Bot
}, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1));
}
public GuildMemberPartial? BotMemberIn(ulong guildId) => _guildMembers.GetValueOrDefault(guildId);
private async Task OnEventReceived(Shard shard, IGatewayEvent evt)
{
await _cache.HandleGatewayEvent(evt);
TryUpdateSelfMember(shard, evt);
// HandleEvent takes a type parameter, automatically inferred by the event type
// It will then look up an IEventHandler<TypeOfEvent> in the DI container and call that object's handler method
// For registering new ones, see Modules.cs
if (evt is MessageCreateEvent mc)
await HandleEvent(shard, mc);
if (evt is MessageUpdateEvent mu)
await HandleEvent(shard, mu);
if (evt is MessageDeleteEvent md)
await HandleEvent(shard, md);
if (evt is MessageDeleteBulkEvent mdb)
await HandleEvent(shard, mdb);
if (evt is MessageReactionAddEvent mra)
await HandleEvent(shard, mra);
// Update shard status for shards immediately on connect
if (evt is ReadyEvent re)
await HandleReady(shard, re);
if (evt is ResumedEvent)
await HandleResumed(shard);
}
private void TryUpdateSelfMember(Shard shard, IGatewayEvent evt)
{
if (evt is GuildCreateEvent gc)
_guildMembers[gc.Id] = gc.Members.FirstOrDefault(m => m.User.Id == shard.User?.Id);
if (evt is MessageCreateEvent mc && mc.Member != null && mc.Author.Id == shard.User?.Id)
_guildMembers[mc.GuildId!.Value] = mc.Member;
if (evt is GuildMemberAddEvent gma && gma.User.Id == shard.User?.Id)
_guildMembers[gma.GuildId] = gma;
if (evt is GuildMemberUpdateEvent gmu && gmu.User.Id == shard.User?.Id)
_guildMembers[gmu.GuildId] = gmu;
}
private Task HandleResumed(Shard shard)
{
return UpdateBotStatus(shard);
}
private Task HandleReady(Shard shard, ReadyEvent _)
{
_hasReceivedReady = true;
return UpdateBotStatus(shard);
}
public async Task Shutdown()
{
// This will stop the timer and prevent any subsequent invocations
@ -92,10 +136,24 @@ namespace PluralKit.Bot
// We're not actually properly disconnecting from the gateway (lol) so it'll linger for a few minutes
// Should be plenty of time for the bot to connect again next startup and set the real status
if (_hasReceivedReady)
await _client.UpdateStatusAsync(new DiscordActivity("Restarting... (please wait)"), UserStatus.Idle);
{
await Task.WhenAll(_cluster.Shards.Values.Select(shard =>
shard.UpdateStatus(new GatewayStatusUpdate
{
Activities = new[]
{
new ActivityPartial
{
Name = "Restarting... (please wait)",
Type = ActivityType.Game
}
},
Status = GatewayStatusUpdate.UserStatus.Idle
})));
}
}
private Task HandleEvent<T>(DiscordClient shard, T evt) where T: DiscordEventArgs
private Task HandleEvent<T>(Shard shard, T evt) where T: IGatewayEvent
{
// We don't want to stall the event pipeline, so we'll "fork" inside here
var _ = HandleEventInner();
@ -121,7 +179,7 @@ namespace PluralKit.Bot
try
{
using var timer = _metrics.Measure.Timer.Time(BotMetrics.EventsHandled,
new MetricTags("event", typeof(T).Name.Replace("EventArgs", "")));
new MetricTags("event", typeof(T).Name.Replace("Event", "")));
// Delegate to the queue to see if it wants to handle this event
// the TryHandle call returns true if it's handled the event
@ -131,13 +189,13 @@ namespace PluralKit.Bot
}
catch (Exception exc)
{
await HandleError(handler, evt, serviceScope, exc);
await HandleError(shard, handler, evt, serviceScope, exc);
}
}
}
private async Task HandleError<T>(IEventHandler<T> handler, T evt, ILifetimeScope serviceScope, Exception exc)
where T: DiscordEventArgs
private async Task HandleError<T>(Shard shard, IEventHandler<T> handler, T evt, ILifetimeScope serviceScope, Exception exc)
where T: IGatewayEvent
{
_metrics.Measure.Meter.Mark(BotMetrics.BotErrors, exc.GetType().FullName);
@ -149,7 +207,7 @@ namespace PluralKit.Bot
.Error(exc, "Exception in event handler: {SentryEventId}", sentryEvent.EventId);
// If the event is us responding to our own error messages, don't bother logging
if (evt is MessageCreateEventArgs mc && mc.Author.Id == _client.CurrentUser.Id)
if (evt is MessageCreateEvent mc && mc.Author.Id == shard.User?.Id)
return;
var shouldReport = exc.IsOurProblem();
@ -160,19 +218,21 @@ namespace PluralKit.Bot
var sentryScope = serviceScope.Resolve<Scope>();
// Add some specific info about Discord error responses, as a breadcrumb
if (exc is BadRequestException bre)
sentryScope.AddBreadcrumb(bre.WebResponse.Response, "response.error", data: new Dictionary<string, string>(bre.WebResponse.Headers));
if (exc is NotFoundException nfe)
sentryScope.AddBreadcrumb(nfe.WebResponse.Response, "response.error", data: new Dictionary<string, string>(nfe.WebResponse.Headers));
if (exc is UnauthorizedException ue)
sentryScope.AddBreadcrumb(ue.WebResponse.Response, "response.error", data: new Dictionary<string, string>(ue.WebResponse.Headers));
// TODO: headers to dict
// if (exc is BadRequestException bre)
// sentryScope.AddBreadcrumb(bre.Response, "response.error", data: new Dictionary<string, string>(bre.Response.Headers));
// if (exc is NotFoundException nfe)
// sentryScope.AddBreadcrumb(nfe.Response, "response.error", data: new Dictionary<string, string>(nfe.Response.Headers));
// if (exc is UnauthorizedException ue)
// sentryScope.AddBreadcrumb(ue.Response, "response.error", data: new Dictionary<string, string>(ue.Response.Headers));
SentrySdk.CaptureEvent(sentryEvent, sentryScope);
// Once we've sent it to Sentry, report it to the user (if we have permission to)
var reportChannel = handler.ErrorChannelFor(evt);
if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks))
await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString());
// TODO: ID lookup
// if (reportChannel != null && reportChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks))
// await _errorMessageService.SendErrorMessage(reportChannel, sentryEvent.EventId.ToString());
}
}
@ -191,23 +251,38 @@ namespace PluralKit.Bot
_logger.Debug("Submitted metrics to backend");
}
private async Task UpdateBotStatus(DiscordClient specificShard = null)
private async Task UpdateBotStatus(Shard specificShard = null)
{
// If we're not on any shards, don't bother (this happens if the periodic timer fires before the first Ready)
if (!_hasReceivedReady) return;
var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count);
var totalGuilds = await _cache.GetAllGuilds().CountAsync();
try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received)
{
Task UpdateStatus(DiscordClient shard) =>
shard.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers | shard #{shard.ShardId}"));
Task UpdateStatus(Shard shard) =>
shard.UpdateStatus(new GatewayStatusUpdate
{
Activities = new[]
{
new ActivityPartial
{
Name = $"pk;help | in {totalGuilds} servers | shard #{shard.ShardInfo?.ShardId}",
Type = ActivityType.Game,
Url = "https://pluralkit.me/"
}
}
});
if (specificShard != null)
await UpdateStatus(specificShard);
else // Run shard updates concurrently
await Task.WhenAll(_client.ShardClients.Values.Select(UpdateStatus));
await Task.WhenAll(_cluster.Shards.Values.Select(UpdateStatus));
}
catch (WebSocketException)
{
// TODO: this still thrown?
}
catch (WebSocketException) { }
}
}
}

View file

@ -9,8 +9,14 @@ using Autofac;
using DSharpPlus;
using DSharpPlus.Entities;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Types;
using PluralKit.Core;
using Permissions = DSharpPlus.Permissions;
namespace PluralKit.Bot
{
public class Context
@ -19,10 +25,17 @@ namespace PluralKit.Bot
private readonly DiscordRestClient _rest;
private readonly DiscordShardedClient _client;
private readonly DiscordClient _shard;
private readonly DiscordMessage _message;
private readonly DiscordClient _shard = null;
private readonly Shard _shardNew;
private readonly Guild? _guild;
private readonly Channel _channel;
private readonly DiscordMessage _message = null;
private readonly Message _messageNew;
private readonly Parameters _parameters;
private readonly MessageContext _messageContext;
private readonly GuildMemberPartial? _botMember;
private readonly PermissionSet _botPermissions;
private readonly PermissionSet _userPermissions;
private readonly IDatabase _db;
private readonly ModelRepository _repo;
@ -32,31 +45,47 @@ namespace PluralKit.Bot
private Command _currentCommand;
public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset,
PKSystem senderSystem, MessageContext messageContext)
public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
PKSystem senderSystem, MessageContext messageContext, GuildMemberPartial? botMember)
{
_rest = provider.Resolve<DiscordRestClient>();
_client = provider.Resolve<DiscordShardedClient>();
_message = message;
_shard = shard;
_messageNew = message;
_shardNew = shard;
_guild = guild;
_channel = channel;
_senderSystem = senderSystem;
_messageContext = messageContext;
_botMember = botMember;
_db = provider.Resolve<IDatabase>();
_repo = provider.Resolve<ModelRepository>();
_metrics = provider.Resolve<IMetrics>();
_provider = provider;
_commandMessageService = provider.Resolve<CommandMessageService>();
_parameters = new Parameters(message.Content.Substring(commandParseOffset));
_botPermissions = message.GuildId != null
? PermissionExtensions.PermissionsFor(guild!, channel, shard.User?.Id ?? default, botMember!.Roles)
: PermissionSet.Dm;
_userPermissions = message.GuildId != null
? PermissionExtensions.PermissionsFor(guild!, channel, message.Author.Id, message.Member!.Roles)
: PermissionSet.Dm;
}
public DiscordUser Author => _message.Author;
public DiscordChannel Channel => _message.Channel;
public Channel ChannelNew => _channel;
public DiscordMessage Message => _message;
public Message MessageNew => _messageNew;
public DiscordGuild Guild => _message.Channel.Guild;
public Guild GuildNew => _guild;
public DiscordClient Shard => _shard;
public DiscordShardedClient Client => _client;
public MessageContext MessageContext => _messageContext;
public PermissionSet BotPermissions => _botPermissions;
public PermissionSet UserPermissions => _userPermissions;
public DiscordRestClient Rest => _rest;
public PKSystem System => _senderSystem;

View file

@ -1,15 +1,13 @@
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using Myriad.Gateway;
namespace PluralKit.Bot
{
public interface IEventHandler<in T> where T: DiscordEventArgs
public interface IEventHandler<in T> where T: IGatewayEvent
{
Task Handle(DiscordClient shard, T evt);
Task Handle(Shard shard, T evt);
DiscordChannel ErrorChannelFor(T evt) => null;
ulong? ErrorChannelFor(T evt) => null;
}
}

View file

@ -5,18 +5,22 @@ using App.Metrics;
using Autofac;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot
{
public class MessageCreated: IEventHandler<MessageCreateEventArgs>
public class MessageCreated: IEventHandler<MessageCreateEvent>
{
private readonly Bot _bot;
private readonly CommandTree _tree;
private readonly DiscordShardedClient _client;
private readonly IDiscordCache _cache;
private readonly LastMessageCacheService _lastMessageCache;
private readonly LoggerCleanService _loggerClean;
private readonly IMetrics _metrics;
@ -25,73 +29,81 @@ namespace PluralKit.Bot
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly BotConfig _config;
private readonly DiscordApiClient _rest;
public MessageCreated(LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean,
IMetrics metrics, ProxyService proxy, DiscordShardedClient client,
CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, ModelRepository repo)
IMetrics metrics, ProxyService proxy,
CommandTree tree, ILifetimeScope services, IDatabase db, BotConfig config, ModelRepository repo, IDiscordCache cache, Bot bot, DiscordApiClient rest)
{
_lastMessageCache = lastMessageCache;
_loggerClean = loggerClean;
_metrics = metrics;
_proxy = proxy;
_client = client;
_tree = tree;
_services = services;
_db = db;
_config = config;
_repo = repo;
_cache = cache;
_bot = bot;
_rest = rest;
}
public DiscordChannel ErrorChannelFor(MessageCreateEventArgs evt) => evt.Channel;
public ulong? ErrorChannelFor(MessageCreateEvent evt) => evt.ChannelId;
private bool IsDuplicateMessage(DiscordMessage evt) =>
private bool IsDuplicateMessage(Message msg) =>
// We consider a message duplicate if it has the same ID as the previous message that hit the gateway
_lastMessageCache.GetLastMessage(evt.ChannelId) == evt.Id;
_lastMessageCache.GetLastMessage(msg.ChannelId) == msg.Id;
public async Task Handle(DiscordClient shard, MessageCreateEventArgs evt)
public async Task Handle(Shard shard, MessageCreateEvent evt)
{
if (evt.Author?.Id == _client.CurrentUser?.Id) return;
if (evt.Message.MessageType != MessageType.Default) return;
if (IsDuplicateMessage(evt.Message)) return;
if (evt.Author.Id == shard.User?.Id) return;
if (evt.Type != Message.MessageType.Default) return;
if (IsDuplicateMessage(evt)) return;
var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null;
var channel = await _cache.GetChannel(evt.ChannelId);
// Log metrics and message info
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
_lastMessageCache.AddMessage(evt.Channel.Id, evt.Message.Id);
_lastMessageCache.AddMessage(evt.ChannelId, evt.Id);
// Get message context from DB (tracking w/ metrics)
MessageContext ctx;
await using (var conn = await _db.Obtain())
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id);
ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.GuildId ?? default, evt.ChannelId);
// Try each handler until we find one that succeeds
if (await TryHandleLogClean(evt, ctx))
return;
// Only do command/proxy handling if it's a user account
if (evt.Message.Author.IsBot || evt.Message.WebhookMessage || evt.Message.Author.IsSystem == true)
if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true)
return;
if (await TryHandleCommand(shard, evt, ctx))
if (await TryHandleCommand(shard, evt, guild, channel, ctx))
return;
await TryHandleProxy(shard, evt, ctx);
await TryHandleProxy(shard, evt, guild, channel, ctx);
}
private async ValueTask<bool> TryHandleLogClean(MessageCreateEventArgs evt, MessageContext ctx)
private async ValueTask<bool> TryHandleLogClean(MessageCreateEvent evt, MessageContext ctx)
{
if (!evt.Message.Author.IsBot || evt.Message.Channel.Type != ChannelType.Text ||
var channel = await _cache.GetChannel(evt.ChannelId);
if (!evt.Author.Bot || channel!.Type != Channel.ChannelType.GuildText ||
!ctx.LogCleanupEnabled) return false;
await _loggerClean.HandleLoggerBotCleanup(evt.Message);
await _loggerClean.HandleLoggerBotCleanup(evt);
return true;
}
private async ValueTask<bool> TryHandleCommand(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx)
private async ValueTask<bool> TryHandleCommand(Shard shard, MessageCreateEvent evt, Guild? guild, Channel channel, MessageContext ctx)
{
var content = evt.Message.Content;
var content = evt.Content;
if (content == null) return false;
// Check for command prefix
if (!HasCommandPrefix(content, out var cmdStart))
if (!HasCommandPrefix(content, shard.User?.Id ?? default, out var cmdStart))
return false;
// Trim leading whitespace from command without actually modifying the string
@ -102,7 +114,7 @@ namespace PluralKit.Bot
try
{
var system = ctx.SystemId != null ? await _db.Execute(c => _repo.GetSystem(c, ctx.SystemId.Value)) : null;
await _tree.ExecuteCommand(new Context(_services, shard, evt.Message, cmdStart, system, ctx));
await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, ctx, _bot.BotMemberIn(channel.GuildId!.Value)));
}
catch (PKError)
{
@ -113,7 +125,7 @@ namespace PluralKit.Bot
return true;
}
private bool HasCommandPrefix(string message, out int argPos)
private bool HasCommandPrefix(string message, ulong currentUserId, out int argPos)
{
// First, try prefixes defined in the config
var prefixes = _config.Prefixes ?? BotConfig.DefaultPrefixes;
@ -128,23 +140,28 @@ namespace PluralKit.Bot
// Then, check mention prefix (must be the bot user, ofc)
argPos = -1;
if (DiscordUtils.HasMentionPrefix(message, ref argPos, out var id))
return id == _client.CurrentUser.Id;
return id == currentUserId;
return false;
}
private async ValueTask<bool> TryHandleProxy(DiscordClient shard, MessageCreateEventArgs evt, MessageContext ctx)
private async ValueTask<bool> TryHandleProxy(Shard shard, MessageCreateEvent evt, Guild guild, Channel channel, MessageContext ctx)
{
var botMember = _bot.BotMemberIn(channel.GuildId!.Value);
var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, shard.User!.Id, botMember!.Roles);
try
{
return await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: ctx.AllowAutoproxy);
return await _proxy.HandleIncomingMessage(shard, evt, ctx, guild, channel, allowAutoproxy: ctx.AllowAutoproxy, botPermissions);
}
catch (PKError e)
{
// User-facing errors, print to the channel properly formatted
var msg = evt.Message;
if (msg.Channel.Guild == null || msg.Channel.BotHasAllPermissions(Permissions.SendMessages))
await msg.Channel.SendMessageFixedAsync($"{Emojis.Error} {e.Message}");
if (botPermissions.HasFlag(PermissionSet.SendMessages))
{
await _rest.CreateMessage(evt.ChannelId,
new MessageRequest {Content = $"{Emojis.Error} {e.Message}"});
}
}
return false;

View file

@ -1,9 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DSharpPlus;
using DSharpPlus.EventArgs;
using Myriad.Gateway;
using PluralKit.Core;
@ -12,7 +10,7 @@ using Serilog;
namespace PluralKit.Bot
{
// Double duty :)
public class MessageDeleted: IEventHandler<MessageDeleteEventArgs>, IEventHandler<MessageBulkDeleteEventArgs>
public class MessageDeleted: IEventHandler<MessageDeleteEvent>, IEventHandler<MessageDeleteBulkEvent>
{
private static readonly TimeSpan MessageDeleteDelay = TimeSpan.FromSeconds(15);
@ -27,7 +25,7 @@ namespace PluralKit.Bot
_logger = logger.ForContext<MessageDeleted>();
}
public Task Handle(DiscordClient shard, MessageDeleteEventArgs evt)
public Task Handle(Shard shard, MessageDeleteEvent evt)
{
// Delete deleted webhook messages from the data store
// Most of the data in the given message is wrong/missing, so always delete just to be sure.
@ -35,7 +33,8 @@ namespace PluralKit.Bot
async Task Inner()
{
await Task.Delay(MessageDeleteDelay);
await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id));
// TODO
// await _db.Execute(c => _repo.DeleteMessage(c, evt.Message.Id));
}
// Fork a task to delete the message after a short delay
@ -44,14 +43,15 @@ namespace PluralKit.Bot
return Task.CompletedTask;
}
public Task Handle(DiscordClient shard, MessageBulkDeleteEventArgs evt)
public Task Handle(Shard shard, MessageDeleteBulkEvent evt)
{
// Same as above, but bulk
async Task Inner()
{
await Task.Delay(MessageDeleteDelay);
_logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id);
await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList()));
// TODO
// _logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id);
// await _db.Execute(c => _repo.DeleteMessagesBulk(c, evt.Messages.Select(m => m.Id).ToList()));
}
_ = Inner();

View file

@ -3,14 +3,15 @@ using System.Threading.Tasks;
using App.Metrics;
using DSharpPlus;
using DSharpPlus.EventArgs;
using Myriad.Gateway;
using PluralKit.Core;
namespace PluralKit.Bot
{
public class MessageEdited: IEventHandler<MessageUpdateEventArgs>
public class MessageEdited: IEventHandler<MessageUpdateEvent>
{
private readonly LastMessageCacheService _lastMessageCache;
private readonly ProxyService _proxy;
@ -29,22 +30,23 @@ namespace PluralKit.Bot
_client = client;
}
public async Task Handle(DiscordClient shard, MessageUpdateEventArgs evt)
public async Task Handle(Shard shard, MessageUpdateEvent evt)
{
if (evt.Author?.Id == _client.CurrentUser?.Id) return;
// Edit message events sometimes arrive with missing data; double-check it's all there
if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return;
// Only react to the last message in the channel
if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return;
// Just run the normal message handling code, with a flag to disable autoproxying
MessageContext ctx;
await using (var conn = await _db.Obtain())
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id);
await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false);
// TODO: fix
// if (evt.Author?.Id == _client.CurrentUser?.Id) return;
//
// // Edit message events sometimes arrive with missing data; double-check it's all there
// if (evt.Message.Content == null || evt.Author == null || evt.Channel.Guild == null) return;
//
// // Only react to the last message in the channel
// if (_lastMessageCache.GetLastMessage(evt.Channel.Id) != evt.Message.Id) return;
//
// // Just run the normal message handling code, with a flag to disable autoproxying
// MessageContext ctx;
// await using (var conn = await _db.Obtain())
// using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
// ctx = await _repo.GetMessageContext(conn, evt.Author.Id, evt.Channel.GuildId, evt.Channel.Id);
// await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: false);
}
}
}

View file

@ -5,13 +5,15 @@ using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using DSharpPlus.Exceptions;
using Myriad.Gateway;
using PluralKit.Core;
using Serilog;
namespace PluralKit.Bot
{
public class ReactionAdded: IEventHandler<MessageReactionAddEventArgs>
public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
{
private readonly IDatabase _db;
private readonly ModelRepository _repo;
@ -28,9 +30,9 @@ namespace PluralKit.Bot
_logger = logger.ForContext<ReactionAdded>();
}
public async Task Handle(DiscordClient shard, MessageReactionAddEventArgs evt)
public async Task Handle(Shard shard, MessageReactionAddEvent evt)
{
await TryHandleProxyMessageReactions(shard, evt);
// await TryHandleProxyMessageReactions(shard, evt);
}
private async ValueTask TryHandleProxyMessageReactions(DiscordClient shard, MessageReactionAddEventArgs evt)

View file

@ -4,10 +4,11 @@ using System.Threading.Tasks;
using Autofac;
using DSharpPlus;
using Microsoft.Extensions.Configuration;
using Myriad.Gateway;
using Myriad.Rest;
using PluralKit.Core;
using Serilog;
@ -47,7 +48,8 @@ namespace PluralKit.Bot
// Start the Discord shards themselves (handlers already set up)
logger.Information("Connecting to Discord");
await services.Resolve<DiscordShardedClient>().StartAsync();
var info = await services.Resolve<DiscordApiClient>().GetGatewayBot();
await services.Resolve<Cluster>().Start(info);
logger.Information("Connected! All is good (probably).");
// Lastly, we just... wait. Everything else is handled in the DiscordClient event loop

View file

@ -6,12 +6,17 @@ using Autofac;
using DSharpPlus;
using DSharpPlus.EventArgs;
using Myriad.Cache;
using Myriad.Gateway;
using NodaTime;
using PluralKit.Core;
using Sentry;
using Serilog;
namespace PluralKit.Bot
{
public class BotModule: Module
@ -30,6 +35,22 @@ namespace PluralKit.Bot
builder.Register(c => new DiscordShardedClient(c.Resolve<DiscordConfiguration>())).AsSelf().SingleInstance();
builder.Register(c => new DiscordRestClient(c.Resolve<DiscordConfiguration>())).AsSelf().SingleInstance();
builder.Register(c => new GatewaySettings
{
Token = c.Resolve<BotConfig>().Token,
Intents = GatewayIntent.Guilds |
GatewayIntent.DirectMessages |
GatewayIntent.DirectMessageReactions |
GatewayIntent.GuildEmojis |
GatewayIntent.GuildMessages |
GatewayIntent.GuildWebhooks |
GatewayIntent.GuildMessageReactions
}).AsSelf().SingleInstance();
builder.RegisterType<Cluster>().AsSelf().SingleInstance();
builder.Register(c => new Myriad.Rest.DiscordApiClient(c.Resolve<BotConfig>().Token, c.Resolve<ILogger>()))
.AsSelf().SingleInstance();
builder.RegisterType<MemoryDiscordCache>().AsSelf().As<IDiscordCache>().SingleInstance();
// Commands
builder.RegisterType<CommandTree>().AsSelf();
builder.RegisterType<Autoproxy>().AsSelf();
@ -55,10 +76,10 @@ namespace PluralKit.Bot
// Bot core
builder.RegisterType<Bot>().AsSelf().SingleInstance();
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEventArgs>>();
builder.RegisterType<MessageDeleted>().As<IEventHandler<MessageDeleteEventArgs>>().As<IEventHandler<MessageBulkDeleteEventArgs>>();
builder.RegisterType<MessageEdited>().As<IEventHandler<MessageUpdateEventArgs>>();
builder.RegisterType<ReactionAdded>().As<IEventHandler<MessageReactionAddEventArgs>>();
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEvent>>();
builder.RegisterType<MessageDeleted>().As<IEventHandler<MessageDeleteEvent>>().As<IEventHandler<MessageDeleteBulkEvent>>();
builder.RegisterType<MessageEdited>().As<IEventHandler<MessageUpdateEvent>>();
builder.RegisterType<ReactionAdded>().As<IEventHandler<MessageReactionAddEvent>>();
// Event handler queue
builder.RegisterType<HandlerQueue<MessageCreateEventArgs>>().AsSelf().SingleInstance();
@ -81,13 +102,14 @@ namespace PluralKit.Bot
// Sentry stuff
builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope();
builder.RegisterType<SentryEnricher>()
.As<ISentryEnricher<MessageCreateEventArgs>>()
.As<ISentryEnricher<MessageDeleteEventArgs>>()
.As<ISentryEnricher<MessageUpdateEventArgs>>()
.As<ISentryEnricher<MessageBulkDeleteEventArgs>>()
.As<ISentryEnricher<MessageReactionAddEventArgs>>()
.SingleInstance();
// TODO:
// builder.RegisterType<SentryEnricher>()
// .As<ISentryEnricher<MessageCreateEvent>>()
// .As<ISentryEnricher<MessageDeleteEvent>>()
// .As<ISentryEnricher<MessageUpdateEvent>>()
// .As<ISentryEnricher<MessageDeleteBulkEvent>>()
// .As<ISentryEnricher<MessageReactionAddEvent>>()
// .SingleInstance();
// Proxy stuff
builder.RegisterType<ProxyMatcher>().AsSelf().SingleInstance();

View file

@ -11,6 +11,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Myriad\Myriad.csproj" />
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
</ItemGroup>

View file

@ -7,9 +7,13 @@ using System.Threading.Tasks;
using App.Metrics;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.Exceptions;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using PluralKit.Core;
@ -28,9 +32,11 @@ namespace PluralKit.Bot
private readonly WebhookExecutorService _webhookExecutor;
private readonly ProxyMatcher _matcher;
private readonly IMetrics _metrics;
private readonly IDiscordCache _cache;
private readonly DiscordApiClient _rest;
public ProxyService(LogChannelService logChannel, ILogger logger,
WebhookExecutorService webhookExecutor, IDatabase db, ProxyMatcher matcher, IMetrics metrics, ModelRepository repo)
WebhookExecutorService webhookExecutor, IDatabase db, ProxyMatcher matcher, IMetrics metrics, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest)
{
_logChannel = logChannel;
_webhookExecutor = webhookExecutor;
@ -38,71 +44,75 @@ namespace PluralKit.Bot
_matcher = matcher;
_metrics = metrics;
_repo = repo;
_cache = cache;
_rest = rest;
_logger = logger.ForContext<ProxyService>();
}
public async Task<bool> HandleIncomingMessage(DiscordClient shard, DiscordMessage message, MessageContext ctx, bool allowAutoproxy)
public async Task<bool> HandleIncomingMessage(Shard shard, MessageCreateEvent message, MessageContext ctx, Guild guild, Channel channel, bool allowAutoproxy, PermissionSet botPermissions)
{
if (!ShouldProxy(message, ctx)) return false;
if (!ShouldProxy(channel, message, ctx))
return false;
// Fetch members and try to match to a specific member
await using var conn = await _db.Obtain();
List<ProxyMember> members;
using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime))
members = (await _repo.GetProxyMembers(conn, message.Author.Id, message.Channel.GuildId)).ToList();
members = (await _repo.GetProxyMembers(conn, message.Author.Id, message.GuildId!.Value)).ToList();
if (!_matcher.TryMatch(ctx, members, out var match, message.Content, message.Attachments.Count > 0,
if (!_matcher.TryMatch(ctx, members, out var match, message.Content, message.Attachments.Length > 0,
allowAutoproxy)) return false;
// Permission check after proxy match so we don't get spammed when not actually proxying
if (!await CheckBotPermissionsOrError(message.Channel)) return false;
if (!await CheckBotPermissionsOrError(botPermissions, message.ChannelId))
return false;
// this method throws, so no need to wrap it in an if statement
CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx));
// Check if the sender account can mention everyone/here + embed links
// we need to "mirror" these permissions when proxying to prevent exploits
var senderPermissions = message.Channel.PermissionsInSync(message.Author);
var allowEveryone = (senderPermissions & Permissions.MentionEveryone) != 0;
var allowEmbeds = (senderPermissions & Permissions.EmbedLinks) != 0;
var senderPermissions = PermissionExtensions.PermissionsFor(guild, channel, message);
var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone);
var allowEmbeds = senderPermissions.HasFlag(PermissionSet.EmbedLinks);
// Everything's in order, we can execute the proxy!
await ExecuteProxy(shard, conn, message, ctx, match, allowEveryone, allowEmbeds);
return true;
}
private bool ShouldProxy(DiscordMessage msg, MessageContext ctx)
private bool ShouldProxy(Channel channel, Message msg, MessageContext ctx)
{
// Make sure author has a system
if (ctx.SystemId == null) return false;
// Make sure channel is a guild text channel and this is a normal message
if ((msg.Channel.Type != ChannelType.Text && msg.Channel.Type != ChannelType.News) || msg.MessageType != MessageType.Default) return false;
if ((channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildNews) || msg.Type != Message.MessageType.Default) return false;
// Make sure author is a normal user
if (msg.Author.IsSystem == true || msg.Author.IsBot || msg.WebhookMessage) return false;
if (msg.Author.System == true || msg.Author.Bot || msg.WebhookId != null) return false;
// Make sure proxying is enabled here
if (!ctx.ProxyEnabled || ctx.InBlacklist) return false;
// Make sure we have either an attachment or message content
var isMessageBlank = msg.Content == null || msg.Content.Trim().Length == 0;
if (isMessageBlank && msg.Attachments.Count == 0) return false;
if (isMessageBlank && msg.Attachments.Length == 0) return false;
// All good!
return true;
}
private async Task ExecuteProxy(DiscordClient shard, IPKConnection conn, DiscordMessage trigger, MessageContext ctx,
private async Task ExecuteProxy(Shard shard, IPKConnection conn, Message trigger, MessageContext ctx,
ProxyMatch match, bool allowEveryone, bool allowEmbeds)
{
// Create reply embed
var embeds = new List<DiscordEmbed>();
if (trigger.Reference?.Channel?.Id == trigger.ChannelId)
var embeds = new List<Embed>();
if (trigger.MessageReference?.ChannelId == trigger.ChannelId)
{
var repliedTo = await FetchReplyOriginalMessage(trigger.Reference);
var embed = await CreateReplyEmbed(repliedTo);
var repliedTo = await FetchReplyOriginalMessage(trigger.MessageReference);
var embed = CreateReplyEmbed(repliedTo);
if (embed != null)
embeds.Add(embed);
}
@ -110,35 +120,44 @@ namespace PluralKit.Bot
// Send the webhook
var content = match.ProxyContent;
if (!allowEmbeds) content = content.BreakLinkEmbeds();
var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, FixSingleCharacterName(match.Member.ProxyName(ctx)),
match.Member.ProxyAvatar(ctx),
content, trigger.Attachments, embeds, allowEveryone);
var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest
{
GuildId = trigger.GuildId!.Value,
ChannelId = trigger.ChannelId,
Name = FixSingleCharacterName(match.Member.ProxyName(ctx)),
AvatarUrl = match.Member.ProxyAvatar(ctx),
Content = content,
Attachments = trigger.Attachments,
Embeds = embeds.ToArray(),
AllowEveryone = allowEveryone,
});
await HandleProxyExecutedActions(shard, conn, ctx, trigger, proxyMessage, match);
}
private async Task<DiscordMessage> FetchReplyOriginalMessage(DiscordMessageReference reference)
private async Task<Message?> FetchReplyOriginalMessage(Message.Reference reference)
{
try
{
return await reference.Channel.GetMessageAsync(reference.Message.Id);
}
catch (NotFoundException)
{
_logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found",
reference.Channel.Id, reference.Message.Id);
var msg = await _rest.GetMessage(reference.ChannelId!.Value, reference.MessageId!.Value);
if (msg == null)
_logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found",
reference.ChannelId, reference.MessageId);
return msg;
}
catch (UnauthorizedException)
{
_logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but bot was not allowed to",
reference.Channel.Id, reference.Message.Id);
reference.ChannelId, reference.MessageId);
}
return null;
}
private async Task<DiscordEmbed> CreateReplyEmbed(DiscordMessage original)
private Embed CreateReplyEmbed(Message original)
{
var jumpLink = $"https://discord.com/channels/{original.GuildId}/{original.ChannelId}/{original.Id}";
var content = new StringBuilder();
var hasContent = !string.IsNullOrWhiteSpace(original.Content);
@ -155,40 +174,45 @@ namespace PluralKit.Bot
msg += "…";
}
content.Append($"**[Reply to:]({original.JumpLink})** ");
content.Append($"**[Reply to:]({jumpLink})** ");
content.Append(msg);
if (original.Attachments.Count > 0)
if (original.Attachments.Length > 0)
content.Append($" {Emojis.Paperclip}");
}
else
{
content.Append($"*[(click to see attachment)]({original.JumpLink})*");
content.Append($"*[(click to see attachment)]({jumpLink})*");
}
var username = (original.Author as DiscordMember)?.Nickname ?? original.Author.Username;
return new DiscordEmbedBuilder()
// TODO: get the nickname somehow
var username = original.Author.Username;
// var username = original.Member?.Nick ?? original.Author.Username;
var avatarUrl = $"https://cdn.discordapp.com/avatars/{original.Author.Id}/{original.Author.Avatar}.png";
return new Embed
{
// unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation]
.WithAuthor($"{username}\u2004\u21a9\ufe0f", iconUrl: original.Author.AvatarUrl)
.WithDescription(content.ToString())
.Build();
Author = new($"{username}\u2004\u21a9\ufe0f", IconUrl: avatarUrl),
Description = content.ToString()
};
}
private async Task HandleProxyExecutedActions(DiscordClient shard, IPKConnection conn, MessageContext ctx,
DiscordMessage triggerMessage, DiscordMessage proxyMessage,
private async Task HandleProxyExecutedActions(Shard shard, IPKConnection conn, MessageContext ctx,
Message triggerMessage, Message proxyMessage,
ProxyMatch match)
{
Task SaveMessageInDatabase() => _repo.AddMessage(conn, new PKMessage
{
Channel = triggerMessage.ChannelId,
Guild = triggerMessage.Channel.GuildId,
Guild = triggerMessage.GuildId,
Member = match.Member.Id,
Mid = proxyMessage.Id,
OriginalMid = triggerMessage.Id,
Sender = triggerMessage.Author.Id
});
Task LogMessageToChannel() => _logChannel.LogMessage(shard, ctx, match, triggerMessage, proxyMessage.Id).AsTask();
Task LogMessageToChannel() => _logChannel.LogMessage(ctx, match, triggerMessage, proxyMessage.Id).AsTask();
async Task DeleteProxyTriggerMessage()
{
@ -196,7 +220,7 @@ namespace PluralKit.Bot
await Task.Delay(MessageDeletionDelay);
try
{
await triggerMessage.DeleteAsync();
await _rest.DeleteMessage(triggerMessage.ChannelId, triggerMessage.Id);
}
catch (NotFoundException)
{
@ -216,7 +240,7 @@ namespace PluralKit.Bot
);
}
private async Task HandleTriggerAlreadyDeleted(DiscordMessage proxyMessage)
private async Task HandleTriggerAlreadyDeleted(Message proxyMessage)
{
// If a trigger message is deleted before we get to delete it, we can assume a mod bot or similar got to it
// In this case we should also delete the now-proxied message.
@ -224,32 +248,35 @@ namespace PluralKit.Bot
try
{
await proxyMessage.DeleteAsync();
await _rest.DeleteMessage(proxyMessage.ChannelId, proxyMessage.Id);
}
catch (NotFoundException) { }
catch (UnauthorizedException) { }
}
private async Task<bool> CheckBotPermissionsOrError(DiscordChannel channel)
private async Task<bool> CheckBotPermissionsOrError(PermissionSet permissions, ulong responseChannel)
{
var permissions = channel.BotPermissions();
// If we can't send messages at all, just bail immediately.
// 2020-04-22: Manage Messages does *not* override a lack of Send Messages.
if ((permissions & Permissions.SendMessages) == 0) return false;
if (!permissions.HasFlag(PermissionSet.SendMessages))
return false;
if ((permissions & Permissions.ManageWebhooks) == 0)
if (!permissions.HasFlag(PermissionSet.ManageWebhooks))
{
// todo: PKError-ify these
await channel.SendMessageFixedAsync(
$"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this.");
await _rest.CreateMessage(responseChannel, new MessageRequest
{
Content = $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this."
});
return false;
}
if ((permissions & Permissions.ManageMessages) == 0)
if (!permissions.HasFlag(PermissionSet.ManageMessages))
{
await channel.SendMessageFixedAsync(
$"{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.");
await _rest.CreateMessage(responseChannel, new MessageRequest
{
Content = $"{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."
});
return false;
}

View file

@ -2,8 +2,9 @@ using System.Threading.Tasks;
using Dapper;
using DSharpPlus;
using DSharpPlus.Entities;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Types;
using PluralKit.Core;
@ -15,56 +16,62 @@ namespace PluralKit.Bot {
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly ILogger _logger;
private readonly IDiscordCache _cache;
private readonly DiscordApiClient _rest;
public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo)
public LogChannelService(EmbedService embed, ILogger logger, IDatabase db, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest)
{
_embed = embed;
_db = db;
_repo = repo;
_cache = cache;
_rest = rest;
_logger = logger.ForContext<LogChannelService>();
}
public async ValueTask LogMessage(DiscordClient client, MessageContext ctx, ProxyMatch proxy, DiscordMessage trigger, ulong hookMessage)
public async ValueTask LogMessage(MessageContext ctx, ProxyMatch proxy, Message trigger, ulong hookMessage)
{
if (ctx.SystemId == null || ctx.LogChannel == null || ctx.InLogBlacklist) return;
// Find log channel and check if valid
var logChannel = await FindLogChannel(client, trigger.Channel.GuildId, ctx.LogChannel.Value);
if (logChannel == null || logChannel.Type != ChannelType.Text) return;
var logChannel = await FindLogChannel(trigger.GuildId!.Value, ctx.LogChannel.Value);
if (logChannel == null || logChannel.Type != Channel.ChannelType.GuildText) return;
// Check bot permissions
if (!logChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks))
{
_logger.Information(
"Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})",
ctx.LogChannel.Value, trigger.Channel.GuildId, trigger.Channel.BotPermissions());
return;
}
// if (!logChannel.BotHasAllPermissions(Permissions.SendMessages | Permissions.EmbedLinks))
// {
// _logger.Information(
// "Does not have permission to proxy log, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})",
// ctx.LogChannel.Value, trigger.GuildId!.Value, trigger.Channel.BotPermissions());
// return;
// }
//
// Send embed!
await using var conn = await _db.Obtain();
var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value),
await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content,
trigger.Channel);
var url = $"https://discord.com/channels/{trigger.Channel.GuildId}/{trigger.ChannelId}/{hookMessage}";
await logChannel.SendMessageFixedAsync(content: url, embed: embed);
// TODO: fix?
// await using var conn = await _db.Obtain();
// var embed = _embed.CreateLoggedMessageEmbed(await _repo.GetSystem(conn, ctx.SystemId.Value),
// await _repo.GetMember(conn, proxy.Member.Id), hookMessage, trigger.Id, trigger.Author, proxy.Content,
// trigger.Channel);
// var url = $"https://discord.com/channels/{trigger.Channel.GuildId}/{trigger.ChannelId}/{hookMessage}";
// await logChannel.SendMessageFixedAsync(content: url, embed: embed);
}
private async Task<DiscordChannel> FindLogChannel(DiscordClient client, ulong guild, ulong channel)
private async Task<Channel?> FindLogChannel(ulong guildId, ulong channelId)
{
// MUST use this client here, otherwise we get strange cache issues where the guild doesn't exist... >.>
var obj = await client.GetChannel(channel);
// TODO: fetch it directly on cache miss?
var channel = await _cache.GetChannel(channelId);
if (obj == null)
if (channel == null)
{
// Channel doesn't exist or we don't have permission to access it, let's remove it from the database too
_logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channel, guild);
_logger.Warning("Attempted to fetch missing log channel {LogChannel} for guild {Guild}, removing from database", channelId, guildId);
await using var conn = await _db.Obtain();
await conn.ExecuteAsync("update servers set log_channel = null where id = @Guild",
new {Guild = guild});
new {Guild = guildId});
}
return obj;
return channel;
}
}
}

View file

@ -4,11 +4,10 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Dapper;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.Exceptions;
using Myriad.Types;
using PluralKit.Core;
@ -68,8 +67,10 @@ namespace PluralKit.Bot
public ICollection<LoggerBot> Bots => _bots.Values;
public async ValueTask HandleLoggerBotCleanup(DiscordMessage msg)
public async ValueTask HandleLoggerBotCleanup(Message msg)
{
// TODO: fix!!
/*
if (msg.Channel.Type != ChannelType.Text) return;
if (!msg.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return;
@ -130,6 +131,7 @@ namespace PluralKit.Bot
// The only thing I can think of that'd cause this are the DeleteAsync() calls which 404 when
// the message doesn't exist anyway - so should be safe to just ignore it, right?
}
*/
}
private static ulong? ExtractAuttaja(DiscordMessage msg)

View file

@ -1,14 +1,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using App.Metrics;
using DSharpPlus;
using DSharpPlus.Entities;
using Myriad.Gateway;
using Myriad.Rest;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using Serilog;
@ -17,38 +18,32 @@ namespace PluralKit.Bot
public class WebhookCacheService
{
public static readonly string WebhookName = "PluralKit Proxy Webhook";
private readonly DiscordShardedClient _client;
private readonly ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>> _webhooks;
private readonly DiscordApiClient _rest;
private readonly ConcurrentDictionary<ulong, Lazy<Task<Webhook>>> _webhooks;
private readonly IMetrics _metrics;
private readonly ILogger _logger;
private readonly Cluster _cluster;
public WebhookCacheService(DiscordShardedClient client, ILogger logger, IMetrics metrics)
public WebhookCacheService(ILogger logger, IMetrics metrics, DiscordApiClient rest, Cluster cluster)
{
_client = client;
_metrics = metrics;
_rest = rest;
_cluster = cluster;
_logger = logger.ForContext<WebhookCacheService>();
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>>();
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<Webhook>>>();
}
public async Task<DiscordWebhook> GetWebhook(DiscordClient client, ulong channelId)
{
var channel = await client.GetChannel(channelId);
if (channel == null) return null;
if (channel.Type == ChannelType.Text) return null;
return await GetWebhook(channel);
}
public async Task<DiscordWebhook> GetWebhook(DiscordChannel channel)
public async Task<Webhook> GetWebhook(ulong channelId)
{
// 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.
Lazy<Task<DiscordWebhook>> GetWebhookTaskInner()
Lazy<Task<Webhook>> GetWebhookTaskInner()
{
Task<DiscordWebhook> Factory() => GetOrCreateWebhook(channel);
return _webhooks.GetOrAdd(channel.Id, new Lazy<Task<DiscordWebhook>>(Factory));
Task<Webhook> Factory() => GetOrCreateWebhook(channelId);
return _webhooks.GetOrAdd(channelId, new Lazy<Task<Webhook>>(Factory));
}
var lazyWebhookValue = GetWebhookTaskInner();
@ -57,36 +52,38 @@ namespace PluralKit.Bot
// although, keep in mind this block gets hit the call *after* the task failed (since we only await it below)
if (lazyWebhookValue.IsValueCreated && lazyWebhookValue.Value.IsFaulted)
{
_logger.Warning(lazyWebhookValue.Value.Exception, "Cached webhook task for {Channel} faulted with below exception", channel.Id);
_logger.Warning(lazyWebhookValue.Value.Exception, "Cached webhook task for {Channel} faulted with below exception", channelId);
// Specifically don't recurse here so we don't infinite-loop - if this one errors too, it'll "stick"
// until next time this function gets hit (which is okay, probably).
_webhooks.TryRemove(channel.Id, out _);
_webhooks.TryRemove(channelId, out _);
lazyWebhookValue = GetWebhookTaskInner();
}
// 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 && webhook.ChannelId != 0) return await InvalidateAndRefreshWebhook(channel, webhook);
if (webhook.ChannelId != channelId && webhook.ChannelId != 0)
return await InvalidateAndRefreshWebhook(channelId, webhook);
return webhook;
}
public async Task<DiscordWebhook> InvalidateAndRefreshWebhook(DiscordChannel channel, DiscordWebhook webhook)
public async Task<Webhook> InvalidateAndRefreshWebhook(ulong channelId, Webhook webhook)
{
// note: webhook.ChannelId may not be the same as channelId >.>
_logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId);
_webhooks.TryRemove(webhook.ChannelId, out _);
return await GetWebhook(channel);
return await GetWebhook(channelId);
}
private async Task<DiscordWebhook> GetOrCreateWebhook(DiscordChannel channel)
private async Task<Webhook?> GetOrCreateWebhook(ulong channelId)
{
_logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id);
_logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channelId);
_metrics.Measure.Meter.Mark(BotMetrics.WebhookCacheMisses);
_logger.Debug("Finding webhook for channel {Channel}", channel.Id);
var webhooks = await FetchChannelWebhooks(channel);
_logger.Debug("Finding webhook for channel {Channel}", channelId);
var webhooks = await FetchChannelWebhooks(channelId);
// If the channel has a webhook created by PK, just return that one
var ourWebhook = webhooks.FirstOrDefault(IsWebhookMine);
@ -95,17 +92,17 @@ namespace PluralKit.Bot
// We don't have one, so we gotta create a new one
// but first, make sure we haven't hit the webhook cap yet...
if (webhooks.Count >= 10)
if (webhooks.Length >= 10)
throw new PKError("This channel has the maximum amount of possible webhooks (10) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying.");
return await DoCreateWebhook(channel);
return await DoCreateWebhook(channelId);
}
private async Task<IReadOnlyList<DiscordWebhook>> FetchChannelWebhooks(DiscordChannel channel)
private async Task<Webhook[]> FetchChannelWebhooks(ulong channelId)
{
try
{
return await channel.GetWebhooksAsync();
return await _rest.GetChannelWebhooks(channelId);
}
catch (HttpRequestException e)
{
@ -113,33 +110,17 @@ namespace PluralKit.Bot
// This happens sometimes when Discord returns a malformed request for the webhook list
// Nothing we can do than just assume that none exist.
return new DiscordWebhook[0];
return new Webhook[0];
}
}
private async Task<DiscordWebhook> FindExistingWebhook(DiscordChannel channel)
private async Task<Webhook> DoCreateWebhook(ulong channelId)
{
_logger.Debug("Finding webhook for channel {Channel}", channel.Id);
try
{
return (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine);
}
catch (HttpRequestException e)
{
_logger.Warning(e, "Error occurred while fetching webhook list");
// This happens sometimes when Discord returns a malformed request for the webhook list
// Nothing we can do than just assume that none exist and return null.
return null;
}
_logger.Information("Creating new webhook for channel {Channel}", channelId);
return await _rest.CreateWebhook(channelId, new CreateWebhookRequest(WebhookName));
}
private Task<DiscordWebhook> DoCreateWebhook(DiscordChannel channel)
{
_logger.Information("Creating new webhook for channel {Channel}", channel.Id);
return channel.CreateWebhookAsync(WebhookName);
}
private bool IsWebhookMine(DiscordWebhook arg) => arg.User.Id == _client.CurrentUser.Id && arg.Name == WebhookName;
private bool IsWebhookMine(Webhook arg) => arg.User?.Id == _cluster.User?.Id && arg.Name == WebhookName;
public int CacheSize => _webhooks.Count;
}

View file

@ -1,19 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using App.Metrics;
using DSharpPlus.Entities;
using DSharpPlus.Exceptions;
using Humanizer;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Rest.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Serilog;
@ -26,64 +27,84 @@ namespace PluralKit.Bot
// Exceptions for control flow? don't mind if I do
// TODO: rewrite both of these as a normal exceptional return value (0?) in case of error to be discarded by caller
}
public record ProxyRequest
{
public ulong GuildId { get; init; }
public ulong ChannelId { get; init; }
public string Name { get; init; }
public string? AvatarUrl { get; init; }
public string? Content { get; init; }
public Message.Attachment[] Attachments { get; init; }
public Embed[] Embeds { get; init; }
public bool AllowEveryone { get; init; }
}
public class WebhookExecutorService
{
private readonly IDiscordCache _cache;
private readonly WebhookCacheService _webhookCache;
private readonly DiscordApiClient _rest;
private readonly ILogger _logger;
private readonly IMetrics _metrics;
private readonly HttpClient _client;
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client)
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client, IDiscordCache cache, DiscordApiClient rest)
{
_metrics = metrics;
_webhookCache = webhookCache;
_client = client;
_cache = cache;
_rest = rest;
_logger = logger.ForContext<WebhookExecutorService>();
}
public async Task<DiscordMessage> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> attachments, IReadOnlyList<DiscordEmbed> embeds, bool allowEveryone)
public async Task<Message> ExecuteWebhook(ProxyRequest req)
{
_logger.Verbose("Invoking webhook in channel {Channel}", channel.Id);
_logger.Verbose("Invoking webhook in channel {Channel}", req.ChannelId);
// Get a webhook, execute it
var webhook = await _webhookCache.GetWebhook(channel);
var webhookMessage = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments, embeds, allowEveryone);
var webhook = await _webhookCache.GetWebhook(req.ChannelId);
var webhookMessage = await ExecuteWebhookInner(webhook, req);
// Log the relevant metrics
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
_logger.Information("Invoked webhook {Webhook} in channel {Channel}", webhook.Id,
channel.Id);
req.ChannelId);
return webhookMessage;
}
private async Task<DiscordMessage> ExecuteWebhookInner(
DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
IReadOnlyList<DiscordAttachment> attachments, IReadOnlyList<DiscordEmbed> embeds, bool allowEveryone, bool hasRetried = false)
private async Task<Message> ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false)
{
content = content.Truncate(2000);
var guild = await _cache.GetGuild(req.GuildId)!;
var content = req.Content.Truncate(2000);
var dwb = new DiscordWebhookBuilder();
dwb.WithUsername(FixClyde(name).Truncate(80));
dwb.WithContent(content);
dwb.AddMentions(content.ParseAllMentions(allowEveryone, channel.Guild));
if (!string.IsNullOrWhiteSpace(avatarUrl))
dwb.WithAvatarUrl(avatarUrl);
dwb.AddEmbeds(embeds);
var webhookReq = new ExecuteWebhookRequest
{
Username = FixClyde(req.Name).Truncate(80),
Content = content,
AllowedMentions = null, // todo
AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null,
Embeds = req.Embeds
};
var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024);
// dwb.AddMentions(content.ParseAllMentions(guild, req.AllowEveryone));
MultipartFile[] files = null;
var attachmentChunks = ChunkAttachmentsOrThrow(req.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.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count);
await AddAttachmentsToBuilder(dwb, attachmentChunks[0]);
_logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks",
req.Attachments.Length, req.Attachments.Select(a => a.Size).Sum() / 1024 / 1024, attachmentChunks.Count);
files = await GetAttachmentFiles(attachmentChunks[0]);
}
DiscordMessage webhookMessage;
Message webhookMessage;
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) {
try
{
webhookMessage = await webhook.ExecuteAsync(dwb);
webhookMessage = await _rest.ExecuteWebhook(webhook.Id, webhook.Token, webhookReq, files);
}
catch (JsonReaderException)
{
@ -91,17 +112,16 @@ namespace PluralKit.Bot
// Nothing we can do about this - happens sometimes under server load, so just drop the message and give up
throw new WebhookExecutionErrorOnDiscordsEnd();
}
catch (NotFoundException e)
catch (Myriad.Rest.Exceptions.NotFoundException e)
{
var errorText = e.WebResponse?.Response;
if (errorText != null && errorText.Contains("10015") && !hasRetried)
if (e.ErrorCode == 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);
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook);
return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, embeds, allowEveryone, hasRetried: true);
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(req.ChannelId, webhook);
return await ExecuteWebhookInner(newWebhook, req, hasRetried: true);
}
throw;
@ -109,53 +129,50 @@ namespace PluralKit.Bot
}
// 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);
var _ = TrySendRemainingAttachments(webhook, req.Name, req.AvatarUrl, attachmentChunks);
return webhookMessage;
}
private async Task TrySendRemainingAttachments(DiscordWebhook webhook, string name, string avatarUrl, IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> attachmentChunks)
private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl, IReadOnlyList<IReadOnlyCollection<Message.Attachment>> 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);
var files = await GetAttachmentFiles(attachmentChunks[i]);
var req = new ExecuteWebhookRequest {Username = name, AvatarUrl = avatarUrl};
await _rest.ExecuteWebhook(webhook.Id, webhook.Token!, req, files);
}
}
private async Task AddAttachmentsToBuilder(DiscordWebhookBuilder dwb, IReadOnlyCollection<DiscordAttachment> attachments)
private async Task<MultipartFile[]> GetAttachmentFiles(IReadOnlyCollection<Message.Attachment> attachments)
{
async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment)
async Task<MultipartFile> GetStream(Message.Attachment attachment)
{
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
return (attachment, await attachmentResponse.Content.ReadAsStreamAsync());
return new(attachment.Filename, await attachmentResponse.Content.ReadAsStreamAsync());
}
foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
dwb.AddFile(attachment.FileName, attachmentStream);
return await Task.WhenAll(attachments.Select(GetStream));
}
private IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> ChunkAttachmentsOrThrow(
IReadOnlyList<DiscordAttachment> attachments, int sizeThreshold)
private IReadOnlyList<IReadOnlyCollection<Message.Attachment>> ChunkAttachmentsOrThrow(
IReadOnlyList<Message.Attachment> 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<DiscordAttachment>>();
var list = new List<DiscordAttachment>();
var chunks = new List<List<Message.Attachment>>();
var list = new List<Message.Attachment>();
foreach (var attachment in attachments)
{
if (attachment.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge;
if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge;
if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold)
if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold)
{
chunks.Add(list);
list = new List<DiscordAttachment>();
list = new List<Message.Attachment>();
}
list.Add(attachment);

View file

@ -12,10 +12,14 @@ using DSharpPlus.Entities;
using DSharpPlus.EventArgs;
using DSharpPlus.Exceptions;
using Myriad.Types;
using NodaTime;
using PluralKit.Core;
using Permissions = DSharpPlus.Permissions;
namespace PluralKit.Bot
{
public static class DiscordUtils
@ -190,8 +194,7 @@ namespace PluralKit.Bot
return false;
}
public static IEnumerable<IMention> ParseAllMentions(this string input, bool allowEveryone = false,
DiscordGuild guild = null)
public static IEnumerable<IMention> ParseAllMentions(this string input, Guild guild, bool allowEveryone = false)
{
var mentions = new List<IMention>();
mentions.AddRange(USER_MENTION.Matches(input)
@ -203,7 +206,7 @@ namespace PluralKit.Bot
// Original fix by Gwen
mentions.AddRange(ROLE_MENTION.Matches(input)
.Select(x => ulong.Parse(x.Groups[1].Value))
.Where(x => allowEveryone || guild != null && guild.GetRole(x).IsMentionable)
.Where(x => allowEveryone || guild != null && (guild.Roles.FirstOrDefault(g => g.Id == x)?.Mentionable ?? false))
.Select(x => new RoleMention(x) as IMention));
if (EVERYONE_HERE_MENTION.IsMatch(input) && allowEveryone)
mentions.Add(new EveryoneMention());

View file

@ -4,26 +4,29 @@ using System.Linq;
using DSharpPlus;
using DSharpPlus.EventArgs;
using Myriad.Gateway;
using Sentry;
namespace PluralKit.Bot
{
public interface ISentryEnricher<T> where T: DiscordEventArgs
public interface ISentryEnricher<T> where T: IGatewayEvent
{
void Enrich(Scope scope, DiscordClient shard, T evt);
void Enrich(Scope scope, Shard shard, T evt);
}
public class SentryEnricher:
ISentryEnricher<MessageCreateEventArgs>,
ISentryEnricher<MessageDeleteEventArgs>,
ISentryEnricher<MessageUpdateEventArgs>,
ISentryEnricher<MessageBulkDeleteEventArgs>,
ISentryEnricher<MessageReactionAddEventArgs>
public class SentryEnricher //:
// TODO!!!
// ISentryEnricher<MessageCreateEventArgs>,
// ISentryEnricher<MessageDeleteEventArgs>,
// ISentryEnricher<MessageUpdateEventArgs>,
// ISentryEnricher<MessageBulkDeleteEventArgs>,
// ISentryEnricher<MessageReactionAddEventArgs>
{
// TODO: should this class take the Scope by dependency injection instead?
// Would allow us to create a centralized "chain of handlers" where this class could just be registered as an entry in
public void Enrich(Scope scope, DiscordClient shard, MessageCreateEventArgs evt)
public void Enrich(Scope scope, Shard shard, MessageCreateEventArgs evt)
{
scope.AddBreadcrumb(evt.Message.Content, "event.message", data: new Dictionary<string, string>
{
@ -32,7 +35,7 @@ namespace PluralKit.Bot
{"guild", evt.Channel.GuildId.ToString()},
{"message", evt.Message.Id.ToString()},
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString());
// Also report information about the bot's permissions in the channel
// We get a lot of permission errors so this'll be useful for determining problems
@ -40,7 +43,7 @@ namespace PluralKit.Bot
scope.AddBreadcrumb(perms.ToPermissionString(), "permissions");
}
public void Enrich(Scope scope, DiscordClient shard, MessageDeleteEventArgs evt)
public void Enrich(Scope scope, Shard shard, MessageDeleteEventArgs evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>()
@ -49,10 +52,10 @@ namespace PluralKit.Bot
{"guild", evt.Channel.GuildId.ToString()},
{"message", evt.Message.Id.ToString()},
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString());
}
public void Enrich(Scope scope, DiscordClient shard, MessageUpdateEventArgs evt)
public void Enrich(Scope scope, Shard shard, MessageUpdateEventArgs evt)
{
scope.AddBreadcrumb(evt.Message.Content ?? "<unknown>", "event.messageEdit",
data: new Dictionary<string, string>()
@ -61,10 +64,10 @@ namespace PluralKit.Bot
{"guild", evt.Channel.GuildId.ToString()},
{"message", evt.Message.Id.ToString()}
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString());
}
public void Enrich(Scope scope, DiscordClient shard, MessageBulkDeleteEventArgs evt)
public void Enrich(Scope scope, Shard shard, MessageBulkDeleteEventArgs evt)
{
scope.AddBreadcrumb("", "event.messageDelete",
data: new Dictionary<string, string>()
@ -73,10 +76,10 @@ namespace PluralKit.Bot
{"guild", evt.Channel.Id.ToString()},
{"messages", string.Join(",", evt.Messages.Select(m => m.Id))},
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString());
}
public void Enrich(Scope scope, DiscordClient shard, MessageReactionAddEventArgs evt)
public void Enrich(Scope scope, Shard shard, MessageReactionAddEventArgs evt)
{
scope.AddBreadcrumb("", "event.reaction",
data: new Dictionary<string, string>()
@ -87,7 +90,7 @@ namespace PluralKit.Bot
{"message", evt.Message.Id.ToString()},
{"reaction", evt.Emoji.Name}
});
scope.SetTag("shard", shard.ShardId.ToString());
scope.SetTag("shard", shard.ShardInfo?.ShardId.ToString());
}
}
}