using System.Net.Http; using System.Net.Http.Headers; namespace Aeshnidae.DevKit; /// /// Posts a message with one or more files to a Discord webhook. /// /// Webhooks take multipart/form-data: a "payload_json" part carrying the normal /// message body, and one "files[n]" part per attachment. Nothing else is needed - no /// bot, no token, no gateway - which is why this is the right transport for handing a /// developer a file: they click it in the channel and it is on their PC. /// internal static class DiscordUpload { private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) }; public sealed record Outcome(bool Ok, string Detail); public sealed record Attachment(string FileName, byte[] Content); public static Task SendFileAsync(string webhookUrl, string? username, string message, string fileName, byte[] content, CancellationToken ct = default) => SendFilesAsync(webhookUrl, username, message, new[] { new Attachment(fileName, content) }, ct); public static async Task SendFilesAsync(string webhookUrl, string? username, string message, IReadOnlyList files, CancellationToken ct = default) { try { var payload = new Dictionary { ["content"] = message, // Never ping anyone from an automated post, whatever the text contains. ["allowed_mentions"] = new { parse = Array.Empty() }, }; if (!string.IsNullOrWhiteSpace(username)) payload["username"] = username; using var form = new MultipartFormDataContent(); form.Add(new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), "payload_json"); for (var i = 0; i < files.Count; i++) { var file = new ByteArrayContent(files[i].Content); file.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); form.Add(file, $"files[{i}]", files[i].FileName); } using var response = await Http.PostAsync(webhookUrl, form, ct).ConfigureAwait(false); if (response.IsSuccessStatusCode) return new Outcome(true, $"{(int)response.StatusCode}"); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); return new Outcome(false, $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(body, 200)}"); } catch (Exception ex) { return new Outcome(false, ex.Message); } } private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…"; }