private async Task <ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content, IAttachment attachment, bool hasRetried = false) { var client = await GetClientFor(webhook); try { // If we have an attachment, use the special SendFileAsync method if (attachment != null) { using (var attachmentStream = await _client.GetStreamAsync(attachment.Url)) using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime)) return(await client.SendFileAsync(attachmentStream, attachment.Filename, content, username : FixClyde(name), avatarUrl : avatarUrl)); } // Otherwise, send normally return(await client.SendMessageAsync(content, username : FixClyde(name), avatarUrl : avatarUrl)); } catch (HttpException e) { // If we hit an error, just retry (if we haven't already) if (e.DiscordCode == 10015 && !hasRetried) // Error 10015 = "Unknown Webhook" { _logger.Warning(e, "Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId); return(await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachment, hasRetried : true)); } throw; } }
private async Task <ulong> ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, IReadOnlyList <DiscordAttachment> 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, channel.Guild)); if (!string.IsNullOrWhiteSpace(avatarUrl)) { 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 <ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content, IReadOnlyCollection <IAttachment> attachments, bool hasRetried = false) { 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"); } 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.Size).Sum() / 1024 / 1024, attachmentChunks.Count); await AddAttachmentsToMultipart(mfd, attachmentChunks.First()); } mfd.Headers.Add("X-RateLimit-Precision", "millisecond"); // Need this for better rate limit support // Adding this check as close to the actual send call as possible to prevent potential race conditions (unlikely, but y'know) if (!_rateLimit.TryExecuteWebhook(webhook)) { throw new WebhookRateLimited(); } 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(); _rateLimit.UpdateRateLimitInfo(webhook, response); if (response.StatusCode == HttpStatusCode.TooManyRequests) { // Rate limits should be respected, we bail early (already updated the limit info so we hopefully won't hit this again) throw new WebhookRateLimited(); } var responseString = await response.Content.ReadAsStringAsync(); JObject responseJson; try { responseJson = JsonConvert.DeserializeObject <JObject>(responseString); } catch (JsonReaderException) { // Sometimes we get invalid JSON from the server, just ignore all of it throw new WebhookExecutionErrorOnDiscordsEnd(); } if (responseJson.ContainsKey("code")) { 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); 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 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(); } // 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)) { 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); // 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}", mfd2); } } // 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>()); }
private async Task <ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content, IReadOnlyCollection <IAttachment> attachments, bool hasRetried = false) { 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"); } 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.Size).Sum() / 1024 / 1024, attachmentChunks.Count); await AddAttachmentsToMultipart(mfd, attachmentChunks.First()); } 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(); 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 response.EnsureSuccessStatusCode(); } var responseJson = JsonConvert.DeserializeObject <JObject>(responseString); if (responseJson.ContainsKey("code")) { 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); 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)) { 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); // 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}", mfd2); } } // 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>()); }