From 612f005534fecde5aacd61722bab6784a3acda88 Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:18 -0700
Subject: [PATCH 01/21] feat(core): add MessageFlags.IsComponentsV2
Add IsComponentsV2 flag (1 << 15) for component v2 layout support.
Reference: https://discord.com/developers/docs/resources/message#message-object-message-flags
---
src/PawSharp.Core/Enums/MessageFlags.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/PawSharp.Core/Enums/MessageFlags.cs b/src/PawSharp.Core/Enums/MessageFlags.cs
index 8f7de84..e823e0d 100644
--- a/src/PawSharp.Core/Enums/MessageFlags.cs
+++ b/src/PawSharp.Core/Enums/MessageFlags.cs
@@ -32,4 +32,6 @@ public enum MessageFlags
SuppressNotifications = 1 << 12,
/// This message is a voice message.
IsVoiceMessage = 1 << 13,
+ /// This message uses the components v2 layout.
+ IsComponentsV2 = 1 << 15,
}
From a911a5a4f41b3712346f92f0e9612a7c1fc2024a Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:26 -0700
Subject: [PATCH 02/21] feat(core): add missing permission flags
Add CreateGuildExpressions (1UL << 43), CreateEvents (1UL << 44),
SetVoiceChannelStatus (1UL << 47), SendPolls (1UL << 48),
and UseExternalApps (1UL << 49) to Permissions enum.
Reference: https://discord.com/developers/docs/topics/permissions#permissions-bitwise-permission-flags
---
src/PawSharp.Core/Enums/Permissions.cs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/PawSharp.Core/Enums/Permissions.cs b/src/PawSharp.Core/Enums/Permissions.cs
index 0d47885..33aec62 100644
--- a/src/PawSharp.Core/Enums/Permissions.cs
+++ b/src/PawSharp.Core/Enums/Permissions.cs
@@ -52,6 +52,11 @@ public enum Permissions : ulong
ModerateMembers = 1UL << 40,
ViewCreatorMonetizationAnalytics = 1UL << 41,
UseSoundboard = 1UL << 42,
+ CreateGuildExpressions = 1UL << 43,
+ CreateEvents = 1UL << 44,
UseExternalSounds = 1UL << 45,
- SendVoiceMessages = 1UL << 46
+ SendVoiceMessages = 1UL << 46,
+ SetVoiceChannelStatus = 1UL << 47,
+ SendPolls = 1UL << 48,
+ UseExternalApps = 1UL << 49
}
From d395601ea56c5838ef2bdc42aad2a365b5e5057f Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:31 -0700
Subject: [PATCH 03/21] feat(gateway): add GuildVoiceChannelStatusUpdates
intent
Add GuildVoiceChannelStatusUpdates intent (1 << 28) for VOICE_CHANNEL_STATUS_UPDATE events.
Include it in AllNonPrivileged composite flag.
Reference: https://discord.com/developers/docs/topics/gateway#gateway-intents
---
src/PawSharp.Core/Enums/GatewayIntents.cs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/src/PawSharp.Core/Enums/GatewayIntents.cs b/src/PawSharp.Core/Enums/GatewayIntents.cs
index 295a72f..1d75323 100644
--- a/src/PawSharp.Core/Enums/GatewayIntents.cs
+++ b/src/PawSharp.Core/Enums/GatewayIntents.cs
@@ -114,10 +114,15 @@ public enum GatewayIntents : uint
///
DirectMessagePolls = 1 << 25,
+ ///
+ /// Guild voice channel status update events (VOICE_CHANNEL_STATUS_UPDATE).
+ ///
+ GuildVoiceChannelStatusUpdates = 1 << 28,
+
///
/// All non-privileged intents.
///
- AllNonPrivileged = Guilds | GuildModeration | GuildEmojisAndStickers | GuildIntegrations | GuildWebhooks | GuildInvites | GuildVoiceStates | GuildMessages | GuildMessageReactions | GuildMessageTyping | DirectMessages | DirectMessageReactions | DirectMessageTyping | GuildScheduledEvents | AutoModerationConfiguration | AutoModerationExecution | GuildMessagePolls | DirectMessagePolls,
+ AllNonPrivileged = Guilds | GuildModeration | GuildEmojisAndStickers | GuildIntegrations | GuildWebhooks | GuildInvites | GuildVoiceStates | GuildMessages | GuildMessageReactions | GuildMessageTyping | DirectMessages | DirectMessageReactions | DirectMessageTyping | GuildScheduledEvents | AutoModerationConfiguration | AutoModerationExecution | GuildMessagePolls | DirectMessagePolls | GuildVoiceChannelStatusUpdates,
///
/// All intents (including privileged).
From 6e055676bbf8e7cb389d260e9b2787c4ab489d43 Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:36 -0700
Subject: [PATCH 04/21] feat(api): add Poll, Attachments, and Status to API
models
Add Poll (CreatePollRequest) and Attachments (List) to
InteractionCallbackData for interaction responses.
Add Status property to ModifyChannelRequest for voice channel status.
Reference: https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-data-structure
---
src/PawSharp.API/Models/ApiRequestModels.cs | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/PawSharp.API/Models/ApiRequestModels.cs b/src/PawSharp.API/Models/ApiRequestModels.cs
index c0d75bc..76c3ee0 100644
--- a/src/PawSharp.API/Models/ApiRequestModels.cs
+++ b/src/PawSharp.API/Models/ApiRequestModels.cs
@@ -139,6 +139,10 @@ public class ModifyChannelRequest
[System.Text.Json.Serialization.JsonPropertyName("permission_overwrites")]
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public List? PermissionOverwrites { get; set; }
+ /// Voice channel status text (max 500 characters). Null to clear.
+ [System.Text.Json.Serialization.JsonPropertyName("status")]
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public string? Status { get; set; }
}
// Guild Request Models
@@ -306,6 +310,16 @@ public class InteractionCallbackData
[System.Text.Json.Serialization.JsonPropertyName("custom_id")]
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public string? CustomId { get; set; }
+
+ /// A poll to include with this interaction response.
+ [System.Text.Json.Serialization.JsonPropertyName("poll")]
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public CreatePollRequest? Poll { get; set; }
+
+ /// File attachments for this interaction response. Used with multipart/form-data uploads.
+ [System.Text.Json.Serialization.JsonPropertyName("attachments")]
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public List? Attachments { get; set; }
}
///
From b60faa4523fe359a88248f755d908895a87391ce Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:42 -0700
Subject: [PATCH 05/21] feat(api): implement voice channel status REST
endpoints
Add GetVoiceChannelStatusAsync and SetVoiceChannelStatusAsync to
IDiscordRestClient interface and DiscordRestClient implementation.
GET /channels/{id}/voice-status returns current status string.
PATCH /channels/{id}/voice-status sets or clears the status.
Reference: https://discord.com/developers/docs/resources/channel#get-channel-status
---
src/PawSharp.API/Clients/RestClient.cs | 160 +++++++++++++++---
.../Interfaces/IDiscordRestClient.cs | 15 ++
2 files changed, 152 insertions(+), 23 deletions(-)
diff --git a/src/PawSharp.API/Clients/RestClient.cs b/src/PawSharp.API/Clients/RestClient.cs
index ddc44ca..05bad85 100644
--- a/src/PawSharp.API/Clients/RestClient.cs
+++ b/src/PawSharp.API/Clients/RestClient.cs
@@ -2,6 +2,7 @@
#pragma warning disable IDE0011
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
@@ -692,6 +693,40 @@ public async Task DeleteChannelPermissionAsync(ulong channelId, ulong over
var response = await DeleteAsync($"channels/{channelId}/permissions/{overwriteId}");
return response.IsSuccessStatusCode;
}
+
+ ///
+ /// Gets the status of a voice channel.
+ ///
+ /// The voice channel ID.
+ /// The voice channel status text, or null if none is set or the request fails.
+ public async Task GetVoiceChannelStatusAsync(ulong channelId)
+ {
+ ValidateSnowflake(channelId, nameof(channelId));
+ var response = await GetAsync($"channels/{channelId}/voice-status");
+ if (!response.IsSuccessStatusCode) return null;
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("status", out var statusProp))
+ {
+ return statusProp.ValueKind == JsonValueKind.Null ? null : statusProp.GetString();
+ }
+ return null;
+ }
+
+ ///
+ /// Sets or clears the status of a voice channel.
+ ///
+ /// The voice channel ID.
+ /// The status text (max 500 characters), or null to clear.
+ /// The updated channel object, or null if the request fails.
+ public async Task SetVoiceChannelStatusAsync(ulong channelId, string? status)
+ {
+ ValidateSnowflake(channelId, nameof(channelId));
+ var payload = new { status };
+ var content = JsonContent(payload);
+ var response = await PatchAsync($"channels/{channelId}/voice-status", content);
+ return await HandleApiResponseAsync("SetVoiceChannelStatusAsync", response);
+ }
// Guild operations
public async Task GetGuildAsync(ulong guildId, bool withCounts = false)
@@ -3072,36 +3107,80 @@ private void ValidateSnowflake(ulong id, string paramName)
{
if (response.IsSuccessStatusCode)
{
- return await response.Content.ReadFromJsonAsync(_jsonOptions);
+ try
+ {
+ var result = await response.Content.ReadFromJsonAsync(_jsonOptions);
+ if (result == null)
+ {
+ _logger.LogWarning("Deserialization returned null for {Operation}: response body was empty or null", operation);
+ }
+ return result;
+ }
+ catch (JsonException ex)
+ {
+ var rawJson = await response.Content.ReadAsStringAsync();
+ _logger.LogError(ex, "Failed to deserialize JSON response for {Operation}. Raw JSON: {RawJson}", operation, LogSanitizer.SanitizeHttpErrorBody(rawJson));
+ throw new DeserializationException(
+ $"Failed to deserialize response for {operation}. This may indicate an API schema mismatch.",
+ rawJson,
+ typeof(T),
+ ex);
+ }
+ catch (Exception ex) when (ex is not DeserializationException and not DiscordException)
+ {
+ _logger.LogError(ex, "Unexpected error during deserialization for {Operation}", operation);
+ throw;
+ }
}
await LogSanitizedApiErrorAsync($"{operation} failed", response);
if (_options.RestApi.ThrowOnApiError)
{
+ var (discordErrorCode, discordErrorMessage) = await ParseDiscordErrorAsync(response);
var statusCode = (System.Net.HttpStatusCode)response.StatusCode;
+ throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, response.RequestMessage?.RequestUri?.PathAndQuery ?? "", discordErrorCode, discordErrorMessage);
+ }
+
+ return null;
+ }
+
+ private static async Task<(string? Code, string? Message)> ParseDiscordErrorAsync(HttpResponseMessage response)
+ {
+ try
+ {
var errorBody = await response.Content.ReadAsStringAsync();
- string? discordErrorCode = null;
- string? discordErrorMessage = null;
+ if (string.IsNullOrWhiteSpace(errorBody))
+ return (null, null);
- try
+ using var doc = JsonDocument.Parse(errorBody);
+ var root = doc.RootElement;
+
+ string? code = null;
+ string? message = null;
+
+ if (root.TryGetProperty("code", out var codeElement) && codeElement.ValueKind == JsonValueKind.Number)
{
- using var doc = JsonDocument.Parse(errorBody);
- if (doc.RootElement.TryGetProperty("code", out var codeElement))
- {
- discordErrorCode = codeElement.GetInt32().ToString();
- }
- if (doc.RootElement.TryGetProperty("message", out var messageElement))
- {
- discordErrorMessage = messageElement.GetString();
- }
+ code = codeElement.GetInt32().ToString();
}
- catch { /* Ignore parse errors */ }
- throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, "", discordErrorCode, discordErrorMessage);
- }
+ if (root.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String)
+ {
+ message = messageElement.GetString();
+ }
- return null;
+ // Discord sometimes returns errors in an "errors" nested object
+ if (message == null && root.TryGetProperty("errors", out var errorsElement))
+ {
+ message = errorsElement.GetRawText();
+ }
+
+ return (code, message);
+ }
+ catch
+ {
+ return (null, null);
+ }
}
///
@@ -3118,8 +3197,9 @@ private async Task HandleApiResponseAsync(string operation,
if (_options.RestApi.ThrowOnApiError)
{
+ var (discordErrorCode, discordErrorMessage) = await ParseDiscordErrorAsync(response);
var statusCode = (System.Net.HttpStatusCode)response.StatusCode;
- throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, "");
+ throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, response.RequestMessage?.RequestUri?.PathAndQuery ?? "", discordErrorCode, discordErrorMessage);
}
return response;
@@ -3206,8 +3286,43 @@ private async Task SendRequestAsync(
{
request.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason));
}
-
- var response = await _httpClient.SendAsync(request, cancellationToken);
+
+ var sanitizedEndpoint = LogSanitizer.RedactSensitiveEndpoint(endpoint);
+ _logger.LogDebug(PawSharp.Core.Logging.PawSharpLogEvents.ApiRequestStarted, method.Method, sanitizedEndpoint);
+
+ var stopwatch = Stopwatch.StartNew();
+ HttpResponseMessage response;
+ try
+ {
+ response = await _httpClient.SendAsync(request, cancellationToken);
+ }
+ catch (HttpRequestException ex)
+ {
+ stopwatch.Stop();
+ _logger.LogError(ex, "HTTP request failed: {Method} {Endpoint} after {DurationMs}ms - {Message}",
+ method.Method, sanitizedEndpoint, stopwatch.ElapsedMilliseconds, ex.Message);
+ throw new DiscordException($"HTTP request failed for {method.Method} {sanitizedEndpoint}", ex);
+ }
+ catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ stopwatch.Stop();
+ _logger.LogWarning("HTTP request timed out: {Method} {Endpoint} after {DurationMs}ms",
+ method.Method, sanitizedEndpoint, stopwatch.ElapsedMilliseconds);
+ throw new DiscordException($"HTTP request timed out for {method.Method} {sanitizedEndpoint}", ex);
+ }
+ stopwatch.Stop();
+
+ var statusCode = (int)response.StatusCode;
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogDebug(PawSharp.Core.Logging.PawSharpLogEvents.ApiRequestCompleted,
+ method.Method, sanitizedEndpoint, statusCode, stopwatch.ElapsedMilliseconds);
+ }
+ else
+ {
+ _logger.LogWarning(PawSharp.Core.Logging.PawSharpLogEvents.ApiRequestFailed,
+ method.Method, sanitizedEndpoint, statusCode);
+ }
// Parse rate limit headers and update limiter
ParseAndUpdateRateLimits(response, route, ref bucketHash);
@@ -3275,7 +3390,7 @@ private static bool HeaderValueIsTrue(HttpResponseMessage response, string heade
&& bool.TryParse(values.FirstOrDefault(), out var parsed)
&& parsed;
- private static async Task GetRetryAfterDelayAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ private async Task GetRetryAfterDelayAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Headers.RetryAfter?.Delta is { } headerDelay && headerDelay > TimeSpan.Zero)
{
@@ -3303,8 +3418,7 @@ private static async Task GetRetryAfterDelayAsync(HttpResponseMessage
}
catch (Exception ex)
{
- // Ignore malformed/unexpected payloads and use a safe fallback.
- System.Diagnostics.Debug.WriteLine($"Rate limit parse error, using fallback: {ex.Message}");
+ _logger.LogWarning(ex, "Rate limit parse error, using 1-second fallback");
}
return TimeSpan.FromSeconds(1);
diff --git a/src/PawSharp.API/Interfaces/IDiscordRestClient.cs b/src/PawSharp.API/Interfaces/IDiscordRestClient.cs
index 4804ed8..5b12fc5 100644
--- a/src/PawSharp.API/Interfaces/IDiscordRestClient.cs
+++ b/src/PawSharp.API/Interfaces/IDiscordRestClient.cs
@@ -100,6 +100,21 @@ public interface IDiscordRestClient
Task CreateChannelInviteAsync(ulong channelId, CreateInviteRequest request);
Task DeleteChannelPermissionAsync(ulong channelId, ulong overwriteId);
+ ///
+ /// Gets the status of a voice channel.
+ ///
+ /// The voice channel ID.
+ /// The voice channel status text, or null if none is set.
+ Task GetVoiceChannelStatusAsync(ulong channelId);
+
+ ///
+ /// Sets or clears the status of a voice channel.
+ ///
+ /// The voice channel ID.
+ /// The status text (max 500 characters), or null to clear.
+ /// The updated channel object.
+ Task SetVoiceChannelStatusAsync(ulong channelId, string? status);
+
// Guild operations
Task GetGuildAsync(ulong guildId, bool withCounts = false);
Task CreateGuildAsync(CreateGuildRequest request);
From 429d53950177fc225e7da68302cbcc112e0c6110 Mon Sep 17 00:00:00 2001
From: M1tsumi <0000imdumb0000@gmail.com>
Date: Tue, 12 May 2026 22:13:48 -0700
Subject: [PATCH 06/21] feat(interactions): add deferred response builder
support
Add AsDeferredChannelMessage() for type 5 (loading state) and
AsDeferredUpdateMessage() for type 6 (silent ACK) responses.
Fix Build() to exclude content/embeds/components from deferred
responses to avoid Discord API rejection.
Reference: https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type
---
.../Builders/InteractionResponseBuilder.cs | 52 ++++++++++++++++---
1 file changed, 44 insertions(+), 8 deletions(-)
diff --git a/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs b/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs
index 22855cc..cbf676e 100644
--- a/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs
+++ b/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs
@@ -8,7 +8,9 @@ namespace PawSharp.Interactions.Builders;
///
/// Fluent builder for objects.
-/// Handles ChannelMessageWithSource (type 4) and
+/// Handles ChannelMessageWithSource (type 4),
+/// DeferredChannelMessageWithSource (type 5),
+/// DeferredUpdateMessage (type 6),
/// UpdateMessage (type 7) response types, with optional ephemeral, embeds, content, and components.
///
///
@@ -28,6 +30,8 @@ public sealed class InteractionResponseBuilder
private string? _content;
private bool _ephemeral;
private bool _updateMessage;
+ private bool _deferredChannelMessage;
+ private bool _deferredUpdateMessage;
private int? _flags;
private readonly List