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 Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Serilog; namespace PluralKit.Bot { public class WebhookExecutionErrorOnDiscordsEnd: Exception { } 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 } public class WebhookExecutorService { private WebhookCacheService _webhookCache; private ILogger _logger; private IMetrics _metrics; private HttpClient _client; public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client) { _metrics = metrics; _webhookCache = webhookCache; _client = client; _logger = logger.ForContext(); } public async Task ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList attachments, bool allowEveryone) { _logger.Verbose("Invoking webhook in channel {Channel}", channel.Id); // Get a webhook, execute it var webhook = await _webhookCache.GetWebhook(channel); var id = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments, allowEveryone); // 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(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, IReadOnlyList attachments, bool allowEveryone, bool hasRetried = false) { content = content.Truncate(2000); var dwb = new DiscordWebhookBuilder(); dwb.WithUsername(FixClyde(name).Truncate(80)); dwb.WithContent(content); dwb.AddMentions(content.ParseAllMentions(allowEveryone)); 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.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count); await AddAttachmentsToBuilder(dwb, attachmentChunks[0]); } DiscordMessage response; using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) { try { response = await webhook.ExecuteAsync(dwb); } 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(); } catch (NotFoundException e) { var errorText = e.WebResponse?.Response; if (errorText != null && errorText.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); var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook); return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, allowEveryone, hasRetried: true); } throw; } } // 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> 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 attachments) { async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment) { var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead); return (attachment, await attachmentResponse.Content.ReadAsStreamAsync()); } foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream))) dwb.AddFile(attachment.FileName, attachmentStream); } private IReadOnlyList> ChunkAttachmentsOrThrow( IReadOnlyList 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.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge; if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold) { chunks.Add(list); list = new List(); } list.Add(attachment); } if (list.Count > 0) chunks.Add(list); return chunks; } 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); } } }