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);
        }
Beispiel #3
0
        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>());
        }
Beispiel #4
0
        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>());
        }