PluralKit/PluralKit.Bot/Services/WebhookExecutorService.cs

206 lines
8.4 KiB
C#
Raw Normal View History

using System;
2019-12-21 19:07:51 +00:00
using System.Collections.Generic;
using System.Linq;
2019-08-12 03:47:55 +00:00
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using App.Metrics;
using Humanizer;
2020-12-22 12:15:26 +00:00
using Myriad.Cache;
using Myriad.Extensions;
2020-12-22 12:15:26 +00:00
using Myriad.Rest;
using Myriad.Rest.Types;
using Myriad.Rest.Types.Requests;
using Myriad.Types;
using Newtonsoft.Json;
2019-08-12 03:47:55 +00:00
using Serilog;
namespace PluralKit.Bot
{
public class WebhookExecutionErrorOnDiscordsEnd: Exception {
}
2020-03-26 23:01:42 +00:00
public class WebhookRateLimited: WebhookExecutionErrorOnDiscordsEnd {
// 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
}
2020-12-22 12:15:26 +00:00
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; }
}
2020-03-26 23:01:42 +00:00
2019-12-22 23:35:42 +00:00
public class WebhookExecutorService
2019-08-12 03:47:55 +00:00
{
2020-12-22 12:15:26 +00:00
private readonly IDiscordCache _cache;
2020-08-29 11:46:27 +00:00
private readonly WebhookCacheService _webhookCache;
2020-12-22 12:15:26 +00:00
private readonly DiscordApiClient _rest;
2020-08-29 11:46:27 +00:00
private readonly ILogger _logger;
private readonly IMetrics _metrics;
private readonly HttpClient _client;
2019-08-12 03:47:55 +00:00
2020-12-22 12:15:26 +00:00
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client, IDiscordCache cache, DiscordApiClient rest)
2019-08-12 03:47:55 +00:00
{
_metrics = metrics;
_webhookCache = webhookCache;
_client = client;
2020-12-22 12:15:26 +00:00
_cache = cache;
_rest = rest;
2019-08-12 03:47:55 +00:00
_logger = logger.ForContext<WebhookExecutorService>();
}
2020-12-22 12:15:26 +00:00
public async Task<Message> ExecuteWebhook(ProxyRequest req)
2019-08-12 03:47:55 +00:00
{
2020-12-22 12:15:26 +00:00
_logger.Verbose("Invoking webhook in channel {Channel}", req.ChannelId);
2019-08-12 03:47:55 +00:00
// Get a webhook, execute it
2020-12-22 12:15:26 +00:00
var webhook = await _webhookCache.GetWebhook(req.ChannelId);
var webhookMessage = await ExecuteWebhookInner(webhook, req);
2019-08-12 03:47:55 +00:00
// Log the relevant metrics
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
_logger.Information("Invoked webhook {Webhook} in channel {Channel}", webhook.Id,
2020-12-22 12:15:26 +00:00
req.ChannelId);
2019-08-12 03:47:55 +00:00
return webhookMessage;
2019-08-12 03:47:55 +00:00
}
2020-12-22 12:15:26 +00:00
private async Task<Message> ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false)
2019-08-12 03:47:55 +00:00
{
var guild = _cache.GetGuild(req.GuildId);
2020-12-22 12:15:26 +00:00
var content = req.Content.Truncate(2000);
var allowedMentions = content.ParseMentions();
if (!req.AllowEveryone)
allowedMentions = allowedMentions.RemoveUnmentionableRoles(guild);
2020-12-22 12:15:26 +00:00
var webhookReq = new ExecuteWebhookRequest
{
2021-01-31 15:02:34 +00:00
Username = FixProxyName(req.Name).Truncate(80),
2020-12-22 12:15:26 +00:00
Content = content,
AllowedMentions = allowedMentions,
2020-12-22 12:15:26 +00:00
AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null,
Embeds = req.Embeds
};
2020-12-22 12:15:26 +00:00
MultipartFile[] files = null;
var attachmentChunks = ChunkAttachmentsOrThrow(req.Attachments, 8 * 1024 * 1024);
2019-12-21 19:07:51 +00:00
if (attachmentChunks.Count > 0)
2019-12-22 21:56:18 +00:00
{
2020-12-22 12:15:26 +00:00
_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]);
2019-12-23 12:55:43 +00:00
}
2020-03-26 23:01:42 +00:00
2020-12-22 12:15:26 +00:00
Message webhookMessage;
2020-06-14 20:19:12 +00:00
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) {
try
{
2020-12-22 12:15:26 +00:00
webhookMessage = await _rest.ExecuteWebhook(webhook.Id, webhook.Token, webhookReq, files);
}
2020-06-14 20:19:12 +00:00
catch (JsonReaderException)
{
// This happens sometimes when we hit a CloudFlare error (or similar) on Discord's end
// Nothing we can do about this - happens sometimes under server load, so just drop the message and give up
throw new WebhookExecutionErrorOnDiscordsEnd();
}
2020-12-22 12:15:26 +00:00
catch (Myriad.Rest.Exceptions.NotFoundException e)
2020-06-14 20:19:12 +00:00
{
2020-12-22 12:15:26 +00:00
if (e.ErrorCode == 10015 && !hasRetried)
2020-06-14 20:19:12 +00:00
{
// 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);
2020-12-22 12:15:26 +00:00
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(req.ChannelId, webhook);
return await ExecuteWebhookInner(newWebhook, req, hasRetried: true);
2020-06-14 20:19:12 +00:00
}
2019-12-21 19:07:51 +00:00
2020-06-14 20:19:12 +00:00
throw;
}
}
2019-12-21 19:07:51 +00:00
// We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off
2020-12-22 12:15:26 +00:00
var _ = TrySendRemainingAttachments(webhook, req.Name, req.AvatarUrl, attachmentChunks);
return webhookMessage;
}
2020-12-22 12:15:26 +00:00
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++)
{
2020-12-22 12:15:26 +00:00
var files = await GetAttachmentFiles(attachmentChunks[i]);
var req = new ExecuteWebhookRequest {Username = name, AvatarUrl = avatarUrl};
await _rest.ExecuteWebhook(webhook.Id, webhook.Token!, req, files);
}
}
2020-12-22 12:15:26 +00:00
private async Task<MultipartFile[]> GetAttachmentFiles(IReadOnlyCollection<Message.Attachment> attachments)
{
2020-12-22 12:15:26 +00:00
async Task<MultipartFile> GetStream(Message.Attachment attachment)
2019-12-21 19:07:51 +00:00
{
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
2020-12-22 12:15:26 +00:00
return new(attachment.Filename, await attachmentResponse.Content.ReadAsStreamAsync());
2019-12-23 12:55:43 +00:00
}
2020-12-22 12:15:26 +00:00
return await Task.WhenAll(attachments.Select(GetStream));
2019-08-12 03:47:55 +00:00
}
2020-12-22 12:15:26 +00:00
private IReadOnlyList<IReadOnlyCollection<Message.Attachment>> ChunkAttachmentsOrThrow(
IReadOnlyList<Message.Attachment> attachments, int sizeThreshold)
2019-12-21 19:07:51 +00:00
{
// Splits a list of attachments into "chunks" of at most 8MB each
// If any individual attachment is larger than 8MB, will throw an error
2020-12-22 12:15:26 +00:00
var chunks = new List<List<Message.Attachment>>();
var list = new List<Message.Attachment>();
2019-12-21 19:07:51 +00:00
foreach (var attachment in attachments)
{
2020-12-22 12:15:26 +00:00
if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge;
2019-12-21 19:07:51 +00:00
2020-12-22 12:15:26 +00:00
if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold)
2019-12-21 19:07:51 +00:00
{
chunks.Add(list);
2020-12-22 12:15:26 +00:00
list = new List<Message.Attachment>();
2019-12-21 19:07:51 +00:00
}
list.Add(attachment);
}
if (list.Count > 0) chunks.Add(list);
return chunks;
}
2021-01-31 15:02:34 +00:00
private string FixProxyName(string name) => FixSingleCharacterName(FixClyde(name));
2019-08-12 03:47:55 +00:00
private string FixClyde(string name)
{
static string Replacement(Match m) => m.Groups[1].Value + "\u200A" + m.Groups[2].Value;
2019-08-12 03:47:55 +00:00
// Adds a Unicode hair space (\u200A) between the "c" and the "lyde" to avoid Discord matching it
2019-08-12 03:47:55 +00:00
// since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
return Regex.Replace(name, "(c)(lyde)", Replacement, RegexOptions.IgnoreCase);
2019-08-12 03:47:55 +00:00
}
2021-01-31 15:02:34 +00:00
private string FixSingleCharacterName(string proxyName)
{
if (proxyName.Length == 1)
return proxyName + "\u17b5";
return proxyName;
}
2019-08-12 03:47:55 +00:00
}
}