2019-08-12 03:47:55 +00:00
|
|
|
using System.Text.RegularExpressions;
|
2021-11-27 02:10:56 +00:00
|
|
|
|
2019-08-12 03:47:55 +00:00
|
|
|
using App.Metrics;
|
2019-12-21 17:50:28 +00:00
|
|
|
|
|
|
|
using Humanizer;
|
|
|
|
|
2020-12-22 12:15:26 +00:00
|
|
|
using Myriad.Cache;
|
2020-12-22 15:55:13 +00:00
|
|
|
using Myriad.Extensions;
|
2020-12-22 12:15:26 +00:00
|
|
|
using Myriad.Rest;
|
2021-11-27 02:10:56 +00:00
|
|
|
using Myriad.Rest.Exceptions;
|
2020-12-22 12:15:26 +00:00
|
|
|
using Myriad.Rest.Types;
|
|
|
|
using Myriad.Rest.Types.Requests;
|
|
|
|
using Myriad.Types;
|
|
|
|
|
2019-12-21 17:50:28 +00:00
|
|
|
using Newtonsoft.Json;
|
|
|
|
|
2019-08-12 03:47:55 +00:00
|
|
|
using Serilog;
|
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
namespace PluralKit.Bot;
|
|
|
|
|
|
|
|
public class WebhookExecutionErrorOnDiscordsEnd: Exception { }
|
|
|
|
|
|
|
|
public class WebhookRateLimited: WebhookExecutionErrorOnDiscordsEnd
|
2019-08-12 03:47:55 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
// 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
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
public record ProxyRequest
|
|
|
|
{
|
|
|
|
public ulong GuildId { get; init; }
|
|
|
|
public ulong ChannelId { get; init; }
|
|
|
|
public ulong? ThreadId { get; init; }
|
|
|
|
public string Name { get; init; }
|
|
|
|
public string? AvatarUrl { get; init; }
|
|
|
|
public string? Content { get; init; }
|
|
|
|
public Message.Attachment[] Attachments { get; init; }
|
|
|
|
public int FileSizeLimit { get; init; }
|
|
|
|
public Embed[] Embeds { get; init; }
|
2022-01-15 04:22:12 +00:00
|
|
|
public Sticker[] Stickers { get; init; }
|
2021-11-27 02:10:56 +00:00
|
|
|
public bool AllowEveryone { get; init; }
|
|
|
|
}
|
|
|
|
|
|
|
|
public class WebhookExecutorService
|
|
|
|
{
|
|
|
|
private readonly IDiscordCache _cache;
|
|
|
|
private readonly HttpClient _client;
|
|
|
|
private readonly ILogger _logger;
|
|
|
|
private readonly IMetrics _metrics;
|
|
|
|
private readonly DiscordApiClient _rest;
|
|
|
|
private readonly WebhookCacheService _webhookCache;
|
|
|
|
|
|
|
|
public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger,
|
|
|
|
HttpClient client, IDiscordCache cache, DiscordApiClient rest)
|
2021-08-27 15:03:47 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
_metrics = metrics;
|
|
|
|
_webhookCache = webhookCache;
|
|
|
|
_client = client;
|
|
|
|
_cache = cache;
|
|
|
|
_rest = rest;
|
|
|
|
_logger = logger.ForContext<WebhookExecutorService>();
|
2020-03-26 23:01:42 +00:00
|
|
|
}
|
2020-12-22 12:15:26 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
public async Task<Message> ExecuteWebhook(ProxyRequest req)
|
2020-12-22 12:15:26 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
_logger.Verbose("Invoking webhook in channel {Channel}", req.ChannelId);
|
|
|
|
|
|
|
|
// Get a webhook, execute it
|
|
|
|
var webhook = await _webhookCache.GetWebhook(req.ChannelId);
|
|
|
|
var webhookMessage = await ExecuteWebhookInner(webhook, req);
|
|
|
|
|
|
|
|
// Log the relevant metrics
|
|
|
|
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
|
|
|
|
_logger.Information("Invoked webhook {Webhook} in channel {Channel} (thread {ThreadId})", webhook.Id,
|
|
|
|
req.ChannelId, req.ThreadId);
|
|
|
|
|
|
|
|
return webhookMessage;
|
2020-12-22 12:15:26 +00:00
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
public async Task<Message> EditWebhookMessage(ulong channelId, ulong messageId, string newContent)
|
2019-08-12 03:47:55 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
var allowedMentions = newContent.ParseMentions() with
|
2019-08-12 03:47:55 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
Roles = Array.Empty<ulong>(),
|
|
|
|
Parse = Array.Empty<AllowedMentions.ParseType>()
|
|
|
|
};
|
2019-08-12 03:47:55 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
ulong? threadId = null;
|
2022-03-31 11:23:31 +00:00
|
|
|
var channel = await _cache.GetOrFetchChannel(_rest, channelId);
|
|
|
|
if (channel.IsThread())
|
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
threadId = channelId;
|
2022-03-31 11:23:31 +00:00
|
|
|
channelId = channel.ParentId.Value;
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2022-03-31 11:23:31 +00:00
|
|
|
var webhook = await _webhookCache.GetWebhook(channelId);
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
return await _rest.EditWebhookMessage(webhook.Id, webhook.Token, messageId,
|
|
|
|
new WebhookMessageEditRequest { Content = newContent, AllowedMentions = allowedMentions },
|
|
|
|
threadId);
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private async Task<Message> ExecuteWebhookInner(Webhook webhook, ProxyRequest req, bool hasRetried = false)
|
|
|
|
{
|
|
|
|
var guild = await _cache.GetGuild(req.GuildId);
|
2022-01-21 23:23:58 +00:00
|
|
|
var content = req.Content.Truncate(2000);
|
2019-08-12 03:47:55 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
var allowedMentions = content.ParseMentions();
|
|
|
|
if (!req.AllowEveryone)
|
|
|
|
allowedMentions = allowedMentions.RemoveUnmentionableRoles(guild) with
|
2021-08-27 15:03:47 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
// also clear @everyones
|
2021-05-03 10:33:30 +00:00
|
|
|
Parse = Array.Empty<AllowedMentions.ParseType>()
|
|
|
|
};
|
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
var webhookReq = new ExecuteWebhookRequest
|
|
|
|
{
|
|
|
|
Username = FixProxyName(req.Name).Truncate(80),
|
|
|
|
Content = content,
|
|
|
|
AllowedMentions = allowedMentions,
|
|
|
|
AvatarUrl = !string.IsNullOrWhiteSpace(req.AvatarUrl) ? req.AvatarUrl : null,
|
2022-01-15 04:22:12 +00:00
|
|
|
Embeds = req.Embeds,
|
|
|
|
Stickers = req.Stickers,
|
2021-11-27 02:10:56 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
MultipartFile[] files = null;
|
|
|
|
var attachmentChunks = ChunkAttachmentsOrThrow(req.Attachments, req.FileSizeLimit);
|
|
|
|
if (attachmentChunks.Count > 0)
|
|
|
|
{
|
|
|
|
_logger.Information(
|
|
|
|
"Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks",
|
|
|
|
req.Attachments.Length, req.Attachments.Select(a => a.Size).Sum() / 1024 / 1024,
|
|
|
|
attachmentChunks.Count);
|
|
|
|
files = await GetAttachmentFiles(attachmentChunks[0]);
|
|
|
|
webhookReq.Attachments = files.Select(f => new Message.Attachment
|
|
|
|
{
|
|
|
|
Id = (ulong)Array.IndexOf(files, f),
|
|
|
|
Description = f.Description,
|
|
|
|
Filename = f.Filename
|
|
|
|
}).ToArray();
|
2021-05-03 10:33:30 +00:00
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
Message webhookMessage;
|
|
|
|
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
|
2019-08-12 03:47:55 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
try
|
2020-12-22 12:15:26 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
webhookMessage =
|
|
|
|
await _rest.ExecuteWebhook(webhook.Id, webhook.Token, webhookReq, files, req.ThreadId);
|
|
|
|
}
|
|
|
|
catch (JsonReaderException)
|
2019-12-22 21:56:18 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
// 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();
|
2019-12-23 12:55:43 +00:00
|
|
|
}
|
2021-11-27 02:10:56 +00:00
|
|
|
catch (NotFoundException e)
|
2021-08-27 15:03:47 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
if (e.ErrorCode == 10015 && !hasRetried)
|
2020-06-14 20:19:12 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
// 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} (thread {ThreadId})",
|
|
|
|
webhook.Id, webhook.ChannelId, req.ThreadId);
|
|
|
|
|
|
|
|
var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(req.ChannelId, webhook);
|
|
|
|
return await ExecuteWebhookInner(newWebhook, req, true);
|
2020-06-14 20:19:12 +00:00
|
|
|
}
|
2021-11-27 02:10:56 +00:00
|
|
|
|
|
|
|
throw;
|
2021-08-27 15:03:47 +00:00
|
|
|
}
|
2021-11-27 02:10:56 +00:00
|
|
|
}
|
2019-12-21 19:07:51 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
// 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, req.Name, req.AvatarUrl, attachmentChunks, req.ThreadId);
|
2022-06-05 22:59:53 +00:00
|
|
|
|
|
|
|
// for some reason discord may(?) return a null guildid here???
|
|
|
|
return webhookMessage with { GuildId = webhookMessage.GuildId ?? req.GuildId };
|
2021-11-27 02:10:56 +00:00
|
|
|
}
|
2020-04-17 21:10:01 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl,
|
|
|
|
IReadOnlyList<IReadOnlyCollection<Message.Attachment>>
|
|
|
|
attachmentChunks, ulong? threadId)
|
|
|
|
{
|
|
|
|
if (attachmentChunks.Count <= 1) return;
|
2020-04-17 21:10:01 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
for (var i = 1; i < attachmentChunks.Count; i++)
|
|
|
|
{
|
|
|
|
var files = await GetAttachmentFiles(attachmentChunks[i]);
|
|
|
|
var req = new ExecuteWebhookRequest
|
2020-04-17 21:10:01 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
Username = name,
|
|
|
|
AvatarUrl = avatarUrl,
|
|
|
|
Attachments = files.Select(f => new Message.Attachment
|
2021-11-19 14:34:52 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
Id = (ulong)Array.IndexOf(files, f),
|
|
|
|
Description = f.Description,
|
|
|
|
Filename = f.Filename
|
|
|
|
}).ToArray()
|
|
|
|
};
|
|
|
|
await _rest.ExecuteWebhook(webhook.Id, webhook.Token!, req, files, threadId);
|
2020-04-17 21:10:01 +00:00
|
|
|
}
|
2021-11-27 02:10:56 +00:00
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private async Task<MultipartFile[]> GetAttachmentFiles(IReadOnlyCollection<Message.Attachment> attachments)
|
|
|
|
{
|
|
|
|
async Task<MultipartFile> GetStream(Message.Attachment attachment)
|
2020-04-17 21:10:01 +00:00
|
|
|
{
|
2021-11-27 02:10:56 +00:00
|
|
|
var attachmentResponse =
|
|
|
|
await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead);
|
|
|
|
return new MultipartFile(attachment.Filename, await attachmentResponse.Content.ReadAsStreamAsync(),
|
|
|
|
attachment.Description);
|
2019-08-12 03:47:55 +00:00
|
|
|
}
|
2020-04-17 21:10:01 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
return await Task.WhenAll(attachments.Select(GetStream));
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private IReadOnlyList<IReadOnlyCollection<Message.Attachment>> ChunkAttachmentsOrThrow(
|
|
|
|
IReadOnlyList<Message.Attachment> 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<Message.Attachment>>();
|
|
|
|
var list = new List<Message.Attachment>();
|
2021-11-19 15:22:11 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
// sizeThreshold is in MB (user-readable)
|
|
|
|
var bytesThreshold = sizeThreshold * 1024 * 1024;
|
2019-12-21 19:07:51 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
foreach (var attachment in attachments)
|
|
|
|
{
|
|
|
|
if (attachment.Size >= bytesThreshold) throw Errors.AttachmentTooLarge(sizeThreshold);
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
if (list.Sum(a => a.Size) + attachment.Size >= bytesThreshold)
|
|
|
|
{
|
|
|
|
chunks.Add(list);
|
|
|
|
list = new List<Message.Attachment>();
|
2019-12-21 19:07:51 +00:00
|
|
|
}
|
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
list.Add(attachment);
|
2019-12-21 19:07:51 +00:00
|
|
|
}
|
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
if (list.Count > 0) chunks.Add(list);
|
|
|
|
return chunks;
|
|
|
|
}
|
2021-01-31 15:02:34 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private string FixProxyName(string name) => FixSingleCharacterName(FixClyde(name));
|
2019-08-12 03:47:55 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
private string FixClyde(string name)
|
|
|
|
{
|
|
|
|
static string Replacement(Match m) => m.Groups[1].Value + "\u200A" + m.Groups[2].Value;
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2021-11-27 02:10:56 +00:00
|
|
|
// Adds a Unicode hair space (\u200A) between the "c" and the "lyde" to avoid Discord matching it
|
|
|
|
// since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
|
|
|
|
return Regex.Replace(name, "(c)(lyde)", Replacement, RegexOptions.IgnoreCase);
|
|
|
|
}
|
|
|
|
|
|
|
|
private string FixSingleCharacterName(string proxyName)
|
|
|
|
{
|
|
|
|
if (proxyName.Length == 1)
|
|
|
|
return proxyName + "\u17b5";
|
|
|
|
return proxyName;
|
2019-08-12 03:47:55 +00:00
|
|
|
}
|
|
|
|
}
|