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 Discord; using Humanizer; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Serilog; namespace PluralKit.Bot { public class WebhookExecutorService: IDisposable { private WebhookCacheService _webhookCache; private ILogger _logger; private IMetrics _metrics; private HttpClient _client; public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger) { _metrics = metrics; _webhookCache = webhookCache; _logger = logger.ForContext(); _client = new HttpClient {Timeout = TimeSpan.FromSeconds(5)}; } public async Task ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection 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); // 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 ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content, IReadOnlyCollection attachments, bool hasRetried = false) { var mfd = new MultipartFormDataContent(); mfd.Add(new StringContent(content.Truncate(2000)), "content"); mfd.Add(new StringContent(FixClyde(name).Truncate(80)), "username"); if (avatarUrl != null) mfd.Add(new StringContent(avatarUrl), "avatar_url"); var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024); if (attachmentChunks.Count > 0) { _logger.Information($"Invoking webhook with {attachments.Count} attachments totalling {attachments.Select(a => a.Size).Sum() / 1024 / 1024} MiB in {attachmentChunks.Count} chunks"); await AddAttachmentsToMultipart(mfd, attachmentChunks.First()); } HttpResponseMessage response; using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) response = await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}?wait=true", mfd); // TODO: are there cases where an error won't also return a parseable JSON object? var responseJson = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); if (responseJson.ContainsKey("code")) { var errorCode = responseJson["code"].Value(); 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); 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 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)) { mfd = new MultipartFormDataContent(); mfd.Add(new StringContent(FixClyde(name).Truncate(80)), "username"); if (avatarUrl != null) mfd.Add(new StringContent(avatarUrl), "avatar_url"); await AddAttachmentsToMultipart(mfd, 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}", mfd); } } // 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(); } private IReadOnlyCollection> ChunkAttachmentsOrThrow( IReadOnlyCollection 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>(); var list = new List(); 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(); } list.Add(attachment); } if (list.Count > 0) chunks.Add(list); return chunks; } private async Task AddAttachmentsToMultipart(MultipartFormDataContent content, IReadOnlyCollection 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 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); } public void Dispose() { _client.Dispose(); } } }