diff --git a/CHANGELOG.md b/CHANGELOG.md index daf887a..3c54b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to PawSharp are documented here. --- +## [1.1.0-alpha.2] - 2026-05-03 + +### New Features + +- **Cache System Enhancements** (`PawSharp.Cache`) + - Added comprehensive telemetry for cache operations (hits, misses, operation durations, evictions) + - Added `ICacheTelemetry` interface and `CacheTelemetry` implementation for monitoring cache performance + - Added `ICacheProviderHealthCheckable` interface for provider health checks + - Implemented health checks on all cache providers (Memory, Redis, Distributed) + - Added telemetry recording to all cache operations (sync and async) + - Added eviction recording telemetry in MemoryCacheProvider LRU eviction + - Updated README with telemetry usage examples and health check documentation + +### Bug Fixes + +- Fixed health check logic in `CacheSwapper` to properly check provider health on registration +- Fixed duplicate `IsHealthy` method in test mock class + ## [1.1.0-alpha.1] - 2026-05-01 ### New Features diff --git a/Directory.Packages.props b/Directory.Packages.props index 37a444e..e4ea1b6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,6 +28,10 @@ + + + + diff --git a/README.md b/README.md index 97d69fb..dce8b66 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,11 @@ This is a public alpha. The library is already usable, but some APIs can still e Most bots should start with the full client package: ```bash -dotnet add package PawSharp.Client --version 1.1.0-alpha.1 -``` - -Add optional modules only when you need them: - -```bash -dotnet add package PawSharp.Commands --version 1.1.0-alpha.1 -dotnet add package PawSharp.Interactions --version 1.1.0-alpha.1 -dotnet add package PawSharp.Interactivity --version 1.1.0-alpha.1 -dotnet add package PawSharp.Voice --version 1.1.0-alpha.1 +dotnet add package PawSharp.Client --version 1.1.0-alpha.2 +dotnet add package PawSharp.Commands --version 1.1.0-alpha.2 +dotnet add package PawSharp.Interactions --version 1.1.0-alpha.2 +dotnet add package PawSharp.Interactivity --version 1.1.0-alpha.2 +dotnet add package PawSharp.Voice --version 1.1.0-alpha.2 ``` ## Quick Start diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md new file mode 100644 index 0000000..abf7ca2 --- /dev/null +++ b/docs/ERROR_HANDLING.md @@ -0,0 +1,878 @@ +# Error Handling Guide + +Comprehensive guide to handling errors in PawSharp for developers. + +## Table of Contents + +1. [Exception Hierarchy](#exception-hierarchy) +2. [Common Error Scenarios](#common-error-scenarios) +3. [Best Practices](#best-practices) +4. [Debugging Errors](#debugging-errors) +5. [Custom Error Handling](#custom-error-handling) +6. [Logging](#logging) +7. [Error Recovery Strategies](#error-recovery-strategies) + +--- + +## Exception Hierarchy + +PawSharp provides a structured exception hierarchy for different types of errors: + +``` +Exception +└── DiscordException (base) + ├── DiscordApiException (REST API errors) + ├── GatewayException (WebSocket connection errors) + ├── ValidationException (Input validation errors) + ├── RateLimitException (Rate limiting errors) + └── DeserializationException (JSON parsing errors) +``` + +### DiscordException + +Base exception for all PawSharp-related errors. + +```csharp +try +{ + // PawSharp operation +} +catch (DiscordException ex) +{ + // Handle any PawSharp-specific error + Console.WriteLine($"PawSharp error: {ex.Message}"); +} +``` + +### DiscordApiException + +Thrown when Discord's REST API returns an error response. Contains detailed context: + +```csharp +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (DiscordApiException ex) +{ + // Access detailed error information + Console.WriteLine($"Status Code: {ex.StatusCode}"); + Console.WriteLine($"Discord Error Code: {ex.DiscordErrorCode}"); + Console.WriteLine($"Discord Error Message: {ex.DiscordErrorMessage}"); + Console.WriteLine($"Request: {ex.RequestMethod} {ex.RequestEndpoint}"); + + // Example output: + // Status Code: 403 + // Discord Error Code: 50001 + // Discord Error Message: Missing Access + // Request: POST /channels/123/messages +} +``` + +**Common Discord Error Codes:** +- `50001` - Missing Access +- `50013` - Missing Permissions +- `10003` - Unknown Channel +- `10004` - Unknown Guild +- `10007` - Unknown Member +- `10008` - Unknown Message +- `10011` - Unknown Role +- `20012` - Max Guilds Reached +- `20016` - Max Friends Reached +- `20018` - Max Pins Reached +- `20028` - Invalid API Version +- `20031` - Rate Limited +- `50009` - Unauthorized + +### GatewayException + +Thrown when WebSocket connection issues occur. Includes recoverability information: + +```csharp +try +{ + await client.ConnectAsync(); +} +catch (GatewayException ex) +{ + Console.WriteLine($"Gateway error: {ex.Message}"); + Console.WriteLine($"Opcode: {ex.Opcode}"); + Console.WriteLine($"Event Type: {ex.EventType}"); + Console.WriteLine($"Is Recoverable: {ex.IsRecoverable}"); + + if (ex.IsRecoverable) + { + // Attempt reconnection + await Task.Delay(TimeSpan.FromSeconds(5)); + await client.ConnectAsync(); + } + else + { + // Fatal error - manual intervention required + throw; + } +} +``` + +### ValidationException + +Thrown when input validation fails before making API requests: + +```csharp +try +{ + await client.Rest.GetChannelMessagesAsync(channelId, limit: 500); // Max is 100 +} +catch (ValidationException ex) +{ + Console.WriteLine($"Parameter: {ex.ParameterName}"); + Console.WriteLine($"Invalid Value: {ex.InvalidValue}"); + Console.WriteLine($"Error: {ex.Message}"); + + // Example output: + // Parameter: limit + // Invalid Value: 500 + // Error: Limit must be between 1 and 100 +} +``` + +### RateLimitException + +Thrown when rate limiting occurs. Includes retry information: + +```csharp +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (RateLimitException ex) +{ + Console.WriteLine($"Retry After: {ex.RetryAfter.TotalSeconds} seconds"); + Console.WriteLine($"Is Global: {ex.IsGlobal}"); + Console.WriteLine($"Bucket: {ex.Bucket}"); + + // Automatic retry with backoff + await Task.Delay(ex.RetryAfter); + await client.Rest.CreateMessageAsync(channelId, request); +} +``` + +**Note:** PawSharp includes built-in rate limiting. You typically won't see this exception unless you bypass the rate limiter or hit global rate limits. + +### DeserializationException + +Thrown when JSON deserialization fails. Includes the raw JSON and target type: + +```csharp +try +{ + var guild = await client.Rest.GetGuildAsync(guildId); +} +catch (DeserializationException ex) +{ + Console.WriteLine($"Target Type: {ex.TargetType}"); + Console.WriteLine($"Raw JSON: {ex.RawJson}"); + Console.WriteLine($"Error: {ex.Message}"); + + // This helps diagnose API changes or malformed responses +} +``` + +--- + +## Common Error Scenarios + +### 1. Authentication Errors + +```csharp +try +{ + await client.ConnectAsync(); +} +catch (GatewayException ex) when (ex.Message.Contains("Invalid token")) +{ + _logger.LogError("Invalid Discord token. Check your PawSharpOptions.Token configuration."); + throw new InvalidOperationException("Discord token is invalid or expired. Please update your token.", ex); +} +``` + +### 2. Permission Errors + +```csharp +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (DiscordApiException ex) when (ex.DiscordErrorCode == "50013") +{ + _logger.LogWarning(ex, "Missing permissions for channel {ChannelId}. Required permissions: {RequiredPermissions}", + channelId, "SEND_MESSAGES"); + + // User-friendly error response + await client.Rest.CreateMessageAsync(channelId, new() + { + Content = "❌ I don't have permission to send messages in this channel." + }); +} +``` + +### 3. Rate Limiting + +```csharp +public async Task WithRetryAsync(Func> operation, int maxRetries = 3) +{ + int attempts = 0; + + while (attempts < maxRetries) + { + try + { + return await operation(); + } + catch (RateLimitException ex) + { + attempts++; + _logger.LogWarning(ex, "Rate limited on attempt {Attempt}/{MaxAttempts}. Waiting {RetryAfter}s", + attempts, maxRetries, ex.RetryAfter.TotalSeconds); + + if (attempts >= maxRetries) + throw; + + await Task.Delay(ex.RetryAfter); + } + } + + throw new InvalidOperationException("Max retries exceeded"); +} +``` + +### 4. Network Errors + +```csharp +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (HttpRequestException ex) +{ + _logger.LogError(ex, "Network error while sending message to channel {ChannelId}", channelId); + + // Implement retry logic for transient network failures + if (IsTransientNetworkError(ex)) + { + await Task.Delay(TimeSpan.FromSeconds(5)); + await client.Rest.CreateMessageAsync(channelId, request); + } + else + { + throw; + } +} +catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) +{ + _logger.LogError(ex, "Request timed out for channel {ChannelId}", channelId); + throw new TimeoutException("Request timed out. Check your network connection.", ex); +} +``` + +### 5. Invalid Input + +```csharp +try +{ + var messages = await client.Rest.GetChannelMessagesAsync(channelId, limit: 150); +} +catch (ValidationException ex) +{ + _logger.LogWarning(ex, "Validation failed: {Parameter} = {Value}", ex.ParameterName, ex.InvalidValue); + + // Automatically correct common mistakes + if (ex.ParameterName == "limit" && (int)ex.InvalidValue! > 100) + { + _logger.LogInformation("Adjusting limit to maximum allowed value (100)"); + var messages = await client.Rest.GetChannelMessagesAsync(channelId, limit: 100); + } +} +``` + +--- + +## Best Practices + +### 1. Always Handle Specific Exceptions First + +```csharp +// ❌ Bad - catches all exceptions +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (Exception ex) +{ + Console.WriteLine($"Error: {ex.Message}"); +} + +// ✅ Good - handles specific exceptions +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (ValidationException ex) +{ + _logger.LogWarning(ex, "Validation error: {Message}", ex.Message); +} +catch (RateLimitException ex) +{ + _logger.LogWarning(ex, "Rate limited. Retry after: {RetryAfter}s", ex.RetryAfter.TotalSeconds); + await Task.Delay(ex.RetryAfter); +} +catch (DiscordApiException ex) +{ + _logger.LogError(ex, "API error: {StatusCode} - {Message}", ex.StatusCode, ex.Message); +} +catch (Exception ex) +{ + _logger.LogCritical(ex, "Unexpected error"); + throw; +} +``` + +### 2. Use Structured Logging + +```csharp +// ❌ Bad - string interpolation +_logger.LogError($"Error sending message: {ex.Message}"); + +// ✅ Good - structured logging with context +_logger.LogError(ex, "Error sending message to channel {ChannelId}", channelId); +``` + +### 3. Provide Context in Error Messages + +```csharp +// ❌ Bad - generic error message +throw new InvalidOperationException("Operation failed"); + +// ✅ Good - specific error with context +throw new InvalidOperationException( + $"Failed to ban user {userId} from guild {guildId}. " + + $"Reason: Missing permission BAN_MEMBERS. " + + $"Bot role position: {botRolePosition}, Target role position: {targetRolePosition}", + ex); +``` + +### 4. Don't Swallow Exceptions + +```csharp +// ❌ Bad - silently swallows exception +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (Exception) +{ + // Do nothing +} + +// ✅ Good - logs and rethrows or handles appropriately +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (Exception ex) +{ + _logger.LogError(ex, "Failed to send message"); + throw; // Rethrow to let caller handle +} +``` + +### 5. Use Exception Filters + +```csharp +// ✅ Good - exception filters for cleaner code +try +{ + await client.ConnectAsync(); +} +catch (GatewayException ex) when (ex.IsRecoverable) +{ + _logger.LogWarning(ex, "Recoverable gateway error, retrying..."); + await Task.Delay(TimeSpan.FromSeconds(5)); + await client.ConnectAsync(); +} +catch (GatewayException ex) +{ + _logger.LogError(ex, "Fatal gateway error"); + throw; +} +``` + +--- + +## Debugging Errors + +### Enable Detailed Logging + +```csharp +services.AddLogging(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); // Show debug messages + + // Enable debug logging for specific namespaces + builder.AddFilter("PawSharp.API", LogLevel.Debug); + builder.AddFilter("PawSharp.Gateway", LogLevel.Debug); + builder.AddFilter("PawSharp.Client", LogLevel.Debug); +}); +``` + +### Capture Full Exception Details + +```csharp +try +{ + await client.Rest.CreateMessageAsync(channelId, request); +} +catch (Exception ex) +{ + _logger.LogError(ex, "Operation failed with full details:"); + _logger.LogError("Exception Type: {Type}", ex.GetType().FullName); + _logger.LogError("Message: {Message}", ex.Message); + _logger.LogError("Stack Trace: {StackTrace}", ex.StackTrace); + + if (ex is DiscordApiException apiEx) + { + _logger.LogError("Status Code: {StatusCode}", apiEx.StatusCode); + _logger.LogError("Discord Error Code: {Code}", apiEx.DiscordErrorCode); + _logger.LogError("Discord Error Message: {DiscordMessage}", apiEx.DiscordErrorMessage); + _logger.LogError("Request: {Method} {Endpoint}", apiEx.RequestMethod, apiEx.RequestEndpoint); + } + + if (ex.InnerException != null) + { + _logger.LogError("Inner Exception: {InnerType} - {InnerMessage}", + ex.InnerException.GetType().FullName, ex.InnerException.Message); + } + + throw; +} +``` + +### Use Developer Exception Page (ASP.NET Core) + +If using PawSharp with ASP.NET Core: + +```csharp +services.AddControllers() + .AddNewtonsoftJson(); + +if (builder.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} +``` + +### Create Error Handler Middleware + +```csharp +public class ErrorHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (DiscordApiException ex) + { + _logger.LogError(ex, "Discord API error"); + context.Response.StatusCode = (int)HttpStatusCode.BadGateway; + await context.Response.WriteAsJsonAsync(new + { + Error = "Discord API Error", + Message = ex.Message, + StatusCode = ex.StatusCode, + DiscordErrorCode = ex.DiscordErrorCode + }); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation error"); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.WriteAsJsonAsync(new + { + Error = "Validation Error", + Message = ex.Message, + Parameter = ex.ParameterName, + InvalidValue = ex.InvalidValue + }); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Unhandled exception"); + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + Error = "Internal Server Error", + Message = "An unexpected error occurred" + }); + } + } +} +``` + +--- + +## Custom Error Handling + +### Create Custom Exceptions + +```csharp +public class BotConfigurationException : DiscordException +{ + public string ConfigurationKey { get; } + public object? InvalidValue { get; } + + public BotConfigurationException(string configurationKey, object? invalidValue, string message) + : base(message) + { + ConfigurationKey = configurationKey; + InvalidValue = invalidValue; + } +} + +// Usage +if (string.IsNullOrEmpty(options.Token)) +{ + throw new BotConfigurationException( + nameof(options.Token), + options.Token, + "Discord bot token cannot be null or empty. Set it in PawSharpOptions or DISCORD_TOKEN environment variable."); +} +``` + +### Create Error Result Pattern + +```csharp +public class Result +{ + public bool IsSuccess { get; private set; } + public T? Value { get; private set; } + public string? Error { get; private set; } + public Exception? Exception { get; private set; } + + public static Result Success(T value) => new() { IsSuccess = true, Value = value }; + public static Result Failure(string error, Exception? ex = null) + => new() { IsSuccess = false, Error = error, Exception = ex }; +} + +// Usage +public async Task> TrySendAsync(ulong channelId, CreateMessageRequest request) +{ + try + { + var message = await client.Rest.CreateMessageAsync(channelId, request); + return Result.Success(message); + } + catch (DiscordApiException ex) + { + return Result.Failure($"API error: {ex.Message}", ex); + } + catch (Exception ex) + { + return Result.Failure($"Unexpected error: {ex.Message}", ex); + } +} +``` + +### Global Error Handler + +```csharp +public class GlobalErrorHandler +{ + private readonly ILogger _logger; + + public GlobalErrorHandler(ILogger logger) + { + _logger = logger; + } + + public void HandleException(Exception ex, string context = "") + { + switch (ex) + { + case ValidationException validationEx: + _logger.LogWarning(validationEx, "Validation error in {Context}: {Parameter} = {Value}", + context, validationEx.ParameterName, validationEx.InvalidValue); + break; + + case RateLimitException rateLimitEx: + _logger.LogWarning(rateLimitEx, "Rate limit hit in {Context}. Retry after: {RetryAfter}s", + context, rateLimitEx.RetryAfter.TotalSeconds); + break; + + case DiscordApiException apiEx: + _logger.LogError(apiEx, "API error in {Context}: {StatusCode} - {DiscordMessage}", + context, apiEx.StatusCode, apiEx.DiscordErrorMessage); + break; + + case GatewayException gatewayEx: + if (gatewayEx.IsRecoverable) + { + _logger.LogWarning(gatewayEx, "Recoverable gateway error in {Context}", context); + } + else + { + _logger.LogError(gatewayEx, "Fatal gateway error in {Context}", context); + } + break; + + default: + _logger.LogCritical(ex, "Unexpected error in {Context}", context); + break; + } + } +} +``` + +--- + +## Logging + +### Configure Log Levels + +```csharp +services.AddLogging(builder => +{ + builder.AddConsole(); + + // Production: Information and above + if (builder.Environment.IsProduction()) + { + builder.SetMinimumLevel(LogLevel.Information); + } + // Development: Debug and above + else + { + builder.SetMinimumLevel(LogLevel.Debug); + } + + // Fine-tune specific namespaces + builder.AddFilter("PawSharp.API", LogLevel.Information); + builder.AddFilter("PawSharp.Gateway", LogLevel.Information); + builder.AddFilter("PawSharp.Voice", LogLevel.Warning); // Voice can be noisy +}); +``` + +### Structured Logging Best Practices + +```csharp +// ✅ Good - structured with named parameters +_logger.LogInformation("User {UserId} sent command {Command} in channel {ChannelId}", + userId, command, channelId); + +// ❌ Bad - string interpolation +_logger.LogInformation($"User {userId} sent command {command} in channel {channelId}"); +``` + +### Log Error Context + +```csharp +catch (DiscordApiException ex) +{ + _logger.LogError(ex, "Failed to {Operation} for {ResourceType} {ResourceId}: {DiscordError}", + "CreateMessage", + "Channel", + channelId, + ex.DiscordErrorMessage); +} +``` + +--- + +## Error Recovery Strategies + +### Exponential Backoff + +```csharp +public async Task WithExponentialBackoffAsync( + Func> operation, + int maxRetries = 5, + TimeSpan? initialDelay = null) +{ + int attempts = 0; + TimeSpan delay = initialDelay ?? TimeSpan.FromSeconds(1); + + while (attempts < maxRetries) + { + try + { + return await operation(); + } + catch (RateLimitException ex) + { + attempts++; + + if (attempts >= maxRetries) + throw; + + _logger.LogWarning(ex, "Attempt {Attempt}/{MaxRetries} failed. Waiting {Delay}s", + attempts, maxRetries, delay.TotalSeconds); + + await Task.Delay(delay); + delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); // Exponential backoff + } + catch (HttpRequestException ex) when (IsTransientNetworkError(ex)) + { + attempts++; + + if (attempts >= maxRetries) + throw; + + _logger.LogWarning(ex, "Network error on attempt {Attempt}/{MaxRetries}. Retrying...", + attempts, maxRetries); + + await Task.Delay(delay); + delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); + } + } + + throw new InvalidOperationException("Max retries exceeded"); +} + +private bool IsTransientNetworkError(HttpRequestException ex) +{ + // Add logic to identify transient network errors + return true; +} +``` + +### Circuit Breaker Pattern + +```csharp +public class CircuitBreaker +{ + private readonly TimeSpan _openTimeout; + private readonly int _failureThreshold; + private int _failureCount; + private DateTime? _lastFailureTime; + private CircuitState _state = CircuitState.Closed; + + public CircuitBreaker(TimeSpan openTimeout, int failureThreshold = 5) + { + _openTimeout = openTimeout; + _failureThreshold = failureThreshold; + } + + public async Task ExecuteAsync(Func> operation) + { + if (_state == CircuitState.Open) + { + if (_lastFailureTime.HasValue && DateTime.UtcNow - _lastFailureTime.Value < _openTimeout) + { + throw new InvalidOperationException("Circuit breaker is open"); + } + + _state = CircuitState.HalfOpen; + } + + try + { + var result = await operation(); + OnSuccess(); + return result; + } + catch (Exception ex) + { + OnFailure(); + throw; + } + } + + private void OnSuccess() + { + _failureCount = 0; + _state = CircuitState.Closed; + } + + private void OnFailure() + { + _failureCount++; + _lastFailureTime = DateTime.UtcNow; + + if (_failureCount >= _failureThreshold) + { + _state = CircuitState.Open; + } + } + + private enum CircuitState + { + Closed, + Open, + HalfOpen + } +} +``` + +### Graceful Degradation + +```csharp +public async Task SendMessageWithFallback(ulong channelId, CreateMessageRequest request) +{ + try + { + // Primary: Send message with embed + return await client.Rest.CreateMessageAsync(channelId, request); + } + catch (DiscordApiException ex) when (ex.DiscordErrorCode == "50013") + { + _logger.LogWarning(ex, "Missing permissions for embed, falling back to plain text"); + + // Fallback: Send plain text message + return await client.Rest.CreateMessageAsync(channelId, new CreateMessageRequest + { + Content = request.Content ?? "Message could not be displayed due to missing permissions" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send message"); + return null; + } +} +``` + +--- + +## Additional Resources + +- [Troubleshooting Guide](./TROUBLESHOOTING.md) - Common issues and solutions +- [Developers Guide](./DEVELOPERS_GUIDE.md) - General development guide +- [REST API Guide](./REST_API_GUIDE.md) - REST API usage +- [Gateway Guide](./GATEWAY_GUIDE.md) - Gateway and event handling + +--- + +## Quick Reference + +| Exception | When Thrown | Key Properties | +|-----------|-------------|----------------| +| `DiscordApiException` | REST API error | `StatusCode`, `DiscordErrorCode`, `DiscordErrorMessage`, `RequestMethod`, `RequestEndpoint` | +| `GatewayException` | WebSocket error | `Opcode`, `EventType`, `IsRecoverable` | +| `ValidationException` | Input validation fails | `ParameterName`, `InvalidValue` | +| `RateLimitException` | Rate limit hit | `RetryAfter`, `IsGlobal`, `Bucket` | +| `DeserializationException` | JSON parse fails | `RawJson`, `TargetType` | + +--- + +**Need more help?** Check the [troubleshooting guide](./TROUBLESHOOTING.md) or open an issue on GitHub. diff --git a/docs/INDEX.md b/docs/INDEX.md index 4583ca7..b45c93f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -304,7 +304,7 @@ PawSharp implements **140+ Discord API endpoints**: ## 📝 Documentation Versions -**Latest:** 1.1.0-alpha.1 (May 1, 2026) +**Latest:** 1.1.0-alpha.2 (May 3, 2026) Documentation covers: - ✅ 1.0.0-alpha.1 and later @@ -344,5 +344,5 @@ PawSharp documentation is available under the MIT License. --- *Last updated: March 29, 2026* -*PawSharp Version: 1.1.0-alpha.1* +*PawSharp Version: 1.1.0-alpha.2* *For the latest documentation, visit [github.com/pawsharp/pawsharp/docs](https://github.com/pawsharp/pawsharp/docs)* diff --git a/docs/VERSIONING_POLICY.md b/docs/VERSIONING_POLICY.md index 40fad2a..ac34f09 100644 --- a/docs/VERSIONING_POLICY.md +++ b/docs/VERSIONING_POLICY.md @@ -30,7 +30,7 @@ PawSharp uses **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH[-pre-release]`). ### Pre-release identifiers (in order of maturity) ``` -1.1.0-alpha.1 ← early development, APIs may change freely +1.1.0-alpha.2 ← early development, APIs may change freely 6.1.0-beta-1 ← feature-complete, undergoing stabilisation 6.1.0-rc-1 ← release candidate, only critical fixes 6.1.0 ← stable release diff --git a/examples/CacheDistributionExample.cs b/examples/CacheDistributionExample.cs new file mode 100644 index 0000000..24210cc --- /dev/null +++ b/examples/CacheDistributionExample.cs @@ -0,0 +1,41 @@ +using PawSharp.Cache.Distribution; +using PawSharp.Cache.Providers; +using StackExchange.Redis; + +// Example: Cache Distribution with Redis Pub/Sub + +// Create Redis connection +var redis = ConnectionMultiplexer.Connect("localhost:6379"); + +// Create cache distributor +var distributor = new RedisCacheDistributor(redis, "pawsharp:cache"); + +// Create a cache provider (can be MemoryCacheProvider or RedisCacheProvider) +var memoryCache = new MemoryCacheProvider(); + +// Wrap the cache provider with distributed support +var distributedCache = new DistributedCacheProvider(memoryCache, distributor); + +// Use the distributed cache like any other cache provider +// Cache invalidations are automatically propagated to all instances +distributedCache.CacheUser(user); +distributedCache.CacheGuild(guild); + +// When you remove an entity, it's automatically invalidated across all instances +distributedCache.RemoveGuild(guildId); // This publishes invalidation to Redis + +// Other instances listening on the same Redis channel will automatically +// invalidate their local cache when they receive the invalidation event + +// The distributor also supports cache clear events +distributedCache.Clear(); // Publishes clear event to all instances + +// Check if the distributor is healthy +if (distributor.IsHealthy()) +{ + Console.WriteLine("Cache distribution is healthy"); +} + +// Dispose when done +distributedCache.Dispose(); +distributor.Dispose(); diff --git a/examples/CacheSwappingExample.cs b/examples/CacheSwappingExample.cs new file mode 100644 index 0000000..7b24372 --- /dev/null +++ b/examples/CacheSwappingExample.cs @@ -0,0 +1,72 @@ +using PawSharp.Cache.Providers; +using PawSharp.Cache.Swapping; +using PawSharp.Cache.Exceptions; + +// Example: Cache Swapping with Fallback Support + +// Create cache swapper with custom options +var swapperOptions = new CacheSwapperOptions +{ + AutoFallback = true, + MaxFailuresBeforeCircuitOpen = 3, + CircuitOpenDuration = TimeSpan.FromMinutes(5), + AutoSwapBackToPrimary = true, + HealthCheckInterval = TimeSpan.FromSeconds(30), + EnableLogging = true +}; + +var cacheSwapper = new CacheSwapper(swapperOptions); + +// Register multiple cache providers with priorities (lower = higher priority) +var memoryCache = new MemoryCacheProvider(); +var redisCache = new RedisCacheProvider("localhost:6379"); + +cacheSwapper.RegisterProvider("memory", memoryCache, priority: 10); // Lower priority (fallback) +cacheSwapper.RegisterProvider("redis", redisCache, priority: 0); // Higher priority (primary) + +// Start automatic health checks +cacheSwapper.StartHealthChecks(); + +// Use the cache swapper like any other cache provider +// It automatically uses the active provider and falls back if needed +try +{ + cacheSwapper.CacheUser(user); + var cachedUser = cacheSwapper.GetUser(userId); + Console.WriteLine($"Retrieved user: {cachedUser?.Username}"); +} +catch (CacheProviderUnavailableException ex) +{ + Console.WriteLine($"Cache provider unavailable: {ex.ProviderName}"); + // Swapper will automatically try to fallback to next provider +} +catch (CacheSwapException ex) +{ + Console.WriteLine($"Cache swap failed: {ex.Message}"); +} + +// Manually switch providers if needed +try +{ + cacheSwapper.SetActiveProvider("memory"); + Console.WriteLine("Switched to memory cache"); +} +catch (CacheProviderNotRegisteredException ex) +{ + Console.WriteLine($"Provider not registered: {ex.Message}"); +} +catch (CacheProviderUnavailableException ex) +{ + Console.WriteLine($"Provider unhealthy: {ex.ProviderName}"); +} + +// Get information about all providers +var providers = cacheSwapper.GetProviders(); +foreach (var provider in providers) +{ + Console.WriteLine($"Provider: {provider.Name}, Active: {provider.IsActive}, Healthy: {provider.IsHealthy}, Priority: {provider.Priority}"); +} + +// Stop health checks when done +cacheSwapper.StopHealthChecks(); +cacheSwapper.Dispose(); diff --git a/examples/ComponentV2Example.cs b/examples/ComponentV2Example.cs new file mode 100644 index 0000000..06d3d79 --- /dev/null +++ b/examples/ComponentV2Example.cs @@ -0,0 +1,274 @@ +#nullable enable +using PawSharp.Core.Entities; +using PawSharp.Core.Builders; + +namespace PawSharp.Examples; + +/// +/// Examples demonstrating the Componentv2 builder API with fluent ergonomics. +/// Componentv2 is Discord's new component system released in 2025, enabling richer message layouts. +/// +public class ComponentV2Example +{ + /// + /// Creates a simple Container with text content and accent color. + /// + public static List SimpleContainer() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# Welcome to the Server!") + .AddText("This is a Componentv2 message with an accent color.") + .WithAccentColor(0x5865F2)) // Discord blurple + .Build(); + + return components; + } + + /// + /// Creates a rich Container with media gallery, sections, and interactive elements. + /// + public static List RichContainer() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# Game Update v7.3") + .AddMediaGallery(g => g + .AddItem("https://example.com/update-preview.png", "Update preview image") + .AddItem("https://example.com/new-feature.png", "New feature screenshot")) + .AddSeparator() + .AddSection(s => s + .AddText("## What's New") + .AddText("- Fixed treasure chest bugs\n- Improved server stability\n- Added gravity mechanics") + .WithButtonAccessory(b => b + .WithLabel("Read Full Notes") + .WithStyle(ButtonStyle.Link) + .WithUrl("https://example.com/notes"))) + .AddSeparator() + .AddRadioGroup(r => r + .WithCustomId("feedback_type") + .WithLabel("What do you think?") + .AddOption("Love it!", "love", "I really enjoy this update") + .AddOption("It's okay", "okay", "Some good, some bad") + .AddOption("Not great", "bad", "Needs improvement")) + .WithAccentColor(0x57F287)) // Discord green + .Build(); + + return components; + } + + /// + /// Creates a modal with Componentv2 form elements (FileUpload, CheckboxGroup, etc.). + /// + public static List ModalForm() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddLabel("Bug Report", l => l + .WithEmoji("🐛")) + .AddFileUpload("screenshot", "Upload Screenshot", f => f + .WithPlaceholder("Attach an image showing the bug") + .WithRequired(true) + .WithMinLength(1) + .WithMaxLength(3) + .WithFileTypes("image/*")) + .AddCheckboxGroup("severity", "Severity Level", cb => cb + .AddOption("Critical", "critical", "Breaks core functionality") + .AddOption("Major", "major", "Significant impact") + .AddOption("Minor", "minor", "Cosmetic or small issue") + .WithMinValues(1) + .WithMaxValues(1)) + .AddCheckbox("reproducible", "I can reproduce this bug consistently", ch => ch + .WithDefaultValue(true))) + .Build(); + + return components; + } + + /// + /// Creates a Section with a thumbnail accessory. + /// + public static List SectionWithThumbnail() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddSection(s => s + .AddText("# User Profile") + .AddText("**Level:** 42") + .AddText("**XP:** 15,420 / 20,000") + .WithThumbnailAccessory("https://example.com/avatar.png", "User avatar")) + .WithAccentColor(0xED4245)) // Discord red + .Build(); + + return components; + } + + /// + /// Creates a Container with a File component. + /// + public static List FileAttachment() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# Document Download") + .AddFile("attachment://document.pdf", f => f + .WithSpoiler(false)) + .AddSeparator() + .AddText("Click the button below to download.")) + .Build(); + + return components; + } + + /// + /// Creates a complex form with multiple Componentv2 interactive elements. + /// + public static List ComplexForm() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# Server Settings Configuration") + .AddSeparator() + .AddRadioGroup("verification_level", "Verification Level", r => r + .AddOption("None", "none", "No verification required") + .AddOption("Low", "low", "Must have verified email") + .AddOption("Medium", "medium", "Must be registered for 5 minutes") + .AddOption("High", "high", "Must be a member for 10 minutes") + .AddOption("Highest", "highest", "Must have verified phone")) + .AddSeparator() + .AddCheckboxGroup("features", "Enable Features", cb => cb + .AddOption("Welcome Messages", "welcome", "Send welcome message to new members") + .AddOption("Auto-moderation", "automod", "Enable automatic moderation") + .AddOption("Leveling System", "leveling", "Enable XP and levels") + .WithMinValues(0) + .WithMaxValues(3)) + .AddSeparator() + .AddCheckbox("agree_rules", "I have read and agree to the server rules", ch => ch + .WithRequired(true)) + .WithAccentColor(0x5865F2)) + .Build(); + + return components; + } + + /// + /// Creates a Container with nested ActionRows for interactive buttons. + /// + public static List ContainerWithActionRows() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# What would you like to do?") + .AddActionRow(ar => ar + .AddPrimaryButton("Create", "create") + .AddSecondaryButton("Edit", "edit") + .AddSuccessButton("Save", "save") + .AddDangerButton("Delete", "delete")) + .AddSeparator() + .AddText("Or select from the options below:") + .AddActionRow(ar => ar + .AddStringSelect(s => s + .WithCustomId("options") + .WithPlaceholder("Choose an option...") + .AddOption("View Profile", "profile") + .AddOption("Settings", "settings") + .AddOption("Help", "help"))) + .WithAccentColor(0xFEE75C)) // Discord yellow + .Build(); + + return components; + } + + /// + /// Demonstrates error handling with Componentv2 builders. + /// + public static void ErrorHandlingExample() + { + try + { + // This will throw because the label is too long + var label = new LabelBuilder(new string('a', 81)).Build(); + } + catch (PawSharp.Core.Exceptions.ValidationException ex) + { + Console.WriteLine($"Validation failed: {ex.Message}"); + Console.WriteLine($"Parameter: {ex.ParameterName}"); + Console.WriteLine($"Value: {ex.Value}"); + } + + try + { + // This will throw because RadioGroup has no options + var radioGroup = new RadioGroupBuilder("id", "Label").Build(); + } + catch (PawSharp.Core.Exceptions.ValidationException ex) + { + Console.WriteLine($"Validation failed: {ex.Message}"); + } + + try + { + // This will throw because Container has too many components + var container = new ContainerBuilder(); + for (int i = 0; i < 21; i++) + { + container.AddText($"Text {i}"); + } + container.Build(); + } + catch (PawSharp.Core.Exceptions.ValidationException ex) + { + Console.WriteLine($"Validation failed: {ex.Message}"); + } + } + + /// + /// Demonstrates the fluent builder API with method chaining. + /// + public static List FluentApiExample() + { + // Method chaining provides excellent ergonomics + var components = new ComponentBuilder() + .AddContainer(c => c + .WithAccentColor(0x5865F2) + .WithSpoiler(false) + .AddText("# Welcome") + .AddSeparator(s => s.WithSpacing(SeparatorSpacing.Large).WithDivider(true)) + .AddSection(s => s + .AddText("## Information") + .AddText("Details go here") + .WithThumbnailAccessory(t => t + .WithUrl("https://example.com/image.png") + .WithDescription("Thumbnail") + .WithSpoiler(false))) + .AddRadioGroup("choice", "Choose", r => r + .WithRequired(true) + .AddOption("A", "a") + .AddOption("B", "b"))) + .Build(); + + return components; + } + + /// + /// Creates a media-rich message with MediaGallery and File components. + /// + public static List MediaRichMessage() + { + var components = new ComponentBuilder() + .AddContainer(c => c + .AddText("# Photo Gallery") + .AddMediaGallery(g => g + .AddItem("https://example.com/photo1.jpg", "Sunset at the beach", spoiler: false) + .AddItem("https://example.com/photo2.jpg", "Mountain view") + .AddItem("https://example.com/photo3.jpg", "City lights", spoiler: false) + .AddItem("https://example.com/photo4.jpg", "Forest path", spoiler: false)) + .AddSeparator() + .AddFile("attachment://album.zip", f => f + .WithSpoiler(false)) + .AddText("Download the full album as a ZIP file.")) + .Build(); + + return components; + } +} diff --git a/examples/DashboardBot/Program.cs b/examples/DashboardBot/Program.cs index 7cffc18..6fbdff1 100644 --- a/examples/DashboardBot/Program.cs +++ b/examples/DashboardBot/Program.cs @@ -122,7 +122,7 @@ public async Task StatsAsync() .AddField("Servers", client.Guilds.Count.ToString(), true) .AddField("Users", client.Guilds.Sum(g => g.MemberCount).ToString(), true) .AddField("Uptime", "Running", true) - .AddField("Version", "PawSharp 1.1.0-alpha.1", false) + .AddField("Version", "PawSharp 1.1.0-alpha.2", false) .WithColor(Color.Purple); await RespondAsync(embed: embed.Build()); diff --git a/index.md b/index.md index f01d37b..3242939 100644 --- a/index.md +++ b/index.md @@ -7,7 +7,7 @@ _disableToc: false A modular Discord API wrapper for **.NET 10** — REST, Gateway, caching, slash commands, prefix commands, interactivity, and voice with full DAVE E2EE. -**Current version:** `1.1.0-alpha.1` | **Discord API:** v10 +**Current version:** `1.1.0-alpha.2` | **Discord API:** v10 --- diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 303aa2a..e85da1a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,7 +10,7 @@ https://github.com/M1tsumi/PawSharp git discord;api;wrapper;bot;csharp;dotnet - 1.1.0-alpha.1 + 1.1.0-alpha.2 true true diff --git a/src/PawSharp.API/Clients/RestClient.cs b/src/PawSharp.API/Clients/RestClient.cs index f9ec89f..ddc44ca 100644 --- a/src/PawSharp.API/Clients/RestClient.cs +++ b/src/PawSharp.API/Clients/RestClient.cs @@ -71,7 +71,7 @@ public DiscordRestClient(HttpClient httpClient, PawSharpOptions options, ILogger _httpClient.BaseAddress = new Uri($"https://discord.com/api/v{_options.ApiVersion}/"); // Discord requires the User-Agent format: DiscordBot ($url, $versionNumber) // Requests without a valid User-Agent may be blocked by Cloudflare. - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordBot (https://github.com/M1tsumi/Pawsharp, 1.1.0-alpha.1)"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("DiscordBot (https://github.com/M1tsumi/Pawsharp, 1.1.0-alpha.2)"); // Apply timeout configuration if specified if (_options.RestApi.TimeoutSeconds > 0) @@ -184,7 +184,11 @@ public async Task ModifyCurrentUserAsync(string? username = // Validate input if (limit < 1 || limit > 200) { - throw new ValidationException("Limit must be between 1 and 200", nameof(limit), limit); + throw new ValidationException( + "Limit must be between 1 and 200. Discord API restricts guild list responses to 200 maximum per request. " + + "For larger servers, use pagination with 'before' or 'after' parameters to fetch additional pages.", + nameof(limit), + limit); } if (before.HasValue) { @@ -500,7 +504,7 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can public async Task?> GetChannelMessagesAsync(ulong channelId, int limit = 50, ulong? around = null, ulong? before = null, ulong? after = null) { // Validate input - SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId)); + ValidateSnowflake(channelId, nameof(channelId)); if (limit < 1 || limit > 100) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 100"); @@ -510,17 +514,17 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can queryParams.Add($"limit={limit}"); if (around.HasValue) { - SnowflakeValidator.ValidateSnowflake(around.Value, nameof(around)); + ValidateSnowflake(around.Value, nameof(around)); queryParams.Add($"around={around.Value}"); } if (before.HasValue) { - SnowflakeValidator.ValidateSnowflake(before.Value, nameof(before)); + ValidateSnowflake(before.Value, nameof(before)); queryParams.Add($"before={before.Value}"); } if (after.HasValue) { - SnowflakeValidator.ValidateSnowflake(after.Value, nameof(after)); + ValidateSnowflake(after.Value, nameof(after)); queryParams.Add($"after={after.Value}"); } @@ -531,7 +535,7 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can public async Task?> GetChannelMessagesAsync(ulong channelId, int limit, ulong? around, ulong? before, ulong? after, CancellationToken cancellationToken) { // Validate input - SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId)); + ValidateSnowflake(channelId, nameof(channelId)); if (limit < 1 || limit > 100) { throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be between 1 and 100"); @@ -541,17 +545,17 @@ public async Task DeleteMessageAsync(ulong channelId, ulong messageId, Can queryParams.Add($"limit={limit}"); if (around.HasValue) { - SnowflakeValidator.ValidateSnowflake(around.Value, nameof(around)); + ValidateSnowflake(around.Value, nameof(around)); queryParams.Add($"around={around.Value}"); } if (before.HasValue) { - SnowflakeValidator.ValidateSnowflake(before.Value, nameof(before)); + ValidateSnowflake(before.Value, nameof(before)); queryParams.Add($"before={before.Value}"); } if (after.HasValue) { - SnowflakeValidator.ValidateSnowflake(after.Value, nameof(after)); + ValidateSnowflake(after.Value, nameof(after)); queryParams.Add($"after={after.Value}"); } @@ -565,7 +569,11 @@ public async Task BulkDeleteMessagesAsync(ulong channelId, List mes SnowflakeValidator.ValidateSnowflake(channelId, nameof(channelId)); if (messageIds == null || messageIds.Count == 0 || messageIds.Count > 100) { - throw new ValidationException("Message IDs list must contain between 1 and 100 IDs", nameof(messageIds), messageIds?.Count ?? 0); + throw new ValidationException( + "Message IDs list must contain between 1 and 100 IDs. Discord's bulk delete endpoint accepts 1-100 messages at a time. " + + "For larger deletions, split into multiple calls with 100 IDs each.", + nameof(messageIds), + messageIds?.Count ?? 0); } foreach (var messageId in messageIds) { @@ -796,7 +804,22 @@ public async Task DeleteGuildAsync(ulong guildId) var response = await GetAsync($"guilds/{guildId}/members/{userId}"); return await HandleApiResponseAsync("GetGuildMemberAsync", response); } - + + public async Task?> GetGuildMembersAsync(ulong guildId, int limit = 1000, ulong? after = null) + { + SnowflakeValidator.ValidateSnowflake(guildId, nameof(guildId)); + var queryParams = new List(); + if (limit != 1000) queryParams.Add($"limit={limit}"); + if (after.HasValue) queryParams.Add($"after={after.Value}"); + var queryString = queryParams.Count > 0 ? $"?{string.Join("&", queryParams)}" : ""; + var response = await GetAsync($"guilds/{guildId}/members{queryString}"); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync>(); + } + return null; + } + public async Task AddGuildMemberAsync(ulong guildId, ulong userId, AddGuildMemberRequest request) { var content = JsonContent(request); @@ -1873,14 +1896,6 @@ public async Task DeleteGuildStickerAsync(ulong guildId, ulong stickerId) } // Message crosspost - public async Task GetMessageAsync(ulong channelId, ulong messageId) - { - ValidateSnowflake(channelId, nameof(channelId)); - ValidateSnowflake(messageId, nameof(messageId)); - var response = await GetAsync($"channels/{channelId}/messages/{messageId}"); - return await HandleApiResponseAsync("GetMessageAsync", response); - } - public async Task GetMessageAsync(ulong channelId, ulong messageId, CancellationToken cancellationToken) { ValidateSnowflake(channelId, nameof(channelId)); @@ -3083,7 +3098,7 @@ private void ValidateSnowflake(ulong id, string paramName) } catch { /* Ignore parse errors */ } - throw DiscordApiException.FromResponse(statusCode, operation, discordErrorCode, discordErrorMessage); + throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, "", discordErrorCode, discordErrorMessage); } return null; @@ -3104,7 +3119,7 @@ private async Task HandleApiResponseAsync(string operation, if (_options.RestApi.ThrowOnApiError) { var statusCode = (System.Net.HttpStatusCode)response.StatusCode; - throw DiscordApiException.FromResponse(statusCode, operation); + throw PawSharp.API.Exceptions.DiscordApiException.FromResponse(statusCode, operation, ""); } return response; diff --git a/src/PawSharp.API/Exceptions/DiscordApiException.cs b/src/PawSharp.API/Exceptions/DiscordApiException.cs index 3dca0fc..22c0afb 100644 --- a/src/PawSharp.API/Exceptions/DiscordApiException.cs +++ b/src/PawSharp.API/Exceptions/DiscordApiException.cs @@ -6,6 +6,43 @@ namespace PawSharp.API.Exceptions; /// /// Represents an error that occurred while interacting with the Discord API. +/// +/// This exception is thrown when Discord's REST API returns an error response. It includes detailed context +/// about the HTTP status code, Discord-specific error code, error message, and the request details that caused the error. +/// +/// +/// +/// +/// try +/// { +/// await client.Rest.CreateMessageAsync(channelId, request); +/// } +/// catch (DiscordApiException ex) +/// { +/// Console.WriteLine($"Status Code: {ex.StatusCode}"); +/// Console.WriteLine($"Discord Error Code: {ex.DiscordErrorCode}"); +/// Console.WriteLine($"Discord Error Message: {ex.DiscordErrorMessage}"); +/// Console.WriteLine($"Request: {ex.RequestMethod} {ex.RequestEndpoint}"); +/// } +/// +/// +/// +/// +/// +/// Common Discord error codes: +/// +/// CodeDescription +/// 50001Missing Access +/// 50013Missing Permissions +/// 10003Unknown Channel +/// 10004Unknown Guild +/// 10007Unknown Member +/// 10008Unknown Message +/// 10011Unknown Role +/// 20031Rate Limited +/// +/// +/// /// public sealed class DiscordApiException : Exception { @@ -16,11 +53,13 @@ public sealed class DiscordApiException : Exception /// /// Gets the Discord error code, if available. + /// This is Discord's internal error code that provides more specific information about what went wrong. /// public int? DiscordErrorCode { get; } /// /// Gets the Discord error message, if available. + /// This is the human-readable error message from Discord explaining the issue. /// public string? DiscordErrorMessage { get; } @@ -54,6 +93,12 @@ public DiscordApiException( /// /// Creates a DiscordApiException from an HTTP response. /// + /// The HTTP status code. + /// The HTTP request method (GET, POST, etc.). + /// The API endpoint that was requested. + /// The Discord error code from the response body, if available. + /// The Discord error message from the response body, if available. + /// A new DiscordApiException instance. public static DiscordApiException FromResponse( HttpStatusCode statusCode, string requestMethod, diff --git a/src/PawSharp.API/README.md b/src/PawSharp.API/README.md index 8772754..3910ec5 100644 --- a/src/PawSharp.API/README.md +++ b/src/PawSharp.API/README.md @@ -19,7 +19,7 @@ It is designed for teams that want direct control over HTTP calls while still ge ## Installation ```bash -dotnet add package PawSharp.API --version 1.1.0-alpha.1 +dotnet add package PawSharp.API --version 1.1.0-alpha.2 ``` ## Quick Start diff --git a/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs b/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs index 9eb50bc..3bb3f36 100644 --- a/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs +++ b/src/PawSharp.API/Serialization/PawSharpApiJsonContext.cs @@ -1,6 +1,7 @@ #nullable enable using System.Text.Json.Serialization; using PawSharp.API.Models; +using PawSharp.Core.Entities; namespace PawSharp.API.Serialization; @@ -29,6 +30,8 @@ namespace PawSharp.API.Serialization; [JsonSerializable(typeof(CreateWebhookRequest))] [JsonSerializable(typeof(ModifyWebhookRequest))] [JsonSerializable(typeof(ExecuteWebhookRequest))] +[JsonSerializable(typeof(PawSharp.Core.Entities.Webhook))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(CreateGuildScheduledEventRequest))] [JsonSerializable(typeof(ModifyGuildScheduledEventRequest))] [JsonSerializable(typeof(CreateAutoModerationRuleRequest))] diff --git a/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs b/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs new file mode 100644 index 0000000..0a8629d --- /dev/null +++ b/src/PawSharp.Cache/Distribution/DistributedCacheProvider.cs @@ -0,0 +1,235 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using PawSharp.Cache.Exceptions; +using PawSharp.Cache.Interfaces; +using PawSharp.Cache.Telemetry; +using PawSharp.Core.Entities; + +namespace PawSharp.Cache.Distribution +{ + /// + /// A cache provider wrapper that distributes cache invalidation events across instances. + /// + public class DistributedCacheProvider : IEntityCache, ICacheProviderHealthCheckable, IDisposable + { + private readonly IEntityCache _innerCache; + private readonly RedisCacheDistributor _distributor; + private readonly ICacheTelemetry? _telemetry; + private bool _disposed; + + public ICacheTelemetry? Telemetry + { + get => _telemetry; + set => throw new InvalidOperationException("Telemetry is set at construction time."); + } + + public event EventHandler? EntityEvicted; + public event EventHandler? CacheCleared; + + /// + /// Creates a new DistributedCacheProvider instance. + /// + /// The underlying cache provider to wrap. + /// The Redis cache distributor for invalidation events. + /// The cache telemetry instance. + public DistributedCacheProvider(IEntityCache innerCache, RedisCacheDistributor distributor, ICacheTelemetry? telemetry = null) + { + _innerCache = innerCache ?? throw new ArgumentNullException(nameof(innerCache)); + _distributor = distributor ?? throw new ArgumentNullException(nameof(distributor)); + _telemetry = telemetry ?? new CacheTelemetry(); + + // Wire up inner cache events + _innerCache.EntityEvicted += OnInnerCacheEvicted; + _innerCache.CacheCleared += OnInnerCacheCleared; + + // Wire up distributor events + _distributor.CacheInvalidationReceived += OnInvalidationReceived; + + // Start listening for invalidations + _distributor.StartListening(); + } + + private void OnInnerCacheEvicted(object? sender, CacheInvalidationEventArgs args) + { + // Publish to other instances + try + { + _ = _distributor.PublishInvalidationAsync(args.EntityType, args.EntityId, args.GuildId); + } + catch (CacheDistributionException ex) + { + Console.WriteLine($"[DistributedCacheProvider] Failed to publish invalidation: {ex.Message}"); + } + + // Raise local event + EntityEvicted?.Invoke(sender, args); + } + + private void OnInnerCacheCleared(object? sender, EventArgs args) + { + // Publish to other instances + try + { + _ = _distributor.PublishClearAsync(); + } + catch (CacheDistributionException ex) + { + Console.WriteLine($"[DistributedCacheProvider] Failed to publish clear: {ex.Message}"); + } + + // Raise local event + CacheCleared?.Invoke(sender, args); + } + + private void OnInvalidationReceived(object? sender, CacheInvalidationMessage message) + { + // Invalidate local cache based on received message + try + { + switch (message.EntityType) + { + case "CLEAR_ALL": + _innerCache.Clear(); + break; + + case "User": + // Users are removed by key, not by ID directly + // Skip for now as IEntityCache doesn't have RemoveUser + break; + + case "Guild": + _innerCache.RemoveGuild(message.EntityId); + break; + + case "Channel": + _innerCache.RemoveChannel(message.EntityId); + break; + + case "Message": + _innerCache.RemoveMessage(message.EntityId); + break; + + case "GuildMember" when message.GuildId.HasValue: + _innerCache.RemoveGuildMember(message.GuildId.Value, message.EntityId); + break; + + case "Role" when message.GuildId.HasValue: + _innerCache.RemoveRole(message.GuildId.Value, message.EntityId); + break; + + case "Emoji" when message.GuildId.HasValue: + // Emojis need guild ID, but we don't have entity ID for the emoji itself + // This is a limitation of the current message format + break; + } + + // Raise event for local subscribers + var eventArgs = new CacheInvalidationEventArgs + { + EntityType = message.EntityType, + EntityId = message.EntityId, + GuildId = message.GuildId + }; + + EntityEvicted?.Invoke(this, eventArgs); + } + catch (Exception ex) + { + Console.WriteLine($"[DistributedCacheProvider] Failed to process invalidation: {ex.Message}"); + } + } + + // IEntityCache implementation - delegate to inner cache + + public void Add(string key, object entity) => _innerCache.Add(key, entity); + public object? Get(string key) => _innerCache.Get(key); + public void Remove(string key) => _innerCache.Remove(key); + public void Clear() => _innerCache.Clear(); + public bool Exists(string key) => _innerCache.Exists(key); + + public void CacheUser(User user) => _innerCache.CacheUser(user); + public User? GetUser(ulong userId) => _innerCache.GetUser(userId); + + public void CacheGuild(Guild guild) => _innerCache.CacheGuild(guild); + public Guild? GetGuild(ulong guildId) => _innerCache.GetGuild(guildId); + public IEnumerable GetAllGuilds() => _innerCache.GetAllGuilds(); + + public void CacheChannel(Channel channel) => _innerCache.CacheChannel(channel); + public Channel? GetChannel(ulong channelId) => _innerCache.GetChannel(channelId); + public IEnumerable GetGuildChannels(ulong guildId) => _innerCache.GetGuildChannels(guildId); + + public void CacheMessage(Message message) => _innerCache.CacheMessage(message); + public Message? GetMessage(ulong messageId) => _innerCache.GetMessage(messageId); + public IEnumerable GetChannelMessages(ulong channelId, int limit = 50) => _innerCache.GetChannelMessages(channelId, limit); + + public void CacheGuildMember(ulong guildId, GuildMember member) => _innerCache.CacheGuildMember(guildId, member); + public GuildMember? GetGuildMember(ulong guildId, ulong userId) => _innerCache.GetGuildMember(guildId, userId); + public IEnumerable GetGuildMembers(ulong guildId) => _innerCache.GetGuildMembers(guildId); + + public void CacheRole(ulong guildId, Role role) => _innerCache.CacheRole(guildId, role); + public Role? GetRole(ulong guildId, ulong roleId) => _innerCache.GetRole(guildId, roleId); + public IEnumerable GetGuildRoles(ulong guildId) => _innerCache.GetGuildRoles(guildId); + + public void CacheEmoji(ulong guildId, Emoji emoji) => _innerCache.CacheEmoji(guildId, emoji); + public Emoji? GetEmoji(ulong guildId, ulong emojiId) => _innerCache.GetEmoji(guildId, emojiId); + public IEnumerable GetGuildEmojis(ulong guildId) => _innerCache.GetGuildEmojis(guildId); + + public void CacheGuildData(Guild guild) => _innerCache.CacheGuildData(guild); + public void RemoveGuild(ulong guildId) => _innerCache.RemoveGuild(guildId); + + public void RemoveChannel(ulong channelId) => _innerCache.RemoveChannel(channelId); + public void RemoveMessage(ulong messageId) => _innerCache.RemoveMessage(messageId); + public void RemoveGuildMember(ulong guildId, ulong userId) => _innerCache.RemoveGuildMember(guildId, userId); + public void RemoveRole(ulong guildId, ulong roleId) => _innerCache.RemoveRole(guildId, roleId); + + public int GetEntityCount() => _innerCache.GetEntityCount(); + public long GetMemoryUsage() => _innerCache.GetMemoryUsage(); + public CacheStats GetCacheStats() => _innerCache.GetCacheStats(); + + public bool IsHealthy() => _innerCache.IsHealthy() && _distributor.IsHealthy(); + + // Async operations - delegate to inner cache + + public Task GetUserAsync(ulong userId) => _innerCache.GetUserAsync(userId); + public Task GetGuildAsync(ulong guildId) => _innerCache.GetGuildAsync(guildId); + public Task GetChannelAsync(ulong channelId) => _innerCache.GetChannelAsync(channelId); + public Task GetMessageAsync(ulong messageId) => _innerCache.GetMessageAsync(messageId); + public Task GetGuildMemberAsync(ulong guildId, ulong userId) => _innerCache.GetGuildMemberAsync(guildId, userId); + public Task GetRoleAsync(ulong guildId, ulong roleId) => _innerCache.GetRoleAsync(guildId, roleId); + public Task GetEmojiAsync(ulong guildId, ulong emojiId) => _innerCache.GetEmojiAsync(guildId, emojiId); + + public Task CacheUserAsync(User user) => _innerCache.CacheUserAsync(user); + public Task CacheGuildAsync(Guild guild) => _innerCache.CacheGuildAsync(guild); + public Task CacheChannelAsync(Channel channel) => _innerCache.CacheChannelAsync(channel); + public Task CacheMessageAsync(Message message) => _innerCache.CacheMessageAsync(message); + public Task CacheGuildMemberAsync(ulong guildId, GuildMember member) => _innerCache.CacheGuildMemberAsync(guildId, member); + public Task CacheRoleAsync(ulong guildId, Role role) => _innerCache.CacheRoleAsync(guildId, role); + public Task CacheEmojiAsync(ulong guildId, Emoji emoji) => _innerCache.CacheEmojiAsync(guildId, emoji); + public Task CacheGuildDataAsync(Guild guild) => _innerCache.CacheGuildDataAsync(guild); + public Task RemoveGuildAsync(ulong guildId) => _innerCache.RemoveGuildAsync(guildId); + public Task ClearAsync() => _innerCache.ClearAsync(); + + public Task RemoveChannelAsync(ulong channelId) => _innerCache.RemoveChannelAsync(channelId); + public Task RemoveMessageAsync(ulong messageId) => _innerCache.RemoveMessageAsync(messageId); + public Task RemoveGuildMemberAsync(ulong guildId, ulong userId) => _innerCache.RemoveGuildMemberAsync(guildId, userId); + public Task RemoveRoleAsync(ulong guildId, ulong roleId) => _innerCache.RemoveRoleAsync(guildId, roleId); + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _distributor.Dispose(); + + if (_innerCache is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs b/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs new file mode 100644 index 0000000..e108d81 --- /dev/null +++ b/src/PawSharp.Cache/Distribution/RedisCacheDistributor.cs @@ -0,0 +1,213 @@ +#nullable enable +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PawSharp.Cache.Exceptions; +using PawSharp.Core.Entities; +using StackExchange.Redis; + +namespace PawSharp.Cache.Distribution +{ + /// + /// Distributes cache invalidation events across multiple bot instances using Redis pub/sub. + /// + public class RedisCacheDistributor : IDisposable + { + private readonly IConnectionMultiplexer _redis; + private readonly string _channelPrefix; + private readonly ISubscriber? _subscriber; + private readonly CancellationTokenSource _cancellationTokenSource; + private Task? _listenerTask; + private bool _disposed; + + /// + /// Event raised when a cache invalidation is received from another instance. + /// + public event EventHandler? CacheInvalidationReceived; + + /// + /// Creates a new RedisCacheDistributor instance. + /// + /// The Redis connection multiplexer. + /// Prefix for Redis pub/sub channels (default: "pawsharp:cache"). + public RedisCacheDistributor(IConnectionMultiplexer redis, string channelPrefix = "pawsharp:cache") + { + _redis = redis ?? throw new ArgumentNullException(nameof(redis)); + _channelPrefix = channelPrefix ?? throw new ArgumentNullException(nameof(channelPrefix)); + _subscriber = redis.GetSubscriber(); + _cancellationTokenSource = new CancellationTokenSource(); + } + + /// + /// Starts listening for cache invalidation events. + /// + public void StartListening() + { + if (_listenerTask != null) + return; + + _listenerTask = Task.Run(() => ListenForInvalidationsAsync(_cancellationTokenSource.Token)); + } + + /// + /// Stops listening for cache invalidation events. + /// + public void StopListening() + { + _cancellationTokenSource.Cancel(); + + if (_listenerTask != null) + { + try + { + _listenerTask.Wait(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) + { + // Expected + } + _listenerTask = null; + } + } + + private async Task ListenForInvalidationsAsync(CancellationToken cancellationToken) + { + try + { + var channel = $"{_channelPrefix}:invalidations"; + await _subscriber!.SubscribeAsync(channel, (channel, message) => + { + try + { + var invalidation = JsonSerializer.Deserialize((string)message!); + if (invalidation != null) + { + CacheInvalidationReceived?.Invoke(this, invalidation); + } + } + catch (JsonException ex) + { + Console.WriteLine($"[RedisCacheDistributor] Failed to deserialize invalidation message: {ex.Message}"); + } + }); + + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (OperationCanceledException) + { + // Expected when stopping + } + catch (Exception ex) + { + throw new CacheDistributionException($"Failed to listen for cache invalidations: {ex.Message}", ex); + } + } + + /// + /// Publishes a cache invalidation event to all other instances. + /// + /// The type of entity that was invalidated. + /// The ID of the entity that was invalidated. + /// The guild ID (if applicable). + public async Task PublishInvalidationAsync(string entityType, ulong entityId, ulong? guildId = null) + { + try + { + var message = new CacheInvalidationMessage + { + EntityType = entityType, + EntityId = entityId, + GuildId = guildId, + Timestamp = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(message); + var channel = $"{_channelPrefix}:invalidations"; + + await _subscriber!.PublishAsync(channel, json, StackExchange.Redis.CommandFlags.FireAndForget); + } + catch (Exception ex) + { + throw new CacheDistributionException($"Failed to publish cache invalidation: {ex.Message}", ex); + } + } + + /// + /// Publishes a cache clear event to all other instances. + /// + public async Task PublishClearAsync() + { + try + { + var message = new CacheInvalidationMessage + { + EntityType = "CLEAR_ALL", + EntityId = 0, + Timestamp = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(message); + var channel = $"{_channelPrefix}:invalidations"; + + await _subscriber!.PublishAsync(channel, json, StackExchange.Redis.CommandFlags.FireAndForget); + } + catch (Exception ex) + { + throw new CacheDistributionException($"Failed to publish cache clear: {ex.Message}", ex); + } + } + + /// + /// Checks if the distributor is healthy (Redis connection is active). + /// + public bool IsHealthy() + { + try + { + _redis.GetDatabase().Ping(); + return true; + } + catch + { + return false; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + StopListening(); + _cancellationTokenSource.Dispose(); + } + } + + /// + /// Message format for cache invalidation events. + /// + public class CacheInvalidationMessage + { + /// + /// The type of entity that was invalidated. + /// + public string EntityType { get; set; } = string.Empty; + + /// + /// The ID of the entity that was invalidated. + /// + public ulong EntityId { get; set; } + + /// + /// The guild ID (if applicable). + /// + public ulong? GuildId { get; set; } + + /// + /// When the invalidation occurred. + /// + public DateTime Timestamp { get; set; } + } +} diff --git a/src/PawSharp.Cache/Exceptions/CacheException.cs b/src/PawSharp.Cache/Exceptions/CacheException.cs new file mode 100644 index 0000000..0b15c4a --- /dev/null +++ b/src/PawSharp.Cache/Exceptions/CacheException.cs @@ -0,0 +1,106 @@ +#nullable enable +using System; + +namespace PawSharp.Cache.Exceptions +{ + /// + /// Base exception for cache-related errors. + /// + public class CacheException : Exception + { + /// + /// The cache provider that threw the exception. + /// + public string? ProviderName { get; } + + /// + /// The cache operation that failed. + /// + public string? Operation { get; } + + public CacheException(string message) : base(message) { } + + public CacheException(string message, Exception innerException) : base(message, innerException) { } + + public CacheException(string message, string? providerName, string? operation) + : base(message) + { + ProviderName = providerName; + Operation = operation; + } + + public CacheException(string message, string? providerName, string? operation, Exception innerException) + : base(message, innerException) + { + ProviderName = providerName; + Operation = operation; + } + } + + /// + /// Thrown when a cache provider is not available or healthy. + /// + public class CacheProviderUnavailableException : CacheException + { + public CacheProviderUnavailableException(string providerName) + : base($"Cache provider '{providerName}' is not available or unhealthy.", providerName, null) { } + + public CacheProviderUnavailableException(string providerName, Exception innerException) + : base($"Cache provider '{providerName}' is not available or unhealthy.", providerName, null, innerException) { } + } + + /// + /// Thrown when cache swapping fails. + /// + public class CacheSwapException : CacheException + { + public CacheSwapException(string message) : base(message) { } + + public CacheSwapException(string message, Exception innerException) : base(message, innerException) { } + + public CacheSwapException(string message, string fromProvider, string toProvider) + : base($"Failed to swap cache from '{fromProvider}' to '{toProvider}': {message}", toProvider, "Swap") { } + } + + /// + /// Thrown when cache distribution fails. + /// + public class CacheDistributionException : CacheException + { + public CacheDistributionException(string message) : base(message) { } + + public CacheDistributionException(string message, Exception innerException) : base(message, innerException) { } + + public CacheDistributionException(string message, string operation) + : base($"Cache distribution failed during '{operation}': {message}", null, operation) { } + } + + /// + /// Thrown when a cache provider is not registered. + /// + public class CacheProviderNotRegisteredException : CacheException + { + public CacheProviderNotRegisteredException(string providerName) + : base($"Cache provider '{providerName}' is not registered.", providerName, null) { } + } + + /// + /// Thrown when a cache provider operation times out. + /// + public class CacheTimeoutException : CacheException + { + public TimeSpan Timeout { get; } + + public CacheTimeoutException(string providerName, string operation, TimeSpan timeout) + : base($"Cache operation '{operation}' on provider '{providerName}' timed out after {timeout.TotalSeconds:F2} seconds.", providerName, operation) + { + Timeout = timeout; + } + + public CacheTimeoutException(string providerName, string operation, TimeSpan timeout, Exception innerException) + : base($"Cache operation '{operation}' on provider '{providerName}' timed out after {timeout.TotalSeconds:F2} seconds.", providerName, operation, innerException) + { + Timeout = timeout; + } + } +} diff --git a/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs b/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs new file mode 100644 index 0000000..f10f710 --- /dev/null +++ b/src/PawSharp.Cache/Interfaces/ICacheProviderHealthCheckable.cs @@ -0,0 +1,14 @@ +#nullable enable +namespace PawSharp.Cache.Interfaces; + +/// +/// Interface for cache providers that support health checks. +/// +public interface ICacheProviderHealthCheckable +{ + /// + /// Checks if the cache provider is healthy and operational. + /// + /// True if the provider is healthy, false otherwise. + bool IsHealthy(); +} diff --git a/src/PawSharp.Cache/Interfaces/IEntityCache.cs b/src/PawSharp.Cache/Interfaces/IEntityCache.cs index d4b9141..b3b2d8f 100644 --- a/src/PawSharp.Cache/Interfaces/IEntityCache.cs +++ b/src/PawSharp.Cache/Interfaces/IEntityCache.cs @@ -2,35 +2,40 @@ using System.Collections.Generic; using System.Threading.Tasks; using PawSharp.Core.Entities; +using PawSharp.Cache.Telemetry; -namespace PawSharp.Cache.Interfaces +namespace PawSharp.Cache.Interfaces; + +/// +/// Event arguments for cache invalidation events. +/// +public class CacheInvalidationEventArgs : EventArgs { /// - /// Event arguments for cache invalidation events. + /// The type of entity that was invalidated. /// - public class CacheInvalidationEventArgs : EventArgs - { - /// - /// The type of entity that was invalidated. - /// - public string EntityType { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; - /// - /// The ID of the entity that was invalidated. - /// - public ulong EntityId { get; set; } + /// + /// The ID of the entity that was invalidated. + /// + public ulong EntityId { get; set; } - /// - /// The guild ID (if applicable). - /// - public ulong? GuildId { get; set; } - } + /// + /// The guild ID (if applicable). + /// + public ulong? GuildId { get; set; } +} +/// +/// Defines the contract for a cache provider that stores Discord entities. +/// +public interface IEntityCache +{ /// - /// Interface for caching Discord entities. + /// Optional telemetry provider for monitoring cache performance. /// - public interface IEntityCache - { + ICacheTelemetry? Telemetry { get; set; } // Cache invalidation events event EventHandler? EntityEvicted; event EventHandler? CacheCleared; @@ -121,66 +126,65 @@ public interface IEntityCache /// /// True if the cache is healthy, false otherwise. bool IsHealthy(); - } +} +/// +/// Cache statistics information. +/// +public class CacheStats +{ /// - /// Cache statistics information. + /// Number of users cached. /// - public class CacheStats - { - /// - /// Number of users cached. - /// - public int UserCount { get; set; } + public int UserCount { get; set; } - /// - /// Number of guilds cached. - /// - public int GuildCount { get; set; } + /// + /// Number of guilds cached. + /// + public int GuildCount { get; set; } - /// - /// Number of channels cached. - /// - public int ChannelCount { get; set; } + /// + /// Number of channels cached. + /// + public int ChannelCount { get; set; } - /// - /// Number of messages cached. - /// - public int MessageCount { get; set; } + /// + /// Number of messages cached. + /// + public int MessageCount { get; set; } - /// - /// Number of guild members cached. - /// - public int MemberCount { get; set; } + /// + /// Number of guild members cached. + /// + public int MemberCount { get; set; } - /// - /// Number of roles cached. - /// - public int RoleCount { get; set; } + /// + /// Number of roles cached. + /// + public int RoleCount { get; set; } - /// - /// Number of emojis cached. - /// - public int EmojiCount { get; set; } + /// + /// Number of emojis cached. + /// + public int EmojiCount { get; set; } - /// - /// Estimated memory usage in bytes. - /// - public long MemoryUsage { get; set; } + /// + /// Estimated memory usage in bytes. + /// + public long MemoryUsage { get; set; } - /// - /// Total number of cache hits. - /// - public long Hits { get; set; } + /// + /// Total number of cache hits. + /// + public long Hits { get; set; } - /// - /// Total number of cache misses. - /// - public long Misses { get; set; } + /// + /// Total number of cache misses. + /// + public long Misses { get; set; } - /// - /// Cache hit ratio (0.0 to 1.0). - /// - public double HitRatio => Hits + Misses > 0 ? (double)Hits / (Hits + Misses) : 0.0; - } + /// + /// Cache hit ratio (0.0 to 1.0). + /// + public double HitRatio => Hits + Misses > 0 ? (double)Hits / (Hits + Misses) : 0.0; } \ No newline at end of file diff --git a/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs b/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs index 8714b23..aae141e 100644 --- a/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs +++ b/src/PawSharp.Cache/Providers/MemoryCacheProvider.cs @@ -2,15 +2,17 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using PawSharp.Cache.Interfaces; +using PawSharp.Cache.Telemetry; using PawSharp.Core.Entities; namespace PawSharp.Cache.Providers { - public class MemoryCacheProvider : IEntityCache + public class MemoryCacheProvider : IEntityCache, ICacheProviderHealthCheckable { private readonly ConcurrentDictionary _guilds; private readonly ConcurrentDictionary _channels; @@ -29,7 +31,17 @@ public class MemoryCacheProvider : IEntityCache private readonly int _maxMembers; private readonly int _maxRoles; private readonly int _maxEmojis; - private readonly object _evictionLock = new object(); + private readonly CacheOptions _options; + private readonly System.Timers.Timer _cleanupTimer; + private readonly ICacheTelemetry? _telemetry; + private readonly object _lock = new(); + private readonly object _evictionLock = new(); + + public ICacheTelemetry? Telemetry + { + get => _telemetry; + set => throw new InvalidOperationException("Telemetry is set at construction time."); + } // Expiration configuration private readonly TimeSpan? _userExpiration; @@ -61,7 +73,7 @@ public class MemoryCacheProvider : IEntityCache public int RoleCacheSize => _roles.Count; public int EmojiCacheSize => _emojis.Count; - public MemoryCacheProvider(CacheOptions? options = null) + public MemoryCacheProvider(CacheOptions? options = null, ICacheTelemetry? telemetry = null) { var opts = options ?? new CacheOptions(); @@ -81,6 +93,8 @@ public MemoryCacheProvider(CacheOptions? options = null) _roleExpiration = opts.RoleExpiration ?? opts.DefaultExpiration; _emojiExpiration = opts.EmojiExpiration ?? opts.DefaultExpiration; + _telemetry = telemetry ?? new CacheTelemetry(); + _guilds = new ConcurrentDictionary(); _channels = new ConcurrentDictionary(); _users = new ConcurrentDictionary(); @@ -231,6 +245,7 @@ private void EnforceEntityCacheBounds(ConcurrentDictionary - public class RedisCacheProvider : IEntityCache, IDisposable + public class RedisCacheProvider : IEntityCache, ICacheProviderHealthCheckable, IDisposable { private readonly ConnectionMultiplexer _redis; private readonly IDatabase _db; private readonly JsonSerializerOptions _jsonOptions; private readonly RedisCacheOptions _options; + private readonly ICacheTelemetry? _telemetry; private bool _disposed; + public ICacheTelemetry? Telemetry + { + get => _telemetry; + set => throw new InvalidOperationException("Telemetry is set at construction time."); + } + // Metrics tracking private long _hits; private long _misses; @@ -67,6 +77,8 @@ public RedisCacheProvider(IOptions options) public RedisCacheProvider(string connectionString) : this(Options.Create(new RedisCacheOptions { ConnectionString = connectionString })) { + _options = new RedisCacheOptions { ConnectionString = connectionString }; + _telemetry = new CacheTelemetry(); } #region Generic Cache Operations @@ -166,14 +178,19 @@ public void CacheUser(User user) /// The cached user, or null if not found. public User? GetUser(ulong userId) { + var stopwatch = Stopwatch.StartNew(); var key = $"user:{userId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("User"); + _telemetry?.RecordOperation("Get", "User", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("User"); + _telemetry?.RecordOperation("Get", "User", stopwatch.Elapsed); return null; } @@ -196,14 +213,19 @@ public void CacheGuild(Guild guild) /// The cached guild, or null if not found. public Guild? GetGuild(ulong guildId) { + var stopwatch = Stopwatch.StartNew(); var key = $"guild:{guildId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Guild"); + _telemetry?.RecordOperation("Get", "Guild", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Guild"); + _telemetry?.RecordOperation("Get", "Guild", stopwatch.Elapsed); return null; } @@ -252,14 +274,19 @@ public void CacheChannel(Channel channel) /// The cached channel, or null if not found. public Channel? GetChannel(ulong channelId) { + var stopwatch = Stopwatch.StartNew(); var key = $"channel:{channelId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Channel"); + _telemetry?.RecordOperation("Get", "Channel", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Channel"); + _telemetry?.RecordOperation("Get", "Channel", stopwatch.Elapsed); return null; } @@ -308,14 +335,19 @@ public void CacheMessage(Message message) /// The cached message, or null if not found. public Message? GetMessage(ulong messageId) { + var stopwatch = Stopwatch.StartNew(); var key = $"message:{messageId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Message"); + _telemetry?.RecordOperation("Get", "Message", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Message"); + _telemetry?.RecordOperation("Get", "Message", stopwatch.Elapsed); return null; } @@ -364,14 +396,19 @@ public void CacheGuildMember(ulong guildId, GuildMember member) /// The cached member, or null if not found. public GuildMember? GetGuildMember(ulong guildId, ulong userId) { + var stopwatch = Stopwatch.StartNew(); var key = $"member:{guildId}:{userId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Member"); + _telemetry?.RecordOperation("Get", "Member", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Member"); + _telemetry?.RecordOperation("Get", "Member", stopwatch.Elapsed); return null; } @@ -415,14 +452,19 @@ public void CacheRole(ulong guildId, PawSharp.Core.Entities.Role role) /// The cached role, or null if not found. public PawSharp.Core.Entities.Role? GetRole(ulong guildId, ulong roleId) { + var stopwatch = Stopwatch.StartNew(); var key = $"role:{guildId}:{roleId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Role"); + _telemetry?.RecordOperation("Get", "Role", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Role"); + _telemetry?.RecordOperation("Get", "Role", stopwatch.Elapsed); return null; } @@ -471,14 +513,19 @@ public void CacheEmoji(ulong guildId, Emoji emoji) /// The cached emoji, or null if not found. public Emoji? GetEmoji(ulong guildId, ulong emojiId) { + var stopwatch = Stopwatch.StartNew(); var key = $"emoji:{guildId}:{emojiId}"; var json = _db.StringGet(key); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Emoji"); + _telemetry?.RecordOperation("Get", "Emoji", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Emoji"); + _telemetry?.RecordOperation("Get", "Emoji", stopwatch.Elapsed); return null; } @@ -680,85 +727,120 @@ public CacheStats GetCacheStats() // Async overloads — leverage Redis native async API public async Task GetUserAsync(ulong userId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"user:{userId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("User"); + _telemetry?.RecordOperation("GetAsync", "User", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("User"); + _telemetry?.RecordOperation("GetAsync", "User", stopwatch.Elapsed); return null; } public async Task GetGuildAsync(ulong guildId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"guild:{guildId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Guild"); + _telemetry?.RecordOperation("GetAsync", "Guild", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Guild"); + _telemetry?.RecordOperation("GetAsync", "Guild", stopwatch.Elapsed); return null; } public async Task GetChannelAsync(ulong channelId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"channel:{channelId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Channel"); + _telemetry?.RecordOperation("GetAsync", "Channel", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Channel"); + _telemetry?.RecordOperation("GetAsync", "Channel", stopwatch.Elapsed); return null; } public async Task GetMessageAsync(ulong messageId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"message:{messageId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Message"); + _telemetry?.RecordOperation("GetAsync", "Message", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Message"); + _telemetry?.RecordOperation("GetAsync", "Message", stopwatch.Elapsed); return null; } public async Task GetGuildMemberAsync(ulong guildId, ulong userId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"member:{guildId}:{userId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Member"); + _telemetry?.RecordOperation("GetAsync", "Member", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Member"); + _telemetry?.RecordOperation("GetAsync", "Member", stopwatch.Elapsed); return null; } public async Task GetRoleAsync(ulong guildId, ulong roleId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"role:{guildId}:{roleId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Role"); + _telemetry?.RecordOperation("GetAsync", "Role", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Role"); + _telemetry?.RecordOperation("GetAsync", "Role", stopwatch.Elapsed); return null; } public async Task GetEmojiAsync(ulong guildId, ulong emojiId) { + var stopwatch = Stopwatch.StartNew(); var json = await _db.StringGetAsync($"emoji:{guildId}:{emojiId}"); if (json.HasValue) { Interlocked.Increment(ref _hits); + _telemetry?.RecordHit("Emoji"); + _telemetry?.RecordOperation("GetAsync", "Emoji", stopwatch.Elapsed); return JsonSerializer.Deserialize((string)json!, _jsonOptions); } Interlocked.Increment(ref _misses); + _telemetry?.RecordMiss("Emoji"); + _telemetry?.RecordOperation("GetAsync", "Emoji", stopwatch.Elapsed); return null; } @@ -963,12 +1045,6 @@ public bool IsHealthy() } } - public async Task GetEmojiAsync(ulong guildId, ulong emojiId) - { - var json = await _db.StringGetAsync($"emoji:{guildId}:{emojiId}"); - return json.HasValue ? JsonSerializer.Deserialize((string)json!, _jsonOptions) : null; - } - /// /// Disposes the Redis connection. /// diff --git a/src/PawSharp.Cache/README.md b/src/PawSharp.Cache/README.md index 68db291..e84ced8 100644 --- a/src/PawSharp.Cache/README.md +++ b/src/PawSharp.Cache/README.md @@ -8,6 +8,8 @@ Use it when you need faster reads, fewer REST calls, and a cleaner way to keep f - In-memory caching for low-latency access - Redis-based distributed caching for scalable deployments +- **Cache swapping with automatic fallback** - Switch between cache providers at runtime +- **Cache distribution** - Share cache invalidations across multiple bot instances - Pluggable cache provider model for custom backends - Designed to work with gateway-driven updates - Configurable entity limits and expiration @@ -21,7 +23,7 @@ Use it when you need faster reads, fewer REST calls, and a cleaner way to keep f ## Installation ```bash -dotnet add package PawSharp.Cache --version 1.1.0-alpha.1 +dotnet add package PawSharp.Cache --version 1.1.0-alpha.2 ``` For Redis support, also add: @@ -229,6 +231,8 @@ var options = new RedisCacheOptions - **Reducing repeat API calls** - Cache frequently accessed entities to reduce REST API calls - **Keeping active data in memory** - Cache guilds, members, channels for quick access - **Distributed caching** - Use Redis for multi-instance bot deployments +- **Cache swapping** - Switch between cache providers at runtime with automatic fallback +- **Cache distribution** - Share cache invalidations across multiple bot instances via Redis pub/sub - **Custom cache backends** - Implement IEntityCache for your own caching solution ## Cache Statistics @@ -253,6 +257,98 @@ var totalEntities = cache.GetEntityCount(); Console.WriteLine($"Total entities: {totalEntities}"); ``` +## Cache Swapping + +Cache swapping allows you to switch between different cache providers at runtime with automatic fallback support: + +```csharp +using PawSharp.Cache.Swapping; + +var swapperOptions = new CacheSwapperOptions +{ + AutoFallback = true, + MaxFailuresBeforeCircuitOpen = 3, + CircuitOpenDuration = TimeSpan.FromMinutes(5), + AutoSwapBackToPrimary = true, + HealthCheckInterval = TimeSpan.FromSeconds(30), + EnableLogging = true +}; + +var cacheSwapper = new CacheSwapper(swapperOptions); + +// Register multiple cache providers with priorities (lower = higher priority) +var memoryCache = new MemoryCacheProvider(); +var redisCache = new RedisCacheProvider("localhost:6379"); + +cacheSwapper.RegisterProvider("memory", memoryCache, priority: 10); // Fallback +cacheSwapper.RegisterProvider("redis", redisCache, priority: 0); // Primary + +// Start automatic health checks +cacheSwapper.StartHealthChecks(); + +// Use like any other cache provider +cacheSwapper.CacheUser(user); +var cachedUser = cacheSwapper.GetUser(userId); + +// Manually switch providers +cacheSwapper.SetActiveProvider("memory"); + +// Get provider information +var providers = cacheSwapper.GetProviders(); +foreach (var provider in providers) +{ + Console.WriteLine($"Provider: {provider.Name}, Healthy: {provider.IsHealthy}"); +} + +cacheSwapper.StopHealthChecks(); +cacheSwapper.Dispose(); +``` + +### Cache Swapping Features + +- **Automatic Fallback**: If the active provider fails, automatically switch to the next healthy provider +- **Circuit Breaker**: Temporarily disable providers that fail repeatedly +- **Health Checks**: Automatic health monitoring with configurable intervals +- **Priority-Based**: Configure provider priority for fallback order +- **Developer-Centric Errors**: Clear exceptions for debugging (CacheSwapException, CacheProviderUnavailableException, etc.) + +## Cache Distribution + +Cache distribution allows multiple bot instances to share cache invalidations via Redis pub/sub: + +```csharp +using PawSharp.Cache.Distribution; +using StackExchange.Redis; + +var redis = ConnectionMultiplexer.Connect("localhost:6379"); +var distributor = new RedisCacheDistributor(redis, "pawsharp:cache"); + +var memoryCache = new MemoryCacheProvider(); +var distributedCache = new DistributedCacheProvider(memoryCache, distributor); + +// Use like any other cache provider +distributedCache.CacheUser(user); +distributedCache.CacheGuild(guild); + +// Invalidations are automatically propagated to all instances +distributedCache.RemoveGuild(guildId); // Publishes to Redis + +// Check health +if (distributedCache.IsHealthy()) +{ + Console.WriteLine("Cache distribution is healthy"); +} + +distributedCache.Dispose(); +``` + +### Cache Distribution Features + +- **Redis Pub/Sub**: Efficient invalidation propagation across instances +- **Automatic Publishing**: Cache invalidations are automatically published +- **Event Handling**: Subscribe to invalidation events from other instances +- **Health Monitoring**: Check distributor health via Redis connection + ## Cache Invalidation Events Both providers support cache invalidation events to monitor when entities are evicted or the cache is cleared: @@ -269,19 +365,62 @@ cache.CacheCleared += (sender, args) => }; ``` -## Health Checks +## Cache Telemetry -Both providers support health checks to verify cache availability: +Cache providers support telemetry for monitoring cache performance and health: ```csharp -if (cache.IsHealthy()) +using PawSharp.Cache.Telemetry; + +// Create a telemetry instance +var telemetry = new CacheTelemetry(); + +// Pass it to the cache provider +var cache = new MemoryCacheProvider(new CacheOptions(), telemetry); + +// Get a snapshot of telemetry data +var snapshot = cache.Telemetry?.GetSnapshot(); +Console.WriteLine($"Hit Rate: {snapshot?.HitRate:P2}"); +Console.WriteLine($"Average Operation Duration: {snapshot?.AverageOperationDuration.TotalMilliseconds}ms"); +Console.WriteLine($"Total Hits: {snapshot?.TotalHits}"); +Console.WriteLine($"Total Misses: {snapshot?.TotalMisses}"); + +// Per-entity metrics +foreach (var (entityType, metrics) in snapshot?.EntityMetrics ?? []) { - Console.WriteLine("Cache is healthy and operational"); + Console.WriteLine($"{entityType}: Hits={metrics.Hits}, Misses={metrics.Misses}, HitRate={metrics.HitRate:P2}"); } -else + +// Per-operation metrics +foreach (var (operation, metrics) in snapshot?.OperationMetrics ?? []) { - Console.WriteLine("Cache is unhealthy - check Redis connection"); + Console.WriteLine($"{operation}: Count={metrics.Count}, AvgDuration={metrics.AverageDuration.TotalMilliseconds}ms"); } + +// Recent evictions +foreach (var eviction in snapshot?.RecentEvictions ?? []) +{ + Console.WriteLine($"Evicted {eviction.EntityType} at {eviction.Timestamp}: {eviction.Reason}"); +} + +// Reset telemetry +cache.Telemetry?.Reset(); +``` + +## Health Checks + +Cache providers support health checks to verify cache availability: + +```csharp +if (cache is ICacheProviderHealthCheckable healthCheckable) +{ + bool isHealthy = healthCheckable.IsHealthy(); + Console.WriteLine($"Cache is {(isHealthy ? "healthy" : "unhealthy")}"); +} + +// For MemoryCacheProvider: checks if cleanup timer is running +// For RedisCacheProvider: checks Redis connection and performs PING +// For DistributedCacheProvider: checks both inner cache and distributor health ``` ## Related Packages diff --git a/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs b/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs new file mode 100644 index 0000000..64d874b --- /dev/null +++ b/src/PawSharp.Cache/Swapping/CacheProviderInfo.cs @@ -0,0 +1,57 @@ +#nullable enable +using PawSharp.Cache.Interfaces; +using System; + +namespace PawSharp.Cache.Swapping +{ + /// + /// Information about a registered cache provider. + /// + public class CacheProviderInfo + { + /// + /// The unique name/identifier for this provider. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The cache provider instance. + /// + public IEntityCache Provider { get; set; } = null!; + + /// + /// Priority for fallback (lower = higher priority). + /// + public int Priority { get; set; } = 0; + + /// + /// Whether this provider is currently active. + /// + public bool IsActive { get; set; } = false; + + /// + /// Whether this provider is healthy (based on last health check). + /// + public bool IsHealthy { get; set; } = true; + + /// + /// Timestamp of last health check. + /// + public DateTime? LastHealthCheck { get; set; } + + /// + /// Number of times this provider has failed. + /// + public int FailureCount { get; set; } = 0; + + /// + /// Whether this provider is currently in circuit breaker (too many failures). + /// + public bool IsCircuitOpen { get; set; } = false; + + /// + /// Timestamp when circuit breaker will reset. + /// + public DateTime? CircuitResetTime { get; set; } + } +} diff --git a/src/PawSharp.Cache/Swapping/CacheSwapper.cs b/src/PawSharp.Cache/Swapping/CacheSwapper.cs new file mode 100644 index 0000000..9a01478 --- /dev/null +++ b/src/PawSharp.Cache/Swapping/CacheSwapper.cs @@ -0,0 +1,566 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PawSharp.Cache.Exceptions; +using PawSharp.Cache.Interfaces; +using PawSharp.Cache.Telemetry; +using PawSharp.Core.Entities; + +namespace PawSharp.Cache.Swapping +{ + /// + /// Manages cache provider swapping with fallback support and circuit breaker pattern. + /// + public class CacheSwapper : IEntityCache, IDisposable + { + private readonly Dictionary _providers; + private readonly CacheSwapperOptions _options; + private readonly ICacheTelemetry? _telemetry; + private readonly object _lock = new(); + private CacheProviderInfo? _activeProvider; + private Timer? _healthCheckTimer; + private bool _disposed; + + public ICacheTelemetry? Telemetry + { + get => _telemetry; + set => throw new InvalidOperationException("Telemetry is set at construction time."); + } + + public event EventHandler? EntityEvicted; + public event EventHandler? CacheCleared; + + /// + /// Creates a new CacheSwapper instance. + /// + /// Configuration options. + /// Telemetry instance. + public CacheSwapper(CacheSwapperOptions? options = null, ICacheTelemetry? telemetry = null) + { + _providers = new Dictionary(); + _options = options ?? new CacheSwapperOptions(); + _telemetry = telemetry ?? new CacheTelemetry(); + } + + /// + /// Registers a cache provider. + /// + /// Unique name for the provider. + /// The cache provider instance. + /// Priority for fallback (lower = higher priority). + /// Thrown if provider name is empty or already registered. + /// Thrown if provider is null. + public void RegisterProvider(string name, IEntityCache provider, int priority = 0) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Provider name cannot be empty.", nameof(name)); + + if (provider == null) + throw new ArgumentNullException(nameof(provider)); + + lock (_lock) + { + if (_providers.ContainsKey(name)) + throw new ArgumentException($"Provider '{name}' is already registered.", nameof(name)); + + // Check if provider is healthy by calling IsHealthy if available + bool isHealthy = true; + try + { + if (provider is ICacheProviderHealthCheckable healthCheckable) + { + isHealthy = healthCheckable.IsHealthy(); + } + } + catch + { + // If health check fails, assume healthy for now + isHealthy = true; + } + + var info = new CacheProviderInfo + { + Name = name, + Provider = provider, + Priority = priority, + IsActive = false, + IsHealthy = isHealthy, + LastHealthCheck = DateTime.UtcNow + }; + + _providers[name] = info; + + // Wire up events + provider.EntityEvicted += (sender, args) => EntityEvicted?.Invoke(sender, args); + provider.CacheCleared += (sender, args) => CacheCleared?.Invoke(sender, args); + + // If this is the first provider, make it active + if (_activeProvider == null) + { + SetActiveProvider(name); + } + + if (_options.EnableLogging) + { + Console.WriteLine($"[CacheSwapper] Registered provider '{name}' with priority {priority}"); + } + } + } + + /// + /// Unregisters a cache provider. + /// + /// Name of the provider to unregister. + /// Thrown if provider is not registered. + public void UnregisterProvider(string name) + { + lock (_lock) + { + if (!_providers.TryGetValue(name, out var info)) + throw new CacheProviderNotRegisteredException(name); + + if (info.IsActive) + { + // Try to switch to another provider if available + var nextProvider = _providers.Values + .Where(p => p.Name != name) + .OrderBy(p => p.Priority) + .FirstOrDefault(); + + if (nextProvider != null) + { + SetActiveProvider(nextProvider.Name); + } + else if (_providers.Count == 1) + { + _activeProvider = null; + } + } + + _providers.Remove(name); + + if (_options.EnableLogging) + { + Console.WriteLine($"[CacheSwapper] Unregistered provider '{name}'"); + } + } + } + + /// + /// Sets the active cache provider. + /// + /// Name of the provider to activate. + /// Thrown if provider is not registered. + /// Thrown if provider is unhealthy. + public void SetActiveProvider(string name) + { + lock (_lock) + { + if (!_providers.TryGetValue(name, out var info)) + throw new CacheProviderNotRegisteredException(name); + + if (!info.IsHealthy) + throw new CacheProviderUnavailableException(name); + + if (_activeProvider != null) + { + _activeProvider.IsActive = false; + } + + info.IsActive = true; + _activeProvider = info; + + if (_options.EnableLogging) + { + Console.WriteLine($"[CacheSwapper] Switched to provider '{name}'"); + } + } + } + + /// + /// Gets the active cache provider. + /// + /// The active provider, or null if none is set. + public IEntityCache? GetActiveProvider() + { + lock (_lock) + { + return _activeProvider?.Provider; + } + } + + /// + /// Gets all registered providers. + /// + public IEnumerable GetProviders() + { + lock (_lock) + { + return _providers.Values.ToList(); + } + } + + /// + /// Performs a health check on all providers and attempts to swap to a healthy one if needed. + /// + public async Task PerformHealthChecksAsync() + { + var providersToCheck = new List(); + + lock (_lock) + { + providersToCheck = _providers.Values.ToList(); + } + + foreach (var provider in providersToCheck) + { + try + { + var isHealthy = await Task.Run(() => provider.Provider.IsHealthy()); + + lock (_lock) + { + provider.IsHealthy = isHealthy; + provider.LastHealthCheck = DateTime.UtcNow; + + // Reset circuit breaker if healthy and circuit was open + if (isHealthy && provider.IsCircuitOpen && DateTime.UtcNow >= provider.CircuitResetTime) + { + provider.IsCircuitOpen = false; + provider.FailureCount = 0; + + if (_options.AutoSwapBackToPrimary && provider.Priority == 0 && _activeProvider?.Name != provider.Name) + { + SetActiveProvider(provider.Name); + } + } + } + + if (_options.EnableLogging) + { + Console.WriteLine($"[CacheSwapper] Health check for '{provider.Name}': {(isHealthy ? "Healthy" : "Unhealthy")}"); + } + } + catch (Exception ex) + { + lock (_lock) + { + provider.IsHealthy = false; + provider.LastHealthCheck = DateTime.UtcNow; + provider.FailureCount++; + + // Open circuit breaker if too many failures + if (provider.FailureCount >= _options.MaxFailuresBeforeCircuitOpen) + { + provider.IsCircuitOpen = true; + provider.CircuitResetTime = DateTime.UtcNow.Add(_options.CircuitOpenDuration); + } + } + + if (_options.EnableLogging) + { + Console.WriteLine($"[CacheSwapper] Health check failed for '{provider.Name}': {ex.Message}"); + } + + // If active provider failed, try to fallback + if (_activeProvider?.Name == provider.Name && _options.AutoFallback) + { + await TryFallbackAsync(provider.Name); + } + } + } + } + + private async Task TryFallbackAsync(string failedProviderName) + { + lock (_lock) + { + var fallbackProviders = _providers.Values + .Where(p => p.Name != failedProviderName && !p.IsCircuitOpen) + .OrderBy(p => p.Priority) + .ToList(); + + foreach (var provider in fallbackProviders) + { + try + { + provider.Provider.IsHealthy(); + SetActiveProvider(provider.Name); + return; + } + catch + { + // Try next provider + continue; + } + } + } + } + + private IEntityCache GetProviderOrThrow() + { + lock (_lock) + { + if (_activeProvider == null || !_activeProvider.IsHealthy) + { + // Try to find a healthy provider + var healthyProvider = _providers.Values + .Where(p => p.IsHealthy && !p.IsCircuitOpen) + .OrderBy(p => p.Priority) + .FirstOrDefault(); + + if (healthyProvider != null) + { + SetActiveProvider(healthyProvider.Name); + } + else + { + throw new CacheProviderUnavailableException(_activeProvider?.Name ?? "No provider"); + } + } + + return _activeProvider.Provider; + } + } + + // IEntityCache implementation + + public void Add(string key, object entity) + { + try + { + var provider = GetProviderOrThrow(); + provider.Add(key, entity); + + if (_options.PropagateToAllProviders) + { + lock (_lock) + { + foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name)) + { + try { otherProvider.Provider.Add(key, entity); } catch { /* Ignore */ } + } + } + } + } + catch (CacheException) + { + if (_options.AutoFallback) + { + var provider = GetProviderOrThrow(); + provider.Add(key, entity); + } + throw; + } + } + + public object? Get(string key) + { + try + { + return GetProviderOrThrow().Get(key); + } + catch (CacheException) + { + if (_options.AutoFallback) + { + return GetProviderOrThrow().Get(key); + } + throw; + } + } + + public void Remove(string key) + { + try + { + var provider = GetProviderOrThrow(); + provider.Remove(key); + + if (_options.PropagateToAllProviders) + { + lock (_lock) + { + foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name)) + { + try { otherProvider.Provider.Remove(key); } catch { /* Ignore */ } + } + } + } + } + catch (CacheException) + { + if (_options.AutoFallback) + { + var provider = GetProviderOrThrow(); + provider.Remove(key); + } + throw; + } + } + + public void Clear() + { + try + { + var provider = GetProviderOrThrow(); + provider.Clear(); + + if (_options.PropagateToAllProviders) + { + lock (_lock) + { + foreach (var otherProvider in _providers.Values.Where(p => p.Name != _activeProvider?.Name)) + { + try { otherProvider.Provider.Clear(); } catch { /* Ignore */ } + } + } + } + } + catch (CacheException) + { + if (_options.AutoFallback) + { + var provider = GetProviderOrThrow(); + provider.Clear(); + } + throw; + } + } + + public bool Exists(string key) + { + try + { + return GetProviderOrThrow().Exists(key); + } + catch (CacheException) + { + if (_options.AutoFallback) + { + return GetProviderOrThrow().Exists(key); + } + throw; + } + } + + // Typed entity operations - delegate to active provider with fallback + + public void CacheUser(User user) => GetProviderOrThrow().CacheUser(user); + public User? GetUser(ulong userId) => GetProviderOrThrow().GetUser(userId); + public void CacheGuild(Guild guild) => GetProviderOrThrow().CacheGuild(guild); + public Guild? GetGuild(ulong guildId) => GetProviderOrThrow().GetGuild(guildId); + public IEnumerable GetAllGuilds() => GetProviderOrThrow().GetAllGuilds(); + public void CacheChannel(Channel channel) => GetProviderOrThrow().CacheChannel(channel); + public Channel? GetChannel(ulong channelId) => GetProviderOrThrow().GetChannel(channelId); + public IEnumerable GetGuildChannels(ulong guildId) => GetProviderOrThrow().GetGuildChannels(guildId); + public void CacheMessage(Message message) => GetProviderOrThrow().CacheMessage(message); + public Message? GetMessage(ulong messageId) => GetProviderOrThrow().GetMessage(messageId); + public IEnumerable GetChannelMessages(ulong channelId, int limit = 50) => GetProviderOrThrow().GetChannelMessages(channelId, limit); + public void CacheGuildMember(ulong guildId, GuildMember member) => GetProviderOrThrow().CacheGuildMember(guildId, member); + public GuildMember? GetGuildMember(ulong guildId, ulong userId) => GetProviderOrThrow().GetGuildMember(guildId, userId); + public IEnumerable GetGuildMembers(ulong guildId) => GetProviderOrThrow().GetGuildMembers(guildId); + public void CacheRole(ulong guildId, Role role) => GetProviderOrThrow().CacheRole(guildId, role); + public Role? GetRole(ulong guildId, ulong roleId) => GetProviderOrThrow().GetRole(guildId, roleId); + public IEnumerable GetGuildRoles(ulong guildId) => GetProviderOrThrow().GetGuildRoles(guildId); + public void CacheEmoji(ulong guildId, Emoji emoji) => GetProviderOrThrow().CacheEmoji(guildId, emoji); + public Emoji? GetEmoji(ulong guildId, ulong emojiId) => GetProviderOrThrow().GetEmoji(guildId, emojiId); + public IEnumerable GetGuildEmojis(ulong guildId) => GetProviderOrThrow().GetGuildEmojis(guildId); + public void CacheGuildData(Guild guild) => GetProviderOrThrow().CacheGuildData(guild); + public void RemoveGuild(ulong guildId) => GetProviderOrThrow().RemoveGuild(guildId); + public void RemoveChannel(ulong channelId) => GetProviderOrThrow().RemoveChannel(channelId); + public void RemoveMessage(ulong messageId) => GetProviderOrThrow().RemoveMessage(messageId); + public void RemoveGuildMember(ulong guildId, ulong userId) => GetProviderOrThrow().RemoveGuildMember(guildId, userId); + public void RemoveRole(ulong guildId, ulong roleId) => GetProviderOrThrow().RemoveRole(guildId, roleId); + public int GetEntityCount() => GetProviderOrThrow().GetEntityCount(); + public long GetMemoryUsage() => GetProviderOrThrow().GetMemoryUsage(); + public CacheStats GetCacheStats() => GetProviderOrThrow().GetCacheStats(); + public bool IsHealthy() => _activeProvider?.Provider.IsHealthy() ?? false; + + // Async operations - delegate to active provider with fallback + + public Task GetUserAsync(ulong userId) => GetProviderOrThrow().GetUserAsync(userId); + public Task GetGuildAsync(ulong guildId) => GetProviderOrThrow().GetGuildAsync(guildId); + public Task GetChannelAsync(ulong channelId) => GetProviderOrThrow().GetChannelAsync(channelId); + public Task GetMessageAsync(ulong messageId) => GetProviderOrThrow().GetMessageAsync(messageId); + public Task GetGuildMemberAsync(ulong guildId, ulong userId) => GetProviderOrThrow().GetGuildMemberAsync(guildId, userId); + public Task GetRoleAsync(ulong guildId, ulong roleId) => GetProviderOrThrow().GetRoleAsync(guildId, roleId); + public Task GetEmojiAsync(ulong guildId, ulong emojiId) => GetProviderOrThrow().GetEmojiAsync(guildId, emojiId); + public Task CacheUserAsync(User user) => GetProviderOrThrow().CacheUserAsync(user); + public Task CacheGuildAsync(Guild guild) => GetProviderOrThrow().CacheGuildAsync(guild); + public Task CacheChannelAsync(Channel channel) => GetProviderOrThrow().CacheChannelAsync(channel); + public Task CacheMessageAsync(Message message) => GetProviderOrThrow().CacheMessageAsync(message); + public Task CacheGuildMemberAsync(ulong guildId, GuildMember member) => GetProviderOrThrow().CacheGuildMemberAsync(guildId, member); + public Task CacheRoleAsync(ulong guildId, Role role) => GetProviderOrThrow().CacheRoleAsync(guildId, role); + public Task CacheEmojiAsync(ulong guildId, Emoji emoji) => GetProviderOrThrow().CacheEmojiAsync(guildId, emoji); + public Task CacheGuildDataAsync(Guild guild) => GetProviderOrThrow().CacheGuildDataAsync(guild); + public Task RemoveGuildAsync(ulong guildId) => GetProviderOrThrow().RemoveGuildAsync(guildId); + public Task ClearAsync() => GetProviderOrThrow().ClearAsync(); + public Task RemoveChannelAsync(ulong channelId) => GetProviderOrThrow().RemoveChannelAsync(channelId); + public Task RemoveMessageAsync(ulong messageId) => GetProviderOrThrow().RemoveMessageAsync(messageId); + public Task RemoveGuildMemberAsync(ulong guildId, ulong userId) => GetProviderOrThrow().RemoveGuildMemberAsync(guildId, userId); + public Task RemoveRoleAsync(ulong guildId, ulong roleId) => GetProviderOrThrow().RemoveRoleAsync(guildId, roleId); + + /// + /// Starts automatic health checks. + /// + public void StartHealthChecks() + { + if (_healthCheckTimer != null) + return; + + _healthCheckTimer = new Timer( + async _ => await PerformHealthChecksAsync(), + null, + TimeSpan.Zero, + _options.HealthCheckInterval + ); + + if (_options.EnableLogging) + { + Console.WriteLine("[CacheSwapper] Started automatic health checks"); + } + } + + /// + /// Stops automatic health checks. + /// + public void StopHealthChecks() + { + if (_healthCheckTimer != null) + { + _healthCheckTimer.Dispose(); + _healthCheckTimer = null; + + if (_options.EnableLogging) + { + Console.WriteLine("[CacheSwapper] Stopped automatic health checks"); + } + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + StopHealthChecks(); + + lock (_lock) + { + foreach (var provider in _providers.Values) + { + if (provider.Provider is IDisposable disposable) + { + disposable.Dispose(); + } + } + _providers.Clear(); + } + } + } +} diff --git a/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs b/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs new file mode 100644 index 0000000..a8b04fe --- /dev/null +++ b/src/PawSharp.Cache/Swapping/CacheSwapperOptions.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; + +namespace PawSharp.Cache.Swapping +{ + /// + /// Configuration options for cache swapping. + /// + public class CacheSwapperOptions + { + /// + /// Whether to automatically enable fallback to next provider on failure. + /// + public bool AutoFallback { get; set; } = true; + + /// + /// Maximum number of consecutive failures before circuit breaker opens. + /// + public int MaxFailuresBeforeCircuitOpen { get; set; } = 5; + + /// + /// Duration to keep circuit breaker open before attempting reset. + /// + public TimeSpan CircuitOpenDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Whether to automatically attempt to swap back to primary provider when it becomes healthy. + /// + public bool AutoSwapBackToPrimary { get; set; } = true; + + /// + /// Interval between health checks for inactive providers. + /// + public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Whether to propagate cache changes to all providers (multi-write). + /// + public bool PropagateToAllProviders { get; set; } = false; + + /// + /// Timeout for cache operations before attempting fallback. + /// + public TimeSpan OperationTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Whether to log cache swap operations. + /// + public bool EnableLogging { get; set; } = true; + } +} diff --git a/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs b/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs new file mode 100644 index 0000000..2918d63 --- /dev/null +++ b/src/PawSharp.Cache/Telemetry/CacheTelemetry.cs @@ -0,0 +1,230 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace PawSharp.Cache.Telemetry; + +/// +/// Telemetry for cache operations including performance metrics and health monitoring. +/// +public interface ICacheTelemetry +{ + /// + /// Records a cache hit for an entity type. + /// + void RecordHit(string entityType); + + /// + /// Records a cache miss for an entity type. + /// + void RecordMiss(string entityType); + + /// + /// Records a cache operation duration. + /// + void RecordOperation(string operation, string entityType, TimeSpan duration); + + /// + /// Records an eviction event. + /// + void RecordEviction(string entityType, string reason); + + /// + /// Gets the current telemetry snapshot. + /// + CacheTelemetrySnapshot GetSnapshot(); + + /// + /// Resets all telemetry data. + /// + void Reset(); +} + +/// +/// Default implementation of cache telemetry. +/// +public class CacheTelemetry : ICacheTelemetry +{ + private readonly ConcurrentDictionary _entityMetrics = new(); + private readonly ConcurrentDictionary _operationMetrics = new(); + private readonly ConcurrentBag _evictions = new(); + + private long _totalHits; + private long _totalMisses; + private long _totalOperations; + private long _totalOperationDurationTicks; + + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + + public void RecordHit(string entityType) + { + Interlocked.Increment(ref _totalHits); + _entityMetrics.AddOrUpdate(entityType, + new EntityTypeMetrics { EntityType = entityType, Hits = 1 }, + (_, metrics) => new EntityTypeMetrics { EntityType = entityType, Hits = metrics.Hits + 1, Misses = metrics.Misses }); + } + + public void RecordMiss(string entityType) + { + Interlocked.Increment(ref _totalMisses); + _entityMetrics.AddOrUpdate(entityType, + new EntityTypeMetrics { EntityType = entityType, Misses = 1 }, + (_, metrics) => new EntityTypeMetrics { EntityType = entityType, Hits = metrics.Hits, Misses = metrics.Misses + 1 }); + } + + public void RecordOperation(string operation, string entityType, TimeSpan duration) + { + Interlocked.Increment(ref _totalOperations); + Interlocked.Add(ref _totalOperationDurationTicks, duration.Ticks); + + string key = $"{operation}:{entityType}"; + _operationMetrics.AddOrUpdate(key, + new OperationMetrics { Operation = operation, EntityType = entityType, Count = 1, TotalDuration = duration, AverageDuration = duration, MinDuration = duration, MaxDuration = duration }, + (_, metrics) => + { + long newCount = metrics.Count + 1; + var newTotal = metrics.TotalDuration.Add(duration); + return new OperationMetrics + { + Operation = operation, + EntityType = entityType, + Count = newCount, + TotalDuration = newTotal, + AverageDuration = TimeSpan.FromTicks(newTotal.Ticks / newCount), + MinDuration = duration < metrics.MinDuration ? duration : metrics.MinDuration, + MaxDuration = duration > metrics.MaxDuration ? duration : metrics.MaxDuration + }; + }); + } + + public void RecordEviction(string entityType, string reason) + { + _evictions.Add(new EvictionEvent + { + EntityType = entityType, + Reason = reason, + Timestamp = DateTimeOffset.UtcNow + }); + + // Keep only last 1000 evictions + if (_evictions.Count > 1000) + { + _evictions.TryTake(out _); + } + } + + public CacheTelemetrySnapshot GetSnapshot() + { + long totalCacheOperations = _totalHits + _totalMisses; + double hitRate = totalCacheOperations > 0 ? (_totalHits * 100.0) / totalCacheOperations : 0; + double missRate = totalCacheOperations > 0 ? (_totalMisses * 100.0) / totalCacheOperations : 0; + + return new CacheTelemetrySnapshot + { + Uptime = _uptime.Elapsed, + + // Overall metrics + TotalHits = _totalHits, + TotalMisses = _totalMisses, + TotalOperations = _totalOperations, + HitRate = hitRate, + MissRate = missRate, + AverageOperationDuration = _totalOperations > 0 + ? TimeSpan.FromTicks(_totalOperationDurationTicks / _totalOperations) + : TimeSpan.Zero, + + // Per-entity metrics + EntityMetrics = _entityMetrics.ToDictionary(x => x.Key, x => x.Value), + + // Per-operation metrics + OperationMetrics = _operationMetrics.ToDictionary(x => x.Key, x => x.Value), + + // Recent evictions + RecentEvictions = _evictions.Take(100).ToList() + }; + } + + public void Reset() + { + _entityMetrics.Clear(); + _operationMetrics.Clear(); + while (_evictions.TryTake(out _)) { } + _totalHits = 0; + _totalMisses = 0; + _totalOperations = 0; + _totalOperationDurationTicks = 0; + _uptime.Restart(); + } +} + +/// +/// Snapshot of cache telemetry at a point in time. +/// +public class CacheTelemetrySnapshot +{ + /// Time since telemetry collection started. + public TimeSpan Uptime { get; set; } + + /// Total cache hits recorded. + public long TotalHits { get; set; } + + /// Total cache misses recorded. + public long TotalMisses { get; set; } + + /// Total cache operations recorded. + public long TotalOperations { get; set; } + + /// Cache hit rate as percentage. + public double HitRate { get; set; } + + /// Cache miss rate as percentage. + public double MissRate { get; set; } + + /// Average duration of all cache operations. + public TimeSpan AverageOperationDuration { get; set; } + + /// Metrics per entity type. + public System.Collections.Generic.Dictionary EntityMetrics { get; set; } = new(); + + /// Metrics per operation type. + public System.Collections.Generic.Dictionary OperationMetrics { get; set; } = new(); + + /// Recent eviction events. + public System.Collections.Generic.List RecentEvictions { get; set; } = new(); +} + +/// +/// Metrics for a specific entity type. +/// +public class EntityTypeMetrics +{ + public string EntityType { get; set; } = string.Empty; + public long Hits { get; set; } + public long Misses { get; set; } + public double HitRate => Hits + Misses > 0 ? (Hits * 100.0) / (Hits + Misses) : 0; +} + +/// +/// Metrics for a specific operation type. +/// +public class OperationMetrics +{ + public string Operation { get; set; } = string.Empty; + public string EntityType { get; set; } = string.Empty; + public long Count { get; set; } + public TimeSpan TotalDuration { get; set; } + public TimeSpan AverageDuration { get; set; } + public TimeSpan MinDuration { get; set; } + public TimeSpan MaxDuration { get; set; } +} + +/// +/// Represents a cache eviction event. +/// +public class EvictionEvent +{ + public string EntityType { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } +} diff --git a/src/PawSharp.Client/DiscordClient.cs b/src/PawSharp.Client/DiscordClient.cs index b74311a..e10f261 100644 --- a/src/PawSharp.Client/DiscordClient.cs +++ b/src/PawSharp.Client/DiscordClient.cs @@ -175,7 +175,7 @@ public async Task DisconnectAsync() return await _restClient.CreateMessageAsync(channelId, new CreateMessageRequest { Content = content, - Embeds = new[] { embed } + Embeds = new List { embed } }); } @@ -342,6 +342,1112 @@ public async Task RemoveUserReactionAsync(ulong channelId, ulong messageId return await SendMessageAsync(message.ChannelId, request); } + // ── Additional REST helpers ─────────────────────────────────────────────── + + // User operations ─────────────────────────────────────────────────────────── + + /// Gets a user by ID. + public async Task GetUserAsync(ulong userId) + { + return await _restClient.GetUserAsync(userId); + } + + /// Modifies the current bot user. + public async Task ModifyCurrentUserAsync(string? username = null, string? avatar = null, string? banner = null, string? avatarDecorationData = null) + { + await _restClient.ModifyCurrentUserAsync(username, avatar, banner, avatarDecorationData); + } + + /// Gets the current bot's guilds. + public async Task?> GetCurrentUserGuildsAsync(int limit = 200, ulong? before = null, ulong? after = null) + { + return await _restClient.GetCurrentUserGuildsAsync(limit, before, after); + } + + /// Leaves a guild. + public async Task LeaveGuildAsync(ulong guildId) + { + return await _restClient.LeaveGuildAsync(guildId); + } + + // Additional Message operations ────────────────────────────────────────────── + + /// Sends a file to a channel. + public async Task SendFileAsync(ulong channelId, System.IO.Stream fileStream, string fileName, CreateMessageRequest? messageRequest = null, System.Threading.CancellationToken cancellationToken = default) + { + return await _restClient.SendFileAsync(channelId, fileStream, fileName, messageRequest, cancellationToken); + } + + /// Sends multiple files to a channel. + public async Task SendFilesAsync(ulong channelId, IEnumerable<(System.IO.Stream Stream, string FileName)> files, CreateMessageRequest? messageRequest = null, System.Threading.CancellationToken cancellationToken = default) + { + return await _restClient.SendFilesAsync(channelId, files, messageRequest, cancellationToken); + } + + /// Gets messages from a channel. + public async Task?> GetChannelMessagesAsync(ulong channelId, int limit = 50, ulong? around = null, ulong? before = null, ulong? after = null) + { + return await _restClient.GetChannelMessagesAsync(channelId, limit, around, before, after); + } + + /// Bulk deletes messages from a channel. + public async Task BulkDeleteMessagesAsync(ulong channelId, List messageIds) + { + return await _restClient.BulkDeleteMessagesAsync(channelId, messageIds); + } + + /// Pins a message in a channel. + public async Task PinMessageAsync(ulong channelId, ulong messageId) + { + return await _restClient.PinMessageAsync(channelId, messageId); + } + + /// Unpins a message in a channel. + public async Task UnpinMessageAsync(ulong channelId, ulong messageId) + { + return await _restClient.UnpinMessageAsync(channelId, messageId); + } + + /// Gets pinned messages from a channel. + public async Task?> GetPinnedMessagesAsync(ulong channelId) + { + return await _restClient.GetPinnedMessagesAsync(channelId); + } + + /// Crossposts a message to following channels. + public async Task CrosspostMessageAsync(ulong channelId, ulong messageId) + { + return await _restClient.CrosspostMessageAsync(channelId, messageId); + } + + // Channel operations ─────────────────────────────────────────────────────── + + /// Deletes a channel. + public async Task DeleteChannelAsync(ulong channelId) + { + return await _restClient.DeleteChannelAsync(channelId); + } + + /// Creates a channel in a guild. + public async Task CreateGuildChannelAsync(ulong guildId, CreateChannelRequest request) + { + return await _restClient.CreateGuildChannelAsync(guildId, request); + } + + /// Gets invites for a channel. + public async Task?> GetChannelInvitesAsync(ulong channelId) + { + return await _restClient.GetChannelInvitesAsync(channelId); + } + + /// Creates an invite for a channel. + public async Task CreateChannelInviteAsync(ulong channelId, CreateInviteRequest request) + { + return await _restClient.CreateChannelInviteAsync(channelId, request); + } + + /// Deletes a channel permission overwrite. + public async Task DeleteChannelPermissionAsync(ulong channelId, ulong overwriteId) + { + return await _restClient.DeleteChannelPermissionAsync(channelId, overwriteId); + } + + /// Edits channel permissions. + public async Task EditChannelPermissionsAsync(ulong channelId, ulong overwriteId, EditChannelPermissionsRequest request) + { + return await _restClient.EditChannelPermissionsAsync(channelId, overwriteId, request); + } + + // Guild operations ─────────────────────────────────────────────────────────── + + /// Creates a guild. + public async Task CreateGuildAsync(CreateGuildRequest request) + { + return await _restClient.CreateGuildAsync(request); + } + + /// Modifies a guild. + public async Task ModifyGuildAsync(ulong guildId, ModifyGuildRequest request) + { + return await _restClient.ModifyGuildAsync(guildId, request); + } + + /// Deletes a guild. + public async Task DeleteGuildAsync(ulong guildId) + { + return await _restClient.DeleteGuildAsync(guildId); + } + + /// Modifies a guild's MFA level. + public async Task ModifyGuildMfaLevelAsync(ulong guildId, int level) + { + return await _restClient.ModifyGuildMfaLevelAsync(guildId, level); + } + + /// Gets channels for a guild. + public async Task?> GetGuildChannelsAsync(ulong guildId) + { + return await _restClient.GetGuildChannelsAsync(guildId); + } + + /// Gets members for a guild. + public async Task?> GetGuildMembersAsync(ulong guildId, int limit = 1000, ulong? after = null) + { + return await _restClient.GetGuildMembersAsync(guildId, limit, after); + } + + /// Adds a member to a guild. + public async Task AddGuildMemberAsync(ulong guildId, ulong userId, AddGuildMemberRequest request) + { + return await _restClient.AddGuildMemberAsync(guildId, userId, request); + } + + /// Modifies a guild member. + public async Task ModifyGuildMemberAsync(ulong guildId, ulong userId, ModifyGuildMemberRequest request) + { + return await _restClient.ModifyGuildMemberAsync(guildId, userId, request); + } + + /// Gets bans for a guild. + public async Task?> GetGuildBansAsync(ulong guildId, ulong? before = null, ulong? after = null, int? limit = null) + { + return await _restClient.GetGuildBansAsync(guildId, before, after, limit); + } + + /// Gets a ban for a guild. + public async Task GetGuildBanAsync(ulong guildId, ulong userId) + { + return await _restClient.GetGuildBanAsync(guildId, userId); + } + + /// Creates a ban for a guild. + public async Task CreateGuildBanAsync(ulong guildId, ulong userId, int? deleteMessageDays = null, string? reason = null) + { + return await _restClient.CreateGuildBanAsync(guildId, userId, deleteMessageDays, reason); + } + + /// Removes a ban from a guild. + public async Task RemoveGuildBanAsync(ulong guildId, ulong userId) + { + return await _restClient.RemoveGuildBanAsync(guildId, userId); + } + + // Role operations ────────────────────────────────────────────────────────── + + /// Modifies a guild role. + public async Task ModifyGuildRoleAsync(ulong guildId, ulong roleId, ModifyRoleRequest request) + { + return await _restClient.ModifyGuildRoleAsync(guildId, roleId, request); + } + + /// Deletes a guild role. + public async Task DeleteGuildRoleAsync(ulong guildId, ulong roleId) + { + return await _restClient.DeleteGuildRoleAsync(guildId, roleId); + } + + /// Adds a role to a guild member. + public async Task AddGuildMemberRoleAsync(ulong guildId, ulong userId, ulong roleId) + { + return await _restClient.AddGuildMemberRoleAsync(guildId, userId, roleId); + } + + /// Removes a role from a guild member. + public async Task RemoveGuildMemberRoleAsync(ulong guildId, ulong userId, ulong roleId) + { + return await _restClient.RemoveGuildMemberRoleAsync(guildId, userId, roleId); + } + + // Thread operations ────────────────────────────────────────────────────────── + + /// Creates a thread. + public async Task CreateThreadAsync(ulong channelId, CreateThreadRequest request) + { + return await _restClient.CreateThreadAsync(channelId, request); + } + + /// Creates a thread from a message. + public async Task CreateThreadFromMessageAsync(ulong channelId, ulong messageId, CreateThreadRequest request) + { + return await _restClient.CreateThreadFromMessageAsync(channelId, messageId, request); + } + + /// Creates a thread in a forum channel. + public async Task CreateThreadInForumAsync(ulong channelId, CreateThreadRequest request) + { + return await _restClient.CreateThreadInForumAsync(channelId, request); + } + + /// Joins a thread. + public async Task JoinThreadAsync(ulong channelId) + { + return await _restClient.JoinThreadAsync(channelId); + } + + /// Adds a member to a thread. + public async Task AddThreadMemberAsync(ulong channelId, ulong userId) + { + return await _restClient.AddThreadMemberAsync(channelId, userId); + } + + /// Leaves a thread. + public async Task LeaveThreadAsync(ulong channelId) + { + return await _restClient.LeaveThreadAsync(channelId); + } + + /// Removes a member from a thread. + public async Task RemoveThreadMemberAsync(ulong channelId, ulong userId) + { + return await _restClient.RemoveThreadMemberAsync(channelId, userId); + } + + /// Gets a thread member. + public async Task GetThreadMemberAsync(ulong channelId, ulong userId) + { + return await _restClient.GetThreadMemberAsync(channelId, userId); + } + + /// Gets thread members. + public async Task?> GetThreadMembersAsync(ulong channelId, bool withMember = false, ulong? after = null, int? limit = null) + { + return await _restClient.GetThreadMembersAsync(channelId, withMember, after, limit); + } + + /// Gets active threads for a guild. + public async Task GetActiveThreadsAsync(ulong guildId) + { + return await _restClient.GetActiveThreadsAsync(guildId); + } + + /// Gets public archived threads for a channel. + public async Task GetPublicArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null) + { + return await _restClient.GetPublicArchivedThreadsAsync(channelId, before, limit); + } + + /// Gets private archived threads for a channel. + public async Task GetPrivateArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null) + { + return await _restClient.GetPrivateArchivedThreadsAsync(channelId, before, limit); + } + + /// Gets joined private archived threads for a channel. + public async Task GetJoinedPrivateArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null) + { + return await _restClient.GetJoinedPrivateArchivedThreadsAsync(channelId, before, limit); + } + + // Webhook operations ───────────────────────────────────────────────────────── + + /// Creates a webhook for a channel. + public async Task CreateWebhookAsync(ulong channelId, CreateWebhookRequest request) + { + return await _restClient.CreateWebhookAsync(channelId, request); + } + + /// Gets webhooks for a channel. + public async Task?> GetChannelWebhooksAsync(ulong channelId) + { + return await _restClient.GetChannelWebhooksAsync(channelId); + } + + /// Gets webhooks for a guild. + public async Task?> GetGuildWebhooksAsync(ulong guildId) + { + return await _restClient.GetGuildWebhooksAsync(guildId); + } + + /// Gets a webhook by ID. + public async Task GetWebhookAsync(ulong webhookId) + { + return await _restClient.GetWebhookAsync(webhookId); + } + + /// Gets a webhook by ID and token. + public async Task GetWebhookWithTokenAsync(ulong webhookId, string token) + { + return await _restClient.GetWebhookWithTokenAsync(webhookId, token); + } + + /// Modifies a webhook. + public async Task ModifyWebhookAsync(ulong webhookId, ModifyWebhookRequest request) + { + return await _restClient.ModifyWebhookAsync(webhookId, request); + } + + /// Modifies a webhook with token. + public async Task ModifyWebhookWithTokenAsync(ulong webhookId, string token, ModifyWebhookRequest request) + { + return await _restClient.ModifyWebhookWithTokenAsync(webhookId, token, request); + } + + /// Deletes a webhook. + public async Task DeleteWebhookAsync(ulong webhookId) + { + return await _restClient.DeleteWebhookAsync(webhookId); + } + + /// Deletes a webhook with token. + public async Task DeleteWebhookWithTokenAsync(ulong webhookId, string token) + { + return await _restClient.DeleteWebhookWithTokenAsync(webhookId, token); + } + + /// Executes a webhook. + public async Task ExecuteWebhookAsync(ulong webhookId, string token, ExecuteWebhookRequest request, ulong? threadId = null) + { + return await _restClient.ExecuteWebhookAsync(webhookId, token, request, threadId); + } + + /// Gets a webhook message. + public async Task GetWebhookMessageAsync(ulong webhookId, string token, ulong messageId, ulong? threadId = null) + { + return await _restClient.GetWebhookMessageAsync(webhookId, token, messageId, threadId); + } + + /// Edits a webhook message. + public async Task EditWebhookMessageAsync(ulong webhookId, string token, ulong messageId, EditMessageRequest request, ulong? threadId = null) + { + return await _restClient.EditWebhookMessageAsync(webhookId, token, messageId, request, threadId); + } + + /// Deletes a webhook message. + public async Task DeleteWebhookMessageAsync(ulong webhookId, string token, ulong messageId, ulong? threadId = null) + { + return await _restClient.DeleteWebhookMessageAsync(webhookId, token, messageId, threadId); + } + + /// Executes a Slack-compatible webhook. + public async Task ExecuteSlackCompatibleWebhookAsync(ulong webhookId, string token, object payload, bool wait = false) + { + return await _restClient.ExecuteSlackCompatibleWebhookAsync(webhookId, token, payload, wait); + } + + /// Executes a GitHub-compatible webhook. + public async Task ExecuteGitHubCompatibleWebhookAsync(ulong webhookId, string token, object payload, bool wait = false) + { + return await _restClient.ExecuteGitHubCompatibleWebhookAsync(webhookId, token, payload, wait); + } + + // DM operations ────────────────────────────────────────────────────────────── + + /// Creates a DM channel. + public async Task CreateDmAsync(ulong recipientId) + { + return await _restClient.CreateDmAsync(recipientId); + } + + /// Creates a group DM. + public async Task CreateGroupDmAsync(List accessTokens, Dictionary? nicks = null) + { + return await _restClient.CreateGroupDmAsync(accessTokens, nicks); + } + + // Scheduled Event operations ─────────────────────────────────────────────────── + + /// Gets scheduled events for a guild. + public async Task?> GetGuildScheduledEventsAsync(ulong guildId, bool? withUserCount = null) + { + return await _restClient.GetGuildScheduledEventsAsync(guildId, withUserCount); + } + + /// Gets a scheduled event for a guild. + public async Task GetGuildScheduledEventAsync(ulong guildId, ulong eventId, bool? withUserCount = null) + { + return await _restClient.GetGuildScheduledEventAsync(guildId, eventId, withUserCount); + } + + /// Deletes a guild scheduled event. + public async Task DeleteGuildScheduledEventAsync(ulong guildId, ulong eventId) + { + return await _restClient.DeleteGuildScheduledEventAsync(guildId, eventId); + } + + /// Gets users for a guild scheduled event. + public async Task?> GetGuildScheduledEventUsersAsync(ulong guildId, ulong eventId, int? limit = null, bool? withMember = null, ulong? before = null, ulong? after = null) + { + return await _restClient.GetGuildScheduledEventUsersAsync(guildId, eventId, limit, withMember, before, after); + } + + // Audit Log operations ─────────────────────────────────────────────────────── + + /// Gets audit logs for a guild. + public async Task GetGuildAuditLogsAsync(ulong guildId, ulong? userId = null, AuditLogEvent? actionType = null, ulong? before = null, ulong? after = null, int? limit = null) + { + return await _restClient.GetGuildAuditLogsAsync(guildId, userId, actionType, before, after, limit); + } + + // Auto-Moderation operations ────────────────────────────────────────────────── + + /// Lists auto-moderation rules for a guild. + public async Task?> ListAutoModerationRulesAsync(ulong guildId) + { + return await _restClient.ListAutoModerationRulesAsync(guildId); + } + + /// Gets an auto-moderation rule for a guild. + public async Task GetAutoModerationRuleAsync(ulong guildId, ulong ruleId) + { + return await _restClient.GetAutoModerationRuleAsync(guildId, ruleId); + } + + /// Deletes an auto-moderation rule for a guild. + public async Task DeleteAutoModerationRuleAsync(ulong guildId, ulong ruleId) + { + return await _restClient.DeleteAutoModerationRuleAsync(guildId, ruleId); + } + + // Stage Instance operations ────────────────────────────────────────────────── + + /// Gets a stage instance. + public async Task GetStageInstanceAsync(ulong channelId) + { + return await _restClient.GetStageInstanceAsync(channelId); + } + + /// Deletes a stage instance. + public async Task DeleteStageInstanceAsync(ulong channelId) + { + return await _restClient.DeleteStageInstanceAsync(channelId); + } + + // Sticker operations ────────────────────────────────────────────────────────── + + /// Gets a sticker. + public async Task GetStickerAsync(ulong stickerId) + { + return await _restClient.GetStickerAsync(stickerId); + } + + /// Gets sticker packs. + public async Task?> GetNitroStickerPacksAsync() + { + return await _restClient.GetNitroStickerPacksAsync(); + } + + /// Gets guild stickers. + public async Task?> GetGuildStickersAsync(ulong guildId) + { + return await _restClient.GetGuildStickersAsync(guildId); + } + + /// Gets a guild sticker. + public async Task GetGuildStickerAsync(ulong guildId, ulong stickerId) + { + return await _restClient.GetGuildStickerAsync(guildId, stickerId); + } + + /// Deletes a guild sticker. + public async Task DeleteGuildStickerAsync(ulong guildId, ulong stickerId) + { + return await _restClient.DeleteGuildStickerAsync(guildId, stickerId); + } + + // Voice Region operations ──────────────────────────────────────────────────── + + /// Gets voice regions. + public async Task?> GetVoiceRegionsAsync() + { + return await _restClient.GetVoiceRegionsAsync(); + } + + /// Gets voice regions for a guild. + public async Task?> GetGuildVoiceRegionsAsync(ulong guildId) + { + return await _restClient.GetGuildVoiceRegionsAsync(guildId); + } + + // Application Command operations ────────────────────────────────────────────── + + /// Gets global application commands. + public async Task?> GetGlobalApplicationCommandsAsync(ulong applicationId) + { + return await _restClient.GetGlobalApplicationCommandsAsync(applicationId); + } + + /// Creates a global application command. + public async Task CreateGlobalApplicationCommandAsync(ulong applicationId, CreateApplicationCommandRequest request) + { + return await _restClient.CreateGlobalApplicationCommandAsync(applicationId, request); + } + + /// Overwrites global application commands. + public async Task?> BulkOverwriteGlobalApplicationCommandsAsync(ulong applicationId, List commands) + { + return await _restClient.BulkOverwriteGlobalApplicationCommandsAsync(applicationId, commands); + } + + /// Gets a global application command. + public async Task GetGlobalApplicationCommandAsync(ulong applicationId, ulong commandId) + { + return await _restClient.GetGlobalApplicationCommandAsync(applicationId, commandId); + } + + /// Edits a global application command. + public async Task EditGlobalApplicationCommandAsync(ulong applicationId, ulong commandId, CreateApplicationCommandRequest request) + { + return await _restClient.EditGlobalApplicationCommandAsync(applicationId, commandId, request); + } + + /// Deletes a global application command. + public async Task DeleteGlobalApplicationCommandAsync(ulong applicationId, ulong commandId) + { + return await _restClient.DeleteGlobalApplicationCommandAsync(applicationId, commandId); + } + + /// Gets guild application commands. + public async Task?> GetGuildApplicationCommandsAsync(ulong applicationId, ulong guildId) + { + return await _restClient.GetGuildApplicationCommandsAsync(applicationId, guildId); + } + + /// Creates a guild application command. + public async Task CreateGuildApplicationCommandAsync(ulong applicationId, ulong guildId, CreateApplicationCommandRequest request) + { + return await _restClient.CreateGuildApplicationCommandAsync(applicationId, guildId, request); + } + + /// Overwrites guild application commands. + public async Task?> BulkOverwriteGuildApplicationCommandsAsync(ulong applicationId, ulong guildId, List commands) + { + return await _restClient.BulkOverwriteGuildApplicationCommandsAsync(applicationId, guildId, commands); + } + + /// Gets a guild application command. + public async Task GetGuildApplicationCommandAsync(ulong applicationId, ulong guildId, ulong commandId) + { + return await _restClient.GetGuildApplicationCommandAsync(applicationId, guildId, commandId); + } + + /// Edits a guild application command. + public async Task EditGuildApplicationCommandAsync(ulong applicationId, ulong guildId, ulong commandId, CreateApplicationCommandRequest request) + { + return await _restClient.EditGuildApplicationCommandAsync(applicationId, guildId, commandId, request); + } + + /// Deletes a guild application command. + public async Task DeleteGuildApplicationCommandAsync(ulong applicationId, ulong guildId, ulong commandId) + { + return await _restClient.DeleteGuildApplicationCommandAsync(applicationId, guildId, commandId); + } + + // Application Command Permissions operations ──────────────────────────────────── + + /// Gets guild application command permissions. + public async Task?> GetGuildApplicationCommandPermissionsAsync(ulong applicationId, ulong guildId) + { + return await _restClient.GetGuildApplicationCommandPermissionsAsync(applicationId, guildId); + } + + /// Gets application command permissions for a specific command. + public async Task GetApplicationCommandPermissionsAsync(ulong applicationId, ulong guildId, ulong commandId) + { + return await _restClient.GetApplicationCommandPermissionsAsync(applicationId, guildId, commandId); + } + + /// Edits application command permissions for a specific command. + public async Task EditApplicationCommandPermissionsAsync(ulong applicationId, ulong guildId, ulong commandId, List permissions) + { + return await _restClient.EditApplicationCommandPermissionsAsync(applicationId, guildId, commandId, permissions); + } + + /// Batch edits application command permissions for all commands. + public async Task?> BatchEditApplicationCommandPermissionsAsync(ulong applicationId, ulong guildId, List permissions) + { + return await _restClient.BatchEditApplicationCommandPermissionsAsync(applicationId, guildId, permissions); + } + + // Guild Emoji operations ──────────────────────────────────────────────────────── + + /// Gets emojis for a guild. + public async Task?> ListGuildEmojisAsync(ulong guildId) + { + return await _restClient.ListGuildEmojisAsync(guildId); + } + + /// Gets an emoji for a guild. + public async Task GetGuildEmojiAsync(ulong guildId, ulong emojiId) + { + return await _restClient.GetGuildEmojiAsync(guildId, emojiId); + } + + /// Deletes an emoji from a guild. + public async Task DeleteGuildEmojiAsync(ulong guildId, ulong emojiId) + { + return await _restClient.DeleteGuildEmojiAsync(guildId, emojiId); + } + + // ApplicationEmoji operations ─────────────────────────────────────────────────── + + /// Gets emojis for the current application. + public async Task?> ListApplicationEmojisAsync(ulong applicationId) + { + return await _restClient.ListApplicationEmojisAsync(applicationId); + } + + /// Gets an emoji for the current application. + public async Task GetApplicationEmojiAsync(ulong applicationId, ulong emojiId) + { + return await _restClient.GetApplicationEmojiAsync(applicationId, emojiId); + } + + /// Deletes an emoji from the current application. + public async Task DeleteApplicationEmojiAsync(ulong applicationId, ulong emojiId) + { + return await _restClient.DeleteApplicationEmojiAsync(applicationId, emojiId); + } + + // Guild Integration operations ────────────────────────────────────────────────── + + /// Gets integrations for a guild. + public async Task?> GetGuildIntegrationsAsync(ulong guildId) + { + return await _restClient.GetGuildIntegrationsAsync(guildId); + } + + /// Deletes an integration from a guild. + public async Task DeleteGuildIntegrationAsync(ulong guildId, ulong integrationId) + { + return await _restClient.DeleteGuildIntegrationAsync(guildId, integrationId); + } + + // Guild Invite operations ───────────────────────────────────────────────────── + + /// Gets invites for a guild. + public async Task?> GetGuildInvitesAsync(ulong guildId) + { + return await _restClient.GetGuildInvitesAsync(guildId); + } + + // Guild Prune operations ────────────────────────────────────────────────────── + + /// Gets prune count for a guild. + public async Task GetGuildPruneCountAsync(ulong guildId, int? days = null, List? includeRoles = null) + { + return await _restClient.GetGuildPruneCountAsync(guildId, days, includeRoles); + } + + /// Begins a prune operation for a guild. + public async Task BeginGuildPruneAsync(ulong guildId, BeginGuildPruneRequest request, string? reason = null) + { + return await _restClient.BeginGuildPruneAsync(guildId, request, reason); + } + + // Guild Template operations ───────────────────────────────────────────────────── + + /// Gets templates for a guild. + public async Task?> GetGuildTemplatesAsync(ulong guildId) + { + return await _restClient.GetGuildTemplatesAsync(guildId); + } + + /// Gets a guild template. + public async Task GetGuildTemplateAsync(string templateCode) + { + return await _restClient.GetGuildTemplateAsync(templateCode); + } + + /// Syncs a guild template. + public async Task SyncGuildTemplateAsync(ulong guildId, string templateCode) + { + return await _restClient.SyncGuildTemplateAsync(guildId, templateCode); + } + + /// Modifies a guild template. + public async Task ModifyGuildTemplateAsync(ulong guildId, string templateCode, ModifyGuildTemplateRequest request) + { + return await _restClient.ModifyGuildTemplateAsync(guildId, templateCode, request); + } + + /// Deletes a guild template. + public async Task DeleteGuildTemplateAsync(ulong guildId, string templateCode) + { + return await _restClient.DeleteGuildTemplateAsync(guildId, templateCode); + } + + // OAuth2 operations ─────────────────────────────────────────────────────────── + + /// Gets the current application. + public async Task GetCurrentApplicationAsync() + { + return await _restClient.GetCurrentApplicationAsync(); + } + + /// Gets the current bot application info. + public async Task GetCurrentBotApplicationInfoAsync() + { + return await _restClient.GetCurrentBotApplicationInfoAsync(); + } + + /// Gets authorization information. + public async Task GetCurrentAuthorizationInfoAsync() + { + return await _restClient.GetCurrentAuthorizationInfoAsync(); + } + + /// Edits the current application. + public async Task EditCurrentApplicationAsync(EditCurrentApplicationRequest request) + { + return await _restClient.EditCurrentApplicationAsync(request); + } + + // Poll operations ───────────────────────────────────────────────────────────── + + /// Gets voters for a poll answer. + public async Task?> GetAnswerVotersAsync(ulong channelId, ulong messageId, int answerId, int? limit = null, ulong? after = null) + { + return await _restClient.GetAnswerVotersAsync(channelId, messageId, answerId, limit, after); + } + + /// Ends a poll. + public async Task EndPollAsync(ulong channelId, ulong messageId) + { + return await _restClient.EndPollAsync(channelId, messageId); + } + + // SKU/Entitlement/Subscription operations ─────────────────────────────────────── + + /// Gets SKUs. + public async Task?> ListSkusAsync(ulong applicationId) + { + return await _restClient.ListSkusAsync(applicationId); + } + + /// Gets entitlements. + public async Task?> ListEntitlementsAsync(ulong applicationId, ulong? userId = null, List? skuIds = null, ulong? before = null, ulong? after = null, int? limit = null, ulong? guildId = null, bool? excludeEnded = null) + { + return await _restClient.ListEntitlementsAsync(applicationId, userId, skuIds, before, after, limit, guildId, excludeEnded); + } + + /// Gets an entitlement. + public async Task GetEntitlementAsync(ulong applicationId, ulong entitlementId) + { + return await _restClient.GetEntitlementAsync(applicationId, entitlementId); + } + + /// Creates a test entitlement. + public async Task CreateTestEntitlementAsync(ulong applicationId, CreateTestEntitlementRequest request) + { + return await _restClient.CreateTestEntitlementAsync(applicationId, request); + } + + /// Deletes a test entitlement. + public async Task DeleteTestEntitlementAsync(ulong applicationId, ulong entitlementId) + { + return await _restClient.DeleteTestEntitlementAsync(applicationId, entitlementId); + } + + /// Consumes an entitlement. + public async Task ConsumeEntitlementAsync(ulong applicationId, ulong entitlementId) + { + return await _restClient.ConsumeEntitlementAsync(applicationId, entitlementId); + } + + /// Lists SKU subscriptions. + public async Task?> ListSkuSubscriptionsAsync(ulong skuId, ulong? before = null, ulong? after = null, int? limit = null, ulong? userId = null) + { + return await _restClient.ListSkuSubscriptionsAsync(skuId, before, after, limit, userId); + } + + /// Gets SKU subscription. + public async Task GetSkuSubscriptionAsync(ulong skuId, ulong subscriptionId) + { + return await _restClient.GetSkuSubscriptionAsync(skuId, subscriptionId); + } + + // Soundboard operations ────────────────────────────────────────────────────────── + + /// Lists default soundboard sounds. + public async Task?> ListDefaultSoundboardSoundsAsync() + { + return await _restClient.ListDefaultSoundboardSoundsAsync(); + } + + /// Lists guild soundboard sounds. + public async Task?> ListGuildSoundboardSoundsAsync(ulong guildId) + { + return await _restClient.ListGuildSoundboardSoundsAsync(guildId); + } + + /// Gets a soundboard sound. + public async Task GetGuildSoundboardSoundAsync(ulong guildId, ulong soundId) + { + return await _restClient.GetGuildSoundboardSoundAsync(guildId, soundId); + } + + /// Deletes a soundboard sound. + public async Task DeleteGuildSoundboardSoundAsync(ulong guildId, ulong soundId) + { + return await _restClient.DeleteGuildSoundboardSoundAsync(guildId, soundId); + } + + /// Sends a soundboard sound. + public async Task SendSoundboardSoundAsync(ulong channelId, SendSoundboardSoundRequest request) + { + return await _restClient.SendSoundboardSoundAsync(channelId, request); + } + + // Guild Onboarding operations ─────────────────────────────────────────────────── + + /// Gets onboarding for a guild. + public async Task GetGuildOnboardingAsync(ulong guildId) + { + return await _restClient.GetGuildOnboardingAsync(guildId); + } + + /// Modifies onboarding for a guild. + public async Task ModifyGuildOnboardingAsync(ulong guildId, ModifyGuildOnboardingRequest request) + { + return await _restClient.ModifyGuildOnboardingAsync(guildId, request); + } + + // Application Role Connection operations ──────────────────────────────────────── + + /// Gets role connection metadata for an application. + public async Task?> GetApplicationRoleConnectionMetadataAsync(ulong applicationId) + { + return await _restClient.GetApplicationRoleConnectionMetadataAsync(applicationId); + } + + /// Updates role connection metadata for an application. + public async Task?> UpdateApplicationRoleConnectionMetadataAsync(ulong applicationId, List records) + { + return await _restClient.UpdateApplicationRoleConnectionMetadataAsync(applicationId, records); + } + + /// Gets role connections for the current user. + public async Task GetUserApplicationRoleConnectionAsync(ulong applicationId) + { + return await _restClient.GetUserApplicationRoleConnectionAsync(applicationId); + } + + /// Updates role connections for the current user. + public async Task UpdateUserApplicationRoleConnectionAsync(ulong applicationId, UpdateUserApplicationRoleConnectionRequest request) + { + return await _restClient.UpdateUserApplicationRoleConnectionAsync(applicationId, request); + } + + // Reaction query operations ───────────────────────────────────────────────────── + + /// Gets reactions for a message. + public async Task?> GetReactionsAsync(ulong channelId, ulong messageId, string emoji, int? type = null, ulong? after = null, int? limit = null) + { + return await _restClient.GetReactionsAsync(channelId, messageId, emoji, type, after, limit); + } + + /// Deletes all reactions for a message. + public async Task DeleteAllReactionsAsync(ulong channelId, ulong messageId) + { + return await _restClient.DeleteAllReactionsAsync(channelId, messageId); + } + + /// Deletes all reactions for an emoji on a message. + public async Task DeleteAllReactionsForEmojiAsync(ulong channelId, ulong messageId, string emoji) + { + return await _restClient.DeleteAllReactionsForEmojiAsync(channelId, messageId, emoji); + } + + // Guild widget operations ─────────────────────────────────────────────────────── + + /// Gets guild widget. + public async Task GetGuildWidgetAsync(ulong guildId) + { + return await _restClient.GetGuildWidgetAsync(guildId); + } + + /// Modifies guild widget. + public async Task ModifyGuildWidgetAsync(ulong guildId, ModifyGuildWidgetRequest request) + { + return await _restClient.ModifyGuildWidgetAsync(guildId, request); + } + + /// Gets guild widget settings. + public async Task GetGuildWidgetSettingsAsync(ulong guildId) + { + return await _restClient.GetGuildWidgetSettingsAsync(guildId); + } + + // Guild vanity URL operations ───────────────────────────────────────────────────── + + /// Gets guild vanity URL. + public async Task GetGuildVanityUrlAsync(ulong guildId) + { + return await _restClient.GetGuildVanityUrlAsync(guildId); + } + + // Guild welcome screen operations ────────────────────────────────────────────────── + + /// Gets guild welcome screen. + public async Task GetGuildWelcomeScreenAsync(ulong guildId) + { + return await _restClient.GetGuildWelcomeScreenAsync(guildId); + } + + /// Modifies guild welcome screen. + public async Task ModifyGuildWelcomeScreenAsync(ulong guildId, ModifyGuildWelcomeScreenRequest request) + { + return await _restClient.ModifyGuildWelcomeScreenAsync(guildId, request); + } + + // Guild channel/role position operations ─────────────────────────────────────────── + + /// Modifies guild channel positions. + public async Task ModifyGuildChannelPositionsAsync(ulong guildId, List positions) + { + return await _restClient.ModifyGuildChannelPositionsAsync(guildId, positions); + } + + /// Modifies guild role positions. + public async Task?> ModifyGuildRolePositionsAsync(ulong guildId, List positions) + { + return await _restClient.ModifyGuildRolePositionsAsync(guildId, positions); + } + + // Invite lookup/deletion operations ──────────────────────────────────────────────── + + /// Gets an invite. + public async Task GetInviteAsync(string inviteCode, bool? withCounts = null, bool? withExpiration = null, ulong? guildScheduledEventId = null) + { + return await _restClient.GetInviteAsync(inviteCode, withCounts, withExpiration, guildScheduledEventId); + } + + /// Deletes an invite. + public async Task DeleteInviteAsync(string inviteCode, string? reason = null) + { + return await _restClient.DeleteInviteAsync(inviteCode, reason); + } + + // Bulk ban operation ─────────────────────────────────────────────────────────────── + + /// Bulk bans users from a guild. + public async Task BulkGuildBanAsync(ulong guildId, BulkGuildBanRequest request, string? reason = null) + { + return await _restClient.BulkGuildBanAsync(guildId, request, reason); + } + + // Guild role extras operations ─────────────────────────────────────────────────────── + + /// Gets a guild role. + public async Task GetGuildRoleAsync(ulong guildId, ulong roleId) + { + return await _restClient.GetGuildRoleAsync(guildId, roleId); + } + + /// Gets guild role member counts. + public async Task?> GetGuildRoleMemberCountsAsync(ulong guildId) + { + return await _restClient.GetGuildRoleMemberCountsAsync(guildId); + } + + // Guild incident actions operation ─────────────────────────────────────────────────── + + /// Modifies guild incident actions. + public async Task ModifyGuildIncidentActionsAsync(ulong guildId, ModifyGuildIncidentActionsRequest request) + { + return await _restClient.ModifyGuildIncidentActionsAsync(guildId, request); + } + + // Current user guild member operation ───────────────────────────────────────────────── + + /// Gets current user guild member. + public async Task GetCurrentUserGuildMemberAsync(ulong guildId) + { + return await _restClient.GetCurrentUserGuildMemberAsync(guildId); + } + + // Voice state modification operations ──────────────────────────────────────────────── + + /// Modifies current user voice state. + public async Task ModifyCurrentUserVoiceStateAsync(ulong guildId, ModifyCurrentUserVoiceStateRequest request) + { + return await _restClient.ModifyCurrentUserVoiceStateAsync(guildId, request); + } + + /// Modifies user voice state. + public async Task ModifyUserVoiceStateAsync(ulong guildId, ulong userId, ModifyUserVoiceStateRequest request) + { + return await _restClient.ModifyUserVoiceStateAsync(guildId, userId, request); + } + + // Activity Instance operation ──────────────────────────────────────────────────────── + + /// Gets activity instance. + public async Task GetActivityInstanceAsync(ulong applicationId, string instanceId) + { + return await _restClient.GetActivityInstanceAsync(applicationId, instanceId); + } + + // Gateway operations ──────────────────────────────────────────────────────────────── + + /// Gets gateway. + public async Task GetGatewayAsync() + { + return await _restClient.GetGatewayAsync(); + } + + /// Gets gateway bot. + public async Task GetGatewayBotAsync() + { + return await _restClient.GetGatewayBotAsync(); + } + + // Current user connections operation ──────────────────────────────────────────────────── + + /// Gets current user connections. + public async Task?> GetCurrentUserConnectionsAsync() + { + return await _restClient.GetCurrentUserConnectionsAsync(); + } + + // Guild member search operation ──────────────────────────────────────────────────────── + + /// Searches guild members. + public async Task?> SearchGuildMembersAsync(ulong guildId, string query, int limit = 25) + { + return await _restClient.SearchGuildMembersAsync(guildId, query, limit); + } + + // Modify current member operation ─────────────────────────────────────────────────────── + + /// Modifies current guild member. + public async Task ModifyCurrentMemberAsync(ulong guildId, string? nick) + { + return await _restClient.ModifyCurrentMemberAsync(guildId, nick); + } + + // Additional operations ────────────────────────────────────────────────────── + + /// Gets guild preview. + public async Task GetGuildPreviewAsync(ulong guildId) + { + return await _restClient.GetGuildPreviewAsync(guildId); + } + + /// Follows an announcement channel. + public async Task FollowAnnouncementChannelAsync(ulong channelId, ulong webhookChannelId) + { + return await _restClient.FollowAnnouncementChannelAsync(channelId, webhookChannelId); + } + + /// Exchanges OAuth2 code for token. + public async Task ExchangeCodeAsync(string code, string clientId, string clientSecret, string redirectUri) + { + return await _restClient.ExchangeCodeAsync(code, clientId, clientSecret, redirectUri); + } + + /// Refreshes OAuth2 token. + public async Task RefreshTokenAsync(string refreshToken, string clientId, string clientSecret) + { + return await _restClient.RefreshTokenAsync(refreshToken, clientId, clientSecret); + } + + /// Revokes OAuth2 token. + public async Task RevokeTokenAsync(string token, string clientId, string clientSecret, string? tokenTypeHint = null) + { + return await _restClient.RevokeTokenAsync(token, clientId, clientSecret, tokenTypeHint); + } + // ── Convenience event subscriptions ─────────────────────────────────────── // Messages ────────────────────────────────────────────────────────────── diff --git a/src/PawSharp.Client/README.md b/src/PawSharp.Client/README.md index 390b4ee..cefeca0 100644 --- a/src/PawSharp.Client/README.md +++ b/src/PawSharp.Client/README.md @@ -21,7 +21,7 @@ It provides a unified client surface for REST API, Gateway WebSocket, entity cac ## Installation ```bash -dotnet add package PawSharp.Client --version 1.1.0-alpha.1 +dotnet add package PawSharp.Client --version 1.1.0-alpha.2 ``` ## Quick Start diff --git a/src/PawSharp.Commands/Autocomplete/BuiltInAutocompleteProviders.cs b/src/PawSharp.Commands/Autocomplete/BuiltInAutocompleteProviders.cs new file mode 100644 index 0000000..4ab3afc --- /dev/null +++ b/src/PawSharp.Commands/Autocomplete/BuiltInAutocompleteProviders.cs @@ -0,0 +1,189 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PawSharp.API.Models; +using PawSharp.Core.Entities; +using PawSharp.Gateway.Events; + +namespace PawSharp.Commands.Autocomplete; + +/// +/// Built-in autocomplete providers for common Discord entities. +/// +public static class BuiltInAutocompleteProviders +{ + /// + /// Autocomplete provider for users in a guild. + /// + public sealed class UserAutocompleteProvider + { + /// + /// Provides autocomplete suggestions for users based on the input. + /// + /// The autocomplete context. + /// The user's input. + /// A list of autocomplete choices. + public async Task> ProvideAsync(AutocompleteContext ctx, string input) + { + if (!ctx.GuildId.HasValue) + return Array.Empty(); + + var guild = ctx.Client.Cache.GetGuild(ctx.GuildId.Value); + if (guild == null) + return Array.Empty(); + + var choices = new List(); + + // Try to get members from cache + var members = guild.Members?.Where(m => + string.IsNullOrEmpty(input) || + m.User?.Username?.Contains(input, StringComparison.OrdinalIgnoreCase) == true || + (m.User?.GlobalName?.Contains(input, StringComparison.OrdinalIgnoreCase) == true) + ).Take(25); + + if (members != null) + { + foreach (var member in members) + { + if (member.User != null) + { + var displayName = string.IsNullOrEmpty(member.User.GlobalName) + ? member.User.Username + : $"{member.User.Username} ({member.User.GlobalName})"; + + choices.Add(new ApplicationCommandOptionChoice + { + Name = displayName.Length > 100 ? displayName.Substring(0, 100) : displayName, + Value = member.User.Id.ToString() + }); + } + } + } + + return choices; + } + } + + /// + /// Autocomplete provider for roles in a guild. + /// + public sealed class RoleAutocompleteProvider + { + /// + /// Provides autocomplete suggestions for roles based on the input. + /// + /// The autocomplete context. + /// The user's input. + /// A list of autocomplete choices. + public async Task> ProvideAsync(AutocompleteContext ctx, string input) + { + if (!ctx.GuildId.HasValue) + return Array.Empty(); + + var guild = ctx.Client.Cache.GetGuild(ctx.GuildId.Value); + if (guild?.Roles == null) + return Array.Empty(); + + var choices = guild.Roles + .Where(r => + string.IsNullOrEmpty(input) || + r.Name?.Contains(input, StringComparison.OrdinalIgnoreCase) == true) + .Where(r => r.Id != ctx.GuildId.Value) // Exclude @everyone + .Take(25) + .Select(r => new ApplicationCommandOptionChoice + { + Name = r.Name?.Length > 100 ? r.Name.Substring(0, 100) : r.Name, + Value = r.Id.ToString() + }) + .ToList(); + + return choices; + } + } + + /// + /// Autocomplete provider for channels in a guild. + /// + public sealed class ChannelAutocompleteProvider + { + /// + /// Provides autocomplete suggestions for channels based on the input. + /// + /// The autocomplete context. + /// The user's input. + /// Optional filter for specific channel types. + /// A list of autocomplete choices. + public async Task> ProvideAsync(AutocompleteContext ctx, string input, PawSharp.Core.Enums.ChannelType? channelType = null) + { + if (!ctx.GuildId.HasValue) + return Array.Empty(); + + var guild = ctx.Client.Cache.GetGuild(ctx.GuildId.Value); + if (guild?.Channels == null) + return Array.Empty(); + + var channels = guild.Channels; + + // Filter by channel type if specified + if (channelType.HasValue) + { + channels = channels.Where(c => c.Type == channelType.Value).ToList(); + } + + var choices = channels + .Where(c => + string.IsNullOrEmpty(input) || + c.Name?.Contains(input, StringComparison.OrdinalIgnoreCase) == true) + .Take(25) + .Select(c => new ApplicationCommandOptionChoice + { + Name = c.Name?.Length > 100 ? c.Name.Substring(0, 100) : c.Name, + Value = c.Id.ToString() + }) + .ToList(); + + return choices; + } + } +} + +/// +/// Context for autocomplete interactions. +/// +public class AutocompleteContext +{ + /// + /// Gets the Discord client. + /// + public PawSharp.Client.DiscordClient Client { get; } + + /// + /// Gets the guild ID if in a guild. + /// + public ulong? GuildId { get; } + + /// + /// Gets the channel ID. + /// + public ulong ChannelId { get; } + + /// + /// Gets the user who triggered the autocomplete. + /// + public PawSharp.Core.Entities.User User { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The Discord client. + /// The autocomplete interaction event. + public AutocompleteContext(PawSharp.Client.DiscordClient client, InteractionCreateEvent interaction) + { + Client = client; + GuildId = interaction.GuildId; + ChannelId = interaction.ChannelId; + User = interaction.Member?.User ?? interaction.User ?? new PawSharp.Core.Entities.User { Id = 0, Username = "unknown", Discriminator = "0" }; + } +} diff --git a/src/PawSharp.Commands/CommandsExtension.cs b/src/PawSharp.Commands/CommandsExtension.cs index bebcd11..5c9e550 100644 --- a/src/PawSharp.Commands/CommandsExtension.cs +++ b/src/PawSharp.Commands/CommandsExtension.cs @@ -344,17 +344,72 @@ public class CommandInfo /// public string? Description { get; } + /// + /// Gets the command parameters. + /// + public IReadOnlyList Parameters { get; } + + /// + /// Gets the command preconditions. + /// + public IReadOnlyList Preconditions { get; } + /// /// Initializes a new instance of the class. /// /// The command name. /// The command aliases. /// The command description. - public CommandInfo(string name, string[] aliases, string? description) + /// The command parameters. + /// The command preconditions. + public CommandInfo(string name, string[] aliases, string? description, CommandParameterInfo[]? parameters = null, string[]? preconditions = null) { Name = name ?? throw new ArgumentNullException(nameof(name)); Aliases = aliases ?? Array.Empty(); Description = description; + Parameters = parameters ?? Array.Empty(); + Preconditions = preconditions ?? Array.Empty(); + } +} + +/// +/// Represents information about a command parameter. +/// +public class CommandParameterInfo +{ + /// + /// Gets the parameter name. + /// + public string Name { get; } + + /// + /// Gets the parameter description. + /// + public string? Description { get; } + + /// + /// Gets whether the parameter is required. + /// + public bool IsRequired { get; } + + /// + /// Gets the parameter type. + /// + public string Type { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The parameter name. + /// The parameter description. + /// Whether the parameter is required. + /// The parameter type. + public CommandParameterInfo(string name, string? description, bool isRequired, string type) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Description = description; + IsRequired = isRequired; + Type = type ?? "object"; } } @@ -1066,7 +1121,7 @@ await CommandErrored(new CommandErrorEventArgs( } private static object?[] BuildInvocationArguments( - ParameterInfo[] parameters, + System.Reflection.ParameterInfo[] parameters, InteractionCreateEvent interaction, IEnumerable? optionScope) { diff --git a/src/PawSharp.Commands/Conversion/BuiltInConverters.cs b/src/PawSharp.Commands/Conversion/BuiltInConverters.cs index eb51e80..642c8f6 100644 --- a/src/PawSharp.Commands/Conversion/BuiltInConverters.cs +++ b/src/PawSharp.Commands/Conversion/BuiltInConverters.cs @@ -33,7 +33,8 @@ protected override TypeConverterResult ConvertSync(string value, CommandCon { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as an integer."); + // Developer note: Provide context about valid range and format + return TypeConverterResult.FromError($"Unable to parse '{value}' as an integer. Valid range: -2,147,483,648 to 2,147,483,647. Ensure the input contains only digits and an optional leading minus sign."); } } @@ -48,7 +49,8 @@ protected override TypeConverterResult ConvertSync(string value, CommandCo { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a long integer."); + // Developer note: Provide context about valid range for Discord IDs + return TypeConverterResult.FromError($"Unable to parse '{value}' as a long integer. Valid range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807. For Discord IDs, use ulong instead."); } } @@ -63,7 +65,8 @@ protected override TypeConverterResult ConvertSync(string value, CommandC { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a snowflake ID."); + // Developer note: Discord snowflake IDs are always positive 64-bit integers + return TypeConverterResult.FromError($"Unable to parse '{value}' as a snowflake ID. Discord IDs are positive 64-bit integers (0 to 18,446,744,073,709,551,615). Ensure the input contains only digits, no minus sign or decimal point."); } } @@ -83,7 +86,8 @@ protected override TypeConverterResult ConvertSync(string value, CommandCo { return TypeConverterResult.FromSuccess(false); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a boolean. Use true/false, yes/no, or 1/0."); + // Developer note: List all accepted values for clarity + return TypeConverterResult.FromError($"Unable to parse '{value}' as a boolean. Accepted values (case-insensitive): true/yes/1/y for true, false/no/0/n for false."); } } @@ -98,7 +102,8 @@ protected override TypeConverterResult ConvertSync(string value, Command { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a number."); + // Developer note: Specify format requirements + return TypeConverterResult.FromError($"Unable to parse '{value}' as a number. Use invariant culture format (dot as decimal separator, no thousands separators). Example: 3.14 or -0.5."); } } @@ -113,7 +118,83 @@ protected override TypeConverterResult ConvertSync(string value, CommandC { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a number."); + // Developer note: Specify format and precision requirements + return TypeConverterResult.FromError($"Unable to parse '{value}' as a number. Use invariant culture format (dot as decimal separator). Single precision range: ±1.5e-45 to ±3.4e38."); + } + } + + /// + /// SByte converter. + /// + internal sealed class SByteConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (sbyte.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as an sbyte."); + } + } + + /// + /// Byte converter. + /// + internal sealed class ByteConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (byte.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a byte."); + } + } + + /// + /// Int16 converter. + /// + internal sealed class Int16Converter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (short.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a short."); + } + } + + /// + /// UInt16 converter. + /// + internal sealed class UInt16Converter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (ushort.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a ushort."); + } + } + + /// + /// UInt32 converter. + /// + internal sealed class UInt32Converter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a uint."); } } @@ -143,7 +224,8 @@ protected override TypeConverterResult ConvertSync(string value, Comma { return TypeConverterResult.FromSuccess(result); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a time span. Try formats like '1:30:00' or '2.5:30:00'."); + // Developer note: Provide comprehensive format examples + return TypeConverterResult.FromError($"Unable to parse '{value}' as a time span. Accepted formats: '1:30:00' (hours:minutes:seconds), '2.5:30:00' (days.hours:minutes:seconds), '1.5h' (ISO 8601 duration), or '00:30:00' (standard time format)."); } } @@ -163,10 +245,200 @@ protected override TypeConverterResult ConvertSync(string value, CommandCo return TypeConverterResult.FromSuccess(user); } - // Create a minimal user object with the ID - return TypeConverterResult.FromSuccess(new User { Id = userId, Username = value, Discriminator = "0000" }); + // Developer note: Explain why user not found and suggest alternatives + return TypeConverterResult.FromError($"User with ID '{value}' not found in cache. Ensure the user is in a shared guild or has been cached. Try mentioning the user (@username) instead of providing the ID directly."); + } + // Developer note: Suggest mention format + return TypeConverterResult.FromError($"Unable to parse '{value}' as a user ID. Provide a valid snowflake ID (numeric string) or mention the user with @username."); + } + } + + /// + /// Decimal converter. + /// + internal sealed class DecimalConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a decimal."); + } + } + + /// + /// Guid converter. + /// + internal sealed class GuidConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (Guid.TryParse(value, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a GUID."); + } + } + + /// + /// Uri converter. + /// + internal sealed class UriConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (Uri.TryCreate(value, UriKind.Absolute, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a URL."); + } + } + + /// + /// DateTimeOffset converter. + /// + internal sealed class DateTimeOffsetConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + return TypeConverterResult.FromError($"Unable to parse '{value}' as a date/time offset."); + } + } + + /// + /// Enum converter (generic base for enum types). + /// + internal sealed class EnumConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + // This is a fallback converter - specific enum types should be handled by the generic enum converter below + return TypeConverterResult.FromError($"Enum conversion requires specific enum type. Use the generic enum converter for your specific enum type."); + } + } + + /// + /// Generic enum converter for specific enum types. + /// + internal sealed class GenericEnumConverter : SyncTypeConverter where T : struct, Enum + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + // Try to parse by name (case-insensitive) + if (Enum.TryParse(value, true, out var result)) + { + return TypeConverterResult.FromSuccess(result); + } + + // Try to parse by numeric value + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + if (Enum.IsDefined(typeof(T), intValue)) + { + return TypeConverterResult.FromSuccess((T)Enum.ToObject(typeof(T), intValue)); + } + } + + var validValues = string.Join(", ", Enum.GetNames()); + // Developer note: Provide both name and numeric value options + return TypeConverterResult.FromError($"Unable to parse '{value}' as {typeof(T).Name}. Valid values (case-insensitive): {validValues}. You can also use the numeric value of the enum member."); + } + } + + /// + /// Channel converter (converts snowflake ID to Channel entity). + /// + internal sealed class ChannelConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId)) + { + // Try to get channel from cache + var channel = context.Client.Cache.GetChannel(channelId); + if (channel != null) + { + return TypeConverterResult.FromSuccess(channel); + } + + // Note: Channel not in cache, returning minimal object with ID only + return TypeConverterResult.FromSuccess(new Channel { Id = channelId, Name = value }); + } + // Developer note: Suggest channel mention format + return TypeConverterResult.FromError($"Unable to parse '{value}' as a channel ID. Provide a valid snowflake ID or mention the channel with #channel-name."); + } + } + + /// + /// Role converter (converts snowflake ID to Role entity). + /// + internal sealed class RoleConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var roleId)) + { + // Try to get role from cache + if (context.GuildId.HasValue) + { + var guild = context.Client.Cache.GetGuild(context.GuildId.Value); + if (guild != null && guild.Roles != null) + { + var role = guild.Roles.FirstOrDefault(r => r.Id == roleId); + if (role != null) + { + return TypeConverterResult.FromSuccess(role); + } + } + } + + // Note: Role not found in cache or missing guild context + return TypeConverterResult.FromSuccess(new Role { Id = roleId, Name = value }); + } + // Developer note: Suggest role mention format + return TypeConverterResult.FromError($"Unable to parse '{value}' as a role ID. Provide a valid snowflake ID or mention the role with @role-name. Note: Role conversion requires guild context."); + } + } + + /// + /// GuildMember converter (converts snowflake ID to GuildMember entity). + /// + internal sealed class GuildMemberConverter : SyncTypeConverter + { + protected override TypeConverterResult ConvertSync(string value, CommandContext context) + { + if (ulong.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var userId)) + { + // Try to get member from cache + if (context.GuildId.HasValue) + { + var member = context.Client.Cache.GetGuildMember(context.GuildId.Value, userId); + if (member != null) + { + return TypeConverterResult.FromSuccess(member); + } + + // Note: Guild member not in cache, falling back to user lookup + var user = context.Client.Cache.GetUser(userId); + if (user != null) + { + return TypeConverterResult.FromSuccess(new GuildMember { User = user }); + } + } + + // Developer note: Provide detailed failure explanation + return TypeConverterResult.FromError($"Unable to resolve guild member for user ID '{value}'. Ensure the user is in the guild and the guild member list is cached. Try mentioning the user (@username) instead."); } - return TypeConverterResult.FromError($"Unable to parse '{value}' as a user ID."); + // Developer note: Suggest user mention format + return TypeConverterResult.FromError($"Unable to parse '{value}' as a user ID. Provide a valid snowflake ID or mention the user with @username. Note: Guild member conversion requires guild context."); } } } diff --git a/src/PawSharp.Commands/Conversion/TypeConverter.cs b/src/PawSharp.Commands/Conversion/TypeConverter.cs index de15f31..d48b879 100644 --- a/src/PawSharp.Commands/Conversion/TypeConverter.cs +++ b/src/PawSharp.Commands/Conversion/TypeConverter.cs @@ -11,6 +11,8 @@ public interface ITypeConverter { } /// /// Defines a type converter for converting string arguments to specific types. +/// Type converters are used by the command framework to automatically convert +/// string command arguments into strongly-typed C# objects. /// /// The target type to convert to. public interface ITypeConverter : ITypeConverter @@ -19,13 +21,14 @@ public interface ITypeConverter : ITypeConverter /// Converts a string argument to the target type. /// /// The string value to convert. - /// The command context for the conversion. - /// A conversion result indicating success or failure. + /// The command context for the conversion, which can provide access to cache or client. + /// A conversion result indicating success or failure with the converted value or error message. Task> ConvertAsync(string value, CommandContext context); } /// /// Base class for type converters providing common functionality. +/// Inherit from this class to create custom type converters for command arguments. /// /// The target type to convert to. public abstract class TypeConverter : ITypeConverter @@ -35,7 +38,8 @@ public abstract class TypeConverter : ITypeConverter } /// -/// Synchronous type converter base class for simple conversions. +/// Synchronous type converter base class for simple conversions that don't require async operations. +/// Inherit from this class when your conversion logic is synchronous for better performance. /// /// The target type to convert to. public abstract class SyncTypeConverter : TypeConverter @@ -48,9 +52,10 @@ public sealed override Task> ConvertAsync(string value, C /// /// Synchronously converts a string argument to the target type. + /// Implement this method instead of for synchronous conversions. /// /// The string value to convert. - /// The command context for the conversion. - /// A conversion result indicating success or failure. + /// The command context for the conversion, which can provide access to cache or client. + /// A conversion result indicating success or failure with the converted value or error message. protected abstract TypeConverterResult ConvertSync(string value, CommandContext context); } diff --git a/src/PawSharp.Commands/Conversion/TypeConverterResult.cs b/src/PawSharp.Commands/Conversion/TypeConverterResult.cs index bd7e0c7..3cfc062 100644 --- a/src/PawSharp.Commands/Conversion/TypeConverterResult.cs +++ b/src/PawSharp.Commands/Conversion/TypeConverterResult.cs @@ -5,7 +5,7 @@ namespace PawSharp.Commands.Conversion; /// /// Represents the result of a type conversion operation. /// -/// The target type. +/// The target type that was attempted to convert to. public sealed class TypeConverterResult { /// @@ -14,12 +14,12 @@ public sealed class TypeConverterResult public bool IsSuccess { get; } /// - /// Gets the converted value when successful. + /// Gets the converted value when successful. Contains the default value of T when conversion failed. /// public T? Value { get; } /// - /// Gets the error message when the conversion failed. + /// Gets the error message when the conversion failed. Null when conversion succeeded. /// public string? ErrorMessage { get; } @@ -31,20 +31,20 @@ private TypeConverterResult(bool isSuccess, T? value, string? errorMessage) } /// - /// Creates a successful conversion result. + /// Creates a successful conversion result with the converted value. /// - /// The converted value. - /// A successful result. + /// The successfully converted value. + /// A successful conversion result. public static TypeConverterResult FromSuccess(T value) { return new TypeConverterResult(true, value, null); } /// - /// Creates a failed conversion result. + /// Creates a failed conversion result with an error message. /// - /// The error message describing the failure. - /// A failed result. + /// The error message describing why the conversion failed. + /// A failed conversion result. public static TypeConverterResult FromError(string errorMessage) { return new TypeConverterResult(false, default, errorMessage); diff --git a/src/PawSharp.Commands/Conversion/TypeConverterService.cs b/src/PawSharp.Commands/Conversion/TypeConverterService.cs index e79f69d..5b2f359 100644 --- a/src/PawSharp.Commands/Conversion/TypeConverterService.cs +++ b/src/PawSharp.Commands/Conversion/TypeConverterService.cs @@ -9,6 +9,8 @@ namespace PawSharp.Commands.Conversion; /// /// Service for managing and using type converters. +/// This service maintains a registry of type converters and handles the conversion +/// of string command arguments to strongly-typed C# objects. /// public class TypeConverterService { @@ -18,7 +20,7 @@ public class TypeConverterService /// /// Initializes a new instance of the class. /// - /// Optional logger. + /// Optional logger for diagnostic information. public TypeConverterService(ILogger? logger = null) { _logger = logger; @@ -27,9 +29,11 @@ public TypeConverterService(ILogger? logger = null) /// /// Registers a type converter for a specific type. + /// If a converter for the type already exists, it will be replaced. /// /// The type to convert to. - /// The converter instance. + /// The converter instance to register. + /// Thrown when is null. public void RegisterConverter(ITypeConverter converter) { if (converter == null) throw new ArgumentNullException(nameof(converter)); @@ -42,6 +46,11 @@ public void RegisterConverter(ITypeConverter converter) /// Uses reflection to determine the target type from the implemented generic interface. /// /// The converter instance implementing ITypeConverter{T}. + /// Thrown when is null. + /// + /// This method is useful when registering converters via dependency injection + /// where the generic type parameter is not known at compile time. + /// public void RegisterConverterFromInterface(ITypeConverter converter) { if (converter == null) throw new ArgumentNullException(nameof(converter)); @@ -62,6 +71,17 @@ public void RegisterConverterFromInterface(ITypeConverter converter) } } + /// + /// Registers a converter for a specific enum type. + /// + /// The enum type to register a converter for. + public void RegisterEnumConverter() where T : struct, Enum + { + var converter = new BuiltInConverters.GenericEnumConverter(); + _converters[typeof(T)] = converter; + _logger?.LogDebug("Registered enum converter for {Type}", typeof(T).Name); + } + /// /// Attempts to convert a string value to the specified type. /// @@ -102,18 +122,34 @@ public async Task> ConvertAsync(string value, CommandC /// A conversion result. public async Task ConvertAsync(Type targetType, string value, CommandContext context) { + // Handle nullable types by unwrapping to the underlying type + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + var method = typeof(TypeConverterService).GetMethod(nameof(ConvertAsync), 1, new[] { typeof(string), typeof(CommandContext) }); if (method == null) return null; - var genericMethod = method.MakeGenericMethod(targetType); + var genericMethod = method.MakeGenericMethod(underlyingType); var result = await (dynamic)genericMethod.Invoke(this, new object[] { value, context }); // Check if conversion was successful var isSuccessProp = result?.GetType().GetProperty("IsSuccess"); if (isSuccessProp != null && (bool)isSuccessProp.GetValue(result) == true) { - return result?.GetType().GetProperty("Value")?.GetValue(result); + var convertedValue = result?.GetType().GetProperty("Value")?.GetValue(result); + + // If the original type was nullable, wrap the converted value appropriately + if (underlyingType != targetType) + { + // For nullable reference types, the value is already correct + // For nullable value types, we need to handle the wrapping + if (targetType.IsValueType) + { + return convertedValue; + } + } + + return convertedValue; } // Conversion failed @@ -142,9 +178,21 @@ private void RegisterBuiltInConverters() RegisterConverter(new BuiltInConverters.BooleanConverter()); RegisterConverter(new BuiltInConverters.DoubleConverter()); RegisterConverter(new BuiltInConverters.FloatConverter()); + RegisterConverter(new BuiltInConverters.DecimalConverter()); RegisterConverter(new BuiltInConverters.DateTimeConverter()); + RegisterConverter(new BuiltInConverters.DateTimeOffsetConverter()); RegisterConverter(new BuiltInConverters.TimeSpanConverter()); + RegisterConverter(new BuiltInConverters.GuidConverter()); + RegisterConverter(new BuiltInConverters.UriConverter()); + RegisterConverter(new BuiltInConverters.SByteConverter()); + RegisterConverter(new BuiltInConverters.ByteConverter()); + RegisterConverter(new BuiltInConverters.Int16Converter()); + RegisterConverter(new BuiltInConverters.UInt16Converter()); + RegisterConverter(new BuiltInConverters.UInt32Converter()); RegisterConverter(new BuiltInConverters.UserConverter()); + RegisterConverter(new BuiltInConverters.ChannelConverter()); + RegisterConverter(new BuiltInConverters.RoleConverter()); + RegisterConverter(new BuiltInConverters.GuildMemberConverter()); _logger?.LogDebug("Registered {Count} built-in type converters", _converters.Count); } diff --git a/src/PawSharp.Commands/DI/CommandsBuilder.cs b/src/PawSharp.Commands/DI/CommandsBuilder.cs index b259320..b8d7922 100644 --- a/src/PawSharp.Commands/DI/CommandsBuilder.cs +++ b/src/PawSharp.Commands/DI/CommandsBuilder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using PawSharp.Commands.Conversion; using PawSharp.Commands.Middleware; using PawSharp.Client; @@ -9,141 +10,153 @@ namespace PawSharp.Commands.DependencyInjection; /// -/// Builder for configuring PawSharp.Commands with dependency injection. +/// Builder for configuring the commands extension with dependency injection. +/// Provides a fluent API for setting up command prefix, case sensitivity, middleware, and type converters. /// public class CommandsBuilder { - private readonly IServiceCollection _services; - private readonly CommandsOptions _options = new(); + private string _prefix = "!"; + private bool _caseSensitive = false; + private TimeSpan? _executionTimeout; + private bool _enableLoggingMiddleware = true; + private bool _enableAuditMiddleware = false; + private readonly List _customMiddleware = new(); + private readonly List _customConverters = new(); + private readonly ILogger? _logger; /// /// Initializes a new instance of the class. /// - /// The service collection. - public CommandsBuilder(IServiceCollection services) + /// Optional logger for diagnostic information. + public CommandsBuilder(ILogger? logger = null) { - _services = services ?? throw new ArgumentNullException(nameof(services)); + _logger = logger; } /// - /// Sets the command prefix. + /// Sets the command prefix used for prefix-based commands. /// - /// The prefix. - /// The builder for chaining. + /// The prefix string (default: "!"). + /// The builder instance for method chaining. public CommandsBuilder WithPrefix(string prefix) { - _options.Prefix = prefix; + _prefix = prefix; return this; } /// - /// Sets whether commands are case-sensitive. + /// Sets whether command names are case-sensitive. /// - /// Whether case-sensitive. - /// The builder for chaining. + /// True for case-sensitive, false for case-insensitive (default). + /// The builder instance for method chaining. public CommandsBuilder WithCaseSensitivity(bool caseSensitive) { - _options.CaseSensitive = caseSensitive; + _caseSensitive = caseSensitive; return this; } /// - /// Sets the command execution timeout. + /// Sets the execution timeout for commands. + /// Commands exceeding this duration will be cancelled. /// /// The timeout duration. - /// The builder for chaining. + /// The builder instance for method chaining. public CommandsBuilder WithExecutionTimeout(TimeSpan timeout) { - _options.ExecutionTimeout = timeout; + _executionTimeout = timeout; return this; } /// - /// Enables built-in logging middleware. + /// Enables or disables the built-in logging middleware. /// - /// The builder for chaining. - public CommandsBuilder WithLoggingMiddleware() + /// True to enable logging middleware (default: true). + /// The builder instance for method chaining. + public CommandsBuilder WithLoggingMiddleware(bool enable) { - _options.EnableLoggingMiddleware = true; + _enableLoggingMiddleware = enable; return this; } /// - /// Enables built-in audit middleware. + /// Enables or disables the built-in audit middleware. /// - /// The builder for chaining. - public CommandsBuilder WithAuditMiddleware() + /// True to enable audit middleware (default: false). + /// The builder instance for method chaining. + public CommandsBuilder WithAuditMiddleware(bool enable) { - _options.EnableAuditMiddleware = true; + _enableAuditMiddleware = enable; return this; } /// - /// Adds a custom middleware to the pipeline. + /// Adds a custom middleware to the command execution pipeline. /// - /// The middleware type. - /// The builder for chaining. - public CommandsBuilder WithMiddleware() - where TMiddleware : class, IMiddleware + /// The middleware instance to add. + /// The builder instance for method chaining. + /// Thrown when is null. + public CommandsBuilder AddMiddleware(IMiddleware middleware) { - _services.AddScoped(); + _customMiddleware.Add(middleware); return this; } /// - /// Registers a custom type converter. + /// Adds a custom type converter for converting command arguments. /// - /// The converter type. - /// The builder for chaining. - public CommandsBuilder WithTypeConverter() - where TConverter : class, ITypeConverter + /// The type converter instance to add. + /// The builder instance for method chaining. + /// Thrown when is null. + public CommandsBuilder AddConverter(ITypeConverter converter) { - _services.AddSingleton(); + _customConverters.Add(converter); return this; } /// - /// Builds the commands extension with the configured services. + /// Builds the configuration and registers all services with the dependency injection container. /// - /// The service collection for chaining. - public IServiceCollection Build() + /// The service collection to register services with. + /// Thrown when is null. + public void Build(IServiceCollection services) { - _services.AddSingleton(_options); - _services.AddSingleton(); + services.AddSingleton(sp => new TypeConverterService(sp.GetService>())); + services.AddSingleton(new MiddlewarePipeline()); - // Register TypeConverterService with DI-registered converters - _services.AddSingleton(sp => + foreach (var converter in _customConverters) { - var logger = sp.GetService>(); - var service = new TypeConverterService(logger); - - // Get all DI-registered custom type converters - var customConverters = sp.GetServices(); - foreach (var converter in customConverters) - { - service.RegisterConverterFromInterface(converter); - } - - return service; - }); + var typeConverterService = services.BuildServiceProvider().GetRequiredService(); + typeConverterService.RegisterConverterFromInterface(converter); + } - if (_options.EnableLoggingMiddleware) + var pipeline = services.BuildServiceProvider().GetRequiredService(); + if (_enableLoggingMiddleware) { - _services.AddCommandMiddleware(); + pipeline.Use(new BuiltInMiddleware.LoggingMiddleware( + services.BuildServiceProvider().GetRequiredService>())); } - - if (_options.EnableAuditMiddleware) + if (_enableAuditMiddleware) { - _services.AddCommandMiddleware(); + pipeline.Use(new BuiltInMiddleware.AuditMiddleware( + services.BuildServiceProvider().GetRequiredService>())); + } + if (_executionTimeout.HasValue) + { + pipeline.Use(new BuiltInMiddleware.TimeoutMiddleware( + _executionTimeout.Value, + services.BuildServiceProvider().GetRequiredService>())); } - if (_options.ExecutionTimeout > TimeSpan.Zero) + foreach (var middleware in _customMiddleware) { - _services.AddSingleton(sp => new Middleware.BuiltInMiddleware.TimeoutMiddleware( - _options.ExecutionTimeout, - sp.GetRequiredService>())); + pipeline.Use(middleware); } - return _services; + services.AddSingleton(new CommandsOptions + { + Prefix = _prefix, + CaseSensitive = _caseSensitive, + ExecutionTimeout = _executionTimeout ?? TimeSpan.FromMinutes(5) + }); } } diff --git a/src/PawSharp.Commands/Help/HelpCommand.cs b/src/PawSharp.Commands/Help/HelpCommand.cs index 0a41af7..850c98b 100644 --- a/src/PawSharp.Commands/Help/HelpCommand.cs +++ b/src/PawSharp.Commands/Help/HelpCommand.cs @@ -19,15 +19,26 @@ public static class HelpCommand /// /// The registered commands. /// The command prefix. - /// A formatted help message. - public static string GenerateHelp(IReadOnlyList commands, string prefix = "!") + /// The page number (1-indexed). + /// The number of commands per page. + /// A formatted help message with pagination info. + public static string GenerateHelp(IReadOnlyList commands, string prefix = "!", int page = 1, int pageSize = 10) { var sb = new StringBuilder(); - sb.AppendLine("📚 **Available Commands**\n"); - var grouped = commands.GroupBy(c => c.Name.Split(' ')[0]); // Group by base command name + var grouped = commands.GroupBy(c => c.Name.Split(' ')[0]).OrderBy(g => g.Key).ToList(); + var totalPages = (int)Math.Ceiling((double)grouped.Count / pageSize); + + if (page < 1 || page > totalPages) + { + page = 1; + } + + var pageCommands = grouped.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + sb.AppendLine($"📚 **Available Commands** (Page {page}/{totalPages})\n"); - foreach (var group in grouped.OrderBy(g => g.Key)) + foreach (var group in pageCommands) { var command = group.First(); sb.AppendLine($"**{prefix}{command.Name}**"); @@ -45,6 +56,11 @@ public static string GenerateHelp(IReadOnlyList commands, string pr sb.AppendLine(); } + if (totalPages > 1) + { + sb.AppendLine($"*Use `{prefix}help {page + 1}` for the next page or `{prefix}help {page - 1}` for the previous page.*"); + } + return sb.ToString(); } @@ -68,6 +84,27 @@ public static string GenerateCommandHelp(CommandInfo command, string prefix = "! { sb.AppendLine($"**Aliases:** {string.Join(", ", command.Aliases.Select(a => $"{prefix}{a}"))}\n"); } + + if (command.Parameters != null && command.Parameters.Any()) + { + sb.AppendLine("**Parameters:**"); + foreach (var param in command.Parameters) + { + var required = param.IsRequired ? "(required)" : "(optional)"; + sb.AppendLine($" • `{param.Name}` {required}: {param.Description ?? "No description"}"); + } + sb.AppendLine(); + } + + if (command.Preconditions != null && command.Preconditions.Any()) + { + sb.AppendLine("**Requirements:**"); + foreach (var precondition in command.Preconditions) + { + sb.AppendLine($" • {precondition}"); + } + sb.AppendLine(); + } return sb.ToString(); } @@ -94,20 +131,21 @@ public HelpModule(CommandsExtension commandsExtension) /// [Command("help")] [Description("Shows help for commands")] - public async Task HelpAsync(CommandContext ctx, [Optional] string? commandName = null) + public async Task HelpAsync(CommandContext ctx, [Optional] string? commandName = null, [Optional] int? page = null) { var commands = _commandsExtension.GetRegisteredCommands(); + var stringComparison = StringComparison.OrdinalIgnoreCase; if (string.IsNullOrEmpty(commandName)) { - var helpMessage = HelpCommand.GenerateHelp(commands, ctx.Prefix); + var helpMessage = HelpCommand.GenerateHelp(commands, ctx.Prefix, page ?? 1); await ctx.RespondAsync(helpMessage); } else { var command = commands.FirstOrDefault(c => - c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase) || - c.Aliases.Any(a => a.Equals(commandName, StringComparison.OrdinalIgnoreCase))); + c.Name.Equals(commandName, stringComparison) || + c.Aliases.Any(a => a.Equals(commandName, stringComparison))); if (command == null) { diff --git a/src/PawSharp.Commands/Middleware/IMiddleware.cs b/src/PawSharp.Commands/Middleware/IMiddleware.cs index b4c238f..f373615 100644 --- a/src/PawSharp.Commands/Middleware/IMiddleware.cs +++ b/src/PawSharp.Commands/Middleware/IMiddleware.cs @@ -4,15 +4,22 @@ namespace PawSharp.Commands.Middleware; /// -/// Defines middleware that can intercept and modify command execution. +/// Interface for command middleware. +/// Middleware can be used to add cross-cutting concerns such as logging, +/// authentication, rate limiting, or performance monitoring to command execution. /// public interface IMiddleware { /// - /// Executes the middleware logic before the command. + /// Invokes the middleware, potentially wrapping the next middleware in the pipeline. /// - /// The command context. - /// The next middleware or command in the pipeline. + /// The command context containing information about the command being executed. + /// A delegate representing the next middleware or the command itself. /// A task representing the asynchronous operation. + /// + /// Call to continue execution to the next middleware or command. + /// You can add logic before and after calling to implement + /// pre- and post-processing behavior. + /// Task InvokeAsync(CommandContext context, Func next); } diff --git a/src/PawSharp.Commands/Middleware/MiddlewarePipeline.cs b/src/PawSharp.Commands/Middleware/MiddlewarePipeline.cs index cd10c91..3bdc789 100644 --- a/src/PawSharp.Commands/Middleware/MiddlewarePipeline.cs +++ b/src/PawSharp.Commands/Middleware/MiddlewarePipeline.cs @@ -8,6 +8,8 @@ namespace PawSharp.Commands.Middleware; /// /// Manages the execution pipeline for command middleware. +/// Middleware are executed in the order they are added, with each middleware able to +/// wrap the next one in the chain. /// public class MiddlewarePipeline { @@ -16,8 +18,9 @@ public class MiddlewarePipeline /// /// Adds a middleware to the pipeline. /// - /// The middleware to add. - /// The pipeline for chaining. + /// The middleware to add to the pipeline. + /// The pipeline instance for method chaining. + /// Thrown when is null. public MiddlewarePipeline Use(IMiddleware middleware) { if (middleware == null) throw new ArgumentNullException(nameof(middleware)); @@ -26,11 +29,15 @@ public MiddlewarePipeline Use(IMiddleware middleware) } /// - /// Executes the middleware pipeline. + /// Executes the middleware pipeline, running each middleware in order before the command. /// - /// The command context. - /// The command delegate to execute after middleware. + /// The command context containing information about the command being executed. + /// The command delegate to execute after all middleware have run. /// A task representing the asynchronous operation. + /// + /// Middleware are executed in the order they were added. Each middleware can choose + /// to call the next middleware in the chain or short-circuit the pipeline. + /// public async Task ExecuteAsync(CommandContext context, Func command) { // Build the pipeline in reverse order @@ -47,7 +54,7 @@ public async Task ExecuteAsync(CommandContext context, Func command) } /// - /// Gets the number of middleware in the pipeline. + /// Gets the number of middleware currently registered in the pipeline. /// public int Count => _middlewares.Count; } diff --git a/src/PawSharp.Commands/Preconditions/CooldownAttribute.cs b/src/PawSharp.Commands/Preconditions/CooldownAttribute.cs index 8c035bb..a4588ae 100644 --- a/src/PawSharp.Commands/Preconditions/CooldownAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/CooldownAttribute.cs @@ -32,6 +32,9 @@ public sealed class CooldownAttribute : Attribute, IPrecondition public CooldownBucketType BucketType { get; } private readonly ConcurrentDictionary _buckets = new(); + private readonly object _cleanupLock = new(); + private DateTimeOffset _lastCleanup = DateTimeOffset.UtcNow; + private const int CleanupIntervalSeconds = 300; // Clean up every 5 minutes /// /// Initialises the attribute. @@ -56,24 +59,73 @@ public Task CheckAsync(CommandContext ctx) var now = DateTimeOffset.UtcNow; var bucket = _buckets.GetOrAdd(key, _ => new BucketState(now)); - lock (bucket) + try { - // Reset the bucket if the window has expired - if (now - bucket.WindowStart >= Per) + lock (bucket) { - bucket.WindowStart = now; - bucket.InvocationCount = 0; + // Reset the bucket if the window has expired + if (now - bucket.WindowStart >= Per) + { + bucket.WindowStart = now; + bucket.InvocationCount = 0; + } + + if (bucket.InvocationCount < MaxUses) + { + bucket.InvocationCount++; + return Task.FromResult(PreconditionResult.FromSuccess()); + } + + var remaining = Per - (now - bucket.WindowStart); + return Task.FromResult(PreconditionResult.FromError( + $"You are on cooldown. Try again in {remaining.TotalSeconds:F1} second(s).")); } + } + finally + { + // Periodically clean up expired buckets to prevent memory leaks + if (now - _lastCleanup >= TimeSpan.FromSeconds(CleanupIntervalSeconds)) + { + CleanupExpiredBuckets(now); + } + } + } + + private void CleanupExpiredBuckets(DateTimeOffset now) + { + // Use a lock to prevent multiple concurrent cleanups + if (!Monitor.TryEnter(_cleanupLock)) + return; - if (bucket.InvocationCount < MaxUses) + try + { + // Double-check after acquiring lock + if (now - _lastCleanup < TimeSpan.FromSeconds(CleanupIntervalSeconds)) + return; + + var expiredKeys = new List(); + foreach (var kvp in _buckets) { - bucket.InvocationCount++; - return Task.FromResult(PreconditionResult.FromSuccess()); + lock (kvp.Value) + { + // Remove buckets that haven't been used for 3x the cooldown period + if (now - kvp.Value.WindowStart > Per * 3) + { + expiredKeys.Add(kvp.Key); + } + } } - var remaining = Per - (now - bucket.WindowStart); - return Task.FromResult(PreconditionResult.FromError( - $"You are on cooldown. Try again in {remaining.TotalSeconds:F1} second(s).")); + foreach (var key in expiredKeys) + { + _buckets.TryRemove(key, out _); + } + + _lastCleanup = now; + } + finally + { + Monitor.Exit(_cleanupLock); } } diff --git a/src/PawSharp.Commands/Preconditions/IPrecondition.cs b/src/PawSharp.Commands/Preconditions/IPrecondition.cs index 8fce5ad..1c78500 100644 --- a/src/PawSharp.Commands/Preconditions/IPrecondition.cs +++ b/src/PawSharp.Commands/Preconditions/IPrecondition.cs @@ -4,14 +4,14 @@ namespace PawSharp.Commands.Preconditions; /// -/// Defines a check that must pass before a command is executed. -/// Apply derived attributes to command methods; evaluates -/// every attribute present before invoking the handler. +/// Interface for command precondition checks. +/// Preconditions are evaluated before command execution and can block execution +/// based on custom logic such as permissions, cooldowns, or user state. /// public interface IPrecondition { /// - /// Evaluates the precondition for the given command context. + /// Checks whether the command can be executed in the given context. /// /// The context of the command being invoked. /// diff --git a/src/PawSharp.Commands/Preconditions/PreconditionFailedException.cs b/src/PawSharp.Commands/Preconditions/PreconditionFailedException.cs index f5844a5..b052ff3 100644 --- a/src/PawSharp.Commands/Preconditions/PreconditionFailedException.cs +++ b/src/PawSharp.Commands/Preconditions/PreconditionFailedException.cs @@ -4,14 +4,17 @@ namespace PawSharp.Commands.Preconditions; /// -/// Thrown when a command precondition check fails. -/// Delivered to so the bot can respond with -/// the specific (e.g. "You are on cooldown. Try again in 3.2 second(s)."). +/// Exception thrown when a precondition check fails. +/// This exception is used to surface precondition failures through the +/// event handler. /// public sealed class PreconditionFailedException : Exception { /// - /// Initialises a new instance with the precondition failure description. + /// Initializes a new instance of the class. /// - public PreconditionFailedException(string message) : base(message) { } + /// The error message describing why the precondition failed. + public PreconditionFailedException(string message) : base(message) + { + } } diff --git a/src/PawSharp.Commands/Preconditions/PreconditionResult.cs b/src/PawSharp.Commands/Preconditions/PreconditionResult.cs index 4163f2c..651780d 100644 --- a/src/PawSharp.Commands/Preconditions/PreconditionResult.cs +++ b/src/PawSharp.Commands/Preconditions/PreconditionResult.cs @@ -3,7 +3,7 @@ namespace PawSharp.Commands.Preconditions; /// -/// The result of a precondition check. +/// Represents the result of a precondition check. /// public sealed class PreconditionResult { @@ -13,25 +13,34 @@ public sealed class PreconditionResult public bool IsSuccess { get; } /// - /// Gets the error message when is ; otherwise . + /// Gets the error message if the check failed. /// public string? ErrorMessage { get; } private PreconditionResult(bool isSuccess, string? errorMessage) { - IsSuccess = isSuccess; + IsSuccess = isSuccess; ErrorMessage = errorMessage; } /// - /// Returns a successful result. + /// Creates a successful precondition result indicating the command can proceed. /// - public static PreconditionResult FromSuccess() => new(true, null); + /// A successful precondition result. + public static PreconditionResult FromSuccess() + { + return new PreconditionResult(true, null); + } /// - /// Returns a failed result with . + /// Creates a failed precondition result with an error message. /// - public static PreconditionResult FromError(string errorMessage) => new(false, errorMessage); + /// The error message describing why the precondition failed. + /// A failed precondition result. + public static PreconditionResult FromError(string errorMessage) + { + return new PreconditionResult(false, errorMessage); + } /// public override string ToString() diff --git a/src/PawSharp.Commands/Preconditions/RequireBotPermissionAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireBotPermissionAttribute.cs new file mode 100644 index 0000000..556d508 --- /dev/null +++ b/src/PawSharp.Commands/Preconditions/RequireBotPermissionAttribute.cs @@ -0,0 +1,198 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using PawSharp.Core.Entities; + +namespace PawSharp.Commands.Preconditions; + +/// +/// Restricts a command so it can only be executed when the bot has the specified permissions. +/// Apply this attribute to a command method or class to ensure the bot has the required +/// permissions before executing the command. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] +public sealed class RequireBotPermissionAttribute : Attribute, IPrecondition +{ + /// + /// Gets the required permissions for the bot. + /// + public ulong RequiredPermissions { get; } + + /// + /// Gets or sets whether to ignore the bot's administrator permission bypass. + /// Defaults to . + /// + public bool IgnoreAdmins { get; set; } = false; + + /// + /// Initializes a new instance of the class. + /// + /// The required permissions for the bot. + public RequireBotPermissionAttribute(ulong requiredPermissions) + { + RequiredPermissions = requiredPermissions; + } + + /// + public async Task CheckAsync(CommandContext ctx) + { + // Must be in a guild + if (!ctx.GuildId.HasValue) + return PreconditionResult.FromError( + "This command can only be used inside a server."); + + var guildId = ctx.GuildId.Value; + + var guild = ctx.Client.Cache.GetGuild(guildId) + ?? await ctx.Client.Rest.GetGuildAsync(guildId); + + if (guild == null) + { + return PreconditionResult.FromError("Unable to resolve guild data for permission checks."); + } + + // Check if bot is the guild owner (bot should never be owner, but handle edge case) + if (guild.OwnerId == ctx.Client.CurrentUser?.Id) + { + return PreconditionResult.FromSuccess(); + } + + var botMember = ctx.Client.Cache.GetGuildMember(guildId, ctx.Client.CurrentUser?.Id ?? 0) + ?? await ctx.Client.Rest.GetGuildMemberAsync(guildId, ctx.Client.CurrentUser?.Id ?? 0); + + if (botMember is null) + { + return PreconditionResult.FromError("Unable to resolve bot's guild member permissions."); + } + + var permissionsResult = await ResolveBotPermissionsAsync(ctx, guild, botMember); + if (permissionsResult == null) + { + return PreconditionResult.FromError("Unable to resolve bot's guild member permissions."); + } + + var effectivePermissions = permissionsResult.Value; + var adminBit = (ulong)PawSharp.Core.Enums.Permissions.Administrator; + + if (IgnoreAdmins) + { + if ((effectivePermissions & adminBit) == adminBit) + return PreconditionResult.FromSuccess(); + } + + return (effectivePermissions & RequiredPermissions) == RequiredPermissions + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("The bot does not have the required permissions to run this command."); + } + + private static async Task ResolveBotPermissionsAsync(CommandContext ctx, Guild guild, GuildMember botMember) + { + if (botMember.Permissions.HasValue) + { + return (ulong)botMember.Permissions.Value; + } + + var channel = ctx.Client.Cache.GetChannel(ctx.ChannelId) + ?? await ctx.Client.Rest.GetChannelAsync(ctx.ChannelId); + + if (channel?.Permissions.HasValue == true) + { + return (ulong)channel.Permissions.Value; + } + + var basePermissions = ComputeBasePermissions(guild, botMember, ctx.Client.CurrentUser?.Id ?? 0); + if (basePermissions == null) + { + return null; + } + + if (channel == null) + { + return basePermissions; + } + + return ApplyChannelOverwrites(basePermissions.Value, channel, botMember, ctx.Client.CurrentUser?.Id ?? 0, guild.Id); + } + + private static ulong? ComputeBasePermissions(Guild guild, GuildMember member, ulong botId) + { + if (guild.Roles == null || guild.Roles.Count == 0) + { + return null; + } + + var everyoneRole = guild.Roles.FirstOrDefault(r => r.Id == guild.Id); + var permissions = ParsePermissions(everyoneRole?.Permissions) ?? 0; + + foreach (var roleId in member.Roles ?? new List()) + { + var role = guild.Roles.FirstOrDefault(r => r.Id == roleId); + if (role != null) + { + permissions |= ParsePermissions(role.Permissions) ?? 0; + } + } + + return permissions; + } + + private static ulong ApplyChannelOverwrites(ulong basePermissions, Channel channel, GuildMember member, ulong botId, ulong guildId) + { + var permissions = basePermissions; + + // Apply @everyone overwrites + var everyoneOverwrite = channel.PermissionOverwrites?.FirstOrDefault(o => o.Id == guildId); + if (everyoneOverwrite != null) + { + var deny = ParsePermissions(everyoneOverwrite.Deny) ?? 0; + var allow = ParsePermissions(everyoneOverwrite.Allow) ?? 0; + permissions &= ~deny; + permissions |= allow; + } + + // Apply role overwrites + if (member.Roles != null) + { + foreach (var roleId in member.Roles) + { + var roleOverwrite = channel.PermissionOverwrites?.FirstOrDefault(o => o.Id == roleId); + if (roleOverwrite != null) + { + var deny = ParsePermissions(roleOverwrite.Deny) ?? 0; + var allow = ParsePermissions(roleOverwrite.Allow) ?? 0; + permissions &= ~deny; + permissions |= allow; + } + } + } + + // Apply member-specific overwrites + var memberOverwrite = channel.PermissionOverwrites?.FirstOrDefault(o => o.Id == botId); + if (memberOverwrite != null) + { + var deny = ParsePermissions(memberOverwrite.Deny) ?? 0; + var allow = ParsePermissions(memberOverwrite.Allow) ?? 0; + permissions &= ~deny; + permissions |= allow; + } + + return permissions; + } + + private static ulong ParsePermissions(ulong? permissions) + { + return permissions ?? 0; + } + + private static ulong? ParsePermissions(string? permissions) + { + if (string.IsNullOrEmpty(permissions)) + return null; + + if (ulong.TryParse(permissions, out var result)) + return result; + + return null; + } +} diff --git a/src/PawSharp.Commands/Preconditions/RequireChannelAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireChannelAttribute.cs index c326d3a..8c694c2 100644 --- a/src/PawSharp.Commands/Preconditions/RequireChannelAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireChannelAttribute.cs @@ -20,6 +20,8 @@ public sealed class RequireChannelAttribute : Attribute, IPrecondition /// Initializes a new instance of the class. /// /// The channel IDs where the command is allowed. + /// Thrown when is null. + /// Thrown when is empty. public RequireChannelAttribute(params ulong[] channelIds) { ChannelIds = channelIds ?? throw new ArgumentNullException(nameof(channelIds)); diff --git a/src/PawSharp.Commands/Preconditions/RequireDmAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireDmAttribute.cs index 3a644d8..ae95de5 100644 --- a/src/PawSharp.Commands/Preconditions/RequireDmAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireDmAttribute.cs @@ -6,6 +6,8 @@ namespace PawSharp.Commands.Preconditions; /// /// Restricts a command so it can only be executed in DMs, not in guilds. +/// Apply this attribute to a command method or class to ensure the command +/// can only be used in direct messages with the bot. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequireDmAttribute : Attribute, IPrecondition diff --git a/src/PawSharp.Commands/Preconditions/RequireGuildAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireGuildAttribute.cs index e4a8ea4..01fc887 100644 --- a/src/PawSharp.Commands/Preconditions/RequireGuildAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireGuildAttribute.cs @@ -6,6 +6,8 @@ namespace PawSharp.Commands.Preconditions; /// /// Restricts a command so it can only be executed inside a guild (server), not in DMs. +/// Apply this attribute to a command method or class to ensure the command +/// can only be used within Discord servers. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequireGuildAttribute : Attribute, IPrecondition diff --git a/src/PawSharp.Commands/Preconditions/RequireNsfwAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireNsfwAttribute.cs index 054c959..60d591f 100644 --- a/src/PawSharp.Commands/Preconditions/RequireNsfwAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireNsfwAttribute.cs @@ -6,6 +6,8 @@ namespace PawSharp.Commands.Preconditions; /// /// Restricts a command so it can only be executed in NSFW channels. +/// Apply this attribute to a command method or class to ensure the command +/// can only be used in channels marked as NSFW (age-restricted). /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequireNsfwAttribute : Attribute, IPrecondition diff --git a/src/PawSharp.Commands/Preconditions/RequireOwnerAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireOwnerAttribute.cs index e2fde96..2460645 100644 --- a/src/PawSharp.Commands/Preconditions/RequireOwnerAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireOwnerAttribute.cs @@ -6,6 +6,8 @@ namespace PawSharp.Commands.Preconditions; /// /// Restricts a command so it can only be executed by the bot owner. +/// Apply this attribute to a command method or class to ensure only the specified +/// bot owner can execute the command. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequireOwnerAttribute : Attribute, IPrecondition @@ -15,7 +17,7 @@ public sealed class RequireOwnerAttribute : Attribute, IPrecondition /// /// Initializes a new instance of the class. /// - /// The owner's user ID. + /// The owner's Discord user ID. public RequireOwnerAttribute(ulong ownerId) { _ownerId = ownerId; diff --git a/src/PawSharp.Commands/Preconditions/RequirePermissionsAttribute.cs b/src/PawSharp.Commands/Preconditions/RequirePermissionsAttribute.cs index c089579..a610512 100644 --- a/src/PawSharp.Commands/Preconditions/RequirePermissionsAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequirePermissionsAttribute.cs @@ -49,7 +49,31 @@ public async Task CheckAsync(CommandContext ctx) "This command can only be used inside a server."); var guildId = ctx.GuildId.Value; + var cacheKey = (guildId, ctx.User.Id, ctx.ChannelId); + var now = DateTimeOffset.UtcNow; + // Check cache first + lock (_cacheLock) + { + if (_permissionCache.TryGetValue(cacheKey, out var cached) && cached.expiry > now) + { + var cachedPermissions = cached.permissions; + var cachedAdminBit = (ulong)PawSharp.Core.Enums.Permissions.Administrator; + + if (IgnoreAdmins) + { + var cachedGuild = ctx.Client.Cache.GetGuild(guildId); + if (cachedGuild != null && (cachedGuild.OwnerId == ctx.User.Id || (cachedPermissions & cachedAdminBit) == cachedAdminBit)) + return PreconditionResult.FromSuccess(); + } + + return (cachedPermissions & RequiredPermissions) == RequiredPermissions + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("You do not have the required permissions to run this command."); + } + } + + // Cache miss - fetch from API var guild = ctx.Client.Cache.GetGuild(guildId) ?? await ctx.Client.Rest.GetGuildAsync(guildId); @@ -76,6 +100,26 @@ public async Task CheckAsync(CommandContext ctx) var effectivePermissions = permissionsResult.Value; var adminBit = (ulong)PawSharp.Core.Enums.Permissions.Administrator; + // Cache the result + lock (_cacheLock) + { + _permissionCache[cacheKey] = (effectivePermissions, now.Add(CacheTtl)); + + // Periodic cleanup of expired cache entries + if (_permissionCache.Count > 1000) + { + var expiredKeys = _permissionCache + .Where(kvp => kvp.Value.expiry <= now) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + _permissionCache.Remove(key); + } + } + } + if (IgnoreAdmins) { if (guild.OwnerId == ctx.User.Id || (effectivePermissions & adminBit) == adminBit) @@ -116,6 +160,12 @@ public async Task CheckAsync(CommandContext ctx) return ApplyChannelOverwrites(basePermissions.Value, channel, member, ctx.User.Id, guild.Id); } + // Permission cache with TTL to reduce API calls + private static readonly Dictionary<(ulong guildId, ulong userId, ulong channelId), (ulong permissions, DateTimeOffset expiry)> + _permissionCache = new(); + private static readonly object _cacheLock = new(); + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + private static ulong? ComputeBasePermissions(Guild guild, GuildMember member, ulong userId) { if (guild.Roles == null || guild.Roles.Count == 0) diff --git a/src/PawSharp.Commands/Preconditions/RequireRoleAttribute.cs b/src/PawSharp.Commands/Preconditions/RequireRoleAttribute.cs index edeb03c..6d335de 100644 --- a/src/PawSharp.Commands/Preconditions/RequireRoleAttribute.cs +++ b/src/PawSharp.Commands/Preconditions/RequireRoleAttribute.cs @@ -8,6 +8,8 @@ namespace PawSharp.Commands.Preconditions; /// /// Restricts a command to users who have at least one of the specified roles. +/// Apply this attribute to a command method or class to ensure only users with +/// the specified role(s) can execute the command. /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] public sealed class RequireRoleAttribute : Attribute, IPrecondition @@ -20,7 +22,9 @@ public sealed class RequireRoleAttribute : Attribute, IPrecondition /// /// Initializes a new instance of the class. /// - /// The role IDs that are required. + /// The role IDs that are required. The user must have at least one of these roles. + /// Thrown when is null. + /// Thrown when is empty. public RequireRoleAttribute(params ulong[] roleIds) { RoleIds = roleIds ?? throw new ArgumentNullException(nameof(roleIds)); diff --git a/src/PawSharp.Commands/Utilities/SlashCommandValidator.cs b/src/PawSharp.Commands/Utilities/SlashCommandValidator.cs new file mode 100644 index 0000000..da54d47 --- /dev/null +++ b/src/PawSharp.Commands/Utilities/SlashCommandValidator.cs @@ -0,0 +1,96 @@ +#nullable enable +using System; +using System.Text.RegularExpressions; + +namespace PawSharp.Commands.Utilities; + +/// +/// Validator for Discord slash command naming conventions. +/// +public static class SlashCommandValidator +{ + private static readonly Regex ValidNameRegex = new(@"^[a-z0-9_-]{1,32}$", RegexOptions.Compiled); + private static readonly Regex ValidDescriptionRegex = new(@"^.{1,100}$", RegexOptions.Compiled); + + /// + /// Validates a slash command name according to Discord's naming rules. + /// + /// The command name to validate. + /// A validation result indicating success or failure with an error message. + public static (bool IsValid, string? ErrorMessage) ValidateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return (false, "Command name cannot be empty."); + } + + if (name.Length > 32) + { + return (false, "Command name cannot exceed 32 characters."); + } + + if (!ValidNameRegex.IsMatch(name)) + { + return (false, "Command name must match the regex '^[a-z0-9_-]{1,32}$' (lowercase alphanumeric, underscores, and hyphens only)."); + } + + if (name.StartsWith("-") || name.StartsWith("_")) + { + return (false, "Command name cannot start with a hyphen or underscore."); + } + + if (name.Contains(" ") || name.Contains("..")) + { + return (false, "Command name cannot contain spaces or consecutive periods."); + } + + return (true, null); + } + + /// + /// Validates a slash command description according to Discord's rules. + /// + /// The description to validate. + /// A validation result indicating success or failure with an error message. + public static (bool IsValid, string? ErrorMessage) ValidateDescription(string description) + { + if (string.IsNullOrWhiteSpace(description)) + { + return (false, "Description cannot be empty."); + } + + if (description.Length > 100) + { + return (false, "Description cannot exceed 100 characters."); + } + + if (!ValidDescriptionRegex.IsMatch(description)) + { + return (false, "Description must be between 1 and 100 characters."); + } + + return (true, null); + } + + /// + /// Validates a slash command option name according to Discord's rules. + /// + /// The option name to validate. + /// A validation result indicating success or failure with an error message. + public static (bool IsValid, string? ErrorMessage) ValidateOptionName(string name) + { + // Option names follow the same rules as command names + return ValidateName(name); + } + + /// + /// Validates a slash command option description according to Discord's rules. + /// + /// The option description to validate. + /// A validation result indicating success or failure with an error message. + public static (bool IsValid, string? ErrorMessage) ValidateOptionDescription(string description) + { + // Option descriptions follow the same rules as command descriptions + return ValidateDescription(description); + } +} diff --git a/src/PawSharp.Core/Builders/CommandBuilder.cs b/src/PawSharp.Core/Builders/CommandBuilder.cs index c8213c1..514fddc 100644 --- a/src/PawSharp.Core/Builders/CommandBuilder.cs +++ b/src/PawSharp.Core/Builders/CommandBuilder.cs @@ -11,6 +11,20 @@ namespace PawSharp.Core.Builders; /// /// Fluent builder for constructing Discord application commands with validation. /// +/// +/// +/// var command = new CommandBuilder() +/// .WithType(ApplicationCommandType.ChatInput) +/// .WithName("greet") +/// .WithDescription("Greets a user") +/// .AddOption(opt => opt +/// .WithType(ApplicationCommandOptionType.String) +/// .WithName("message") +/// .WithDescription("The message to send") +/// .WithRequired(true)) +/// .Build(); +/// +/// public class CommandBuilder { private ApplicationCommandType _type = ApplicationCommandType.ChatInput; diff --git a/src/PawSharp.Core/Builders/ComponentBuilder.cs b/src/PawSharp.Core/Builders/ComponentBuilder.cs index a92e9f0..033db66 100644 --- a/src/PawSharp.Core/Builders/ComponentBuilder.cs +++ b/src/PawSharp.Core/Builders/ComponentBuilder.cs @@ -954,7 +954,7 @@ public class SectionBuilder { private readonly List _components = new(); private MessageComponent? _accessory; - + /// /// Adds a TextDisplay to the section. /// @@ -967,7 +967,18 @@ public SectionBuilder AddTextDisplay(Action configure) _components.Add(builder.Build()); return this; } - + + /// + /// Adds a TextDisplay with content directly. + /// + /// The text content (max 4000 characters). + /// The builder for method chaining. + public SectionBuilder AddText(string content) + { + _components.Add(new TextDisplay { Content = content }); + return this; + } + /// /// Sets the accessory (button, select menu, or thumbnail). /// @@ -978,13 +989,79 @@ public SectionBuilder WithAccessory(MessageComponent accessory) _accessory = accessory; return this; } - + + /// + /// Sets a button as the accessory using a ButtonBuilder. + /// + /// Action to configure the button. + /// The builder for method chaining. + public SectionBuilder WithButtonAccessory(Action configure) + { + var builder = new ButtonBuilder(); + configure(builder); + _accessory = builder.Build(); + return this; + } + + /// + /// Sets a thumbnail as the accessory using a ThumbnailBuilder. + /// + /// Action to configure the thumbnail. + /// The builder for method chaining. + public SectionBuilder WithThumbnailAccessory(Action configure) + { + var builder = new ThumbnailBuilder(""); + configure(builder); + _accessory = builder.Build(); + return this; + } + + /// + /// Sets a thumbnail as the accessory directly from a URL. + /// + /// The thumbnail URL. + /// Optional alt text. + /// Whether the thumbnail is a spoiler. + /// The builder for method chaining. + public SectionBuilder WithThumbnailAccessory(string url, string? description = null, bool spoiler = false) + { + _accessory = new ThumbnailBuilder(url) + .WithDescription(description) + .WithSpoiler(spoiler) + .Build(); + return this; + } + /// /// Builds the Section. /// /// The Section component. + /// Thrown when validation fails. public Section Build() { + // Validate that all components are TextDisplay + foreach (var component in _components) + { + if (component is not TextDisplay) + { + throw new ValidationException( + "Section can only contain TextDisplay components.", + nameof(_components), + null + ); + } + } + + // Validate accessory if present + if (_accessory != null && _accessory is not Button && _accessory is not ThumbnailComponent) + { + throw new ValidationException( + "Section accessory must be a Button or Thumbnail component.", + nameof(_accessory), + null + ); + } + return new Section { Components = new List(_components), @@ -1040,7 +1117,8 @@ public TextDisplay Build() public class SeparatorBuilder { private SeparatorSpacing _spacing = SeparatorSpacing.Small; - + private bool _divider = true; + /// /// Sets the spacing size. /// @@ -1051,7 +1129,18 @@ public SeparatorBuilder WithSpacing(SeparatorSpacing spacing) _spacing = spacing; return this; } - + + /// + /// Sets whether to show a visible dividing line. + /// + /// Whether to show the divider. + /// The builder for method chaining. + public SeparatorBuilder WithDivider(bool divider = true) + { + _divider = divider; + return this; + } + /// /// Builds the Separator. /// @@ -1060,7 +1149,8 @@ public Separator Build() { return new Separator { - Spacing = _spacing + Spacing = _spacing, + Divider = _divider }; } } @@ -1070,9 +1160,10 @@ public Separator Build() /// public class ContainerBuilder { - private List? _components; + private readonly List _components = new(); private int? _accentColor; - + private bool? _spoiler; + /// /// Adds a component to the container. /// @@ -1080,11 +1171,171 @@ public class ContainerBuilder /// The builder for method chaining. public ContainerBuilder AddComponent(MessageComponent component) { - _components ??= new List(); _components.Add(component); return this; } - + + /// + /// Adds a TextDisplay with content directly. + /// + /// The text content (max 4000 characters). + /// The builder for method chaining. + public ContainerBuilder AddText(string content) + { + _components.Add(new TextDisplay { Content = content }); + return this; + } + + /// + /// Adds a Section using a SectionBuilder. + /// + /// Action to configure the section. + /// The builder for method chaining. + public ContainerBuilder AddSection(Action configure) + { + var builder = new SectionBuilder(); + configure(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a Separator using a SeparatorBuilder. + /// + /// Action to configure the separator. + /// The builder for method chaining. + public ContainerBuilder AddSeparator(Action configure) + { + var builder = new SeparatorBuilder(); + configure(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a Separator with default settings. + /// + /// The spacing size. + /// Whether to show the divider. + /// The builder for method chaining. + public ContainerBuilder AddSeparator(SeparatorSpacing spacing = SeparatorSpacing.Small, bool divider = true) + { + _components.Add(new Separator { Spacing = spacing, Divider = divider }); + return this; + } + + /// + /// Adds a MediaGallery using a MediaGalleryBuilder. + /// + /// Action to configure the media gallery. + /// The builder for method chaining. + public ContainerBuilder AddMediaGallery(Action configure) + { + var builder = new MediaGalleryBuilder(); + configure(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a File using a FileBuilder. + /// + /// The file URL. + /// Action to configure the file. + /// The builder for method chaining. + public ContainerBuilder AddFile(string fileUrl, Action? configure = null) + { + var builder = new FileBuilder(fileUrl); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a FileUpload using a FileUploadBuilder. + /// + /// The custom ID. + /// The label. + /// Action to configure the file upload. + /// The builder for method chaining. + public ContainerBuilder AddFileUpload(string customId, string label, Action? configure = null) + { + var builder = new FileUploadBuilder(customId, label); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a pre-built FileUpload component. + /// + /// The FileUpload component. + /// The builder for method chaining. + public ContainerBuilder AddFileUpload(FileUpload fileUpload) + { + _components.Add(fileUpload); + return this; + } + + /// + /// Adds a Label using a LabelBuilder. + /// + /// The label text. + /// Action to configure the label. + /// The builder for method chaining. + public ContainerBuilder AddLabel(string text, Action? configure = null) + { + var builder = new LabelBuilder(text); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a RadioGroup using a RadioGroupBuilder. + /// + /// The custom ID. + /// The label. + /// Action to configure the radio group. + /// The builder for method chaining. + public ContainerBuilder AddRadioGroup(string customId, string label, Action? configure = null) + { + var builder = new RadioGroupBuilder(customId, label); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a CheckboxGroup using a CheckboxGroupBuilder. + /// + /// The custom ID. + /// The label. + /// Action to configure the checkbox group. + /// The builder for method chaining. + public ContainerBuilder AddCheckboxGroup(string customId, string label, Action? configure = null) + { + var builder = new CheckboxGroupBuilder(customId, label); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + + /// + /// Adds a Checkbox using a CheckboxBuilder. + /// + /// The custom ID. + /// The label. + /// Action to configure the checkbox. + /// The builder for method chaining. + public ContainerBuilder AddCheckbox(string customId, string label, Action? configure = null) + { + var builder = new CheckboxBuilder(customId, label); + configure?.Invoke(builder); + _components.Add(builder.Build()); + return this; + } + /// /// Sets the accent color. /// @@ -1095,17 +1346,1365 @@ public ContainerBuilder WithAccentColor(int accentColor) _accentColor = accentColor; return this; } - + + /// + /// Sets whether the entire container is a spoiler. + /// + /// Whether the container is a spoiler. + /// The builder for method chaining. + public ContainerBuilder WithSpoiler(bool spoiler = true) + { + _spoiler = spoiler; + return this; + } + /// /// Builds the Container. /// /// The Container component. + /// Thrown when validation fails. public Container Build() { + if (_components.Count > DiscordLimits.MaxComponentsPerContainer) + { + throw new ValidationException( + $"Container can contain at most {DiscordLimits.MaxComponentsPerContainer} components.", + nameof(_components), + _components.Count + ); + } + return new Container { - Components = _components, - AccentColor = _accentColor + Components = new List(_components), + AccentColor = _accentColor, + Spoiler = _spoiler + }; + } +} + +/// +/// Builder for Thumbnail components (Components v2). +/// +public class ThumbnailBuilder +{ + private readonly UnfurledMediaItem _media = new(); + private string? _description; + private bool? _spoiler; + + /// + /// Creates a new ThumbnailBuilder with a URL. + /// + /// The media URL. + public ThumbnailBuilder(string url) + { + _media.Url = url; + } + + /// + /// Sets the media URL. + /// + /// The media URL. + /// The builder for method chaining. + public ThumbnailBuilder WithUrl(string url) + { + _media.Url = url; + return this; + } + + /// + /// Sets the optional description/alt text. + /// + /// The description. + /// The builder for method chaining. + public ThumbnailBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + /// + /// Sets whether the thumbnail is a spoiler. + /// + /// Whether the thumbnail is a spoiler. + /// The builder for method chaining. + public ThumbnailBuilder WithSpoiler(bool spoiler = true) + { + _spoiler = spoiler; + return this; + } + + /// + /// Builds the Thumbnail component. + /// + /// The Thumbnail component. + /// Thrown when validation fails. + public ThumbnailComponent Build() + { + if (string.IsNullOrEmpty(_media.Url)) + { + throw new ValidationException( + "Thumbnail must have a URL.", + nameof(_media.Url), + null + ); + } + + return new ThumbnailComponent + { + Media = _media, + Description = _description, + Spoiler = _spoiler + }; + } +} + +/// +/// Builder for File components (Components v2). +/// +public class FileBuilder +{ + private readonly UnfurledMediaItem _file = new(); + private bool? _spoiler; + + /// + /// Creates a new FileBuilder with a file reference URL. + /// + /// The file URL (typically attachment://filename). + public FileBuilder(string fileUrl) + { + _file.Url = fileUrl; + } + + /// + /// Sets the file reference URL. + /// + /// The file URL (typically attachment://filename). + /// The builder for method chaining. + public FileBuilder WithFile(string fileUrl) + { + _file.Url = fileUrl; + return this; + } + + /// + /// Sets whether the file is a spoiler. + /// + /// Whether the file is a spoiler. + /// The builder for method chaining. + public FileBuilder WithSpoiler(bool spoiler = true) + { + _spoiler = spoiler; + return this; + } + + /// + /// Builds the File component. + /// + /// The File component. + /// Thrown when validation fails. + public FileComponent Build() + { + if (string.IsNullOrEmpty(_file.Url)) + { + throw new ValidationException( + "File must have a URL.", + nameof(_file.Url), + null + ); + } + + return new FileComponent + { + File = _file, + Spoiler = _spoiler + }; + } +} + +/// +/// Builder for MediaGallery components (Components v2). +/// +public class MediaGalleryBuilder +{ + private readonly List _items = new(); + + /// + /// Adds a media item to the gallery. + /// + /// The media URL. + /// Optional description/alt text. + /// Whether the item is a spoiler. + /// The builder for method chaining. + public MediaGalleryBuilder AddItem(string url, string? description = null, bool spoiler = false) + { + _items.Add(new MediaGalleryItem + { + Media = new UnfurledMediaItem { Url = url }, + Description = description, + Spoiler = spoiler + }); + return this; + } + + /// + /// Adds a media item using a MediaItemBuilder. + /// + /// Action to configure the media item. + /// The builder for method chaining. + public MediaGalleryBuilder AddItem(Action configure) + { + var builder = new MediaItemBuilder(); + configure(builder); + _items.Add(builder.Build()); + return this; + } + + /// + /// Builds the MediaGallery component. + /// + /// The MediaGallery component. + /// Thrown when validation fails. + public MediaGallery Build() + { + if (_items.Count < DiscordLimits.MinMediaGalleryItems) + { + throw new ValidationException( + $"MediaGallery must have at least {DiscordLimits.MinMediaGalleryItems} item(s).", + nameof(_items), + _items.Count + ); + } + + if (_items.Count > DiscordLimits.MaxMediaGalleryItems) + { + throw new ValidationException( + $"MediaGallery can have at most {DiscordLimits.MaxMediaGalleryItems} items.", + nameof(_items), + _items.Count + ); + } + + return new MediaGallery + { + Items = new List(_items) + }; + } +} + +/// +/// Builder for MediaGalleryItem. +/// +public class MediaItemBuilder +{ + private readonly UnfurledMediaItem _media = new(); + private string? _description; + private bool? _spoiler; + + /// + /// Sets the media URL. + /// + /// The media URL. + /// The builder for method chaining. + public MediaItemBuilder WithUrl(string url) + { + _media.Url = url; + return this; + } + + /// + /// Sets the optional description/alt text. + /// + /// The description. + /// The builder for method chaining. + public MediaItemBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + /// + /// Sets whether the item is a spoiler. + /// + /// Whether the item is a spoiler. + /// The builder for method chaining. + public MediaItemBuilder WithSpoiler(bool spoiler = true) + { + _spoiler = spoiler; + return this; + } + + /// + /// Builds the MediaGalleryItem. + /// + /// The MediaGalleryItem. + /// Thrown when validation fails. + public MediaGalleryItem Build() + { + if (string.IsNullOrEmpty(_media.Url)) + { + throw new ValidationException( + "Media item must have a URL.", + nameof(_media.Url), + null + ); + } + + return new MediaGalleryItem + { + Media = _media, + Description = _description, + Spoiler = _spoiler + }; + } +} + +/// +/// Builder for Label components (Components v2). +/// +public class LabelBuilder +{ + private string _text = string.Empty; + private string? _description; + private Emoji? _emoji; + + /// + /// Creates a new LabelBuilder with text. + /// + /// The label text (max 80 characters). + public LabelBuilder(string text) + { + _text = text; + } + + /// + /// Sets the label text. + /// + /// The label text (max 80 characters). + /// The builder for method chaining. + public LabelBuilder WithText(string text) + { + _text = text; + return this; + } + + /// + /// Sets the optional description. + /// + /// The description (max 200 characters). + /// The builder for method chaining. + public LabelBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + /// + /// Sets the emoji. + /// + /// The emoji. + /// The builder for method chaining. + public LabelBuilder WithEmoji(Emoji emoji) + { + _emoji = emoji; + return this; + } + + /// + /// Sets a Unicode emoji. + /// + /// The Unicode emoji (e.g., "🔥"). + /// The builder for method chaining. + public LabelBuilder WithEmoji(string unicodeEmoji) + { + _emoji = new Emoji { Name = unicodeEmoji }; + return this; + } + + /// + /// Sets a custom guild emoji. + /// + /// The emoji name. + /// The emoji ID. + /// Whether the emoji is animated. + /// The builder for method chaining. + public LabelBuilder WithCustomEmoji(string name, ulong id, bool animated = false) + { + _emoji = new Emoji { Name = name, Id = id, Animated = animated }; + return this; + } + + /// + /// Builds the Label. + /// + /// The Label component. + /// Thrown when validation fails. + public Label Build() + { + if (string.IsNullOrEmpty(_text)) + { + throw new ValidationException( + "Label text is required.", + nameof(_text), + null + ); + } + + if (_text.Length > DiscordLimits.MaxLabelTextLength) + { + throw new ValidationException( + $"Label text must not exceed {DiscordLimits.MaxLabelTextLength} characters.", + nameof(_text), + _text.Length + ); + } + + if (_description != null && _description.Length > DiscordLimits.MaxLabelDescriptionLength) + { + throw new ValidationException( + $"Label description must not exceed {DiscordLimits.MaxLabelDescriptionLength} characters.", + nameof(_description), + _description.Length + ); + } + + return new Label + { + Text = _text, + Emoji = _emoji + }; + } +} + +/// +/// Builder for FileUpload components (Components v2). +/// +public class FileUploadBuilder +{ + private string _customId = string.Empty; + private string _label = string.Empty; + private bool? _required = true; + private string? _placeholder; + private int? _minLength; + private int? _maxLength; + private List? _fileTypes; + + /// + /// Creates a new FileUploadBuilder. + /// + /// The custom ID (max 100 characters). + /// The label (max 45 characters). + public FileUploadBuilder(string customId, string label) + { + _customId = customId; + _label = label; + } + + /// + /// Sets the custom ID. + /// + /// The custom ID (max 100 characters). + /// The builder for method chaining. + public FileUploadBuilder WithCustomId(string customId) + { + _customId = customId; + return this; + } + + /// + /// Sets the label. + /// + /// The label (max 45 characters). + /// The builder for method chaining. + public FileUploadBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Sets whether the file upload is required. + /// + /// Whether required. + /// The builder for method chaining. + public FileUploadBuilder WithRequired(bool required = true) + { + _required = required; + return this; + } + + /// + /// Sets the placeholder text. + /// + /// The placeholder (max 100 characters). + /// The builder for method chaining. + public FileUploadBuilder WithPlaceholder(string? placeholder) + { + _placeholder = placeholder; + return this; + } + + /// + /// Sets the minimum number of files. + /// + /// Minimum files (0-10). + /// The builder for method chaining. + public FileUploadBuilder WithMinLength(int minLength) + { + _minLength = minLength; + return this; + } + + /// + /// Sets the maximum number of files. + /// + /// Maximum files (1-10). + /// The builder for method chaining. + public FileUploadBuilder WithMaxLength(int maxLength) + { + _maxLength = maxLength; + return this; + } + + /// + /// Sets the accepted file types (MIME types). + /// + /// The file types (e.g., "image/*", "application/pdf"). + /// The builder for method chaining. + public FileUploadBuilder WithFileTypes(params string[] fileTypes) + { + _fileTypes = new List(fileTypes); + return this; + } + + /// + /// Builds the FileUpload. + /// + /// The FileUpload component. + /// Thrown when validation fails. + public FileUpload Build() + { + if (string.IsNullOrEmpty(_customId)) + { + throw new ValidationException( + "FileUpload custom ID is required.", + nameof(_customId), + null + ); + } + + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "FileUpload label is required.", + nameof(_label), + null + ); + } + + if (_customId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + $"FileUpload custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters.", + nameof(_customId), + _customId.Length + ); + } + + if (_label.Length > DiscordLimits.MaxFileUploadLabelLength) + { + throw new ValidationException( + $"FileUpload label must not exceed {DiscordLimits.MaxFileUploadLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + if (_placeholder != null && _placeholder.Length > DiscordLimits.MaxFileUploadPlaceholderLength) + { + throw new ValidationException( + $"FileUpload placeholder must not exceed {DiscordLimits.MaxFileUploadPlaceholderLength} characters.", + nameof(_placeholder), + _placeholder.Length + ); + } + + if (_minLength.HasValue && _minLength.Value > DiscordLimits.MaxFileUploadMinLength) + { + throw new ValidationException( + $"FileUpload minimum length must not exceed {DiscordLimits.MaxFileUploadMinLength}.", + nameof(_minLength), + _minLength.Value + ); + } + + if (_maxLength.HasValue && _maxLength.Value > DiscordLimits.MaxFileUploadMaxLength) + { + throw new ValidationException( + $"FileUpload maximum length must not exceed {DiscordLimits.MaxFileUploadMaxLength}.", + nameof(_maxLength), + _maxLength.Value + ); + } + + if (_minLength.HasValue && _maxLength.HasValue && _minLength.Value > _maxLength.Value) + { + throw new ValidationException( + "FileUpload minimum length cannot be greater than maximum length.", + nameof(_minLength), + _minLength.Value + ); + } + + return new FileUpload + { + CustomId = _customId, + Label = _label, + Required = _required, + Placeholder = _placeholder, + MinLength = _minLength, + MaxLength = _maxLength, + FileTypes = _fileTypes + }; + } +} + +/// +/// Builder for RadioGroup components (Components v2). +/// +public class RadioGroupBuilder +{ + private string _customId = string.Empty; + private string _label = string.Empty; + private readonly List _options = new(); + private bool? _required = true; + private int? _defaultValue; + + /// + /// Creates a new RadioGroupBuilder. + /// + /// The custom ID (max 100 characters). + /// The label (max 45 characters). + public RadioGroupBuilder(string customId, string label) + { + _customId = customId; + _label = label; + } + + /// + /// Sets the custom ID. + /// + /// The custom ID (max 100 characters). + /// The builder for method chaining. + public RadioGroupBuilder WithCustomId(string customId) + { + _customId = customId; + return this; + } + + /// + /// Sets the label. + /// + /// The label (max 45 characters). + /// The builder for method chaining. + public RadioGroupBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Adds an option to the radio group. + /// + /// The option label (max 100 characters). + /// The option value (max 100 characters). + /// Optional description (max 100 characters). + /// Whether this option is selected by default. + /// The builder for method chaining. + public RadioGroupBuilder AddOption(string label, string value, string? description = null, bool isDefault = false) + { + _options.Add(new RadioOption + { + Label = label, + Value = value, + Description = description, + Default = isDefault + }); + return this; + } + + /// + /// Adds an option using a RadioOptionBuilder. + /// + /// Action to configure the option. + /// The builder for method chaining. + public RadioGroupBuilder AddOption(Action configure) + { + var builder = new RadioOptionBuilder(); + configure(builder); + _options.Add(builder.Build()); + return this; + } + + /// + /// Sets whether the radio group is required. + /// + /// Whether required. + /// The builder for method chaining. + public RadioGroupBuilder WithRequired(bool required = true) + { + _required = required; + return this; + } + + /// + /// Sets the default selected option index. + /// + /// The index of the default option (0-based). + /// The builder for method chaining. + public RadioGroupBuilder WithDefaultValue(int index) + { + _defaultValue = index; + return this; + } + + /// + /// Builds the RadioGroup component. + /// + /// The RadioGroup component. + /// Thrown when validation fails. + public RadioGroup Build() + { + if (string.IsNullOrEmpty(_customId)) + { + throw new ValidationException( + "RadioGroup custom ID is required.", + nameof(_customId), + null + ); + } + + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "RadioGroup label is required.", + nameof(_label), + null + ); + } + + if (_customId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + $"RadioGroup custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters.", + nameof(_customId), + _customId.Length + ); + } + + if (_label.Length > DiscordLimits.MaxTextInputLabelLength) + { + throw new ValidationException( + $"RadioGroup label must not exceed {DiscordLimits.MaxTextInputLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + if (_options.Count > DiscordLimits.MaxRadioGroupOptions) + { + throw new ValidationException( + $"RadioGroup can have at most {DiscordLimits.MaxRadioGroupOptions} options.", + nameof(_options), + _options.Count + ); + } + + if (_options.Count == 0) + { + throw new ValidationException( + "RadioGroup must have at least one option.", + nameof(_options), + null + ); + } + + return new RadioGroup + { + CustomId = _customId, + Options = new List(_options), + Label = _label, + Required = _required, + DefaultValue = _defaultValue + }; + } +} + +/// +/// Builder for RadioOption. +/// +public class RadioOptionBuilder +{ + private string _label = string.Empty; + private string _value = string.Empty; + private string? _description; + private Emoji? _emoji; + private bool _default = false; + + /// + /// Sets the option label. + /// + /// The label (max 100 characters). + /// The builder for method chaining. + public RadioOptionBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Sets the option value. + /// + /// The value (max 100 characters). + /// The builder for method chaining. + public RadioOptionBuilder WithValue(string value) + { + _value = value; + return this; + } + + /// + /// Sets the optional description. + /// + /// The description (max 100 characters). + /// The builder for method chaining. + public RadioOptionBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + /// + /// Sets the emoji. + /// + /// The emoji. + /// The builder for method chaining. + public RadioOptionBuilder WithEmoji(Emoji emoji) + { + _emoji = emoji; + return this; + } + + /// + /// Sets whether this option is selected by default. + /// + /// Whether default. + /// The builder for method chaining. + public RadioOptionBuilder WithDefault(bool isDefault = true) + { + _default = isDefault; + return this; + } + + /// + /// Builds the RadioOption. + /// + /// The RadioOption. + /// Thrown when validation fails. + public RadioOption Build() + { + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "RadioOption label is required.", + nameof(_label), + null + ); + } + + if (string.IsNullOrEmpty(_value)) + { + throw new ValidationException( + "RadioOption value is required.", + nameof(_value), + null + ); + } + + if (_label.Length > DiscordLimits.MaxRadioGroupOptionLabelLength) + { + throw new ValidationException( + $"RadioOption label must not exceed {DiscordLimits.MaxRadioGroupOptionLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + if (_value.Length > DiscordLimits.MaxRadioGroupOptionValueLength) + { + throw new ValidationException( + $"RadioOption value must not exceed {DiscordLimits.MaxRadioGroupOptionValueLength} characters.", + nameof(_value), + _value.Length + ); + } + + if (_description != null && _description.Length > DiscordLimits.MaxRadioGroupOptionDescriptionLength) + { + throw new ValidationException( + $"RadioOption description must not exceed {DiscordLimits.MaxRadioGroupOptionDescriptionLength} characters.", + nameof(_description), + _description.Length + ); + } + + return new RadioOption + { + Label = _label, + Value = _value, + Description = _description, + Emoji = _emoji, + Default = _default + }; + } +} + +/// +/// Builder for CheckboxGroup components (Components v2). +/// +public class CheckboxGroupBuilder +{ + private string _customId = string.Empty; + private string _label = string.Empty; + private readonly List _options = new(); + private bool? _required = true; + private int? _minValues; + private int? _maxValues; + + /// + /// Creates a new CheckboxGroupBuilder. + /// + /// The custom ID (max 100 characters). + /// The label (max 45 characters). + public CheckboxGroupBuilder(string customId, string label) + { + _customId = customId; + _label = label; + } + + /// + /// Sets the custom ID. + /// + /// The custom ID (max 100 characters). + /// The builder for method chaining. + public CheckboxGroupBuilder WithCustomId(string customId) + { + _customId = customId; + return this; + } + + /// + /// Sets the label. + /// + /// The label (max 45 characters). + /// The builder for method chaining. + public CheckboxGroupBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Adds an option to the checkbox group. + /// + /// The option label (max 100 characters). + /// The option value (max 100 characters). + /// Optional description (max 100 characters). + /// Whether this option is checked by default. + /// The builder for method chaining. + public CheckboxGroupBuilder AddOption(string label, string value, string? description = null, bool isDefault = false) + { + _options.Add(new CheckboxOption + { + Label = label, + Value = value, + Description = description, + Default = isDefault + }); + return this; + } + + /// + /// Adds an option using a CheckboxOptionBuilder. + /// + /// Action to configure the option. + /// The builder for method chaining. + public CheckboxGroupBuilder AddOption(Action configure) + { + var builder = new CheckboxOptionBuilder(); + configure(builder); + _options.Add(builder.Build()); + return this; + } + + /// + /// Sets whether the checkbox group is required. + /// + /// Whether required. + /// The builder for method chaining. + public CheckboxGroupBuilder WithRequired(bool required = true) + { + _required = required; + return this; + } + + /// + /// Sets the minimum number of items that must be selected. + /// + /// Minimum values (0-25). + /// The builder for method chaining. + public CheckboxGroupBuilder WithMinValues(int minValues) + { + _minValues = minValues; + return this; + } + + /// + /// Sets the maximum number of items that can be selected. + /// + /// Maximum values (1-25). + /// The builder for method chaining. + public CheckboxGroupBuilder WithMaxValues(int maxValues) + { + _maxValues = maxValues; + return this; + } + + /// + /// Builds the CheckboxGroup component. + /// + /// The CheckboxGroup component. + /// Thrown when validation fails. + public CheckboxGroup Build() + { + if (string.IsNullOrEmpty(_customId)) + { + throw new ValidationException( + "CheckboxGroup custom ID is required.", + nameof(_customId), + null + ); + } + + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "CheckboxGroup label is required.", + nameof(_label), + null + ); + } + + if (_customId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + $"CheckboxGroup custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters.", + nameof(_customId), + _customId.Length + ); + } + + if (_label.Length > DiscordLimits.MaxTextInputLabelLength) + { + throw new ValidationException( + $"CheckboxGroup label must not exceed {DiscordLimits.MaxTextInputLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + if (_options.Count > DiscordLimits.MaxCheckboxGroupOptions) + { + throw new ValidationException( + $"CheckboxGroup can have at most {DiscordLimits.MaxCheckboxGroupOptions} options.", + nameof(_options), + _options.Count + ); + } + + if (_options.Count == 0) + { + throw new ValidationException( + "CheckboxGroup must have at least one option.", + nameof(_options), + null + ); + } + + if (_minValues.HasValue && _maxValues.HasValue && _minValues.Value > _maxValues.Value) + { + throw new ValidationException( + "CheckboxGroup minimum values cannot be greater than maximum values.", + nameof(_minValues), + _minValues.Value + ); + } + + return new CheckboxGroup + { + CustomId = _customId, + Options = new List(_options), + Label = _label, + Required = _required, + MinValues = _minValues, + MaxValues = _maxValues + }; + } +} + +/// +/// Builder for CheckboxOption. +/// +public class CheckboxOptionBuilder +{ + private string _label = string.Empty; + private string _value = string.Empty; + private string? _description; + private Emoji? _emoji; + private bool _default = false; + + /// + /// Sets the option label. + /// + /// The label (max 100 characters). + /// The builder for method chaining. + public CheckboxOptionBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Sets the option value. + /// + /// The value (max 100 characters). + /// The builder for method chaining. + public CheckboxOptionBuilder WithValue(string value) + { + _value = value; + return this; + } + + /// + /// Sets the optional description. + /// + /// The description (max 100 characters). + /// The builder for method chaining. + public CheckboxOptionBuilder WithDescription(string? description) + { + _description = description; + return this; + } + + /// + /// Sets the emoji. + /// + /// The emoji. + /// The builder for method chaining. + public CheckboxOptionBuilder WithEmoji(Emoji emoji) + { + _emoji = emoji; + return this; + } + + /// + /// Sets whether this option is checked by default. + /// + /// Whether default. + /// The builder for method chaining. + public CheckboxOptionBuilder WithDefault(bool isDefault = true) + { + _default = isDefault; + return this; + } + + /// + /// Builds the CheckboxOption. + /// + /// The CheckboxOption. + /// Thrown when validation fails. + public CheckboxOption Build() + { + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "CheckboxOption label is required.", + nameof(_label), + null + ); + } + + if (string.IsNullOrEmpty(_value)) + { + throw new ValidationException( + "CheckboxOption value is required.", + nameof(_value), + null + ); + } + + if (_label.Length > DiscordLimits.MaxCheckboxGroupOptionLabelLength) + { + throw new ValidationException( + $"CheckboxOption label must not exceed {DiscordLimits.MaxCheckboxGroupOptionLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + if (_value.Length > DiscordLimits.MaxCheckboxGroupOptionValueLength) + { + throw new ValidationException( + $"CheckboxOption value must not exceed {DiscordLimits.MaxCheckboxGroupOptionValueLength} characters.", + nameof(_value), + _value.Length + ); + } + + if (_description != null && _description.Length > DiscordLimits.MaxCheckboxGroupOptionDescriptionLength) + { + throw new ValidationException( + $"CheckboxOption description must not exceed {DiscordLimits.MaxCheckboxGroupOptionDescriptionLength} characters.", + nameof(_description), + _description.Length + ); + } + + return new CheckboxOption + { + Label = _label, + Value = _value, + Description = _description, + Emoji = _emoji, + Default = _default + }; + } +} + +/// +/// Builder for Checkbox components (Components v2). +/// +public class CheckboxBuilder +{ + private string _customId = string.Empty; + private string _label = string.Empty; + private bool? _defaultValue = false; + private bool? _required = false; + + /// + /// Creates a new CheckboxBuilder. + /// + /// The custom ID (max 100 characters). + /// The label (max 80 characters). + public CheckboxBuilder(string customId, string label) + { + _customId = customId; + _label = label; + } + + /// + /// Sets the custom ID. + /// + /// The custom ID (max 100 characters). + /// The builder for method chaining. + public CheckboxBuilder WithCustomId(string customId) + { + _customId = customId; + return this; + } + + /// + /// Sets the label. + /// + /// The label (max 80 characters). + /// The builder for method chaining. + public CheckboxBuilder WithLabel(string label) + { + _label = label; + return this; + } + + /// + /// Sets whether the checkbox is checked by default. + /// + /// Whether checked by default. + /// The builder for method chaining. + public CheckboxBuilder WithDefaultValue(bool defaultValue = true) + { + _defaultValue = defaultValue; + return this; + } + + /// + /// Sets whether the checkbox is required. + /// + /// Whether required. + /// The builder for method chaining. + public CheckboxBuilder WithRequired(bool required = true) + { + _required = required; + return this; + } + + /// + /// Builds the Checkbox. + /// + /// The Checkbox component. + /// Thrown when validation fails. + public Checkbox Build() + { + if (string.IsNullOrEmpty(_customId)) + { + throw new ValidationException( + "Checkbox custom ID is required.", + nameof(_customId), + null + ); + } + + if (string.IsNullOrEmpty(_label)) + { + throw new ValidationException( + "Checkbox label is required.", + nameof(_label), + null + ); + } + + if (_customId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + $"Checkbox custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters.", + nameof(_customId), + _customId.Length + ); + } + + if (_label.Length > DiscordLimits.MaxCheckboxLabelLength) + { + throw new ValidationException( + $"Checkbox label must not exceed {DiscordLimits.MaxCheckboxLabelLength} characters.", + nameof(_label), + _label.Length + ); + } + + return new Checkbox + { + CustomId = _customId, + Label = _label, + DefaultValue = _defaultValue, + Required = _required }; } } diff --git a/src/PawSharp.Core/CDN/DiscordCdn.cs b/src/PawSharp.Core/CDN/DiscordCdn.cs index 2a237dc..dcab4bc 100644 --- a/src/PawSharp.Core/CDN/DiscordCdn.cs +++ b/src/PawSharp.Core/CDN/DiscordCdn.cs @@ -20,17 +20,28 @@ public static class Format // ── Users ────────────────────────────────────────────────────────────────── /// - /// URL for a user's avatar. Returns the default avatar URL when is null. - /// Animated avatars (hash starts with "a_") are returned as GIF when is - /// or when format is not explicitly specified. + /// Gets the URL for a user's avatar. /// - public static string GetUserAvatar(ulong userId, string? avatarHash, int size = 256, string? format = null) + /// The user ID. + /// The avatar hash. + /// Optional size in pixels (16, 32, 64, 128, 256, 512, 1024, 2048, 4096). + /// Optional image format. + /// The avatar URL. + /// + /// + /// ulong userId = 123456789012345678; + /// string avatarHash = "a_bcdefghijklmnopqrstuvwxyz"; + /// string url = DiscordCdn.GetUserAvatar(userId, avatarHash, 256, Format.Png); + /// // Returns: "https://cdn.discordapp.com/avatars/123456789012345678/a_bcdefghijklmnopqrstuvwxyz.png?size=256" + /// + /// + public static string GetUserAvatar(ulong userId, string avatarHash, int? size = null, string? format = null) { if (avatarHash is null) return GetDefaultAvatar(userId); var ext = format ?? (avatarHash.StartsWith("a_") ? Format.Gif : Format.WebP); - return $"{BaseUrl}/avatars/{userId}/{avatarHash}.{ext}?size={size}"; + return $"{BaseUrl}/avatars/{userId}/{avatarHash}.{ext}?size={size ?? 256}"; } /// diff --git a/src/PawSharp.Core/Entities/Invite.cs b/src/PawSharp.Core/Entities/Invite.cs index 27e8522..ba41c6f 100644 --- a/src/PawSharp.Core/Entities/Invite.cs +++ b/src/PawSharp.Core/Entities/Invite.cs @@ -112,6 +112,17 @@ public class Invite : DiscordEntity /// [JsonPropertyName("created_at")] public new DateTimeOffset CreatedAt { get; set; } + + /// + /// Invite flags bitfield. + /// + [JsonPropertyName("flags")] + public InviteFlags? Flags { get; set; } + + /// + /// Gets whether this is a guest invite (grants non-permanent access). + /// + public bool IsGuest => Flags.HasValue && (Flags.Value & InviteFlags.Guest) == InviteFlags.Guest; } /// @@ -158,4 +169,15 @@ public enum InviteTargetType /// Embedded Application. /// EmbeddedApplication = 2 +} + +/// +/// Bitfield flags for a Discord invite. +/// +[System.Flags] +public enum InviteFlags +{ + None = 0, + /// This invite is a guest invite (grants non-permanent access). + Guest = 1 << 2, } \ No newline at end of file diff --git a/src/PawSharp.Core/Entities/Message.cs b/src/PawSharp.Core/Entities/Message.cs index db990ec..af380d4 100644 --- a/src/PawSharp.Core/Entities/Message.cs +++ b/src/PawSharp.Core/Entities/Message.cs @@ -692,6 +692,12 @@ public class PartialMessage [JsonPropertyName("sticker_items")] public System.Collections.Generic.List? StickerItems { get; set; } + + /// + /// Stickers attached to this message (legacy). + /// + [JsonPropertyName("stickers")] + public System.Collections.Generic.List? Stickers { get; set; } } // ── Channel mention (crosspost) ─────────────────────────────────────────────── diff --git a/src/PawSharp.Core/Entities/MessageComponent.cs b/src/PawSharp.Core/Entities/MessageComponent.cs index c5fccba..422bf02 100644 --- a/src/PawSharp.Core/Entities/MessageComponent.cs +++ b/src/PawSharp.Core/Entities/MessageComponent.cs @@ -101,6 +101,11 @@ public sealed class MessageComponentJsonConverter : JsonConverter JsonSerializer.Deserialize(raw, _context.FileComponent), ComponentType.Separator => JsonSerializer.Deserialize(raw, _context.Separator), ComponentType.Container => JsonSerializer.Deserialize(raw, _context.Container), + ComponentType.Label => JsonSerializer.Deserialize(raw, _context.Label), + ComponentType.FileUpload => JsonSerializer.Deserialize(raw, _context.FileUpload), + ComponentType.RadioGroup => JsonSerializer.Deserialize(raw, _context.RadioGroup), + ComponentType.CheckboxGroup => JsonSerializer.Deserialize(raw, _context.CheckboxGroup), + ComponentType.Checkbox => JsonSerializer.Deserialize(raw, _context.Checkbox), _ => JsonSerializer.Deserialize(raw, _context.UnknownComponent), }; } @@ -124,6 +129,11 @@ public override void Write(Utf8JsonWriter writer, MessageComponent value, JsonSe var t when t == typeof(FileComponent) => _context.FileComponent, var t when t == typeof(Separator) => _context.Separator, var t when t == typeof(Container) => _context.Container, + var t when t == typeof(Label) => _context.Label, + var t when t == typeof(FileUpload) => _context.FileUpload, + var t when t == typeof(RadioGroup) => _context.RadioGroup, + var t when t == typeof(CheckboxGroup) => _context.CheckboxGroup, + var t when t == typeof(Checkbox) => _context.Checkbox, _ => _context.UnknownComponent }; @@ -543,3 +553,201 @@ public class Container : MessageComponent [JsonPropertyName("spoiler")] public bool? Spoiler { get; set; } } + +// ── Label (type 18) ───────────────────────────────────────────────────────────── + +/// +/// A Label component (type 18) displays text with optional emoji. +/// Only valid as a top-level component inside a Container. +/// +public class Label : MessageComponent +{ + public Label() => Type = ComponentType.Label; + + /// The text content of the label (max 80 characters). + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + /// Optional emoji to display next to the label text. + [JsonPropertyName("emoji")] + public Emoji? Emoji { get; set; } +} + +// ── FileUpload (type 19) ───────────────────────────────────────────────────────── + +/// +/// A FileUpload component (type 19) allows users to upload files in a modal or message. +/// +public class FileUpload : MessageComponent +{ + public FileUpload() => Type = ComponentType.FileUpload; + + /// Developer-defined identifier (max 100 characters). + [JsonPropertyName("custom_id")] + public string CustomId { get; set; } = string.Empty; + + /// Label shown above the file input (max 45 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Whether this component is required. Defaults to true. + [JsonPropertyName("required")] + public bool? Required { get; set; } + + /// Placeholder text when no file is selected (max 100 characters). + [JsonPropertyName("placeholder")] + public string? Placeholder { get; set; } + + /// Minimum number of files that must be uploaded (0–10). Default 0. + [JsonPropertyName("min_length")] + public int? MinLength { get; set; } + + /// Maximum number of files that can be uploaded (1–10). Default 1. + [JsonPropertyName("max_length")] + public int? MaxLength { get; set; } + + /// File types that can be uploaded (MIME types, e.g., "image/*"). + [JsonPropertyName("file_types")] + public List? FileTypes { get; set; } +} + +// ── RadioGroup (type 21) ──────────────────────────────────────────────────────── + +/// +/// A RadioGroup component (type 21) allows selecting one option from a list. +/// Only valid as a top-level component inside a Container. +/// +public class RadioGroup : MessageComponent +{ + public RadioGroup() => Type = ComponentType.RadioGroup; + + /// Developer-defined identifier (max 100 characters). + [JsonPropertyName("custom_id")] + public string CustomId { get; set; } = string.Empty; + + /// Options available in this radio group (max 25). + [JsonPropertyName("options")] + public List Options { get; set; } = new(); + + /// Label shown above the radio group (max 45 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Whether this component is required. Defaults to true. + [JsonPropertyName("required")] + public bool? Required { get; set; } + + /// Index of the default selected option (0-based). + [JsonPropertyName("default_value")] + public int? DefaultValue { get; set; } +} + +/// One option shown inside a RadioGroup. +public class RadioOption +{ + /// User-facing name (max 100 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Developer-defined value returned in the interaction payload (max 100 characters). + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + /// Optional description shown beneath the label (max 100 characters). + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Partial emoji rendered alongside the label. + [JsonPropertyName("emoji")] + public Emoji? Emoji { get; set; } + + /// Whether this option is pre-selected by default. + [JsonPropertyName("default")] + public bool? Default { get; set; } +} + +// ── CheckboxGroup (type 22) ────────────────────────────────────────────────────── + +/// +/// A CheckboxGroup component (type 22) allows selecting multiple options from a list. +/// Only valid as a top-level component inside a Container. +/// +public class CheckboxGroup : MessageComponent +{ + public CheckboxGroup() => Type = ComponentType.CheckboxGroup; + + /// Developer-defined identifier (max 100 characters). + [JsonPropertyName("custom_id")] + public string CustomId { get; set; } = string.Empty; + + /// Options available in this checkbox group (max 25). + [JsonPropertyName("options")] + public List Options { get; set; } = new(); + + /// Label shown above the checkbox group (max 45 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Minimum number of items that must be chosen (0–25). Default 1. + [JsonPropertyName("min_values")] + public int? MinValues { get; set; } + + /// Maximum number of items that can be chosen (1–25). Default 1. + [JsonPropertyName("max_values")] + public int? MaxValues { get; set; } + + /// Whether this component is required. Defaults to true. + [JsonPropertyName("required")] + public bool? Required { get; set; } +} + +/// One option shown inside a CheckboxGroup. +public class CheckboxOption +{ + /// User-facing name (max 100 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Developer-defined value returned in the interaction payload (max 100 characters). + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + /// Optional description shown beneath the label (max 100 characters). + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Partial emoji rendered alongside the label. + [JsonPropertyName("emoji")] + public Emoji? Emoji { get; set; } + + /// Whether this option is pre-selected by default. + [JsonPropertyName("default")] + public bool? Default { get; set; } +} + +// ── Checkbox (type 23) ─────────────────────────────────────────────────────────── + +/// +/// A Checkbox component (type 23) is a single toggleable checkbox. +/// Only valid as a top-level component inside a Container. +/// +public class Checkbox : MessageComponent +{ + public Checkbox() => Type = ComponentType.Checkbox; + + /// Developer-defined identifier (max 100 characters). + [JsonPropertyName("custom_id")] + public string CustomId { get; set; } = string.Empty; + + /// Label shown next to the checkbox (max 80 characters). + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + /// Whether the checkbox is checked by default. + [JsonPropertyName("default_value")] + public bool? DefaultValue { get; set; } + + /// Whether this component is required. Defaults to false. + [JsonPropertyName("required")] + public bool? Required { get; set; } +} diff --git a/src/PawSharp.Core/Entities/Role.cs b/src/PawSharp.Core/Entities/Role.cs index 1ecbd60..5abbdc8 100644 --- a/src/PawSharp.Core/Entities/Role.cs +++ b/src/PawSharp.Core/Entities/Role.cs @@ -55,6 +55,10 @@ public class Role : DiscordEntity [JsonPropertyName("flags")] public RoleFlags Flags { get; set; } + /// Enhanced role style colors (gradient/holographic). + [JsonPropertyName("colors")] + public RoleColors? Colors { get; set; } + /// /// Gets whether this role is managed by an integration. /// @@ -166,3 +170,62 @@ public enum RoleFlags /// Role can be selected by members in an onboarding prompt. InPrompt = 1 << 0, } + +/// +/// Enhanced role style colors for gradient and holographic effects. +/// +public class RoleColors +{ + /// The primary color of the role (hex color code as integer). + [JsonPropertyName("primaryColor")] + public int PrimaryColor { get; set; } + + /// + /// The secondary color of the role (hex color code as integer). + /// When set, this creates a gradient between primary and secondary colors. + /// + [JsonPropertyName("secondaryColor")] + public int? SecondaryColor { get; set; } + + /// + /// The tertiary color of the role (hex color code as integer). + /// When set, this creates a holographic style with specific enforced values: + /// primaryColor = 11127295, secondaryColor = 16759788, tertiaryColor = 16761760. + /// + [JsonPropertyName("tertiaryColor")] + public int? TertiaryColor { get; set; } + + /// + /// Gets whether this role has a gradient color style. + /// + public bool IsGradient => SecondaryColor.HasValue; + + /// + /// Gets whether this role has a holographic color style. + /// + public bool IsHolographic => TertiaryColor.HasValue; + + /// + /// Gets the primary color as a hexadecimal string. + /// + public string GetPrimaryColorHex() + { + return PrimaryColor.ToString("X6").PadLeft(6, '0'); + } + + /// + /// Gets the secondary color as a hexadecimal string, if set. + /// + public string? GetSecondaryColorHex() + { + return SecondaryColor?.ToString("X6").PadLeft(6, '0'); + } + + /// + /// Gets the tertiary color as a hexadecimal string, if set. + /// + public string? GetTertiaryColorHex() + { + return TertiaryColor?.ToString("X6").PadLeft(6, '0'); + } +} diff --git a/src/PawSharp.Core/Entities/User.cs b/src/PawSharp.Core/Entities/User.cs index 6bdd2bf..9b1e099 100644 --- a/src/PawSharp.Core/Entities/User.cs +++ b/src/PawSharp.Core/Entities/User.cs @@ -1,6 +1,7 @@ #nullable enable using System.Text.Json.Serialization; using PawSharp.Core.Enums; +using PawSharp.Core.Serialization; namespace PawSharp.Core.Entities; @@ -104,7 +105,13 @@ public class User : DiscordEntity /// [JsonPropertyName("avatar_decoration_data")] public AvatarDecorationData? AvatarDecorationData { get; set; } - + + /// + /// The user's primary guild (for server tags/profile display). + /// + [JsonPropertyName("primary_guild")] + public UserPrimaryGuild? PrimaryGuild { get; set; } + /// /// Gets the user's avatar URL. /// @@ -162,3 +169,39 @@ public string GetAvatarUrl(ushort size = 128) /// public string FullTag => Discriminator == "0" ? Username : $"{Username}#{Discriminator}"; } + +/// +/// Represents a user's primary guild for profile display (server tags). +/// +public class UserPrimaryGuild +{ + /// + /// The guild ID being used as the primary guild. + /// + [JsonPropertyName("identity_guild_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong IdentityGuildId { get; set; } + + /// + /// Whether the primary guild identity is enabled. + /// + [JsonPropertyName("identity_enabled")] + public bool IdentityEnabled { get; set; } + + /// + /// The 4-character server tag displayed on the user's profile. + /// + [JsonPropertyName("tag")] + public string Tag { get; set; } = string.Empty; + + /// + /// The server tag badge hash. + /// + [JsonPropertyName("badge")] + public string? Badge { get; set; } + + /// + /// Gets whether the server tag is currently displayed. + /// + public bool IsDisplayed => IdentityEnabled && !string.IsNullOrEmpty(Tag); +} diff --git a/src/PawSharp.Core/Exceptions/DeserializationException.cs b/src/PawSharp.Core/Exceptions/DeserializationException.cs index 3fff3e4..242ebb5 100644 --- a/src/PawSharp.Core/Exceptions/DeserializationException.cs +++ b/src/PawSharp.Core/Exceptions/DeserializationException.cs @@ -5,6 +5,34 @@ namespace PawSharp.Core.Exceptions; /// /// Exception thrown when JSON deserialization fails. +/// +/// This exception is thrown when the library fails to deserialize JSON responses from Discord's API. +/// It includes the raw JSON content that failed to parse and the target type that was being deserialized to. +/// +/// +/// +/// +/// try +/// { +/// var guild = await client.Rest.GetGuildAsync(guildId); +/// } +/// catch (DeserializationException ex) +/// { +/// Console.WriteLine($"Target Type: {ex.TargetType}"); +/// Console.WriteLine($"Raw JSON: {ex.RawJson}"); +/// Console.WriteLine($"Error: {ex.Message}"); +/// +/// // This helps diagnose API changes or malformed responses +/// } +/// +/// +/// +/// +/// +/// This exception typically indicates a mismatch between the library's data models and Discord's API response format. +/// It may occur when Discord introduces breaking changes to their API. +/// +/// /// public class DeserializationException : DiscordException { diff --git a/src/PawSharp.Core/Exceptions/DiscordException.cs b/src/PawSharp.Core/Exceptions/DiscordException.cs index 2d3e7b7..0009122 100644 --- a/src/PawSharp.Core/Exceptions/DiscordException.cs +++ b/src/PawSharp.Core/Exceptions/DiscordException.cs @@ -5,6 +5,37 @@ namespace PawSharp.Core.Exceptions; /// /// Base exception for all PawSharp-related errors. +/// +/// This is the root exception type for all custom exceptions thrown by the PawSharp library. +/// Catching this exception allows handling any PawSharp-specific error in a single catch block. +/// +/// +/// +/// +/// try +/// { +/// await client.Rest.CreateMessageAsync(channelId, request); +/// } +/// catch (DiscordException ex) +/// { +/// // Handle any PawSharp-specific error +/// Console.WriteLine($"PawSharp error: {ex.Message}"); +/// } +/// +/// +/// +/// +/// +/// For more specific error handling, catch the derived exception types: +/// +/// - REST API errors +/// - WebSocket connection errors +/// - Input validation errors +/// - Rate limiting errors +/// - JSON parsing errors +/// +/// +/// /// public class DiscordException : Exception { diff --git a/src/PawSharp.Core/Exceptions/GatewayException.cs b/src/PawSharp.Core/Exceptions/GatewayException.cs index 284760b..d157a70 100644 --- a/src/PawSharp.Core/Exceptions/GatewayException.cs +++ b/src/PawSharp.Core/Exceptions/GatewayException.cs @@ -5,11 +5,46 @@ namespace PawSharp.Core.Exceptions; /// /// Exception thrown when gateway connection issues occur. +/// +/// This exception is thrown when WebSocket connection to Discord's gateway fails or encounters issues. +/// It includes information about the gateway opcode and event type that caused the error, as well as +/// whether the error is recoverable (can be retried automatically) or requires manual intervention. +/// +/// +/// +/// +/// try +/// { +/// await client.ConnectAsync(); +/// } +/// catch (GatewayException ex) +/// { +/// Console.WriteLine($"Gateway error: {ex.Message}"); +/// Console.WriteLine($"Opcode: {ex.Opcode}"); +/// Console.WriteLine($"Event Type: {ex.EventType}"); +/// Console.WriteLine($"Is Recoverable: {ex.IsRecoverable}"); +/// +/// if (ex.IsRecoverable) +/// { +/// // Attempt reconnection +/// await Task.Delay(TimeSpan.FromSeconds(5)); +/// await client.ConnectAsync(); +/// } +/// else +/// { +/// // Fatal error - manual intervention required +/// throw; +/// } +/// } +/// +/// +/// /// public class GatewayException : DiscordException { /// /// Gets the gateway opcode that caused the error, if applicable. + /// Discord gateway opcodes indicate the type of message being sent or received. /// public int? Opcode { get; } @@ -20,6 +55,7 @@ public class GatewayException : DiscordException /// /// Gets whether this error is recoverable. + /// Recoverable errors can be automatically retried by the library. Non-recoverable errors require manual intervention. /// public bool IsRecoverable { get; } diff --git a/src/PawSharp.Core/Exceptions/RateLimitException.cs b/src/PawSharp.Core/Exceptions/RateLimitException.cs index c2a9dc1..e73f1f4 100644 --- a/src/PawSharp.Core/Exceptions/RateLimitException.cs +++ b/src/PawSharp.Core/Exceptions/RateLimitException.cs @@ -5,6 +5,36 @@ namespace PawSharp.Core.Exceptions; /// /// Exception thrown when rate limiting occurs. +/// +/// This exception is thrown when Discord's rate limits are exceeded. It includes information +/// about how long to wait before retrying, whether the rate limit is global, and the rate limit bucket identifier. +/// +/// +/// +/// +/// try +/// { +/// await client.Rest.CreateMessageAsync(channelId, request); +/// } +/// catch (RateLimitException ex) +/// { +/// Console.WriteLine($"Retry After: {ex.RetryAfter.TotalSeconds} seconds"); +/// Console.WriteLine($"Is Global: {ex.IsGlobal}"); +/// Console.WriteLine($"Bucket: {ex.Bucket}"); +/// +/// // Automatic retry with backoff +/// await Task.Delay(ex.RetryAfter); +/// await client.Rest.CreateMessageAsync(channelId, request); +/// } +/// +/// +/// +/// +/// +/// PawSharp includes built-in rate limiting that handles most rate limit scenarios automatically. +/// You typically won't see this exception unless you bypass the rate limiter or hit global rate limits. +/// +/// /// public class RateLimitException : DiscordException { @@ -15,11 +45,13 @@ public class RateLimitException : DiscordException /// /// Gets whether this is a global rate limit. + /// Global rate limits affect all requests to Discord, not just a specific endpoint. /// public bool IsGlobal { get; } /// /// Gets the rate limit bucket identifier, if available. + /// Buckets group similar endpoints together for rate limiting purposes. /// public string? Bucket { get; } diff --git a/src/PawSharp.Core/Exceptions/ValidationException.cs b/src/PawSharp.Core/Exceptions/ValidationException.cs index 286ddb8..60dbd9a 100644 --- a/src/PawSharp.Core/Exceptions/ValidationException.cs +++ b/src/PawSharp.Core/Exceptions/ValidationException.cs @@ -5,6 +5,26 @@ namespace PawSharp.Core.Exceptions; /// /// Exception thrown when input validation fails. +/// +/// This exception is thrown when user input or parameters fail validation before being sent to Discord's API. +/// It includes detailed information about which parameter failed validation and what value was provided. +/// +/// +/// +/// +/// try +/// { +/// await client.Rest.GetChannelMessagesAsync(channelId, limit: 500); // Max is 100 +/// } +/// catch (ValidationException ex) +/// { +/// Console.WriteLine($"Parameter: {ex.ParameterName}"); +/// Console.WriteLine($"Invalid Value: {ex.InvalidValue}"); +/// Console.WriteLine($"Error: {ex.Message}"); +/// } +/// +/// +/// /// public class ValidationException : DiscordException { diff --git a/src/PawSharp.Core/Extensions/ColorExtensions.cs b/src/PawSharp.Core/Extensions/ColorExtensions.cs index 8e2f2ad..6bf972a 100644 --- a/src/PawSharp.Core/Extensions/ColorExtensions.cs +++ b/src/PawSharp.Core/Extensions/ColorExtensions.cs @@ -28,6 +28,12 @@ public static class ColorExtensions /// /// The color integer. /// The hexadecimal string (e.g., "5865F2"). + /// + /// + /// int blurple = 0x5865F2; + /// string hex = blurple.ToHex(); // Returns "5865F2" + /// + /// public static string ToHex(this int color) { return color.ToString("X6").PadLeft(6, '0'); diff --git a/src/PawSharp.Core/Extensions/PermissionsExtensions.cs b/src/PawSharp.Core/Extensions/PermissionsExtensions.cs index 4a3e912..73f6eb9 100644 --- a/src/PawSharp.Core/Extensions/PermissionsExtensions.cs +++ b/src/PawSharp.Core/Extensions/PermissionsExtensions.cs @@ -14,6 +14,12 @@ public static class PermissionsExtensions /// The source permissions. /// The permission to check. /// True if the source has the permission. + /// + /// + /// Permissions userPerms = Permissions.SendMessages | Permissions.EmbedLinks; + /// bool canEmbed = userPerms.HasPermission(Permissions.EmbedLinks); // true + /// + /// public static bool HasPermission(this Permissions source, Permissions permission) { return (source & permission) == permission; diff --git a/src/PawSharp.Core/Extensions/SnowflakeExtensions.cs b/src/PawSharp.Core/Extensions/SnowflakeExtensions.cs index 0b35abc..36ff9ea 100644 --- a/src/PawSharp.Core/Extensions/SnowflakeExtensions.cs +++ b/src/PawSharp.Core/Extensions/SnowflakeExtensions.cs @@ -14,6 +14,13 @@ public static class SnowflakeExtensions /// /// The snowflake ID. /// The DateTimeOffset when the snowflake was created. + /// + /// + /// ulong userId = 123456789012345678; + /// DateTimeOffset createdAt = userId.GetCreatedAt(); + /// Console.WriteLine($"User created at: {createdAt}"); + /// + /// public static DateTimeOffset GetCreatedAt(this ulong snowflake) { return DateTimeOffset.FromUnixTimeMilliseconds((long)((snowflake >> 22) + 1420070400000UL)); diff --git a/src/PawSharp.Core/Extensions/StringExtensions.cs b/src/PawSharp.Core/Extensions/StringExtensions.cs index 24ef2e2..d5887f7 100644 --- a/src/PawSharp.Core/Extensions/StringExtensions.cs +++ b/src/PawSharp.Core/Extensions/StringExtensions.cs @@ -10,6 +10,11 @@ public static class StringExtensions /// /// The text to format. /// The formatted text. + /// + /// + /// string formatted = "Hello".ToBold(); // Returns "**Hello**" + /// + /// public static string ToBold(this string text) { return $"**{text}**"; @@ -83,6 +88,12 @@ public static string ToInlineCode(this string text) /// /// The user ID. /// The mention string. + /// + /// + /// ulong userId = 123456789012345678; + /// string mention = userId.ToUserMention(); // Returns "<@123456789012345678>" + /// + /// public static string ToUserMention(this ulong userId) { return $"<@{userId}>"; diff --git a/src/PawSharp.Core/Models/PawSharpOptions.cs b/src/PawSharp.Core/Models/PawSharpOptions.cs index eaa7649..89f5fbd 100644 --- a/src/PawSharp.Core/Models/PawSharpOptions.cs +++ b/src/PawSharp.Core/Models/PawSharpOptions.cs @@ -340,4 +340,168 @@ public int TimeoutSeconds /// public int MaxTransientErrorRetries { get; set; } = 3; } + + /// + /// Builder for creating PawSharpOptions with a fluent API. + /// + public class Builder + { + private readonly PawSharpOptions _options = new PawSharpOptions(); + + /// + /// Sets the Discord bot token. + /// + public Builder WithToken(string token) + { + _options.Token = token; + return this; + } + + /// + /// Sets the gateway intents. + /// + public Builder WithIntents(GatewayIntents intents) + { + _options.Intents = intents; + return this; + } + + /// + /// Sets the intent validation mode. + /// + public Builder WithIntentValidation(IntentValidationMode mode) + { + _options.IntentValidation = mode; + return this; + } + + /// + /// Sets the shard configuration. + /// + public Builder WithShards(int shards, int shardCount) + { + _options.Shards = shards; + _options.ShardCount = shardCount; + return this; + } + + /// + /// Sets the shard connection delay. + /// + public Builder WithShardConnectionDelayMs(int delayMs) + { + _options.ShardConnectionDelayMs = delayMs; + return this; + } + + /// + /// Sets the API version. + /// + public Builder WithApiVersion(int version) + { + _options.ApiVersion = version; + return this; + } + + /// + /// Sets a custom gateway URL for testing. + /// + public Builder WithCustomGatewayUrl(string url) + { + _options.CustomGatewayUrl = url; + return this; + } + + /// + /// Enables or disables gateway compression. + /// + public Builder WithCompression(bool enabled) + { + _options.EnableCompression = enabled; + return this; + } + + /// + /// Sets the WebSocket buffer size. + /// + public Builder WithWebSocketBufferSizeKb(int sizeKb) + { + _options.WebSocketBufferSizeKb = sizeKb; + return this; + } + + /// + /// Sets the maximum missed heartbeat ACKs. + /// + public Builder WithMaxMissedHeartbeatAcks(int max) + { + _options.MaxMissedHeartbeatAcks = max; + return this; + } + + /// + /// Configures reconnection options. + /// + public Builder ConfigureReconnection(Action configure) + { + configure(_options.Reconnection); + return this; + } + + /// + /// Configures event dispatch options. + /// + public Builder ConfigureEventDispatch(Action configure) + { + configure(_options.EventDispatch); + return this; + } + + /// + /// Configures cache options. + /// + public Builder ConfigureCache(Action configure) + { + configure(_options.Cache); + return this; + } + + /// + /// Configures REST API options. + /// + public Builder ConfigureRestApi(Action configure) + { + configure(_options.RestApi); + return this; + } + + /// + /// Sets the initial bot presence. + /// + public Builder WithPresence(string status, string? activityName = null, int activityType = 0, string? streamUrl = null) + { + _options.Presence = new PresenceOptions + { + Status = status, + ActivityName = activityName, + ActivityType = activityType, + StreamUrl = streamUrl + }; + return this; + } + + /// + /// Builds the PawSharpOptions instance. + /// + public PawSharpOptions Build() + { + _options.ValidateApiVersion(); + return _options; + } + + /// + /// Creates a new builder instance. + /// + public static Builder Create() => new Builder(); + } } \ No newline at end of file diff --git a/src/PawSharp.Core/README.md b/src/PawSharp.Core/README.md index a80597a..2ea8b85 100644 --- a/src/PawSharp.Core/README.md +++ b/src/PawSharp.Core/README.md @@ -18,7 +18,7 @@ If you are building integrations, middleware, or custom abstractions, this packa ## Installation ```bash -dotnet add package PawSharp.Core --version 1.1.0-alpha.1 +dotnet add package PawSharp.Core --version 1.1.0-alpha.2 ``` ## Quick Start diff --git a/src/PawSharp.Core/Serialization/PawSharpJsonContext.cs b/src/PawSharp.Core/Serialization/PawSharpJsonContext.cs index ba24b60..37eeb12 100644 --- a/src/PawSharp.Core/Serialization/PawSharpJsonContext.cs +++ b/src/PawSharp.Core/Serialization/PawSharpJsonContext.cs @@ -28,6 +28,13 @@ namespace PawSharp.Core.Serialization; [JsonSerializable(typeof(FileComponent))] [JsonSerializable(typeof(Separator))] [JsonSerializable(typeof(Container))] +[JsonSerializable(typeof(Label))] +[JsonSerializable(typeof(FileUpload))] +[JsonSerializable(typeof(RadioGroup))] +[JsonSerializable(typeof(RadioOption))] +[JsonSerializable(typeof(CheckboxGroup))] +[JsonSerializable(typeof(CheckboxOption))] +[JsonSerializable(typeof(Checkbox))] [JsonSerializable(typeof(UnfurledMediaItem))] [JsonSerializable(typeof(MediaGalleryItem))] [JsonSerializable(typeof(Emoji))] diff --git a/src/PawSharp.Core/Validation/ComponentValidator.cs b/src/PawSharp.Core/Validation/ComponentValidator.cs index 357cd13..7c6ef68 100644 --- a/src/PawSharp.Core/Validation/ComponentValidator.cs +++ b/src/PawSharp.Core/Validation/ComponentValidator.cs @@ -340,11 +340,35 @@ public static void ValidateComponent(MessageComponent component) ValidateActionRow(actionRow); break; case Container container: - ValidateComponentHierarchy(container.Components); + ValidateContainer(container); break; case Section section: ValidateSection(section); break; + case Separator separator: + // Separator has no specific validation beyond structure + break; + case Label label: + ValidateLabel(label); + break; + case FileUpload fileUpload: + ValidateFileUpload(fileUpload); + break; + case RadioGroup radioGroup: + ValidateRadioGroup(radioGroup); + break; + case CheckboxGroup checkboxGroup: + ValidateCheckboxGroup(checkboxGroup); + break; + case Checkbox checkbox: + ValidateCheckbox(checkbox); + break; + case ThumbnailComponent thumbnail: + ValidateThumbnail(thumbnail); + break; + case FileComponent file: + ValidateFile(file); + break; case UnknownComponent: // Unknown components are not validated break; @@ -368,4 +392,460 @@ private static void ValidateSection(Section section) ValidateComponent(section.Accessory); } } + + /// + /// Validates a Label component. + /// + /// The label to validate. + /// Thrown when validation fails. + public static void ValidateLabel(Label label) + { + if (string.IsNullOrEmpty(label.Text)) + { + throw new ValidationException( + "Label text is required.", + nameof(label.Text), + null + ); + } + + if (label.Text.Length > DiscordLimits.MaxLabelTextLength) + { + throw new ValidationException( + nameof(label.Text), + label.Text, + $"Label text must not exceed {DiscordLimits.MaxLabelTextLength} characters." + ); + } + } + + /// + /// Validates a FileUpload component. + /// + /// The file upload to validate. + /// Thrown when validation fails. + public static void ValidateFileUpload(FileUpload fileUpload) + { + if (string.IsNullOrEmpty(fileUpload.CustomId)) + { + throw new ValidationException( + "FileUpload custom ID is required.", + nameof(fileUpload.CustomId), + null + ); + } + + if (string.IsNullOrEmpty(fileUpload.Label)) + { + throw new ValidationException( + "FileUpload label is required.", + nameof(fileUpload.Label), + null + ); + } + + if (fileUpload.CustomId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + nameof(fileUpload.CustomId), + fileUpload.CustomId, + $"FileUpload custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters." + ); + } + + if (fileUpload.Label.Length > DiscordLimits.MaxFileUploadLabelLength) + { + throw new ValidationException( + nameof(fileUpload.Label), + fileUpload.Label, + $"FileUpload label must not exceed {DiscordLimits.MaxFileUploadLabelLength} characters." + ); + } + + if (fileUpload.Placeholder != null && fileUpload.Placeholder.Length > DiscordLimits.MaxFileUploadPlaceholderLength) + { + throw new ValidationException( + nameof(fileUpload.Placeholder), + fileUpload.Placeholder, + $"FileUpload placeholder must not exceed {DiscordLimits.MaxFileUploadPlaceholderLength} characters." + ); + } + + if (fileUpload.MinLength.HasValue && fileUpload.MinLength.Value > DiscordLimits.MaxFileUploadMinLength) + { + throw new ValidationException( + $"FileUpload minimum length must not exceed {DiscordLimits.MaxFileUploadMinLength}.", + nameof(fileUpload.MinLength), + fileUpload.MinLength.Value + ); + } + + if (fileUpload.MaxLength.HasValue && fileUpload.MaxLength.Value > DiscordLimits.MaxFileUploadMaxLength) + { + throw new ValidationException( + $"FileUpload maximum length must not exceed {DiscordLimits.MaxFileUploadMaxLength}.", + nameof(fileUpload.MaxLength), + fileUpload.MaxLength.Value + ); + } + + if (fileUpload.MinLength.HasValue && fileUpload.MaxLength.HasValue && fileUpload.MinLength.Value > fileUpload.MaxLength.Value) + { + throw new ValidationException( + "FileUpload minimum length cannot be greater than maximum length.", + nameof(fileUpload.MinLength), + fileUpload.MinLength.Value + ); + } + } + + /// + /// Validates a RadioGroup component. + /// + /// The radio group to validate. + /// Thrown when validation fails. + public static void ValidateRadioGroup(RadioGroup radioGroup) + { + if (string.IsNullOrEmpty(radioGroup.CustomId)) + { + throw new ValidationException( + "RadioGroup custom ID is required.", + nameof(radioGroup.CustomId), + null + ); + } + + if (string.IsNullOrEmpty(radioGroup.Label)) + { + throw new ValidationException( + "RadioGroup label is required.", + nameof(radioGroup.Label), + null + ); + } + + if (radioGroup.CustomId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + nameof(radioGroup.CustomId), + radioGroup.CustomId, + $"RadioGroup custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters." + ); + } + + if (radioGroup.Label.Length > DiscordLimits.MaxTextInputLabelLength) + { + throw new ValidationException( + nameof(radioGroup.Label), + radioGroup.Label, + $"RadioGroup label must not exceed {DiscordLimits.MaxTextInputLabelLength} characters." + ); + } + + if (radioGroup.Options.Count > DiscordLimits.MaxRadioGroupOptions) + { + throw new ValidationException( + $"RadioGroup must not have more than {DiscordLimits.MaxRadioGroupOptions} options.", + nameof(radioGroup.Options), + radioGroup.Options.Count + ); + } + + if (radioGroup.Options.Count == 0) + { + throw new ValidationException( + "RadioGroup must have at least one option.", + nameof(radioGroup.Options), + null + ); + } + + foreach (var option in radioGroup.Options) + { + ValidateRadioOption(option); + } + } + + /// + /// Validates a RadioOption. + /// + /// The option to validate. + /// Thrown when validation fails. + public static void ValidateRadioOption(RadioOption option) + { + if (string.IsNullOrEmpty(option.Label)) + { + throw new ValidationException( + "RadioOption label is required.", + nameof(option.Label), + null + ); + } + + if (string.IsNullOrEmpty(option.Value)) + { + throw new ValidationException( + "RadioOption value is required.", + nameof(option.Value), + null + ); + } + + if (option.Label.Length > DiscordLimits.MaxRadioGroupOptionLabelLength) + { + throw new ValidationException( + nameof(option.Label), + option.Label, + $"RadioOption label must not exceed {DiscordLimits.MaxRadioGroupOptionLabelLength} characters." + ); + } + + if (option.Value.Length > DiscordLimits.MaxRadioGroupOptionValueLength) + { + throw new ValidationException( + nameof(option.Value), + option.Value, + $"RadioOption value must not exceed {DiscordLimits.MaxRadioGroupOptionValueLength} characters." + ); + } + + if (option.Description != null && option.Description.Length > DiscordLimits.MaxRadioGroupOptionDescriptionLength) + { + throw new ValidationException( + nameof(option.Description), + option.Description, + $"RadioOption description must not exceed {DiscordLimits.MaxRadioGroupOptionDescriptionLength} characters." + ); + } + } + + /// + /// Validates a CheckboxGroup component. + /// + /// The checkbox group to validate. + /// Thrown when validation fails. + public static void ValidateCheckboxGroup(CheckboxGroup checkboxGroup) + { + if (string.IsNullOrEmpty(checkboxGroup.CustomId)) + { + throw new ValidationException( + "CheckboxGroup custom ID is required.", + nameof(checkboxGroup.CustomId), + null + ); + } + + if (string.IsNullOrEmpty(checkboxGroup.Label)) + { + throw new ValidationException( + "CheckboxGroup label is required.", + nameof(checkboxGroup.Label), + null + ); + } + + if (checkboxGroup.CustomId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + nameof(checkboxGroup.CustomId), + checkboxGroup.CustomId, + $"CheckboxGroup custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters." + ); + } + + if (checkboxGroup.Label.Length > DiscordLimits.MaxTextInputLabelLength) + { + throw new ValidationException( + nameof(checkboxGroup.Label), + checkboxGroup.Label, + $"CheckboxGroup label must not exceed {DiscordLimits.MaxTextInputLabelLength} characters." + ); + } + + if (checkboxGroup.Options.Count > DiscordLimits.MaxCheckboxGroupOptions) + { + throw new ValidationException( + $"CheckboxGroup must not have more than {DiscordLimits.MaxCheckboxGroupOptions} options.", + nameof(checkboxGroup.Options), + checkboxGroup.Options.Count + ); + } + + if (checkboxGroup.Options.Count == 0) + { + throw new ValidationException( + "CheckboxGroup must have at least one option.", + nameof(checkboxGroup.Options), + null + ); + } + + if (checkboxGroup.MinValues.HasValue && checkboxGroup.MaxValues.HasValue && checkboxGroup.MinValues.Value > checkboxGroup.MaxValues.Value) + { + throw new ValidationException( + "CheckboxGroup minimum values cannot be greater than maximum values.", + nameof(checkboxGroup.MinValues), + checkboxGroup.MinValues.Value + ); + } + + foreach (var option in checkboxGroup.Options) + { + ValidateCheckboxOption(option); + } + } + + /// + /// Validates a CheckboxOption. + /// + /// The option to validate. + /// Thrown when validation fails. + public static void ValidateCheckboxOption(CheckboxOption option) + { + if (string.IsNullOrEmpty(option.Label)) + { + throw new ValidationException( + "CheckboxOption label is required.", + nameof(option.Label), + null + ); + } + + if (string.IsNullOrEmpty(option.Value)) + { + throw new ValidationException( + "CheckboxOption value is required.", + nameof(option.Value), + null + ); + } + + if (option.Label.Length > DiscordLimits.MaxCheckboxGroupOptionLabelLength) + { + throw new ValidationException( + nameof(option.Label), + option.Label, + $"CheckboxOption label must not exceed {DiscordLimits.MaxCheckboxGroupOptionLabelLength} characters." + ); + } + + if (option.Value.Length > DiscordLimits.MaxCheckboxGroupOptionValueLength) + { + throw new ValidationException( + nameof(option.Value), + option.Value, + $"CheckboxOption value must not exceed {DiscordLimits.MaxCheckboxGroupOptionValueLength} characters." + ); + } + + if (option.Description != null && option.Description.Length > DiscordLimits.MaxCheckboxGroupOptionDescriptionLength) + { + throw new ValidationException( + nameof(option.Description), + option.Description, + $"CheckboxOption description must not exceed {DiscordLimits.MaxCheckboxGroupOptionDescriptionLength} characters." + ); + } + } + + /// + /// Validates a Checkbox component. + /// + /// The checkbox to validate. + /// Thrown when validation fails. + public static void ValidateCheckbox(Checkbox checkbox) + { + if (string.IsNullOrEmpty(checkbox.CustomId)) + { + throw new ValidationException( + "Checkbox custom ID is required.", + nameof(checkbox.CustomId), + null + ); + } + + if (string.IsNullOrEmpty(checkbox.Label)) + { + throw new ValidationException( + "Checkbox label is required.", + nameof(checkbox.Label), + null + ); + } + + if (checkbox.CustomId.Length > DiscordLimits.MaxTextInputCustomIdLength) + { + throw new ValidationException( + nameof(checkbox.CustomId), + checkbox.CustomId, + $"Checkbox custom ID must not exceed {DiscordLimits.MaxTextInputCustomIdLength} characters." + ); + } + + if (checkbox.Label.Length > DiscordLimits.MaxCheckboxLabelLength) + { + throw new ValidationException( + nameof(checkbox.Label), + checkbox.Label, + $"Checkbox label must not exceed {DiscordLimits.MaxCheckboxLabelLength} characters." + ); + } + } + + /// + /// Validates a Container component. + /// + /// The container to validate. + /// Thrown when validation fails. + public static void ValidateContainer(Container container) + { + if (container.Components.Count > DiscordLimits.MaxComponentsPerContainer) + { + throw new ValidationException( + $"Container must not contain more than {DiscordLimits.MaxComponentsPerContainer} components.", + nameof(container.Components), + container.Components.Count + ); + } + + // Validate each component in the container + foreach (var component in container.Components) + { + ValidateComponent(component); + } + } + + /// + /// Validates a Thumbnail component. + /// + /// The thumbnail to validate. + /// Thrown when validation fails. + public static void ValidateThumbnail(ThumbnailComponent thumbnail) + { + if (string.IsNullOrEmpty(thumbnail.Media.Url)) + { + throw new ValidationException( + "Thumbnail must have a URL.", + nameof(thumbnail.Media.Url), + null + ); + } + } + + /// + /// Validates a File component. + /// + /// The file to validate. + /// Thrown when validation fails. + public static void ValidateFile(FileComponent file) + { + if (string.IsNullOrEmpty(file.File.Url)) + { + throw new ValidationException( + "File must have a URL.", + nameof(file.File.Url), + null + ); + } + } } diff --git a/src/PawSharp.Core/Validation/DiscordLimits.cs b/src/PawSharp.Core/Validation/DiscordLimits.cs index 8a94b58..839ee9f 100644 --- a/src/PawSharp.Core/Validation/DiscordLimits.cs +++ b/src/PawSharp.Core/Validation/DiscordLimits.cs @@ -104,7 +104,57 @@ public static class DiscordLimits /// Minimum media gallery items. public const int MinMediaGalleryItems = 1; - + + // ── Components v2 Limits ───────────────────────────────────────────────────── + + /// Maximum label text length. + public const int MaxLabelTextLength = 80; + + /// Maximum label description length. + public const int MaxLabelDescriptionLength = 200; + + /// Maximum file upload label length. + public const int MaxFileUploadLabelLength = 45; + + /// Maximum file upload placeholder length. + public const int MaxFileUploadPlaceholderLength = 100; + + /// Maximum file upload minimum length (number of files). + public const int MaxFileUploadMinLength = 10; + + /// Maximum file upload maximum length (number of files). + public const int MaxFileUploadMaxLength = 10; + + /// Maximum radio group options. + public const int MaxRadioGroupOptions = 25; + + /// Maximum radio group option label length. + public const int MaxRadioGroupOptionLabelLength = 100; + + /// Maximum radio group option value length. + public const int MaxRadioGroupOptionValueLength = 100; + + /// Maximum radio group option description length. + public const int MaxRadioGroupOptionDescriptionLength = 100; + + /// Maximum checkbox group options. + public const int MaxCheckboxGroupOptions = 25; + + /// Maximum checkbox group option label length. + public const int MaxCheckboxGroupOptionLabelLength = 100; + + /// Maximum checkbox group option value length. + public const int MaxCheckboxGroupOptionValueLength = 100; + + /// Maximum checkbox group option description length. + public const int MaxCheckboxGroupOptionDescriptionLength = 100; + + /// Maximum checkbox label length. + public const int MaxCheckboxLabelLength = 80; + + /// Maximum components per container. + public const int MaxComponentsPerContainer = 20; + // ── Application Command Limits ─────────────────────────────────────────────── /// Maximum command name length. diff --git a/src/PawSharp.Core/Validation/SnowflakeValidator.cs b/src/PawSharp.Core/Validation/SnowflakeValidator.cs index 1a61e2a..7c532d7 100644 --- a/src/PawSharp.Core/Validation/SnowflakeValidator.cs +++ b/src/PawSharp.Core/Validation/SnowflakeValidator.cs @@ -19,7 +19,11 @@ public static void ValidateSnowflake(ulong snowflake, string parameterName = "sn { if (snowflake == 0) { - throw new ValidationException($"Snowflake ID must be a valid Snowflake (non-zero).", parameterName, snowflake); + throw new ValidationException( + $"Snowflake ID must be a valid Discord ID (non-zero). Discord IDs are 64-bit unsigned integers. " + + $"Ensure you're using a valid ID from Discord (e.g., from message mentions, user profiles, or API responses).", + parameterName, + snowflake); } } diff --git a/src/PawSharp.Gateway/Events/EventDispatcherExtensions.cs b/src/PawSharp.Gateway/Events/EventDispatcherExtensions.cs new file mode 100644 index 0000000..875662c --- /dev/null +++ b/src/PawSharp.Gateway/Events/EventDispatcherExtensions.cs @@ -0,0 +1,1021 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using PawSharp.Gateway.Events; + +namespace PawSharp.Gateway.Events; + +/// +/// Strongly-typed extension methods for EventDispatcher to eliminate string literals in event subscription. +/// Provides compile-time safety and IntelliSense for all Discord gateway events. +/// +public static class EventDispatcherExtensions +{ + // Core Events + + /// + /// Subscribes to READY events. + /// + public static IDisposable OnReady(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("READY", handler); + + /// + /// Subscribes to READY events (synchronous). + /// + public static IDisposable OnReady(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("READY", handler); + + /// + /// Subscribes to RESUMED events. + /// + public static IDisposable OnResumed(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("RESUMED", handler); + + /// + /// Subscribes to RESUMED events (synchronous). + /// + public static IDisposable OnResumed(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("RESUMED", handler); + + // Message Events + + /// + /// Subscribes to MESSAGE_CREATE events. + /// + public static IDisposable OnMessageCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_CREATE", handler); + + /// + /// Subscribes to MESSAGE_CREATE events (synchronous). + /// + public static IDisposable OnMessageCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_CREATE", handler); + + /// + /// Subscribes to MESSAGE_UPDATE events. + /// + public static IDisposable OnMessageUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_UPDATE", handler); + + /// + /// Subscribes to MESSAGE_UPDATE events (synchronous). + /// + public static IDisposable OnMessageUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_UPDATE", handler); + + /// + /// Subscribes to MESSAGE_DELETE events. + /// + public static IDisposable OnMessageDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_DELETE", handler); + + /// + /// Subscribes to MESSAGE_DELETE events (synchronous). + /// + public static IDisposable OnMessageDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_DELETE", handler); + + /// + /// Subscribes to MESSAGE_DELETE_BULK events. + /// + public static IDisposable OnMessageDeleteBulk(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_DELETE_BULK", handler); + + /// + /// Subscribes to MESSAGE_DELETE_BULK events (synchronous). + /// + public static IDisposable OnMessageDeleteBulk(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_DELETE_BULK", handler); + + // Reaction Events + + /// + /// Subscribes to MESSAGE_REACTION_ADD events. + /// + public static IDisposable OnMessageReactionAdd(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_REACTION_ADD", handler); + + /// + /// Subscribes to MESSAGE_REACTION_ADD events (synchronous). + /// + public static IDisposable OnMessageReactionAdd(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_REACTION_ADD", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE events. + /// + public static IDisposable OnMessageReactionRemove(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE events (synchronous). + /// + public static IDisposable OnMessageReactionRemove(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE_ALL events. + /// + public static IDisposable OnMessageReactionRemoveAll(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE_ALL", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE_ALL events (synchronous). + /// + public static IDisposable OnMessageReactionRemoveAll(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE_ALL", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE_EMOJI events. + /// + public static IDisposable OnMessageReactionRemoveEmoji(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE_EMOJI", handler); + + /// + /// Subscribes to MESSAGE_REACTION_REMOVE_EMOJI events (synchronous). + /// + public static IDisposable OnMessageReactionRemoveEmoji(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_REACTION_REMOVE_EMOJI", handler); + + // Poll Events + + /// + /// Subscribes to MESSAGE_POLL_VOTE_ADD events. + /// + public static IDisposable OnMessagePollVoteAdd(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_POLL_VOTE_ADD", handler); + + /// + /// Subscribes to MESSAGE_POLL_VOTE_ADD events (synchronous). + /// + public static IDisposable OnMessagePollVoteAdd(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_POLL_VOTE_ADD", handler); + + /// + /// Subscribes to MESSAGE_POLL_VOTE_REMOVE events. + /// + public static IDisposable OnMessagePollVoteRemove(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("MESSAGE_POLL_VOTE_REMOVE", handler); + + /// + /// Subscribes to MESSAGE_POLL_VOTE_REMOVE events (synchronous). + /// + public static IDisposable OnMessagePollVoteRemove(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("MESSAGE_POLL_VOTE_REMOVE", handler); + + // Guild Events + + /// + /// Subscribes to GUILD_CREATE events. + /// + public static IDisposable OnGuildCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_CREATE", handler); + + /// + /// Subscribes to GUILD_CREATE events (synchronous). + /// + public static IDisposable OnGuildCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_CREATE", handler); + + /// + /// Subscribes to GUILD_UPDATE events. + /// + public static IDisposable OnGuildUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_UPDATE", handler); + + /// + /// Subscribes to GUILD_UPDATE events (synchronous). + /// + public static IDisposable OnGuildUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_UPDATE", handler); + + /// + /// Subscribes to GUILD_DELETE events. + /// + public static IDisposable OnGuildDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_DELETE", handler); + + /// + /// Subscribes to GUILD_DELETE events (synchronous). + /// + public static IDisposable OnGuildDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_DELETE", handler); + + /// + /// Subscribes to GUILD_AVAILABLE events. + /// + public static IDisposable OnGuildAvailable(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_AVAILABLE", handler); + + /// + /// Subscribes to GUILD_AVAILABLE events (synchronous). + /// + public static IDisposable OnGuildAvailable(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_AVAILABLE", handler); + + /// + /// Subscribes to GUILD_UNAVAILABLE events. + /// + public static IDisposable OnGuildUnavailable(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_UNAVAILABLE", handler); + + /// + /// Subscribes to GUILD_UNAVAILABLE events (synchronous). + /// + public static IDisposable OnGuildUnavailable(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_UNAVAILABLE", handler); + + /// + /// Subscribes to GUILD_EMOJIS_UPDATE events. + /// + public static IDisposable OnGuildEmojisUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_EMOJIS_UPDATE", handler); + + /// + /// Subscribes to GUILD_EMOJIS_UPDATE events (synchronous). + /// + public static IDisposable OnGuildEmojisUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_EMOJIS_UPDATE", handler); + + /// + /// Subscribes to GUILD_STICKERS_UPDATE events. + /// + public static IDisposable OnGuildStickersUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_STICKERS_UPDATE", handler); + + /// + /// Subscribes to GUILD_STICKERS_UPDATE events (synchronous). + /// + public static IDisposable OnGuildStickersUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_STICKERS_UPDATE", handler); + + /// + /// Subscribes to GUILD_BAN_ADD events. + /// + public static IDisposable OnGuildBanAdd(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_BAN_ADD", handler); + + /// + /// Subscribes to GUILD_BAN_ADD events (synchronous). + /// + public static IDisposable OnGuildBanAdd(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_BAN_ADD", handler); + + /// + /// Subscribes to GUILD_BAN_REMOVE events. + /// + public static IDisposable OnGuildBanRemove(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_BAN_REMOVE", handler); + + /// + /// Subscribes to GUILD_BAN_REMOVE events (synchronous). + /// + public static IDisposable OnGuildBanRemove(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_BAN_REMOVE", handler); + + /// + /// Subscribes to GUILD_INTEGRATIONS_UPDATE events. + /// + public static IDisposable OnGuildIntegrationsUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_INTEGRATIONS_UPDATE", handler); + + /// + /// Subscribes to GUILD_INTEGRATIONS_UPDATE events (synchronous). + /// + public static IDisposable OnGuildIntegrationsUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_INTEGRATIONS_UPDATE", handler); + + /// + /// Subscribes to GUILD_AUDIT_LOG_ENTRY_CREATE events. + /// + public static IDisposable OnGuildAuditLogEntryCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_AUDIT_LOG_ENTRY_CREATE", handler); + + /// + /// Subscribes to GUILD_AUDIT_LOG_ENTRY_CREATE events (synchronous). + /// + public static IDisposable OnGuildAuditLogEntryCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_AUDIT_LOG_ENTRY_CREATE", handler); + + // Guild Member Events + + /// + /// Subscribes to GUILD_MEMBER_ADD events. + /// + public static IDisposable OnGuildMemberAdd(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_MEMBER_ADD", handler); + + /// + /// Subscribes to GUILD_MEMBER_ADD events (synchronous). + /// + public static IDisposable OnGuildMemberAdd(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_MEMBER_ADD", handler); + + /// + /// Subscribes to GUILD_MEMBER_UPDATE events. + /// + public static IDisposable OnGuildMemberUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_MEMBER_UPDATE", handler); + + /// + /// Subscribes to GUILD_MEMBER_UPDATE events (synchronous). + /// + public static IDisposable OnGuildMemberUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_MEMBER_UPDATE", handler); + + /// + /// Subscribes to GUILD_MEMBER_REMOVE events. + /// + public static IDisposable OnGuildMemberRemove(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_MEMBER_REMOVE", handler); + + /// + /// Subscribes to GUILD_MEMBER_REMOVE events (synchronous). + /// + public static IDisposable OnGuildMemberRemove(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_MEMBER_REMOVE", handler); + + /// + /// Subscribes to GUILD_MEMBERS_CHUNK events. + /// + public static IDisposable OnGuildMembersChunk(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_MEMBERS_CHUNK", handler); + + /// + /// Subscribes to GUILD_MEMBERS_CHUNK events (synchronous). + /// + public static IDisposable OnGuildMembersChunk(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_MEMBERS_CHUNK", handler); + + // Guild Role Events + + /// + /// Subscribes to GUILD_ROLE_CREATE events. + /// + public static IDisposable OnGuildRoleCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_ROLE_CREATE", handler); + + /// + /// Subscribes to GUILD_ROLE_CREATE events (synchronous). + /// + public static IDisposable OnGuildRoleCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_ROLE_CREATE", handler); + + /// + /// Subscribes to GUILD_ROLE_UPDATE events. + /// + public static IDisposable OnGuildRoleUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_ROLE_UPDATE", handler); + + /// + /// Subscribes to GUILD_ROLE_UPDATE events (synchronous). + /// + public static IDisposable OnGuildRoleUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_ROLE_UPDATE", handler); + + /// + /// Subscribes to GUILD_ROLE_DELETE events. + /// + public static IDisposable OnGuildRoleDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_ROLE_DELETE", handler); + + /// + /// Subscribes to GUILD_ROLE_DELETE events (synchronous). + /// + public static IDisposable OnGuildRoleDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_ROLE_DELETE", handler); + + // Guild Scheduled Events + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_CREATE events. + /// + public static IDisposable OnGuildScheduledEventCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_CREATE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_CREATE events (synchronous). + /// + public static IDisposable OnGuildScheduledEventCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_CREATE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_UPDATE events. + /// + public static IDisposable OnGuildScheduledEventUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_UPDATE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_UPDATE events (synchronous). + /// + public static IDisposable OnGuildScheduledEventUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_UPDATE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_DELETE events. + /// + public static IDisposable OnGuildScheduledEventDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_DELETE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_DELETE events (synchronous). + /// + public static IDisposable OnGuildScheduledEventDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_DELETE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_USER_ADD events. + /// + public static IDisposable OnGuildScheduledEventUserAdd(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_USER_ADD", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_USER_ADD events (synchronous). + /// + public static IDisposable OnGuildScheduledEventUserAdd(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_USER_ADD", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_USER_REMOVE events. + /// + public static IDisposable OnGuildScheduledEventUserRemove(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_USER_REMOVE", handler); + + /// + /// Subscribes to GUILD_SCHEDULED_EVENT_USER_REMOVE events (synchronous). + /// + public static IDisposable OnGuildScheduledEventUserRemove(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SCHEDULED_EVENT_USER_REMOVE", handler); + + // Guild Soundboard Events + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_CREATE events. + /// + public static IDisposable OnGuildSoundboardSoundCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_CREATE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_CREATE events (synchronous). + /// + public static IDisposable OnGuildSoundboardSoundCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_CREATE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_UPDATE events. + /// + public static IDisposable OnGuildSoundboardSoundUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_UPDATE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_UPDATE events (synchronous). + /// + public static IDisposable OnGuildSoundboardSoundUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_UPDATE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_DELETE events. + /// + public static IDisposable OnGuildSoundboardSoundDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_DELETE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUND_DELETE events (synchronous). + /// + public static IDisposable OnGuildSoundboardSoundDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUND_DELETE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUNDS_UPDATE events. + /// + public static IDisposable OnGuildSoundboardSoundsUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUNDS_UPDATE", handler); + + /// + /// Subscribes to GUILD_SOUNDBOARD_SOUNDS_UPDATE events (synchronous). + /// + public static IDisposable OnGuildSoundboardSoundsUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_SOUNDBOARD_SOUNDS_UPDATE", handler); + + // Channel Events + + /// + /// Subscribes to CHANNEL_CREATE events. + /// + public static IDisposable OnChannelCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("CHANNEL_CREATE", handler); + + /// + /// Subscribes to CHANNEL_CREATE events (synchronous). + /// + public static IDisposable OnChannelCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("CHANNEL_CREATE", handler); + + /// + /// Subscribes to CHANNEL_UPDATE events. + /// + public static IDisposable OnChannelUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("CHANNEL_UPDATE", handler); + + /// + /// Subscribes to CHANNEL_UPDATE events (synchronous). + /// + public static IDisposable OnChannelUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("CHANNEL_UPDATE", handler); + + /// + /// Subscribes to CHANNEL_DELETE events. + /// + public static IDisposable OnChannelDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("CHANNEL_DELETE", handler); + + /// + /// Subscribes to CHANNEL_DELETE events (synchronous). + /// + public static IDisposable OnChannelDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("CHANNEL_DELETE", handler); + + /// + /// Subscribes to CHANNEL_PINS_UPDATE events. + /// + public static IDisposable OnChannelPinsUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("CHANNEL_PINS_UPDATE", handler); + + /// + /// Subscribes to CHANNEL_PINS_UPDATE events (synchronous). + /// + public static IDisposable OnChannelPinsUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("CHANNEL_PINS_UPDATE", handler); + + // Thread Events + + /// + /// Subscribes to THREAD_CREATE events. + /// + public static IDisposable OnThreadCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_CREATE", handler); + + /// + /// Subscribes to THREAD_CREATE events (synchronous). + /// + public static IDisposable OnThreadCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_CREATE", handler); + + /// + /// Subscribes to THREAD_UPDATE events. + /// + public static IDisposable OnThreadUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_UPDATE", handler); + + /// + /// Subscribes to THREAD_UPDATE events (synchronous). + /// + public static IDisposable OnThreadUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_UPDATE", handler); + + /// + /// Subscribes to THREAD_DELETE events. + /// + public static IDisposable OnThreadDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_DELETE", handler); + + /// + /// Subscribes to THREAD_DELETE events (synchronous). + /// + public static IDisposable OnThreadDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_DELETE", handler); + + /// + /// Subscribes to THREAD_LIST_SYNC events. + /// + public static IDisposable OnThreadListSync(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_LIST_SYNC", handler); + + /// + /// Subscribes to THREAD_LIST_SYNC events (synchronous). + /// + public static IDisposable OnThreadListSync(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_LIST_SYNC", handler); + + /// + /// Subscribes to THREAD_MEMBER_UPDATE events. + /// + public static IDisposable OnThreadMemberUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_MEMBER_UPDATE", handler); + + /// + /// Subscribes to THREAD_MEMBER_UPDATE events (synchronous). + /// + public static IDisposable OnThreadMemberUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_MEMBER_UPDATE", handler); + + /// + /// Subscribes to THREAD_MEMBERS_UPDATE events. + /// + public static IDisposable OnThreadMembersUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("THREAD_MEMBERS_UPDATE", handler); + + /// + /// Subscribes to THREAD_MEMBERS_UPDATE events (synchronous). + /// + public static IDisposable OnThreadMembersUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("THREAD_MEMBERS_UPDATE", handler); + + // Interaction Events + + /// + /// Subscribes to INTERACTION_CREATE events. + /// + public static IDisposable OnInteractionCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INTERACTION_CREATE", handler); + + /// + /// Subscribes to INTERACTION_CREATE events (synchronous). + /// + public static IDisposable OnInteractionCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INTERACTION_CREATE", handler); + + // Voice Events + + /// + /// Subscribes to VOICE_STATE_UPDATE events. + /// + public static IDisposable OnVoiceStateUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("VOICE_STATE_UPDATE", handler); + + /// + /// Subscribes to VOICE_STATE_UPDATE events (synchronous). + /// + public static IDisposable OnVoiceStateUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("VOICE_STATE_UPDATE", handler); + + /// + /// Subscribes to VOICE_SERVER_UPDATE events. + /// + public static IDisposable OnVoiceServerUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("VOICE_SERVER_UPDATE", handler); + + /// + /// Subscribes to VOICE_SERVER_UPDATE events (synchronous). + /// + public static IDisposable OnVoiceServerUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("VOICE_SERVER_UPDATE", handler); + + /// + /// Subscribes to VOICE_CHANNEL_EFFECT_SEND events. + /// + public static IDisposable OnVoiceChannelEffectSend(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("VOICE_CHANNEL_EFFECT_SEND", handler); + + /// + /// Subscribes to VOICE_CHANNEL_EFFECT_SEND events (synchronous). + /// + public static IDisposable OnVoiceChannelEffectSend(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("VOICE_CHANNEL_EFFECT_SEND", handler); + + /// + /// Subscribes to VOICE_CHANNEL_STATUS_UPDATE events. + /// + public static IDisposable OnVoiceChannelStatusUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("VOICE_CHANNEL_STATUS_UPDATE", handler); + + /// + /// Subscribes to VOICE_CHANNEL_STATUS_UPDATE events (synchronous). + /// + public static IDisposable OnVoiceChannelStatusUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("VOICE_CHANNEL_STATUS_UPDATE", handler); + + // Presence Events + + /// + /// Subscribes to PRESENCE_UPDATE events. + /// + public static IDisposable OnPresenceUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("PRESENCE_UPDATE", handler); + + /// + /// Subscribes to PRESENCE_UPDATE events (synchronous). + /// + public static IDisposable OnPresenceUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("PRESENCE_UPDATE", handler); + + /// + /// Subscribes to TYPING_START events. + /// + public static IDisposable OnTypingStart(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("TYPING_START", handler); + + /// + /// Subscribes to TYPING_START events (synchronous). + /// + public static IDisposable OnTypingStart(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("TYPING_START", handler); + + // Auto Moderation Events + + /// + /// Subscribes to AUTO_MODERATION_RULE_CREATE events. + /// + public static IDisposable OnAutoModerationRuleCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("AUTO_MODERATION_RULE_CREATE", handler); + + /// + /// Subscribes to AUTO_MODERATION_RULE_CREATE events (synchronous). + /// + public static IDisposable OnAutoModerationRuleCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("AUTO_MODERATION_RULE_CREATE", handler); + + /// + /// Subscribes to AUTO_MODERATION_RULE_UPDATE events. + /// + public static IDisposable OnAutoModerationRuleUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("AUTO_MODERATION_RULE_UPDATE", handler); + + /// + /// Subscribes to AUTO_MODERATION_RULE_UPDATE events (synchronous). + /// + public static IDisposable OnAutoModerationRuleUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("AUTO_MODERATION_RULE_UPDATE", handler); + + /// + /// Subscribes to AUTO_MODERATION_RULE_DELETE events. + /// + public static IDisposable OnAutoModerationRuleDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("AUTO_MODERATION_RULE_DELETE", handler); + + /// + /// Subscribes to AUTO_MODERATION_RULE_DELETE events (synchronous). + /// + public static IDisposable OnAutoModerationRuleDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("AUTO_MODERATION_RULE_DELETE", handler); + + /// + /// Subscribes to AUTO_MODERATION_ACTION_EXECUTION events. + /// + public static IDisposable OnAutoModerationActionExecution(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("AUTO_MODERATION_ACTION_EXECUTION", handler); + + /// + /// Subscribes to AUTO_MODERATION_ACTION_EXECUTION events (synchronous). + /// + public static IDisposable OnAutoModerationActionExecution(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("AUTO_MODERATION_ACTION_EXECUTION", handler); + + // Stage Events + + /// + /// Subscribes to STAGE_INSTANCE_CREATE events. + /// + public static IDisposable OnStageInstanceCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("STAGE_INSTANCE_CREATE", handler); + + /// + /// Subscribes to STAGE_INSTANCE_CREATE events (synchronous). + /// + public static IDisposable OnStageInstanceCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("STAGE_INSTANCE_CREATE", handler); + + /// + /// Subscribes to STAGE_INSTANCE_UPDATE events. + /// + public static IDisposable OnStageInstanceUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("STAGE_INSTANCE_UPDATE", handler); + + /// + /// Subscribes to STAGE_INSTANCE_UPDATE events (synchronous). + /// + public static IDisposable OnStageInstanceUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("STAGE_INSTANCE_UPDATE", handler); + + /// + /// Subscribes to STAGE_INSTANCE_DELETE events. + /// + public static IDisposable OnStageInstanceDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("STAGE_INSTANCE_DELETE", handler); + + /// + /// Subscribes to STAGE_INSTANCE_DELETE events (synchronous). + /// + public static IDisposable OnStageInstanceDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("STAGE_INSTANCE_DELETE", handler); + + // Entitlement Events + + /// + /// Subscribes to ENTITLEMENT_CREATE events. + /// + public static IDisposable OnEntitlementCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("ENTITLEMENT_CREATE", handler); + + /// + /// Subscribes to ENTITLEMENT_CREATE events (synchronous). + /// + public static IDisposable OnEntitlementCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("ENTITLEMENT_CREATE", handler); + + /// + /// Subscribes to ENTITLEMENT_UPDATE events. + /// + public static IDisposable OnEntitlementUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("ENTITLEMENT_UPDATE", handler); + + /// + /// Subscribes to ENTITLEMENT_UPDATE events (synchronous). + /// + public static IDisposable OnEntitlementUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("ENTITLEMENT_UPDATE", handler); + + /// + /// Subscribes to ENTITLEMENT_DELETE events. + /// + public static IDisposable OnEntitlementDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("ENTITLEMENT_DELETE", handler); + + /// + /// Subscribes to ENTITLEMENT_DELETE events (synchronous). + /// + public static IDisposable OnEntitlementDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("ENTITLEMENT_DELETE", handler); + + // Subscription Events + + /// + /// Subscribes to SUBSCRIPTION_CREATE events. + /// + public static IDisposable OnSubscriptionCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("SUBSCRIPTION_CREATE", handler); + + /// + /// Subscribes to SUBSCRIPTION_CREATE events (synchronous). + /// + public static IDisposable OnSubscriptionCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("SUBSCRIPTION_CREATE", handler); + + /// + /// Subscribes to SUBSCRIPTION_UPDATE events. + /// + public static IDisposable OnSubscriptionUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("SUBSCRIPTION_UPDATE", handler); + + /// + /// Subscribes to SUBSCRIPTION_UPDATE events (synchronous). + /// + public static IDisposable OnSubscriptionUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("SUBSCRIPTION_UPDATE", handler); + + /// + /// Subscribes to SUBSCRIPTION_DELETE events. + /// + public static IDisposable OnSubscriptionDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("SUBSCRIPTION_DELETE", handler); + + /// + /// Subscribes to SUBSCRIPTION_DELETE events (synchronous). + /// + public static IDisposable OnSubscriptionDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("SUBSCRIPTION_DELETE", handler); + + // Invite Events + + /// + /// Subscribes to INVITE_CREATE events. + /// + public static IDisposable OnInviteCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INVITE_CREATE", handler); + + /// + /// Subscribes to INVITE_CREATE events (synchronous). + /// + public static IDisposable OnInviteCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INVITE_CREATE", handler); + + /// + /// Subscribes to INVITE_DELETE events. + /// + public static IDisposable OnInviteDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INVITE_DELETE", handler); + + /// + /// Subscribes to INVITE_DELETE events (synchronous). + /// + public static IDisposable OnInviteDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INVITE_DELETE", handler); + + // Webhook Events + + /// + /// Subscribes to WEBHOOKS_UPDATE events. + /// + public static IDisposable OnWebhooksUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("WEBHOOKS_UPDATE", handler); + + /// + /// Subscribes to WEBHOOKS_UPDATE events (synchronous). + /// + public static IDisposable OnWebhooksUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("WEBHOOKS_UPDATE", handler); + + // Permission Events + + /// + /// Subscribes to APPLICATION_COMMAND_PERMISSIONS_UPDATE events. + /// + public static IDisposable OnApplicationCommandPermissionsUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("APPLICATION_COMMAND_PERMISSIONS_UPDATE", handler); + + /// + /// Subscribes to APPLICATION_COMMAND_PERMISSIONS_UPDATE events (synchronous). + /// + public static IDisposable OnApplicationCommandPermissionsUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("APPLICATION_COMMAND_PERMISSIONS_UPDATE", handler); + + // App Command Events + + /// + /// Subscribes to GUILD_APP_COMMAND_CREATE events. + /// + public static IDisposable OnGuildAppCommandCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_APP_COMMAND_CREATE", handler); + + /// + /// Subscribes to GUILD_APP_COMMAND_CREATE events (synchronous). + /// + public static IDisposable OnGuildAppCommandCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_APP_COMMAND_CREATE", handler); + + /// + /// Subscribes to GUILD_APP_COMMAND_UPDATE events. + /// + public static IDisposable OnGuildAppCommandUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_APP_COMMAND_UPDATE", handler); + + /// + /// Subscribes to GUILD_APP_COMMAND_UPDATE events (synchronous). + /// + public static IDisposable OnGuildAppCommandUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_APP_COMMAND_UPDATE", handler); + + /// + /// Subscribes to GUILD_APP_COMMAND_DELETE events. + /// + public static IDisposable OnGuildAppCommandDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("GUILD_APP_COMMAND_DELETE", handler); + + /// + /// Subscribes to GUILD_APP_COMMAND_DELETE events (synchronous). + /// + public static IDisposable OnGuildAppCommandDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("GUILD_APP_COMMAND_DELETE", handler); + + // Integration Events + + /// + /// Subscribes to INTEGRATION_CREATE events. + /// + public static IDisposable OnIntegrationCreate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INTEGRATION_CREATE", handler); + + /// + /// Subscribes to INTEGRATION_CREATE events (synchronous). + /// + public static IDisposable OnIntegrationCreate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INTEGRATION_CREATE", handler); + + /// + /// Subscribes to INTEGRATION_UPDATE events. + /// + public static IDisposable OnIntegrationUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INTEGRATION_UPDATE", handler); + + /// + /// Subscribes to INTEGRATION_UPDATE events (synchronous). + /// + public static IDisposable OnIntegrationUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INTEGRATION_UPDATE", handler); + + /// + /// Subscribes to INTEGRATION_DELETE events. + /// + public static IDisposable OnIntegrationDelete(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("INTEGRATION_DELETE", handler); + + /// + /// Subscribes to INTEGRATION_DELETE events (synchronous). + /// + public static IDisposable OnIntegrationDelete(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("INTEGRATION_DELETE", handler); + + // User Events + + /// + /// Subscribes to USER_UPDATE events. + /// + public static IDisposable OnUserUpdate(this EventDispatcher dispatcher, Func handler) + => dispatcher.On("USER_UPDATE", handler); + + /// + /// Subscribes to USER_UPDATE events (synchronous). + /// + public static IDisposable OnUserUpdate(this EventDispatcher dispatcher, Action handler) + => dispatcher.On("USER_UPDATE", handler); +} diff --git a/src/PawSharp.Gateway/Events/EventFilteringExtensions.cs b/src/PawSharp.Gateway/Events/EventFilteringExtensions.cs new file mode 100644 index 0000000..055c7b6 --- /dev/null +++ b/src/PawSharp.Gateway/Events/EventFilteringExtensions.cs @@ -0,0 +1,176 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using PawSharp.Gateway.Events; + +namespace PawSharp.Gateway.Events; + +/// +/// Extension methods for filtering events before they reach handlers. +/// Provides convenient methods for common filtering patterns. +/// +public static class EventFilteringExtensions +{ + /// + /// Registers an event handler that only processes events matching a predicate. + /// + /// The event type + /// The event dispatcher + /// The event name + /// The filter predicate + /// The event handler + /// An IDisposable to unsubscribe + public static IDisposable OnWhere( + this EventDispatcher dispatcher, + string eventName, + Func predicate, + Func handler) where TEvent : GatewayEvent + { + return dispatcher.On(eventName, async evt => + { + if (predicate(evt)) + { + await handler(evt); + } + }); + } + + /// + /// Registers an event handler that only processes events matching a predicate (synchronous). + /// + /// The event type + /// The event dispatcher + /// The event name + /// The filter predicate + /// The event handler + /// An IDisposable to unsubscribe + public static IDisposable OnWhere( + this EventDispatcher dispatcher, + string eventName, + Func predicate, + Action handler) where TEvent : GatewayEvent + { + return dispatcher.On(eventName, evt => + { + if (predicate(evt)) + { + handler(evt); + } + }); + } + + // Message filtering extensions + + /// + /// Registers a handler for messages from a specific guild. + /// + public static IDisposable OnMessageFromGuild( + this EventDispatcher dispatcher, + ulong guildId, + Func handler) + { + return dispatcher.OnWhere("MESSAGE_CREATE", evt => evt.GuildId == guildId, handler); + } + + /// + /// Registers a handler for messages from a specific channel. + /// + public static IDisposable OnMessageFromChannel( + this EventDispatcher dispatcher, + ulong channelId, + Func handler) + { + return dispatcher.OnWhere("MESSAGE_CREATE", evt => evt.ChannelId == channelId, handler); + } + + /// + /// Registers a handler for messages from a specific user. + /// + public static IDisposable OnMessageFromUser( + this EventDispatcher dispatcher, + ulong userId, + Func handler) + { + return dispatcher.OnWhere("MESSAGE_CREATE", evt => evt.Author.Id == userId, handler); + } + + /// + /// Registers a handler for messages starting with a specific prefix. + /// + public static IDisposable OnMessageWithPrefix( + this EventDispatcher dispatcher, + string prefix, + Func handler) + { + return dispatcher.OnWhere("MESSAGE_CREATE", evt => evt.Content.StartsWith(prefix), handler); + } + + /// + /// Registers a handler for messages containing a specific substring. + /// + public static IDisposable OnMessageContaining( + this EventDispatcher dispatcher, + string substring, + Func handler) + { + return dispatcher.OnWhere("MESSAGE_CREATE", evt => evt.Content.Contains(substring), handler); + } + + // Guild filtering extensions + + /// + /// Registers a handler for events from a specific guild. + /// + public static IDisposable OnGuildEvent( + this EventDispatcher dispatcher, + string eventName, + ulong guildId, + Func handler) where TEvent : GatewayEvent + { + return dispatcher.OnWhere(eventName, evt => + { + if (evt is IGuildEvent guildEvent) + { + return guildEvent.GuildId == guildId; + } + return false; + }, handler); + } + + // User filtering extensions + + /// + /// Registers a handler for events from a specific user. + /// + public static IDisposable OnUserEvent( + this EventDispatcher dispatcher, + string eventName, + ulong userId, + Func handler) where TEvent : GatewayEvent + { + return dispatcher.OnWhere(eventName, evt => + { + if (evt is IUserEvent userEvent) + { + return userEvent.UserId == userId; + } + return false; + }, handler); + } +} + +/// +/// Interface for events that have a GuildId property. +/// +public interface IGuildEvent +{ + ulong GuildId { get; } +} + +/// +/// Interface for events that have a UserId property. +/// +public interface IUserEvent +{ + ulong UserId { get; } +} diff --git a/src/PawSharp.Gateway/Events/GatewayEvents.cs b/src/PawSharp.Gateway/Events/GatewayEvents.cs index 8882549..630fc34 100644 --- a/src/PawSharp.Gateway/Events/GatewayEvents.cs +++ b/src/PawSharp.Gateway/Events/GatewayEvents.cs @@ -32,26 +32,35 @@ public class ReadyEvent : GatewayEvent { [JsonPropertyName("v")] public int Version { get; set; } - + [JsonPropertyName("user")] public User User { get; set; } = null!; - + [JsonPropertyName("guilds")] public List Guilds { get; set; } = new(); - + [JsonPropertyName("session_id")] public string SessionId { get; set; } = string.Empty; - + [JsonPropertyName("resume_gateway_url")] public string ResumeGatewayUrl { get; set; } = string.Empty; - + [JsonPropertyName("shard")] public int[]? Shard { get; set; } - + [JsonPropertyName("application")] public PartialApplication? Application { get; set; } } +/// +/// RESUMED event - fired when a session has been successfully resumed. +/// +public class ResumedEvent : GatewayEvent +{ + // The RESUMED event has no additional data field according to Discord docs + // It only confirms the session was successfully resumed +} + /// /// MESSAGE_CREATE event. /// @@ -291,11 +300,31 @@ public class GuildDeleteEvent : GatewayEvent [JsonPropertyName("id")] [JsonConverter(typeof(SnowflakeJsonConverter))] public ulong Id { get; set; } - + [JsonPropertyName("unavailable")] public bool? Unavailable { get; set; } } +/// +/// GUILD_AVAILABLE event - fired when a guild becomes available. +/// +public class GuildAvailableEvent : GatewayEvent +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong Id { get; set; } +} + +/// +/// GUILD_UNAVAILABLE event - fired when a guild becomes unavailable. +/// +public class GuildUnavailableEvent : GatewayEvent +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong Id { get; set; } +} + /// /// GUILD_EMOJIS_UPDATE event. /// @@ -2157,6 +2186,78 @@ public class ApplicationCommandPermissionsUpdateEvent : GatewayEvent public List? Permissions { get; set; } } +/// +/// GUILD_APP_COMMAND_CREATE event — fired when a guild application command is created. +/// +public class GuildAppCommandCreateEvent : GatewayEvent +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong Id { get; set; } + + [JsonPropertyName("application_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong ApplicationId { get; set; } + + [JsonPropertyName("guild_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong GuildId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public int Type { get; set; } +} + +/// +/// GUILD_APP_COMMAND_UPDATE event — fired when a guild application command is updated. +/// +public class GuildAppCommandUpdateEvent : GatewayEvent +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong Id { get; set; } + + [JsonPropertyName("application_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong ApplicationId { get; set; } + + [JsonPropertyName("guild_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong GuildId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public int Type { get; set; } +} + +/// +/// GUILD_APP_COMMAND_DELETE event — fired when a guild application command is deleted. +/// +public class GuildAppCommandDeleteEvent : GatewayEvent +{ + [JsonPropertyName("id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong Id { get; set; } + + [JsonPropertyName("application_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong ApplicationId { get; set; } + + [JsonPropertyName("guild_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public ulong GuildId { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public int Type { get; set; } +} + /// /// INTEGRATION_CREATE event — fired when a guild integration is created. /// diff --git a/src/PawSharp.Gateway/GatewayClient.cs b/src/PawSharp.Gateway/GatewayClient.cs index 07ea06e..6d32187 100644 --- a/src/PawSharp.Gateway/GatewayClient.cs +++ b/src/PawSharp.Gateway/GatewayClient.cs @@ -230,7 +230,10 @@ public async Task ConnectAsync() } else { - _logger.LogWarning("Failed to fetch gateway URL from API, falling back to default"); + _logger.LogWarning( + "Failed to fetch gateway URL from API, falling back to default wss://gateway.discord.gg. " + + "This may cause connection issues if Discord has changed their gateway URL. " + + "Ensure your REST client is properly configured."); gatewayHost = "wss://gateway.discord.gg"; } } @@ -267,7 +270,8 @@ public async Task ConnectAsync() } catch (Exception ex) { - _logger.LogError(ex, "Failed to connect to Gateway"); + _logger.LogError(ex, "Failed to connect to Gateway. Error: {MessageType} - {Message}. Check your network connection and Discord service status.", + ex.GetType().Name, ex.Message); await SetStateAsync(GatewayState.Disconnected); throw; } @@ -726,7 +730,7 @@ private async Task HandleHelloAsync(JsonElement data) _logger.LogError("Zombie connection detected - reconnecting..."); await ReconnectAsync(); }; - _heartbeatManager.Start(); + _heartbeatManager.StartWithJitter(); } } catch (Exception ex) @@ -821,6 +825,7 @@ private async Task HandleDispatchEventAsync(string eventType, string eventData) case "RESUMED": _logger.LogInformation("Session resumed successfully"); await SetStateAsync(GatewayState.Ready); + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); break; case "MESSAGE_CREATE": await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); @@ -840,6 +845,12 @@ private async Task HandleDispatchEventAsync(string eventType, string eventData) case "GUILD_DELETE": await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); break; + case "GUILD_AVAILABLE": + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); + break; + case "GUILD_UNAVAILABLE": + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); + break; case "GUILD_EMOJIS_UPDATE": await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); break; @@ -1049,6 +1060,15 @@ private async Task HandleDispatchEventAsync(string eventType, string eventData) case "APPLICATION_COMMAND_PERMISSIONS_UPDATE": await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); break; + case "GUILD_APP_COMMAND_CREATE": + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); + break; + case "GUILD_APP_COMMAND_UPDATE": + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); + break; + case "GUILD_APP_COMMAND_DELETE": + await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); + break; case "INTEGRATION_CREATE": await _eventDispatcher.DispatchFromJsonAsync(eventType, eventData); break; diff --git a/src/PawSharp.Gateway/GatewayClientExtensions.cs b/src/PawSharp.Gateway/GatewayClientExtensions.cs new file mode 100644 index 0000000..19077f9 --- /dev/null +++ b/src/PawSharp.Gateway/GatewayClientExtensions.cs @@ -0,0 +1,225 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using PawSharp.Core.Models; + +namespace PawSharp.Gateway; + +/// +/// Extension methods for GatewayClient to provide convenience methods for common operations. +/// +public static class GatewayClientExtensions +{ + // Presence convenience methods + + /// + /// Sets the bot's presence to online. + /// + public static Task SetOnlineAsync(this IGatewayClient client, string? activityName = null) + { + return client.UpdatePresenceAsync("online", activityName); + } + + /// + /// Sets the bot's presence to idle. + /// + public static Task SetIdleAsync(this IGatewayClient client, string? activityName = null) + { + return client.UpdatePresenceAsync("idle", activityName); + } + + /// + /// Sets the bot's presence to Do Not Disturb. + /// + public static Task SetDndAsync(this IGatewayClient client, string? activityName = null) + { + return client.UpdatePresenceAsync("dnd", activityName); + } + + /// + /// Sets the bot's presence to invisible. + /// + public static Task SetInvisibleAsync(this IGatewayClient client) + { + return client.UpdatePresenceAsync("invisible"); + } + + /// + /// Sets the bot's presence to "Playing" activity. + /// + public static Task SetPlayingAsync(this IGatewayClient client, string game) + { + return client.UpdatePresenceAsync("online", game); + } + + /// + /// Sets the bot's presence to "Watching" activity. + /// + public static Task SetWatchingAsync(this IGatewayClient client, string activity) + { + return client.UpdatePresenceAsync("online", activity); + } + + /// + /// Sets the bot's presence to "Listening to" activity. + /// + public static Task SetListeningAsync(this IGatewayClient client, string activity) + { + return client.UpdatePresenceAsync("online", activity); + } + + /// + /// Sets the bot's presence to "Streaming" activity. + /// + public static Task SetStreamingAsync(this IGatewayClient client, string game, string streamUrl) + { + return client.UpdatePresenceAsync("online", game, streamUrl); + } + + /// + /// Sets the bot's presence to "Competing in" activity. + /// + public static Task SetCompetingAsync(this IGatewayClient client, string activity) + { + return client.UpdatePresenceAsync("online", activity); + } + + // Connection state convenience methods + + /// + /// Checks if the gateway is currently connected. + /// + public static bool IsConnected(this IGatewayClient client) + { + return client.CurrentState == GatewayState.Connected || client.CurrentState == GatewayState.Ready; + } + + /// + /// Checks if the gateway is currently ready to receive events. + /// + public static bool IsReady(this IGatewayClient client) + { + return client.CurrentState == GatewayState.Ready; + } + + /// + /// Checks if the gateway is currently disconnected. + /// + public static bool IsDisconnected(this IGatewayClient client) + { + return client.CurrentState == GatewayState.Disconnected; + } + + /// + /// Checks if the gateway is currently connecting. + /// + public static bool IsConnecting(this IGatewayClient client) + { + return client.CurrentState == GatewayState.Connecting; + } + + /// + /// Checks if the gateway is currently in a failed state. + /// + public static bool IsFailed(this IGatewayClient client) + { + return client.CurrentState == GatewayState.Failed; + } + + /// + /// Waits for the gateway to reach the Ready state. + /// + /// The gateway client + /// Maximum time to wait (default: 30 seconds) + /// True if the gateway became ready, false if timeout occurred + public static async Task WaitForReadyAsync(this IGatewayClient client, TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(30); + var startTime = DateTime.UtcNow; + + while (client.CurrentState != GatewayState.Ready) + { + if (DateTime.UtcNow - startTime > effectiveTimeout) + { + return false; + } + + if (client.CurrentState == GatewayState.Failed) + { + return false; + } + + await Task.Delay(100); + } + + return true; + } + + // Guild member request convenience methods + + /// + /// Requests all members of a guild. + /// + public static Task RequestAllGuildMembersAsync(this IGatewayClient client, ulong guildId, bool presences = false) + { + return client.RequestGuildMembersAsync(guildId, 0, "", presences); + } + + /// + /// Requests specific members of a guild by user IDs. + /// + public static Task RequestGuildMembersAsync(this IGatewayClient client, ulong guildId, params ulong[] userIds) + { + return client.RequestGuildMembersAsync(guildId, userIds.Length, userIds: userIds); + } + + /// + /// Requests guild members matching a query. + /// + public static Task RequestGuildMembersAsync(this IGatewayClient client, ulong guildId, string query, int limit = 100, bool presences = false) + { + return client.RequestGuildMembersAsync(guildId, limit, query, presences); + } + + // Voice state convenience methods + + /// + /// Joins a voice channel. + /// + public static Task JoinVoiceChannelAsync(this IGatewayClient client, ulong guildId, ulong channelId) + { + return client.SendVoiceStateUpdateAsync(guildId, channelId, false, false); + } + + /// + /// Joins a voice channel muted. + /// + public static Task JoinVoiceChannelMutedAsync(this IGatewayClient client, ulong guildId, ulong channelId) + { + return client.SendVoiceStateUpdateAsync(guildId, channelId, true, false); + } + + /// + /// Joins a voice channel deafened. + /// + public static Task JoinVoiceChannelDeafenedAsync(this IGatewayClient client, ulong guildId, ulong channelId) + { + return client.SendVoiceStateUpdateAsync(guildId, channelId, false, true); + } + + /// + /// Leaves a voice channel. + /// + public static Task LeaveVoiceChannelAsync(this IGatewayClient client, ulong guildId) + { + return client.SendVoiceStateUpdateAsync(guildId, null, false, false); + } + + /// + /// Moves to a different voice channel. + /// + public static Task MoveVoiceChannelAsync(this IGatewayClient client, ulong guildId, ulong channelId) + { + return client.SendVoiceStateUpdateAsync(guildId, channelId, false, false); + } +} diff --git a/src/PawSharp.Gateway/Heartbeat/HeartbeatManager.cs b/src/PawSharp.Gateway/Heartbeat/HeartbeatManager.cs index f06c57f..6049c69 100644 --- a/src/PawSharp.Gateway/Heartbeat/HeartbeatManager.cs +++ b/src/PawSharp.Gateway/Heartbeat/HeartbeatManager.cs @@ -58,6 +58,19 @@ public void Start() _heartbeatTask = RunHeartbeatLoopAsync(_cts.Token); } + /// + /// Starts the heartbeat manager with initial jitter to avoid thundering herd. + /// Discord recommends adding random jitter (0.8-1.0x) to the first heartbeat after HELLO. + /// + public void StartWithJitter() + { + _ackReceived = true; + _missedAcks = 0; + _cts = new CancellationTokenSource(); + // Fire-and-store: exceptions are caught inside the loop, not propagated as async void. + _heartbeatTask = RunHeartbeatLoopWithJitterAsync(_cts.Token); + } + /// /// Stops the heartbeat manager and waits for the heartbeat task to complete. /// Use this overload during graceful shutdown to ensure proper cleanup. @@ -157,5 +170,42 @@ private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken) // Normal shutdown via Stop() — not an error. } } + + private async Task RunHeartbeatLoopWithJitterAsync(CancellationToken cancellationToken) + { + // Discord recommends adding random jitter (0.8-1.0x) to the first heartbeat after HELLO + // to avoid thundering herd when many clients connect simultaneously + var random = new Random(); + var jitter = random.NextDouble() * 0.2 + 0.8; // 0.8 to 1.0 + var initialDelayMs = (int)(_heartbeatInterval * jitter); + + _logger?.LogDebug("Applying initial heartbeat jitter: {DelayMs}ms ({Jitter:P1} of interval)", initialDelayMs, jitter); + + try + { + // Apply initial jitter delay + await Task.Delay(initialDelayMs, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + + // Send first heartbeat after jitter + try + { + await _sendHeartbeat(); + _ackReceived = false; + if (OnHeartbeatSent is { } sentHandler) + await sentHandler(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Initial heartbeat after jitter threw unexpectedly"); + } + + // Continue with regular heartbeat loop + await RunHeartbeatLoopAsync(cancellationToken); + } } } \ No newline at end of file diff --git a/src/PawSharp.Gateway/README.md b/src/PawSharp.Gateway/README.md index 11bef02..8d60512 100644 --- a/src/PawSharp.Gateway/README.md +++ b/src/PawSharp.Gateway/README.md @@ -19,7 +19,7 @@ It handles the moving parts you do not want to rebuild repeatedly: identify/resu ## Installation ```bash -dotnet add package PawSharp.Gateway --version 1.1.0-alpha.1 +dotnet add package PawSharp.Gateway --version 1.1.0-alpha.2 ``` ## Quick Start @@ -33,7 +33,8 @@ var gateway = new GatewayClient(new PawSharpOptions Intents = GatewayIntents.Guilds | GatewayIntents.GuildMessages }); -gateway.Events.On(async evt => +// Strongly-typed event subscription (no string literals!) +gateway.Events.OnMessageCreate(async evt => { Console.WriteLine($"Message in {evt.ChannelId}: {evt.Content}"); }); @@ -41,6 +42,112 @@ gateway.Events.On(async evt => await gateway.ConnectAsync(); ``` +## Sharding Example + +For bots in 1000+ guilds, Discord requires sharding. PawSharp.Gateway makes this easy: + +```csharp +using PawSharp.Gateway; + +// Calculate recommended shard count or use a fixed number +var shardCount = ShardManager.CalculateRecommendedShardCount(guildCount: 2500); // 3 shards + +var options = new PawSharpOptions +{ + Token = Environment.GetEnvironmentVariable("DISCORD_TOKEN")!, + Intents = GatewayIntents.All, + ShardCount = shardCount, + ShardConnectionDelayMs = 5000 // Discord recommends 5s between connections +}; + +var shardManager = new ShardManager(options, logger); + +// Connect all shards +await shardManager.ConnectAllAsync(); + +// Subscribe to events across all shards +shardManager.Events.OnMessageCreate(async evt => +{ + Console.WriteLine($"[Shard {evt.ShardId}] Message: {evt.Content}"); +}); + +// Monitor shard health +var statuses = shardManager.GetAllShardStatuses(); +Console.WriteLine($"Connected shards: {shardManager.ConnectedShardCount}/{shardCount}"); +``` + +## Advanced Configuration + +Use the builder pattern for cleaner configuration: + +```csharp +using PawSharp.Gateway; +using PawSharp.Core.Models; + +var options = PawSharpOptions.Builder.Create() + .WithToken(Environment.GetEnvironmentVariable("DISCORD_TOKEN")!) + .WithIntents(GatewayIntents.Guilds | GatewayIntents.GuildMessages) + .WithCompression(true) // Enable zlib-stream compression + .WithWebSocketBufferSizeKb(128) // Larger buffer for large guilds + .WithMaxMissedHeartbeatAcks(3) + .ConfigureReconnection(recon => + { + recon.MaxAttempts = 15; + recon.InitialDelayMs = 1000; + recon.MaxDelayMs = 30000; + recon.JitterFactor = 0.25; + }) + .ConfigureEventDispatch(dispatch => + { + dispatch.MaxQueueSize = 2000; + dispatch.EnableParallelDispatch = true; + dispatch.MaxDegreeOfParallelism = 8; + dispatch.HandlerTimeoutMs = 5000; + }) + .WithPresence("online", "Playing with PawSharp") + .Build(); + +var gateway = new GatewayClient(options); +``` + +## Reconnection Handling + +The library handles reconnection automatically with exponential backoff. You can monitor reconnection events: + +```csharp +// The gateway automatically reconnects on disconnect +// You can monitor the connection state +gateway.Events.OnResumed(async evt => +{ + Console.WriteLine("Session resumed successfully"); +}); + +// For custom reconnection logic, you can use the ReconnectionManager +// This is automatically used by GatewayClient, but you can configure it +``` + +## Event Filtering + +Filter events before they reach your handlers using middleware: + +```csharp +// Add middleware to filter events +gateway.Events.UseMiddleware(async (eventName, eventData, next) => +{ + // Only process events from a specific guild + if (eventName == "MESSAGE_CREATE") + { + var evt = JsonSerializer.Deserialize(eventData); + if (evt?.GuildId == 123456789) + { + await next(); // Process this event + return; + } + } + // Skip all other events +}); +``` + ## Typical Use Cases - Bots that need direct control over gateway behavior diff --git a/src/PawSharp.Gateway/Serialization/PawSharpGatewayJsonContext.cs b/src/PawSharp.Gateway/Serialization/PawSharpGatewayJsonContext.cs index eedb6e9..163f9f1 100644 --- a/src/PawSharp.Gateway/Serialization/PawSharpGatewayJsonContext.cs +++ b/src/PawSharp.Gateway/Serialization/PawSharpGatewayJsonContext.cs @@ -9,12 +9,16 @@ namespace PawSharp.Gateway.Serialization; /// Enables Native AOT compatibility by eliminating reflection-based serialization. /// [JsonSerializable(typeof(ReadyEvent))] +[JsonSerializable(typeof(ResumedEvent))] [JsonSerializable(typeof(MessageCreateEvent))] [JsonSerializable(typeof(MessageUpdateEvent))] [JsonSerializable(typeof(MessageDeleteEvent))] +[JsonSerializable(typeof(MessageDeleteBulkEvent))] [JsonSerializable(typeof(GuildCreateEvent))] [JsonSerializable(typeof(GuildUpdateEvent))] [JsonSerializable(typeof(GuildDeleteEvent))] +[JsonSerializable(typeof(GuildAvailableEvent))] +[JsonSerializable(typeof(GuildUnavailableEvent))] [JsonSerializable(typeof(GuildEmojisUpdateEvent))] [JsonSerializable(typeof(ChannelCreateEvent))] [JsonSerializable(typeof(ChannelUpdateEvent))] @@ -22,21 +26,67 @@ namespace PawSharp.Gateway.Serialization; [JsonSerializable(typeof(GuildMemberAddEvent))] [JsonSerializable(typeof(GuildMemberUpdateEvent))] [JsonSerializable(typeof(GuildMemberRemoveEvent))] +[JsonSerializable(typeof(GuildMembersChunkEvent))] [JsonSerializable(typeof(InteractionCreateEvent))] [JsonSerializable(typeof(TypingStartEvent))] [JsonSerializable(typeof(MessageReactionAddEvent))] [JsonSerializable(typeof(MessageReactionRemoveEvent))] [JsonSerializable(typeof(MessageReactionRemoveAllEvent))] +[JsonSerializable(typeof(MessageReactionRemoveEmojiEvent))] [JsonSerializable(typeof(PresenceUpdateEvent))] [JsonSerializable(typeof(ChannelPinsUpdateEvent))] [JsonSerializable(typeof(GuildBanAddEvent))] [JsonSerializable(typeof(GuildBanRemoveEvent))] +[JsonSerializable(typeof(GuildRoleCreateEvent))] +[JsonSerializable(typeof(GuildRoleUpdateEvent))] +[JsonSerializable(typeof(GuildRoleDeleteEvent))] +[JsonSerializable(typeof(GuildStickersUpdateEvent))] +[JsonSerializable(typeof(GuildIntegrationsUpdateEvent))] [JsonSerializable(typeof(VoiceStateUpdateEvent))] [JsonSerializable(typeof(VoiceServerUpdateEvent))] [JsonSerializable(typeof(ThreadCreateEvent))] -[JsonSerializable(typeof(MessagePollVoteAddEvent))] +[JsonSerializable(typeof(ThreadUpdateEvent))] +[JsonSerializable(typeof(ThreadDeleteEvent))] +[JsonSerializable(typeof(ThreadListSyncEvent))] +[JsonSerializable(typeof(ThreadMemberUpdateEvent))] +[JsonSerializable(typeof(ThreadMembersUpdateEvent))] [JsonSerializable(typeof(GuildScheduledEventCreateEvent))] +[JsonSerializable(typeof(GuildScheduledEventUpdateEvent))] +[JsonSerializable(typeof(GuildScheduledEventDeleteEvent))] +[JsonSerializable(typeof(GuildScheduledEventUserAddEvent))] +[JsonSerializable(typeof(GuildScheduledEventUserRemoveEvent))] +[JsonSerializable(typeof(AutoModerationRuleCreateEvent))] +[JsonSerializable(typeof(AutoModerationRuleUpdateEvent))] +[JsonSerializable(typeof(AutoModerationRuleDeleteEvent))] +[JsonSerializable(typeof(AutoModerationActionExecutionEvent))] +[JsonSerializable(typeof(StageInstanceCreateEvent))] +[JsonSerializable(typeof(StageInstanceUpdateEvent))] +[JsonSerializable(typeof(StageInstanceDeleteEvent))] +[JsonSerializable(typeof(GuildAuditLogEntryCreateEvent))] +[JsonSerializable(typeof(EntitlementCreateEvent))] +[JsonSerializable(typeof(EntitlementUpdateEvent))] +[JsonSerializable(typeof(EntitlementDeleteEvent))] +[JsonSerializable(typeof(MessagePollVoteAddEvent))] +[JsonSerializable(typeof(MessagePollVoteRemoveEvent))] +[JsonSerializable(typeof(GuildSoundboardSoundCreateEvent))] +[JsonSerializable(typeof(GuildSoundboardSoundUpdateEvent))] +[JsonSerializable(typeof(GuildSoundboardSoundDeleteEvent))] +[JsonSerializable(typeof(GuildSoundboardSoundsUpdateEvent))] +[JsonSerializable(typeof(VoiceChannelEffectSendEvent))] +[JsonSerializable(typeof(VoiceChannelStatusUpdateEvent))] +[JsonSerializable(typeof(SubscriptionCreateEvent))] +[JsonSerializable(typeof(SubscriptionUpdateEvent))] +[JsonSerializable(typeof(SubscriptionDeleteEvent))] [JsonSerializable(typeof(InviteCreateEvent))] +[JsonSerializable(typeof(InviteDeleteEvent))] +[JsonSerializable(typeof(WebhooksUpdateEvent))] +[JsonSerializable(typeof(ApplicationCommandPermissionsUpdateEvent))] +[JsonSerializable(typeof(GuildAppCommandCreateEvent))] +[JsonSerializable(typeof(GuildAppCommandUpdateEvent))] +[JsonSerializable(typeof(GuildAppCommandDeleteEvent))] +[JsonSerializable(typeof(IntegrationCreateEvent))] +[JsonSerializable(typeof(IntegrationUpdateEvent))] +[JsonSerializable(typeof(IntegrationDeleteEvent))] [JsonSerializable(typeof(UserUpdateEvent))] public partial class PawSharpGatewayJsonContext : JsonSerializerContext { diff --git a/src/PawSharp.Gateway/ShardManager.cs b/src/PawSharp.Gateway/ShardManager.cs index 5c5ade4..23df43d 100644 --- a/src/PawSharp.Gateway/ShardManager.cs +++ b/src/PawSharp.Gateway/ShardManager.cs @@ -147,7 +147,10 @@ public async Task ConnectAllAsync() // Validate session start limits if (!ValidateSessionStartLimits(_options.Shards)) { - throw new InvalidOperationException("Cannot connect: insufficient session start limits remaining."); + throw new InvalidOperationException( + "Cannot connect: insufficient session start limits remaining. " + + "Discord limits how many sessions can be started within a time window. " + + "Wait for the session start limit to reset (typically 5-10 seconds) or increase ShardConnectionDelayMs."); } // Validate shard concurrency configuration @@ -219,9 +222,32 @@ public async Task ReconnectShardAsync(int shardId) return; } + // Check session start limits before attempting reconnection + if (_sessionStartLimits != null) + { + if (_sessionStartLimits.Remaining <= 0) + { + var resetTime = DateTimeOffset.UtcNow.AddMilliseconds(_sessionStartLimits.ResetAfter); + _logger.LogError( + "Cannot reconnect shard {ShardId}: Session start limit exhausted. " + + "Resets at {ResetTime} (in {RemainingMs}ms).", + shardId, + resetTime, + _sessionStartLimits.ResetAfter); + _shardStatuses[shardId] = ShardStatus.Failed; + return; + } + + _logger.LogDebug( + "Session start limits check passed for shard {ShardId}: {Remaining}/{Total} remaining", + shardId, + _sessionStartLimits.Remaining, + _sessionStartLimits.Total); + } + _shardStatuses[shardId] = ShardStatus.Reconnecting; _logger.LogInformation("Reconnecting shard {ShardId}...", shardId); - + try { await shard.DisconnectAsync(); @@ -282,6 +308,22 @@ public static int CalculateRecommendedShardCount(int guildCount) return Math.Max(1, (int)Math.Ceiling(guildCount / 1000.0)); } + /// + /// Automatically configures sharding based on guild count. + /// This is a convenience method that updates the ShardCount property + /// based on the calculated recommended shard count. + /// + /// The number of guilds the bot is in + public void AutoConfigureSharding(int guildCount) + { + var recommendedShardCount = CalculateRecommendedShardCount(guildCount); + _options.ShardCount = recommendedShardCount; + _logger.LogInformation( + "Auto-configured sharding: {GuildCount} guilds -> {ShardCount} shards", + guildCount, + recommendedShardCount); + } + /// /// Gets the current session start limits, if fetched from Discord API. /// diff --git a/src/PawSharp.Interactions/Builders/ComponentBuilders.cs b/src/PawSharp.Interactions/Builders/ComponentBuilders.cs index 1fa69b7..cccd025 100644 --- a/src/PawSharp.Interactions/Builders/ComponentBuilders.cs +++ b/src/PawSharp.Interactions/Builders/ComponentBuilders.cs @@ -463,3 +463,201 @@ public FileBuilder SetSpoiler(bool spoiler) public CoreComponents.FileComponent Build() => _component; } + +/// +/// Builder for (component type 18). +/// A Label displays text with optional emoji in a Components v2 layout. +/// +public class LabelBuilder +{ + private readonly CoreComponents.Label _component = new(); + + public LabelBuilder(string text) + { + _component.Text = text; + } + + public LabelBuilder SetText(string text) + { + _component.Text = text; + return this; + } + + public LabelBuilder SetEmoji(string unicodeEmoji) + { + _component.Emoji = new CoreComponents.Emoji { Name = unicodeEmoji }; + return this; + } + + public LabelBuilder SetCustomEmoji(string name, ulong id, bool animated = false) + { + _component.Emoji = new CoreComponents.Emoji { Name = name, Id = id, Animated = animated }; + return this; + } + + public CoreComponents.Label Build() => _component; +} + +/// +/// Builder for (component type 19). +/// A FileUpload allows users to upload files in a modal or message. +/// +public class FileUploadBuilder +{ + private readonly CoreComponents.FileUpload _component = new(); + + public FileUploadBuilder(string customId, string label) + { + _component.CustomId = customId; + _component.Label = label; + } + + public FileUploadBuilder SetRequired(bool required) + { + _component.Required = required; + return this; + } + + public FileUploadBuilder SetPlaceholder(string placeholder) + { + _component.Placeholder = placeholder; + return this; + } + + public FileUploadBuilder SetMinLength(int min) + { + _component.MinLength = min; + return this; + } + + public FileUploadBuilder SetMaxLength(int max) + { + _component.MaxLength = max; + return this; + } + + public FileUploadBuilder SetFileTypes(params string[] fileTypes) + { + _component.FileTypes = new List(fileTypes); + return this; + } + + public CoreComponents.FileUpload Build() => _component; +} + +/// +/// Builder for (component type 21). +/// A RadioGroup allows selecting one option from a list in a Components v2 layout. +/// +public class RadioGroupBuilder +{ + private readonly CoreComponents.RadioGroup _component = new(); + + public RadioGroupBuilder(string customId, string label) + { + _component.CustomId = customId; + _component.Label = label; + } + + public RadioGroupBuilder AddOption(string label, string value, string? description = null, bool isDefault = false) + { + _component.Options.Add(new CoreComponents.RadioOption + { + Label = label, + Value = value, + Description = description, + Default = isDefault + }); + return this; + } + + public RadioGroupBuilder SetRequired(bool required) + { + _component.Required = required; + return this; + } + + public RadioGroupBuilder SetDefaultValue(int index) + { + _component.DefaultValue = index; + return this; + } + + public CoreComponents.RadioGroup Build() => _component; +} + +/// +/// Builder for (component type 22). +/// A CheckboxGroup allows selecting multiple options from a list in a Components v2 layout. +/// +public class CheckboxGroupBuilder +{ + private readonly CoreComponents.CheckboxGroup _component = new(); + + public CheckboxGroupBuilder(string customId, string label) + { + _component.CustomId = customId; + _component.Label = label; + } + + public CheckboxGroupBuilder AddOption(string label, string value, string? description = null, bool isDefault = false) + { + _component.Options.Add(new CoreComponents.CheckboxOption + { + Label = label, + Value = value, + Description = description, + Default = isDefault + }); + return this; + } + + public CheckboxGroupBuilder SetMinValues(int min) + { + _component.MinValues = min; + return this; + } + + public CheckboxGroupBuilder SetMaxValues(int max) + { + _component.MaxValues = max; + return this; + } + + public CheckboxGroupBuilder SetRequired(bool required) + { + _component.Required = required; + return this; + } + + public CoreComponents.CheckboxGroup Build() => _component; +} + +/// +/// Builder for (component type 23). +/// A Checkbox is a single toggleable checkbox in a Components v2 layout. +/// +public class CheckboxBuilder +{ + private readonly CoreComponents.Checkbox _component = new(); + + public CheckboxBuilder(string customId, string label) + { + _component.CustomId = customId; + _component.Label = label; + } + + public CheckboxBuilder SetDefaultValue(bool defaultValue) + { + _component.DefaultValue = defaultValue; + return this; + } + + public CheckboxBuilder SetRequired(bool required) + { + _component.Required = required; + return this; + } + + public CoreComponents.Checkbox Build() => _component; +} diff --git a/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs b/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs index 85d2f7f..22855cc 100644 --- a/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs +++ b/src/PawSharp.Interactions/Builders/InteractionResponseBuilder.cs @@ -28,6 +28,7 @@ public sealed class InteractionResponseBuilder private string? _content; private bool _ephemeral; private bool _updateMessage; + private int? _flags; private readonly List _embeds = new(); private readonly List _actionRows = new(); @@ -81,6 +82,23 @@ public InteractionResponseBuilder AsEphemeral(bool ephemeral = true) return this; } + /// + /// Sets message flags directly (useful for custom flag combinations). + /// + public InteractionResponseBuilder WithFlags(int flags) + { + if (_ephemeral) flags |= 64; + _ephemeral = false; + return this.WithFlagsInternal(flags); + } + + private InteractionResponseBuilder WithFlagsInternal(int flags) + { + // Store flags to be applied in Build + _flags = flags; + return this; + } + /// /// Produces an UpdateMessage (type 7) response that edits the original component message /// instead of sending a new one. Used in button / select menu handlers. @@ -101,7 +119,7 @@ public InteractionResponse Build() Content = _content, Embeds = _embeds.Count > 0 ? new List(_embeds) : null, Components = _actionRows.Count > 0 ? new List(_actionRows) : null, - Flags = _ephemeral ? 64 : null, + Flags = _flags ?? (_ephemeral ? 64 : null), }; int type = _updateMessage diff --git a/src/PawSharp.Interactions/Extensions/InteractionExtensions.cs b/src/PawSharp.Interactions/Extensions/InteractionExtensions.cs index 7a8107f..13af3d7 100644 --- a/src/PawSharp.Interactions/Extensions/InteractionExtensions.cs +++ b/src/PawSharp.Interactions/Extensions/InteractionExtensions.cs @@ -5,6 +5,7 @@ using InteractionOption = PawSharp.Gateway.Events.ApplicationCommandInteractionDataOption; using PawSharp.Core.Enums; using PawSharp.Gateway.Events; +using PawSharp.Core.Entities; namespace PawSharp.Interactions.Extensions; @@ -106,6 +107,86 @@ public static bool IsGuildInteraction(this InteractionCreateEvent interaction) public static bool IsDmInteraction(this InteractionCreateEvent interaction) => !interaction.GuildId.HasValue; + // ── Modal value retrieval ───────────────────────────────────────────────────── + + /// + /// Gets the value of a text input field from a modal submission by its custom ID. + /// + /// The modal submit interaction event. + /// The custom ID of the text input field. + /// The submitted text value, or null if not found. + public static string? GetModalValue(this InteractionCreateEvent interaction, string customId) + { + if (interaction.Data?.Components is null) return null; + + foreach (var actionRow in interaction.Data.Components) + { + if (actionRow is ActionRow row && row.Components is not null) + { + foreach (var component in row.Components) + { + if (component is PawSharp.Core.Entities.TextInput textInput && + textInput.CustomId == customId) + { + return textInput.Value; + } + } + } + } + + return null; + } + + /// + /// Gets all modal input values as a dictionary keyed by custom ID. + /// + /// The modal submit interaction event. + /// A dictionary of custom ID to submitted value. + public static Dictionary GetModalValues(this InteractionCreateEvent interaction) + { + var values = new Dictionary(); + + if (interaction.Data?.Components is null) return values; + + foreach (var actionRow in interaction.Data.Components) + { + if (actionRow is ActionRow row && row.Components is not null) + { + foreach (var component in row.Components) + { + if (component is PawSharp.Core.Entities.TextInput textInput) + { + values[textInput.CustomId] = textInput.Value ?? string.Empty; + } + } + } + } + + return values; + } + + // ── Component value retrieval ───────────────────────────────────────────────────── + + /// + /// Gets the selected values from a select menu component interaction. + /// + /// The component interaction event. + /// A list of selected string values, or empty list if not found. + public static List GetSelectedValues(this InteractionCreateEvent interaction) + { + return interaction.Data?.Values ?? new List(); + } + + /// + /// Gets the component type from a component interaction. + /// + /// The component interaction event. + /// The component type as an integer, or null if not found. + public static int? GetComponentType(this InteractionCreateEvent interaction) + { + return interaction.Data?.ComponentType; + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static T? GetOptionValueFromList(List? options, string name) diff --git a/src/PawSharp.Interactions/InteractionHandler.cs b/src/PawSharp.Interactions/InteractionHandler.cs index 302af23..2356f70 100644 --- a/src/PawSharp.Interactions/InteractionHandler.cs +++ b/src/PawSharp.Interactions/InteractionHandler.cs @@ -12,10 +12,15 @@ using PawSharp.API.Clients; using PawSharp.API.Interfaces; using PawSharp.API.Models; +using AutocompleteChoice = PawSharp.API.Models.AutocompleteChoice; +using InteractionResponse = PawSharp.API.Models.InteractionResponse; +using EditMessageRequest = PawSharp.API.Models.EditMessageRequest; +using CreateMessageRequest = PawSharp.API.Models.CreateMessageRequest; using PawSharp.Gateway; using PawSharp.Gateway.Events; -using PawSharp.Interactions.Models; using PawSharp.Core.Entities; +using DiscordApiException = PawSharp.API.Exceptions.DiscordApiException; +using ValidationException = PawSharp.Core.Exceptions.ValidationException; namespace PawSharp.Interactions; @@ -83,6 +88,82 @@ public void RegisterCommands(params (string name, Func public bool HasModalHandler(string customId) => _modalHandlers.ContainsKey(customId); + /// + /// Checks if an autocomplete handler is registered for the given command name. + /// + public bool HasAutocompleteHandler(string commandName) => _autocompleteHandlers.ContainsKey(commandName); + + /// + /// Checks if a user context menu handler is registered for the given name. + /// + public bool HasUserContextMenuHandler(string name) => _userContextMenuHandlers.ContainsKey(name); + + /// + /// Checks if a message context menu handler is registered for the given name. + /// + public bool HasMessageContextMenuHandler(string name) => _messageContextMenuHandlers.ContainsKey(name); + + /// + /// Checks if an entry point handler is registered for the given name. + /// + public bool HasEntryPointHandler(string name) => _entryPointHandlers.ContainsKey(name); + + /// + /// Unregisters a slash command handler by name. + /// + /// True if the handler was found and removed. + public bool UnregisterCommand(string name) => _commandHandlers.TryRemove(name, out _); + + /// + /// Unregisters a component handler by custom ID. + /// + /// True if the handler was found and removed. + public bool UnregisterComponent(string customId) => _componentHandlers.TryRemove(customId, out _); + + /// + /// Unregisters a modal handler by custom ID. + /// + /// True if the handler was found and removed. + public bool UnregisterModal(string customId) => _modalHandlers.TryRemove(customId, out _); + + /// + /// Unregisters an autocomplete handler by command name. + /// + /// True if the handler was found and removed. + public bool UnregisterAutocomplete(string commandName) => _autocompleteHandlers.TryRemove(commandName, out _); + + /// + /// Unregisters a user context menu handler by name. + /// + /// True if the handler was found and removed. + public bool UnregisterUserContextMenu(string name) => _userContextMenuHandlers.TryRemove(name, out _); + + /// + /// Unregisters a message context menu handler by name. + /// + /// True if the handler was found and removed. + public bool UnregisterMessageContextMenu(string name) => _messageContextMenuHandlers.TryRemove(name, out _); + + /// + /// Unregisters an entry point handler by name. + /// + /// True if the handler was found and removed. + public bool UnregisterEntryPoint(string name) => _entryPointHandlers.TryRemove(name, out _); + + /// + /// Clears all registered handlers. + /// + public void ClearAllHandlers() + { + _commandHandlers.Clear(); + _componentHandlers.Clear(); + _modalHandlers.Clear(); + _autocompleteHandlers.Clear(); + _userContextMenuHandlers.Clear(); + _messageContextMenuHandlers.Clear(); + _entryPointHandlers.Clear(); + } + /// /// Registers a component handler. /// @@ -211,7 +292,8 @@ public async Task HandleInteractionAsync(InteractionCreateEvent interaction) } catch (Exception ex) { - _logger?.LogError(ex, "Unhandled exception in HandleInteractionAsync for interaction ID: {Id}", interaction.Id); + _logger?.LogError(ex, "Unhandled exception in HandleInteractionAsync for interaction ID: {Id}, Type: {Type}. Error: {MessageType}", + interaction.Id, interaction.Type, ex.GetType().Name); throw; } } @@ -227,7 +309,7 @@ private async Task HandleApplicationCommandAsync(InteractionCreateEvent interact // Route by application command type: CHAT_INPUT=1, USER=2, MESSAGE=3, PRIMARY_ENTRY_POINT=4 switch (interaction.Data.Type) { - case (int)PawSharp.Interactions.Models.ApplicationCommandType.User: + case (int)ApplicationCommandType.User: if (_userContextMenuHandlers.TryGetValue(interaction.Data.Name, out var userHandler)) { await InvokeHandlerSafelyAsync(userHandler, interaction, "user context menu", interaction.Data.Name); @@ -285,9 +367,34 @@ private async Task HandleAutocompleteAsync(InteractionCreateEvent interaction, F }; await _restClient.CreateInteractionResponseAsync(interaction.Id, interaction.Token, response); } + catch (DiscordApiException ex) + { + _logger?.LogError(ex, "Autocomplete handler failed for command '{CommandName}'. API Error: {StatusCode} - {DiscordMessage}", + interaction.Data?.Name, ex.StatusCode, ex.DiscordErrorMessage); + // Send empty choices to prevent timeout + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.ApplicationCommandAutocompleteResult, + Data = new InteractionCallbackData { Choices = new List() } + }; + await _restClient.CreateInteractionResponseAsync(interaction.Id, interaction.Token, response); + } + catch (ValidationException ex) + { + _logger?.LogError(ex, "Autocomplete handler failed for command '{CommandName}'. Validation Error: {Parameter} = {Value}", + interaction.Data?.Name, ex.ParameterName, ex.InvalidValue); + // Send empty choices to prevent timeout + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.ApplicationCommandAutocompleteResult, + Data = new InteractionCallbackData { Choices = new List() } + }; + await _restClient.CreateInteractionResponseAsync(interaction.Id, interaction.Token, response); + } catch (Exception ex) { - _logger?.LogError(ex, "Autocomplete handler failed for command: {CommandName}", interaction.Data?.Name); + _logger?.LogError(ex, "Autocomplete handler failed for command '{CommandName}'. Unexpected error: {MessageType}", + interaction.Data?.Name, ex.GetType().Name); // Send empty choices to prevent timeout var response = new InteractionResponse { @@ -302,7 +409,7 @@ private async Task InvokeHandlerSafelyAsync(Func h { if (handler == null) { - _logger?.LogWarning("Handler is null for {HandlerType} with key '{Key}'", handlerType, key); + _logger?.LogWarning("Handler is null for {HandlerType} with key '{Key}' - this may indicate a registration issue", handlerType, key); return; } @@ -310,9 +417,45 @@ private async Task InvokeHandlerSafelyAsync(Func h { await handler(interaction); } + catch (DiscordApiException ex) + { + _logger?.LogError(ex, "{HandlerType} handler failed for key '{Key}'. API Error: {StatusCode} - {DiscordMessage}", + handlerType, key, ex.StatusCode, ex.DiscordErrorMessage); + // Optionally send error response to user + try + { + await _restClient.CreateInteractionResponseAsync(interaction.Id, interaction.Token, new InteractionResponse + { + Type = (int)InteractionResponseType.ChannelMessageWithSource, + Data = new InteractionCallbackData { Content = "An error occurred while processing this interaction.", Flags = 64 } + }); + } + catch (Exception responseEx) + { + _logger?.LogError(responseEx, "Failed to send error response for failed {HandlerType} handler with key '{Key}'", handlerType, key); + } + } + catch (ValidationException ex) + { + _logger?.LogError(ex, "{HandlerType} handler failed for key '{Key}'. Validation Error: {Parameter} = {Value}", + handlerType, key, ex.ParameterName, ex.InvalidValue); + try + { + await _restClient.CreateInteractionResponseAsync(interaction.Id, interaction.Token, new InteractionResponse + { + Type = (int)InteractionResponseType.ChannelMessageWithSource, + Data = new InteractionCallbackData { Content = $"Invalid input: {ex.Message}", Flags = 64 } + }); + } + catch (Exception responseEx) + { + _logger?.LogError(responseEx, "Failed to send error response for failed {HandlerType} handler with key '{Key}'", handlerType, key); + } + } catch (Exception ex) { - _logger?.LogError(ex, "{HandlerType} handler failed for key '{Key}'", handlerType, key); + _logger?.LogError(ex, "{HandlerType} handler failed for key '{Key}'. Unexpected error: {MessageType}", + handlerType, key, ex.GetType().Name); // Optionally send error response to user try { @@ -324,7 +467,7 @@ private async Task InvokeHandlerSafelyAsync(Func h } catch (Exception responseEx) { - _logger?.LogError(responseEx, "Failed to send error response for failed {HandlerType} handler", handlerType); + _logger?.LogError(responseEx, "Failed to send error response for failed {HandlerType} handler with key '{Key}'", handlerType, key); } } } @@ -350,6 +493,58 @@ public Task RespondEphemeralAsync(ulong interactionId, string interactionT return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); } + /// + /// Responds to a slash command interaction with an ephemeral message and embeds. + /// + public Task RespondEphemeralAsync(ulong interactionId, string interactionToken, string content, List embeds) + { + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.ChannelMessageWithSource, + Data = new InteractionCallbackData { Content = content, Embeds = embeds, Flags = 64 } + }; + return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); + } + + /// + /// Responds to an interaction with a message and embeds. + /// + public Task RespondWithEmbedsAsync(ulong interactionId, string interactionToken, string content, List embeds, bool ephemeral = false) + { + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.ChannelMessageWithSource, + Data = new InteractionCallbackData { Content = content, Embeds = embeds, Flags = ephemeral ? 64 : null } + }; + return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); + } + + /// + /// Responds to an interaction by updating the component's message. + /// + public Task RespondUpdateAsync(ulong interactionId, string interactionToken, string content) + { + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.UpdateMessage, + Data = new InteractionCallbackData { Content = content } + }; + return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); + } + + /// + /// Responds to an interaction by updating the component's message with embeds. + /// + public Task RespondUpdateAsync(ulong interactionId, string interactionToken, string content, List? embeds, List? components = null) + { + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.UpdateMessage, + Data = new InteractionCallbackData { Content = content, Embeds = embeds, Components = components } + }; + return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); + } + /// /// Defers a slash command interaction, showing a "Bot is thinking…" state. /// Use or to follow up. @@ -389,6 +584,22 @@ public async Task EditResponseAsync(string applicationId, string interacti return response.IsSuccessStatusCode; } + /// + /// Gets the original interaction response. + /// + public async Task GetOriginalResponseAsync(string applicationId, string interactionToken) + { + return await _restClient.GetOriginalInteractionResponseAsync(applicationId, interactionToken); + } + + /// + /// Deletes the original interaction response. + /// + public async Task DeleteOriginalResponseAsync(string applicationId, string interactionToken) + { + return await _restClient.DeleteOriginalInteractionResponseAsync(applicationId, interactionToken); + } + /// /// Follows up with an additional message. Returns the created Message. /// @@ -397,6 +608,14 @@ public async Task EditResponseAsync(string applicationId, string interacti return await _restClient.CreateFollowupMessageAsync(applicationId, interactionToken, request); } + /// + /// Gets a follow-up message. + /// + public async Task GetFollowupAsync(string applicationId, string interactionToken, ulong messageId) + { + return await _restClient.GetFollowupMessageAsync(applicationId, interactionToken, messageId); + } + /// /// Edits a previously sent follow-up message. /// @@ -428,6 +647,20 @@ public Task RespondWithActivityAsync(ulong interactionId, string interacti return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); } + /// + /// Responds with a premium required response (deprecated). + /// + /// The interaction ID from the event. + /// The interaction token from the event. + public Task RespondPremiumRequiredAsync(ulong interactionId, string interactionToken) + { + var response = new InteractionResponse + { + Type = (int)InteractionResponseType.PremiumRequired + }; + return _restClient.CreateInteractionResponseAsync(interactionId, interactionToken, response); + } + /// /// Gets all application command permissions for a guild. /// @@ -485,6 +718,8 @@ public enum InteractionResponseType UpdateMessage = 7, ApplicationCommandAutocompleteResult = 8, Modal = 9, + /// Deprecated. Respond to an interaction with an upgrade button. + PremiumRequired = 10, /// Launch the Activity associated with the app. Only for apps with Activities enabled. LaunchActivity = 12 } \ No newline at end of file diff --git a/src/PawSharp.Interactions/README.md b/src/PawSharp.Interactions/README.md index 7d07f42..8ed77cf 100644 --- a/src/PawSharp.Interactions/README.md +++ b/src/PawSharp.Interactions/README.md @@ -10,16 +10,21 @@ Use it for slash commands, button/select interactions, and modal submissions wit - Support for modals and follow-up responses - Strongly typed interaction data - Clean extension workflow with PawSharp.Client +- Webhook signature verification for HTTP interactions +- Full support for Components v2 (Labels, RadioGroups, CheckboxGroups, etc.) ## Requirements - .NET 10 (`net10.0`) - `PawSharp.Client` +- `PawSharp.API` +- `PawSharp.Gateway` +- `PawSharp.Core` ## Installation ```bash -dotnet add package PawSharp.Interactions --version 1.1.0-alpha.1 +dotnet add package PawSharp.Interactions --version 1.1.0-alpha.2 ``` ## Quick Start @@ -38,17 +43,248 @@ interactions.OnInteractionCreate += async interaction => }; ``` +## Features + +### Interaction Types + +- **ApplicationCommand**: Slash commands, user context menus, message context menus +- **MessageComponent**: Buttons, select menus (string, user, role, mentionable, channel) +- **ModalSubmit**: Modal form submissions +- **ApplicationCommandAutocomplete**: Autocomplete suggestions for slash commands + +### Response Types + +- `Pong`: Respond to ping interactions +- `ChannelMessageWithSource`: Send a message response +- `DeferredChannelMessageWithSource`: Defer with loading state +- `UpdateMessage`: Update the component's message +- `DeferredUpdateMessage`: Defer component update without loading state +- `ApplicationCommandAutocompleteResult`: Send autocomplete choices +- `Modal`: Show a modal dialog +- `PremiumRequired`: Show premium upgrade button (deprecated) +- `LaunchActivity`: Launch a Discord Activity + +### Component Builders + +The package includes fluent builders for all Discord component types: + +#### Basic Components +- `ButtonBuilder`: Create buttons with styles, emojis, and URLs +- `SelectMenuBuilder`: Create string select menus with options +- `UserSelectMenuBuilder`: Select from guild users +- `RoleSelectMenuBuilder`: Select from guild roles +- `MentionableSelectMenuBuilder`: Select from users and roles +- `ChannelSelectMenuBuilder`: Select from channels with type filtering +- `ActionRowBuilder`: Container for up to 5 components +- `ModalBuilder`: Create modal dialogs with text inputs + +#### Components v2 +- `LabelBuilder`: Display text with optional emoji +- `TextDisplayBuilder`: Render markdown text +- `ThumbnailBuilder`: Display images as accessories +- `MediaGalleryBuilder`: Display collections of media +- `FileBuilder`: Render file attachments +- `SeparatorBuilder`: Add visual dividers +- `ContainerBuilder`: Top-level container with accent colors +- `SectionBuilder`: Group text displays with accessories +- `FileUploadBuilder`: Allow file uploads in modals +- `RadioGroupBuilder`: Single-select option groups +- `CheckboxGroupBuilder`: Multi-select option groups +- `CheckboxBuilder`: Single toggleable checkbox + +## Usage Examples + +### Slash Command Handler + +```csharp +var handler = new InteractionHandler(restClient, logger); + +handler.RegisterCommand("ping", async interaction => +{ + await handler.RespondEphemeralAsync(interaction.Id, interaction.Token, "Pong!"); +}); + +await handler.HandleInteractionAsync(interactionEvent); +``` + +### Button Click Handler + +```csharp +handler.RegisterComponent("confirm_button", async interaction => +{ + await handler.RespondAsync(interaction.Id, interaction.Token, new InteractionResponse + { + Type = (int)InteractionResponseType.ChannelMessageWithSource, + Data = new InteractionCallbackData + { + Content = "Button clicked!", + Flags = 64 // Ephemeral + } + }); +}); +``` + +### Modal Submission Handler + +```csharp +handler.RegisterModal("feedback_modal", async interaction => +{ + var feedback = interaction.GetModalValue("feedback_input"); + await handler.RespondEphemeralAsync(interaction.Id, interaction.Token, + $"Thanks for your feedback: {feedback}"); +}); +``` + +### Creating a Modal + +```csharp +var modal = new ModalBuilder() + .WithCustomId("feedback_modal") + .WithTitle("Feedback") + .AddTextInput("Your Feedback", "feedback_input", + TextInputStyle.Paragraph, placeholder: "Share your thoughts...") + .BuildResponse(); + +await handler.RespondAsync(interaction.Id, interaction.Token, modal); +``` + +### Building Buttons with ActionRow + +```csharp +var response = new InteractionResponseBuilder() + .WithContent("Choose an option:") + .AddActionRow(row => + { + row.AddButton(new ButtonBuilder("accept", "Accept", ButtonStyle.Success)); + row.AddButton(new ButtonBuilder("decline", "Decline", ButtonStyle.Danger)); + }) + .AsEphemeral() + .Build(); + +await handler.RespondAsync(interaction.Id, interaction.Token, response); +``` + +### Using Extension Methods + +```csharp +// Get option values from slash commands +var userId = interaction.GetOptionValue("user"); +var reason = interaction.GetOptionValue("reason"); + +// Get subcommand name +var subcommand = interaction.GetSubcommandName(); + +// Check interaction context +if (interaction.IsGuildInteraction()) +{ + // Handle guild interaction +} + +// Get modal values +var feedback = interaction.GetModalValue("feedback_input"); +var allValues = interaction.GetModalValues(); +``` + +### Follow-up Messages + +```csharp +// Send a follow-up message +var followup = await handler.CreateFollowupAsync(applicationId, interactionToken, + new CreateMessageRequest { Content = "Additional info" }); + +// Edit the follow-up +await handler.EditFollowupAsync(applicationId, interactionToken, followup.Id, + new EditMessageRequest { Content = "Updated info" }); + +// Delete the follow-up +await handler.DeleteFollowupAsync(applicationId, interactionToken, followup.Id); +``` + +### Webhook Verification (HTTP Interactions) + +```csharp +var verifier = new WebhookVerifier("your_public_key_hex"); + +// In your HTTP endpoint +var signature = Request.Headers["X-Signature-Ed25519"]; +var timestamp = Request.Headers["X-Signature-Timestamp"]; +var body = await new StreamReader(Request.Body).ReadToEndAsync(); + +if (!verifier.Verify(signature, timestamp, body)) +{ + return StatusCode(401); +} + +// Process the interaction +``` + +### Autocomplete Handler + +```csharp +handler.RegisterAutocomplete("search", async interaction => +{ + var query = interaction.GetOptionValue("query"); + var choices = new List + { + new AutocompleteChoice { Name = "Option 1", Value = "opt1" }, + new AutocompleteChoice { Name = "Option 2", Value = "opt2" } + }; + return choices; +}); +``` + +### Context Menu Handlers + +```csharp +// User context menu (right-click on user) +handler.RegisterUserContextMenu("Ban User", async interaction => +{ + var targetId = interaction.Data?.TargetId; + // Handle ban logic +}); + +// Message context menu (right-click on message) +handler.RegisterMessageContextMenu("Pin Message", async interaction => +{ + var targetId = interaction.Data?.TargetId; + // Handle pin logic +}); +``` + +## Dependency Injection + +```csharp +// In ConfigureServices +services.AddInteractionHandler(); + +// Inject into your service +public class MyService +{ + private readonly InteractionHandler _handler; + + public MyService(InteractionHandler handler) + { + _handler = handler; + } +} +``` + ## Typical Use Cases - Slash-first bot command experiences - Rich UI flows with buttons, menus, and modals - Hybrid bots using both commands and interactions +- HTTP-based interaction endpoints with webhook verification +- Context menu integrations for users and messages ## Related Packages - `PawSharp.Client`: recommended host for interaction handlers - `PawSharp.Commands`: prefix command workflows - `PawSharp.Interactivity`: user-response waiters and paginated UX +- `PawSharp.API`: REST API client for Discord +- `PawSharp.Gateway`: WebSocket gateway client +- `PawSharp.Core`: Shared entities and enums ## Documentation diff --git a/src/PawSharp.Interactivity/Builders/InteractivityFlowBuilder.cs b/src/PawSharp.Interactivity/Builders/InteractivityFlowBuilder.cs new file mode 100644 index 0000000..b743341 --- /dev/null +++ b/src/PawSharp.Interactivity/Builders/InteractivityFlowBuilder.cs @@ -0,0 +1,187 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PawSharp.Client; +using PawSharp.Core.Entities; +using PawSharp.Interactivity.Extensions; + +namespace PawSharp.Interactivity.Builders; + +/// +/// Builder for creating complex interactivity flows with chained operations. +/// +public class InteractivityFlowBuilder +{ + private readonly DiscordClient _client; + private readonly Channel _channel; + private readonly User _user; + private readonly TimeSpan? _timeout; + private readonly CancellationToken _cancellationToken; + private readonly List>>> _steps; + + /// + /// Initializes a new instance of the class. + /// + /// The Discord client. + /// The channel to use for the flow. + /// The user to interact with. + /// Optional timeout for the flow. + /// Cancellation token. + public InteractivityFlowBuilder( + DiscordClient client, + Channel channel, + User user, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + _client = client; + _channel = channel; + _user = user; + _timeout = timeout; + _cancellationToken = cancellationToken; + _steps = new List>>>(); + } + + /// + /// Adds a message input step to the flow. + /// + /// The prompt message to display. + /// Optional validator function for the input. + /// Error message to show on validation failure. + /// Maximum number of attempts for validation. + /// The builder for chaining. + public InteractivityFlowBuilder WithMessageInput( + string prompt, + Func? validator = null, + string? errorMessage = null, + int maxAttempts = 3) + { + _steps.Add(async () => + { + if (validator == null) + { + var result = await _channel.GetInputAsync(_client, _user, prompt, _timeout, _cancellationToken); + return new InteractivityResult { TimedOut = result.TimedOut, Result = result.Result }; + } + else + { + var result = await _channel.GetValidInputAsync( + _client, + _user, + prompt, + validator, + errorMessage ?? "Invalid input. Please try again.", + maxAttempts, + _timeout, + _cancellationToken); + return new InteractivityResult { TimedOut = result.TimedOut, Result = result.Result }; + } + }); + return this; + } + + /// + /// Adds a confirmation step to the flow. + /// + /// The question to ask. + /// Label for the Yes button. + /// Label for the No button. + /// The builder for chaining. + public InteractivityFlowBuilder WithConfirmation( + string question, + string yesLabel = "Yes", + string noLabel = "No") + { + _steps.Add(async () => + { + var result = await _channel.ConfirmAsync(_client, question, _user, yesLabel, noLabel, _timeout, _cancellationToken); + return new InteractivityResult { TimedOut = result.TimedOut, Result = result.Result }; + }); + return this; + } + + /// + /// Adds a custom step to the flow. + /// + /// The custom step function. + /// The builder for chaining. + public InteractivityFlowBuilder WithCustomStep(Func>> step) + { + _steps.Add(step); + return this; + } + + /// + /// Executes the flow and returns the results of all steps. + /// + /// A list of results from each step. + public async Task>> ExecuteAsync() + { + var results = new List>(); + + foreach (var step in _steps) + { + var result = await step(); + results.Add(result); + + // Stop if a step times out + if (result.TimedOut) + break; + } + + return results; + } + + /// + /// Executes the flow and returns the results of all steps with typed values. + /// + /// The type of results. + /// A list of typed results from each step. + public async Task>> ExecuteAsync() + { + var results = new List>(); + + foreach (var step in _steps) + { + var result = await step(); + results.Add(new InteractivityResult + { + TimedOut = result.TimedOut, + Result = result.Result is T t ? t : default + }); + + // Stop if a step times out + if (result.TimedOut) + break; + } + + return results; + } +} + +/// +/// Extension methods for creating interactivity flows. +/// +public static class InteractivityFlowExtensions +{ + /// + /// Creates a new interactivity flow builder. + /// + /// The channel to use for the flow. + /// The Discord client. + /// The user to interact with. + /// Optional timeout for the flow. + /// Cancellation token. + /// A new interactivity flow builder. + public static InteractivityFlowBuilder CreateFlow( + this Channel channel, + DiscordClient client, + User user, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + return new InteractivityFlowBuilder(client, channel, user, timeout, cancellationToken); + } +} diff --git a/src/PawSharp.Interactivity/Extensions/ChannelExtensions.cs b/src/PawSharp.Interactivity/Extensions/ChannelExtensions.cs index 8d2ce21..75ea4f6 100644 --- a/src/PawSharp.Interactivity/Extensions/ChannelExtensions.cs +++ b/src/PawSharp.Interactivity/Extensions/ChannelExtensions.cs @@ -9,6 +9,7 @@ using PawSharp.Core.Entities; using PawSharp.Gateway.Events; using PawSharp.Interactions; +using PawSharp.Interactivity.Validation; namespace PawSharp.Interactivity.Extensions; @@ -25,14 +26,20 @@ public static class ChannelExtensions /// The user who can control the pagination. /// The pages to paginate. /// The timeout for the pagination. + /// Cancellation token for the operation. /// A task representing the asynchronous operation. public static async Task SendPaginatedMessageAsync( this Channel channel, DiscordClient client, User user, IEnumerable pages, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) { + InteractivityValidation.RequireNotNull(channel, nameof(channel)); + InteractivityValidation.RequireNotNull(client, nameof(client)); + InteractivityValidation.RequireNotNull(user, nameof(user)); + var pageList = pages.ToList(); if (!pageList.Any()) return; @@ -42,6 +49,7 @@ public static async Task SendPaginatedMessageAsync( var emojis = interactivity.PaginationEmojis; var behaviour = interactivity.PollBehaviour; + var callbacks = interactivity.PaginationCallbacks; var currentPage = 0; var message = await client.Rest.CreateMessageAsync(channel.Id, new CreateMessageRequest @@ -65,7 +73,8 @@ public static async Task SendPaginatedMessageAsync( var tcs = new TaskCompletionSource(); using var cts = new CancellationTokenSource(timeout!.Value); - cts.Token.Register(() => tcs.TrySetResult(false)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + linkedCts.Token.Register(() => tcs.TrySetResult(false)); async Task OnReactionAdd(MessageReactionAddEvent evt) { @@ -107,6 +116,12 @@ async Task OnReactionAdd(MessageReactionAddEvent evt) ? new List { pageList[currentPage].Embed! } : new List() }); + + // Invoke page changed callback + if (callbacks?.OnPageChanged != null) + { + await callbacks.OnPageChanged(currentPage, pageList[currentPage]); + } } catch (Exception ex) { @@ -118,7 +133,15 @@ async Task OnReactionAdd(MessageReactionAddEvent evt) try { - await tcs.Task; + var result = await tcs.Task; + if (!result && callbacks?.OnTimeout != null) + { + await callbacks.OnTimeout(); + } + else if (result && callbacks?.OnStopped != null) + { + await callbacks.OnStopped(); + } } finally { @@ -327,6 +350,8 @@ public static async Task SendButtonPaginatedMessageAsync( var interactivity = InteractivityExtensions.GetExtension(client) ?? new InteractivityExtension(); timeout ??= interactivity.Timeout; + var labels = interactivity.PaginationButtonLabels; + var callbacks = interactivity.PaginationCallbacks; var currentPage = 0; var totalPages = pageList.Count; @@ -338,7 +363,7 @@ public static async Task SendButtonPaginatedMessageAsync( Embeds = pageList[currentPage].Embed != null ? new List { pageList[currentPage].Embed! } : null, - Components = BuildPaginationButtons(currentPage, totalPages) + Components = BuildPaginationButtons(currentPage, totalPages, labels) }; var message = await client.Rest.CreateMessageAsync(channel.Id, initialRequest); @@ -354,11 +379,20 @@ public static async Task SendButtonPaginatedMessageAsync( cancellationToken: cancellationToken); if (result.TimedOut || result.Result == null) + { + // Invoke timeout callback + if (callbacks?.OnTimeout != null) + { + await callbacks.OnTimeout(); + } break; + } var customId = result.Result.Data?.CustomId; if (customId == null) continue; + var previousPage = currentPage; + // Handle button clicks switch (customId) { @@ -390,9 +424,23 @@ await client.Rest.CreateInteractionResponseAsync( : null, Components = new List() }); + + // Invoke stopped callback + if (callbacks?.OnStopped != null) + { + await callbacks.OnStopped(); + } return; } + if (currentPage == previousPage) continue; // No change, skip update + + // Invoke page changed callback + if (callbacks?.OnPageChanged != null) + { + await callbacks.OnPageChanged(currentPage, pageList[currentPage]); + } + // Update message with new page and button states var updateResponse = new InteractionResponse { @@ -403,7 +451,7 @@ await client.Rest.CreateInteractionResponseAsync( Embeds = pageList[currentPage].Embed != null ? new List { pageList[currentPage].Embed! } : null, - Components = BuildPaginationButtons(currentPage, totalPages) + Components = BuildPaginationButtons(currentPage, totalPages, labels) } }; @@ -417,41 +465,41 @@ await client.Rest.CreateInteractionResponseAsync( /// /// Builds pagination buttons with appropriate disabled states. /// - private static List BuildPaginationButtons(int currentPage, int totalPages) + private static List BuildPaginationButtons(int currentPage, int totalPages, PaginationButtonLabels labels) { var buttons = new List