PluralKit/PluralKit.Bot/Services/WebhookExecutorService.cs

178 lines
8.1 KiB
C#
Raw Normal View History

using System;
2019-12-21 19:07:51 +00:00
using System.Collections.Generic;
using System.IO;
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;
2019-08-12 03:47:55 +00:00
using Discord;
using Humanizer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
2019-08-12 03:47:55 +00:00
using Serilog;
namespace PluralKit.Bot
{
public class WebhookExecutionErrorOnDiscordsEnd: Exception {
}
2019-12-22 23:35:42 +00:00
public class WebhookExecutorService
2019-08-12 03:47:55 +00:00
{
private WebhookCacheService _webhookCache;
private ILogger _logger;
private IMetrics _metrics;
private HttpClient _client;
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client)
2019-08-12 03:47:55 +00:00
{
_metrics = metrics;
_webhookCache = webhookCache;
_client = client;
2019-08-12 03:47:55 +00:00
_logger = logger.ForContext<WebhookExecutorService>();
}
2019-12-21 19:07:51 +00:00
public async Task<ulong> ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection<IAttachment> attachments)
2019-08-12 03:47:55 +00:00
{
_logger.Verbose("Invoking webhook in channel {Channel}", channel.Id);
2019-08-12 03:47:55 +00:00
// Get a webhook, execute it
var webhook = await _webhookCache.GetWebhook(channel);
2019-12-21 19:07:51 +00:00
var id = await ExecuteWebhookInner(webhook, name, avatarUrl, content, attachments);
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,
channel.Id);
return id;
}
private async Task<ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content,
2019-12-21 19:07:51 +00:00
IReadOnlyCollection<IAttachment> attachments, bool hasRetried = false)
2019-08-12 03:47:55 +00:00
{
2019-12-23 00:58:10 +00:00
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");
2019-12-23 00:58:10 +00:00
2019-12-23 12:55:43 +00:00
var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024);
2019-12-21 19:07:51 +00:00
if (attachmentChunks.Count > 0)
2019-12-22 21:56:18 +00:00
{
_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);
2019-12-21 19:07:51 +00:00
await AddAttachmentsToMultipart(mfd, attachmentChunks.First());
2019-12-23 12:55:43 +00:00
}
2019-12-23 00:58:10 +00:00
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();
2019-08-12 03:47:55 +00:00
var responseString = await response.Content.ReadAsStringAsync();
if (responseString.StartsWith("<"))
// if the response starts with a < it's probably a CloudFlare error or similar, so just force-break
throw new WebhookExecutionErrorOnDiscordsEnd();
var responseJson = JsonConvert.DeserializeObject<JObject>(responseString);
if (responseJson.ContainsKey("code"))
{
2019-12-21 19:07:51 +00:00
var errorCode = responseJson["code"].Value<int>();
if (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);
2019-12-21 19:07:51 +00:00
return await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachments, hasRetried: true);
}
2019-12-21 19:07:51 +00:00
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();
}
2019-12-21 19:07:51 +00:00
// If we have any leftover attachment chunks, send those
2019-12-23 12:55:43 +00:00
if (attachmentChunks.Count > 1)
2019-12-21 19:07:51 +00:00
{
// Deliberately not adding a content, just the remaining files
foreach (var chunk in attachmentChunks.Skip(1))
{
2019-12-23 12:55:43 +00:00
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);
2019-12-21 19:07:51 +00:00
// Don't bother with ?wait, we're just kinda firehosing this stuff
// also don't error check, the real message itself is already sent
2019-12-23 12:55:43 +00:00
await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}", mfd2);
2019-12-21 19:07:51 +00:00
}
2019-12-23 12:55:43 +00:00
}
2019-12-21 19:07:51 +00:00
// 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>();
2019-08-12 03:47:55 +00:00
}
2019-12-21 19:07:51 +00:00
private IReadOnlyCollection<IReadOnlyCollection<IAttachment>> ChunkAttachmentsOrThrow(
IReadOnlyCollection<IAttachment> 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>();
foreach (var attachment in attachments)
{
if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge;
if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold)
{
chunks.Add(list);
list = new List<IAttachment>();
}
list.Add(attachment);
}
if (list.Count > 0) chunks.Add(list);
return chunks;
}
private async Task AddAttachmentsToMultipart(MultipartFormDataContent content,
IReadOnlyCollection<IAttachment> attachments)
{
async Task<(IAttachment, Stream)> GetStream(IAttachment attachment)
{
2019-12-22 21:56:18 +00:00
var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
2019-12-21 19:07:51 +00:00
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);
}
2019-08-12 03:47:55 +00:00
private string FixClyde(string name)
{
// Check if the name contains "Clyde" - if not, do nothing
var match = Regex.Match(name, "clyde", RegexOptions.IgnoreCase);
if (!match.Success) return name;
// Put a hair space (\u200A) between the "c" and the "lyde" in the match to avoid Discord matching it
// since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
return name.Substring(0, match.Index + 1) + '\u200A' + name.Substring(match.Index + 1);
}
}
}