diff --git a/DisCatSharp/Clients/BaseDiscordClient.cs b/DisCatSharp/Clients/BaseDiscordClient.cs index 2a9949032..b13491c59 100644 --- a/DisCatSharp/Clients/BaseDiscordClient.cs +++ b/DisCatSharp/Clients/BaseDiscordClient.cs @@ -1,358 +1,370 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. #pragma warning disable CS0618 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading.Tasks; using DisCatSharp.Entities; using DisCatSharp.Enums; using DisCatSharp.Exceptions; using DisCatSharp.Net; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Sentry; namespace DisCatSharp; /// /// Represents a common base for various Discord Client implementations. /// public abstract class BaseDiscordClient : IDisposable { /// /// Gets the api client. /// internal protected DiscordApiClient ApiClient { get; } internal SentryClient Sentry { get; set; } /// /// Gets the configuration. /// internal protected DiscordConfiguration Configuration { get; } /// /// Gets the instance of the logger for this client. /// public ILogger Logger { get; internal set; } /// /// Gets the string representing the version of bot lib. /// public string VersionString { get; } /// /// Gets the bot library name. /// public string BotLibrary { get; } [Obsolete("Use GetLibraryDeveloperTeamAsync")] public DisCatSharpTeam LibraryDeveloperTeamAsync => this.GetLibraryDevelopmentTeamAsync().Result; /// /// Gets the current user. /// public DiscordUser CurrentUser { get; internal set; } /// /// Gets the current application. /// public DiscordApplication CurrentApplication { get; internal set; } /// /// Exposes a Http Client for custom operations. /// public HttpClient RestClient { get; internal set; } /// /// Gets the cached guilds for this client. /// public abstract IReadOnlyDictionary Guilds { get; } /// /// Gets the cached users for this client. /// public ConcurrentDictionary UserCache { get; internal set; } /// /// Gets the service provider. /// This allows passing data around without resorting to static members. /// Defaults to null. /// internal IServiceProvider ServiceProvider { get; set; } /// /// Gets the list of available voice regions. Note that this property will not contain VIP voice regions. /// public IReadOnlyDictionary VoiceRegions => this.VoiceRegionsLazy.Value; /// /// Gets the list of available voice regions. This property is meant as a way to modify . /// protected internal ConcurrentDictionary InternalVoiceRegions { get; set; } internal Lazy> VoiceRegionsLazy; /// /// Initializes this Discord API client. /// /// Configuration for this client. protected BaseDiscordClient(DiscordConfiguration config) { this.Configuration = new DiscordConfiguration(config); this.ServiceProvider = config.ServiceProvider; if (this.ServiceProvider != null) { this.Configuration.LoggerFactory ??= config.ServiceProvider.GetService(); this.Logger = config.ServiceProvider.GetService>(); } if (this.Configuration.LoggerFactory == null) { this.Configuration.LoggerFactory = new DefaultLoggerFactory(); this.Configuration.LoggerFactory.AddProvider(new DefaultLoggerProvider(this)); if (this.Configuration.EnableSentry) this.Configuration.LoggerFactory.AddSentry(x => x.DiagnosticLevel = SentryLevel.Error); } this.Logger ??= this.Configuration.LoggerFactory.CreateLogger(); this.ApiClient = new DiscordApiClient(this); this.UserCache = new ConcurrentDictionary(); this.InternalVoiceRegions = new ConcurrentDictionary(); this.VoiceRegionsLazy = new Lazy>(() => new ReadOnlyDictionary(this.InternalVoiceRegions)); this.RestClient = new(); this.RestClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", Utilities.GetUserAgent()); this.RestClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Discord-Locale", this.Configuration.Locale); var a = typeof(DiscordClient).GetTypeInfo().Assembly; var iv = a.GetCustomAttribute(); if (iv != null) { this.VersionString = iv.InformationalVersion; } else { var v = a.GetName().Version; var vs = v.ToString(3); if (v.Revision > 0) this.VersionString = $"{vs}, CI build {v.Revision}"; } this.BotLibrary = "DisCatSharp"; } /// /// Gets the current API application. /// public async Task GetCurrentApplicationAsync() { var tapp = await this.ApiClient.GetCurrentApplicationOauth2InfoAsync().ConfigureAwait(false); var app = new DiscordApplication { Discord = this, Id = tapp.Id, Name = tapp.Name, Description = tapp.Description, Summary = tapp.Summary, IconHash = tapp.IconHash, RpcOrigins = tapp.RpcOrigins != null ? new ReadOnlyCollection(tapp.RpcOrigins) : null, Flags = tapp.Flags, IsHook = tapp.IsHook, Type = tapp.Type, PrivacyPolicyUrl = tapp.PrivacyPolicyUrl, TermsOfServiceUrl = tapp.TermsOfServiceUrl, CustomInstallUrl = tapp.CustomInstallUrl, InstallParams = tapp.InstallParams, RoleConnectionsVerificationUrl = tapp.RoleConnectionsVerificationUrl, Tags = (tapp.Tags ?? Enumerable.Empty()).ToArray() }; if (tapp.Team == null) { app.Owners = new List(new[] { new DiscordUser(tapp.Owner) }); app.Team = null; app.TeamName = null; } else { app.Team = new DiscordTeam(tapp.Team); var members = tapp.Team.Members .Select(x => new DiscordTeamMember(x) { TeamId = app.Team.Id, TeamName = app.Team.Name, User = new DiscordUser(x.User) }) .ToArray(); var owners = members .Where(x => x.MembershipStatus == DiscordTeamMembershipStatus.Accepted) .Select(x => x.User) .ToArray(); app.Owners = new List(owners); app.Team.Owner = owners.FirstOrDefault(x => x.Id == tapp.Team.OwnerId); app.Team.Members = new List(members); app.TeamName = app.Team.Name; } app.GuildId = tapp.GuildId.ValueOrDefault(); app.Slug = tapp.Slug.ValueOrDefault(); app.PrimarySkuId = tapp.PrimarySkuId.ValueOrDefault(); app.VerifyKey = tapp.VerifyKey.ValueOrDefault(); app.CoverImageHash = tapp.CoverImageHash.ValueOrDefault(); app.Guild = tapp.Guild.ValueOrDefault(); app.ApproximateGuildCount = tapp.ApproximateGuildCount.ValueOrDefault(); app.RequiresCodeGrant = tapp.BotRequiresCodeGrant.ValueOrDefault(); app.IsPublic = tapp.IsPublicBot.ValueOrDefault(); app.RedirectUris = tapp.RedirectUris.ValueOrDefault(); app.InteractionsEndpointUrl = tapp.InteractionsEndpointUrl.ValueOrDefault(); return app; } /// /// Updates the current API application. /// /// The new description. /// The new interactions endpoint url. /// The new role connections verification url. /// The new tags. /// The new application icon. /// The updated application. public async Task UpdateCurrentApplicationInfoAsync(Optional description, Optional interactionsEndpointUrl, Optional roleConnectionsVerificationUrl, Optional?> tags, Optional icon) { var iconb64 = ImageTool.Base64FromStream(icon); if (tags != null && tags.HasValue && tags.Value != null) if (tags.Value.Any(x => x.Length > 20)) throw new InvalidOperationException("Tags can not exceed 20 chars."); _ = await this.ApiClient.ModifyCurrentApplicationInfoAsync(description, interactionsEndpointUrl, roleConnectionsVerificationUrl, tags, iconb64); // We use GetCurrentApplicationAsync because modify returns internal data not meant for developers. var app = await this.GetCurrentApplicationAsync(); this.CurrentApplication = app; return app; } /// /// Gets a list of voice regions. /// /// Thrown when Discord is unable to process the request. public Task> ListVoiceRegionsAsync() => this.ApiClient.ListVoiceRegionsAsync(); /// /// Initializes this client. This method fetches information about current user, application, and voice regions. /// public virtual async Task InitializeAsync() { if (this.CurrentUser == null) { this.CurrentUser = await this.ApiClient.GetCurrentUserAsync().ConfigureAwait(false); this.UserCache.AddOrUpdate(this.CurrentUser.Id, this.CurrentUser, (id, xu) => this.CurrentUser); } if (this.Configuration.TokenType == TokenType.Bot && this.CurrentApplication == null) this.CurrentApplication = await this.GetCurrentApplicationAsync().ConfigureAwait(false); if (this.Configuration.TokenType != TokenType.Bearer && this.InternalVoiceRegions.IsEmpty) { var vrs = await this.ListVoiceRegionsAsync().ConfigureAwait(false); foreach (var xvr in vrs) this.InternalVoiceRegions.TryAdd(xvr.Id, xvr); } + + if (this.Configuration.EnableSentry && this.Configuration.AttachUserInfo) + SentrySdk.ConfigureScope(x => x.User = new User() + { + Id = this.CurrentUser.Id.ToString(), + Username = this.CurrentUser.UsernameWithDiscriminator, + Other = new Dictionary() + { + { "developer", this.Configuration.DeveloperUserId?.ToString() ?? "not_given" }, + { "email", this.Configuration.FeedbackEmail ?? "not_given" } + } + }); } /// /// Gets the current gateway info for the provided token. /// If no value is provided, the configuration value will be used instead. /// /// A gateway info object. public async Task GetGatewayInfoAsync(string token = null) { if (this.Configuration.TokenType != TokenType.Bot) throw new InvalidOperationException("Only bot tokens can access this info."); if (string.IsNullOrEmpty(this.Configuration.Token)) { if (string.IsNullOrEmpty(token)) throw new InvalidOperationException("Could not locate a valid token."); this.Configuration.Token = token; var res = await this.ApiClient.GetGatewayInfoAsync().ConfigureAwait(false); this.Configuration.Token = null; return res; } return await this.ApiClient.GetGatewayInfoAsync().ConfigureAwait(false); } /// /// Gets some information about the development team behind DisCatSharp. /// Can be used for crediting etc. /// Note: This call contacts servers managed by the DCS team, no information is collected. /// The team, or null with errors being logged on failure. /// [Obsolete("Don't use this right now, inactive")] public async Task GetLibraryDevelopmentTeamAsync() => await DisCatSharpTeam.Get(this.RestClient, this.Logger, this.ApiClient).ConfigureAwait(false); /// /// Gets a cached user. /// /// The user id. internal DiscordUser GetCachedOrEmptyUserInternal(ulong userId) { this.TryGetCachedUserInternal(userId, out var user); return user; } /// /// Tries the get a cached user. /// /// The user id. /// The user. internal bool TryGetCachedUserInternal(ulong userId, out DiscordUser user) { if (this.UserCache.TryGetValue(userId, out user)) return true; user = new DiscordUser { Id = userId, Discord = this }; return false; } /// /// Disposes this client. /// public abstract void Dispose(); } diff --git a/DisCatSharp/Clients/DiscordClient.WebSocket.cs b/DisCatSharp/Clients/DiscordClient.WebSocket.cs index 60f9b3fde..106c706c4 100644 --- a/DisCatSharp/Clients/DiscordClient.WebSocket.cs +++ b/DisCatSharp/Clients/DiscordClient.WebSocket.cs @@ -1,653 +1,654 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using DisCatSharp.Entities; using DisCatSharp.Enums; using DisCatSharp.EventArgs; using DisCatSharp.Net.Abstractions; using DisCatSharp.Net.WebSocket; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Sentry; namespace DisCatSharp; /// /// Represents a discord websocket client. /// public sealed partial class DiscordClient { #region Private Fields private int _heartbeatInterval; private DateTimeOffset _lastHeartbeat; private Task _heartbeatTask; internal static DateTimeOffset DiscordEpoch = new(2015, 1, 1, 0, 0, 0, TimeSpan.Zero); private int _skippedHeartbeats; private long _lastSequence; internal IWebSocketClient WebSocketClient; private PayloadDecompressor _payloadDecompressor; private CancellationTokenSource _cancelTokenSource; private CancellationToken _cancelToken; #endregion #region Connection Semaphore /// /// Gets the socket locks. /// private static ConcurrentDictionary s_socketLocks { get; } = new(); /// /// Gets the session lock. /// private readonly ManualResetEventSlim _sessionLock = new(true); #endregion #region Internal Connection Methods /// /// Reconnects the websocket client. /// /// Whether to start a new session. /// The reconnect code. /// The reconnect message. private Task InternalReconnectAsync(bool startNewSession = false, int code = 1000, string message = "") { if (startNewSession) this._sessionId = null; _ = this.WebSocketClient.DisconnectAsync(code, message); return Task.CompletedTask; } /// /// Connects the websocket client. /// internal async Task InternalConnectAsync() { var a = typeof(DiscordClient).GetTypeInfo().Assembly; var vs = ""; var iv = a.GetCustomAttribute(); if (iv != null) vs = iv.InformationalVersion; else { var v = a.GetName().Version; vs = v.ToString(3); } - using (SentrySdk.Init(o => { - o.DetectStartupTime = StartupTimeDetectionMode.Fast; - o.DiagnosticLevel = SentryLevel.Debug; - o.Environment = "prod"; - o.IsGlobalModeEnabled = true; - o.TracesSampleRate = 1.0; - o.ReportAssembliesMode = ReportAssembliesMode.InformationalVersion; - o.Dsn = "https://1da216e26a2741b99e8ccfccea1b7ac8@o1113828.ingest.sentry.io/4504901362515968"; - o.AddInAppInclude("DisCatSharp"); - o.AttachStacktrace = true; - o.AutoSessionTracking = this.Configuration.EnableSentry; - o.StackTraceMode = StackTraceMode.Enhanced; - o.Release = $"{this.BotLibrary}@{vs}"; - o.SendClientReports = true; - })) + if (this.Configuration.EnableSentry) { - if (this.Configuration.EnableSentry) + using (SentrySdk.Init(o => + { + o.DetectStartupTime = StartupTimeDetectionMode.Fast; + o.DiagnosticLevel = SentryLevel.Debug; + o.Environment = "dev"; + o.IsGlobalModeEnabled = true; + o.TracesSampleRate = 1.0; + o.ReportAssembliesMode = ReportAssembliesMode.InformationalVersion; + o.Dsn = "https://1da216e26a2741b99e8ccfccea1b7ac8@o1113828.ingest.sentry.io/4504901362515968"; + o.AddInAppInclude("DisCatSharp"); + o.AttachStacktrace = true; + o.AutoSessionTracking = this.Configuration.EnableSentry; + o.StackTraceMode = StackTraceMode.Enhanced; + o.Release = $"{this.BotLibrary}@{vs}"; + o.SendClientReports = true; + o.IsEnvironmentUser = false; + o.UseAsyncFileIO = true; + o.EnableScopeSync = true; + })) { this.Sentry = new SentryClient(new SentryOptions() { DetectStartupTime = StartupTimeDetectionMode.Fast, DiagnosticLevel = SentryLevel.Debug, - Environment = "prod", + Environment = "dev", IsGlobalModeEnabled = true, TracesSampleRate = 1.0, ReportAssembliesMode = ReportAssembliesMode.InformationalVersion, Dsn = "https://1da216e26a2741b99e8ccfccea1b7ac8@o1113828.ingest.sentry.io/4504901362515968", AttachStacktrace = true, AutoSessionTracking = this.Configuration.EnableSentry, StackTraceMode = StackTraceMode.Enhanced, SendClientReports = true, - Release = $"{this.BotLibrary}@{vs}" - }); + Release = $"{this.BotLibrary}@{vs}", + IsEnvironmentUser = false, + UseAsyncFileIO = true, + EnableScopeSync = true + }); SentrySdk.BindClient(this.Sentry); SentrySdk.StartSession(); } + } SocketLock socketLock = null; try { if (this.GatewayInfo == null) await this.InternalUpdateGatewayAsync().ConfigureAwait(false); await this.InitializeAsync().ConfigureAwait(false); socketLock = this.GetSocketLock(); await socketLock.LockAsync().ConfigureAwait(false); - SentrySdk.ConfigureScope(o => o.User = new User() - { - Id = this.CurrentApplication.Id.ToString(), - Username = this.CurrentUser.UsernameWithDiscriminator - }); - //SentrySdk.CaptureMessage($"Testing {DateTime.UtcNow.Ticks}"); - } + } catch { socketLock?.UnlockAfter(TimeSpan.Zero); throw; } if (!this.Presences.ContainsKey(this.CurrentUser.Id)) { this.PresencesInternal[this.CurrentUser.Id] = new DiscordPresence { Discord = this, RawActivity = new TransportActivity(), Activity = new DiscordActivity(), Status = UserStatus.Online, InternalUser = new UserWithIdOnly() { Id = this.CurrentUser.Id } }; } else { var pr = this.PresencesInternal[this.CurrentUser.Id]; pr.RawActivity = new TransportActivity(); pr.Activity = new DiscordActivity(); pr.Status = UserStatus.Online; } Volatile.Write(ref this._skippedHeartbeats, 0); this.WebSocketClient = this.Configuration.WebSocketClientFactory(this.Configuration.Proxy, this.ServiceProvider); this._payloadDecompressor = this.Configuration.GatewayCompressionLevel != GatewayCompressionLevel.None ? new PayloadDecompressor(this.Configuration.GatewayCompressionLevel) : null; this._cancelTokenSource = new CancellationTokenSource(); this._cancelToken = this._cancelTokenSource.Token; this.WebSocketClient.Connected += SocketOnConnect; this.WebSocketClient.Disconnected += SocketOnDisconnect; this.WebSocketClient.MessageReceived += SocketOnMessage; this.WebSocketClient.ExceptionThrown += SocketOnException; var gwuri = new QueryUriBuilder(this.GatewayUri) .AddParameter("v", this.Configuration.ApiVersion) .AddParameter("encoding", "json"); if (this.Configuration.GatewayCompressionLevel == GatewayCompressionLevel.Stream) gwuri.AddParameter("compress", "zlib-stream"); this.Logger.LogDebug(LoggerEvents.Startup, "Connecting to {gw}", this.GatewayUri.AbsoluteUri); await this.WebSocketClient.ConnectAsync(gwuri.Build()).ConfigureAwait(false); Task SocketOnConnect(IWebSocketClient sender, SocketEventArgs e) => this._socketOpened.InvokeAsync(this, e); async Task SocketOnMessage(IWebSocketClient sender, SocketMessageEventArgs e) { string msg = null; if (e is SocketTextMessageEventArgs etext) { msg = etext.Message; } else if (e is SocketBinaryMessageEventArgs ebin) { using var ms = new MemoryStream(); if (!this._payloadDecompressor.TryDecompress(new ArraySegment(ebin.Message), ms)) { this.Logger.LogError(LoggerEvents.WebSocketReceiveFailure, "Payload decompression failed"); return; } ms.Position = 0; using var sr = new StreamReader(ms, Utilities.UTF8); msg = await sr.ReadToEndAsync().ConfigureAwait(false); } try { this.Logger.LogTrace(LoggerEvents.GatewayWsRx, msg); await this.HandleSocketMessageAsync(msg).ConfigureAwait(false); } catch (Exception ex) { this.Logger.LogError(LoggerEvents.WebSocketReceiveFailure, ex, "Socket handler suppressed an exception"); if (this.Configuration.EnableSentry) - SentrySdk.CaptureException(ex); + this.Sentry.CaptureException(ex); } } Task SocketOnException(IWebSocketClient sender, SocketErrorEventArgs e) => this._socketErrored.InvokeAsync(this, e); async Task SocketOnDisconnect(IWebSocketClient sender, SocketCloseEventArgs e) { // release session and connection this._connectionLock.Set(); this._sessionLock.Set(); if (!this._disposed) this._cancelTokenSource.Cancel(); this.Logger.LogDebug(LoggerEvents.ConnectionClose, "Connection closed ({0}, '{1}')", e.CloseCode, e.CloseMessage); await this._socketClosed.InvokeAsync(this, e).ConfigureAwait(false); if (this.Configuration.AutoReconnect && (e.CloseCode < 4001 || e.CloseCode >= 5000)) { this.Logger.LogCritical(LoggerEvents.ConnectionClose, "Connection terminated ({0}, '{1}'), reconnecting", e.CloseCode, e.CloseMessage); if (this._status == null) await this.ConnectAsync().ConfigureAwait(false); else if (this._status.IdleSince.HasValue) await this.ConnectAsync(this._status.ActivityInternal, this._status.Status, Utilities.GetDateTimeOffsetFromMilliseconds(this._status.IdleSince.Value)).ConfigureAwait(false); else await this.ConnectAsync(this._status.ActivityInternal, this._status.Status).ConfigureAwait(false); } else { this.Logger.LogCritical(LoggerEvents.ConnectionClose, "Connection terminated ({0}, '{1}')", e.CloseCode, e.CloseMessage); } - } } } #endregion #region WebSocket (Events) /// /// Handles the socket message. /// /// The data. internal async Task HandleSocketMessageAsync(string data) { var payload = JsonConvert.DeserializeObject(data); this._lastSequence = payload.Sequence ?? this._lastSequence; switch (payload.OpCode) { case GatewayOpCode.Dispatch: await Task.Run(async () => await this.HandleDispatchAsync(payload).ConfigureAwait(false)); break; case GatewayOpCode.Heartbeat: await this.OnHeartbeatAsync((long)payload.Data).ConfigureAwait(false); break; case GatewayOpCode.Reconnect: await this.OnReconnectAsync().ConfigureAwait(false); break; case GatewayOpCode.InvalidSession: await this.OnInvalidateSessionAsync((bool)payload.Data).ConfigureAwait(false); break; case GatewayOpCode.Hello: await this.OnHelloAsync((payload.Data as JObject).ToObject()).ConfigureAwait(false); break; case GatewayOpCode.HeartbeatAck: await this.OnHeartbeatAckAsync().ConfigureAwait(false); break; default: this.Logger.LogWarning(LoggerEvents.WebSocketReceive, "Unknown Discord opcode: {0}\nPayload: {1}", payload.OpCode, payload.Data); break; } } /// /// Handles the heartbeat. /// /// The sequence. internal async Task OnHeartbeatAsync(long seq) { this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received HEARTBEAT (OP1)"); await this.SendHeartbeatAsync(seq).ConfigureAwait(false); } /// /// Handles the reconnect event. /// internal async Task OnReconnectAsync() { this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received RECONNECT (OP7)"); await this.InternalReconnectAsync(code: 4000, message: "OP7 acknowledged").ConfigureAwait(false); } /// /// Handles the invalidate session event /// /// Unknown. Please fill documentation. internal async Task OnInvalidateSessionAsync(bool data) { // begin a session if one is not open already if (this._sessionLock.Wait(0)) this._sessionLock.Reset(); // we are sending a fresh resume/identify, so lock the socket var socketLock = this.GetSocketLock(); await socketLock.LockAsync().ConfigureAwait(false); socketLock.UnlockAfter(TimeSpan.FromSeconds(5)); if (data) { this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received INVALID_SESSION (OP9, true)"); await Task.Delay(6000).ConfigureAwait(false); await this.SendResumeAsync().ConfigureAwait(false); } else { this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received INVALID_SESSION (OP9, false)"); this._sessionId = null; await this.SendIdentifyAsync(this._status).ConfigureAwait(false); } } /// /// Handles the hello event. /// /// The gateway hello payload. internal async Task OnHelloAsync(GatewayHello hello) { this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received HELLO (OP10)"); if (this._sessionLock.Wait(0)) { this._sessionLock.Reset(); this.GetSocketLock().UnlockAfter(TimeSpan.FromSeconds(5)); } else { this.Logger.LogWarning(LoggerEvents.SessionUpdate, "Attempt to start a session while another session is active"); return; } Interlocked.CompareExchange(ref this._skippedHeartbeats, 0, 0); this._heartbeatInterval = hello.HeartbeatInterval; this._heartbeatTask = Task.Run(this.HeartbeatLoopAsync, this._cancelToken); if (string.IsNullOrEmpty(this._sessionId)) await this.SendIdentifyAsync(this._status).ConfigureAwait(false); else await this.SendResumeAsync().ConfigureAwait(false); } /// /// Handles the heartbeat acknowledge event. /// internal async Task OnHeartbeatAckAsync() { Interlocked.Decrement(ref this._skippedHeartbeats); var ping = (int)(DateTime.Now - this._lastHeartbeat).TotalMilliseconds; this.Logger.LogTrace(LoggerEvents.WebSocketReceive, "Received HEARTBEAT_ACK (OP11, {0}ms)", ping); Volatile.Write(ref this._ping, ping); var args = new HeartbeatEventArgs(this.ServiceProvider) { Ping = this.Ping, Timestamp = DateTimeOffset.Now }; await this._heartbeated.InvokeAsync(this, args).ConfigureAwait(false); } /// /// Handles the heartbeat loop. /// internal async Task HeartbeatLoopAsync() { this.Logger.LogDebug(LoggerEvents.Heartbeat, "Heartbeat task started"); var token = this._cancelToken; try { while (true) { await this.SendHeartbeatAsync(this._lastSequence).ConfigureAwait(false); await Task.Delay(this._heartbeatInterval, token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); } } catch (OperationCanceledException) { } } #endregion #region Internal Gateway Methods /// /// Updates the status. /// /// The activity. /// The optional user status. /// Since when is the client performing the specified activity. internal async Task InternalUpdateStatusAsync(DiscordActivity activity, UserStatus? userStatus, DateTimeOffset? idleSince) { if (activity != null && activity.Name != null && activity.Name.Length > 128) throw new Exception("Game name can't be longer than 128 characters!"); var sinceUnix = idleSince != null ? (long?)Utilities.GetUnixTime(idleSince.Value) : null; var act = activity ?? new DiscordActivity(); var status = new StatusUpdate { Activity = new TransportActivity(act), IdleSince = sinceUnix, IsAfk = idleSince != null, Status = userStatus ?? UserStatus.Online }; // Solution to have status persist between sessions this._status = status; var statusUpdate = new GatewayPayload { OpCode = GatewayOpCode.StatusUpdate, Data = status }; var statusstr = JsonConvert.SerializeObject(statusUpdate); await this.WsSendAsync(statusstr).ConfigureAwait(false); if (!this.PresencesInternal.ContainsKey(this.CurrentUser.Id)) { this.PresencesInternal[this.CurrentUser.Id] = new DiscordPresence { Discord = this, Activity = act, Status = userStatus ?? UserStatus.Online, InternalUser = new UserWithIdOnly { Id = this.CurrentUser.Id } }; } else { var pr = this.PresencesInternal[this.CurrentUser.Id]; pr.Activity = act; pr.Status = userStatus ?? pr.Status; } } /// /// Sends the heartbeat. /// /// The sequenze. internal async Task SendHeartbeatAsync(long seq) { var moreThan5 = Volatile.Read(ref this._skippedHeartbeats) > 5; var guildsComp = Volatile.Read(ref this._guildDownloadCompleted); if (guildsComp && moreThan5) { this.Logger.LogCritical(LoggerEvents.HeartbeatFailure, "Server failed to acknowledge more than 5 heartbeats - connection is zombie"); var args = new ZombiedEventArgs(this.ServiceProvider) { Failures = Volatile.Read(ref this._skippedHeartbeats), GuildDownloadCompleted = true }; await this._zombied.InvokeAsync(this, args).ConfigureAwait(false); await this.InternalReconnectAsync(code: 4001, message: "Too many heartbeats missed").ConfigureAwait(false); return; } else if (!guildsComp && moreThan5) { var args = new ZombiedEventArgs(this.ServiceProvider) { Failures = Volatile.Read(ref this._skippedHeartbeats), GuildDownloadCompleted = false }; await this._zombied.InvokeAsync(this, args).ConfigureAwait(false); this.Logger.LogWarning(LoggerEvents.HeartbeatFailure, "Server failed to acknowledge more than 5 heartbeats, but the guild download is still running - check your connection speed"); } Volatile.Write(ref this._lastSequence, seq); this.Logger.LogTrace(LoggerEvents.Heartbeat, "Sending heartbeat"); var heartbeat = new GatewayPayload { OpCode = GatewayOpCode.Heartbeat, Data = seq }; var heartbeatStr = JsonConvert.SerializeObject(heartbeat); await this.WsSendAsync(heartbeatStr).ConfigureAwait(false); this._lastHeartbeat = DateTimeOffset.Now; Interlocked.Increment(ref this._skippedHeartbeats); } /// /// Sends the identify payload. /// /// The status update payload. internal async Task SendIdentifyAsync(StatusUpdate status) { var identify = new GatewayIdentify { Token = Utilities.GetFormattedToken(this), Compress = this.Configuration.GatewayCompressionLevel == GatewayCompressionLevel.Payload, LargeThreshold = this.Configuration.LargeThreshold, ShardInfo = new ShardInfo { ShardId = this.Configuration.ShardId, ShardCount = this.Configuration.ShardCount }, Presence = status, Intents = this.Configuration.Intents, Discord = this }; var payload = new GatewayPayload { OpCode = GatewayOpCode.Identify, Data = identify }; var payloadstr = JsonConvert.SerializeObject(payload); await this.WsSendAsync(payloadstr).ConfigureAwait(false); this.Logger.LogDebug(LoggerEvents.Intents, "Registered gateway intents ({0})", this.Configuration.Intents); } /// /// Sends the resume payload. /// internal async Task SendResumeAsync() { var resume = new GatewayResume { Token = Utilities.GetFormattedToken(this), SessionId = this._sessionId, SequenceNumber = Volatile.Read(ref this._lastSequence) }; var resumePayload = new GatewayPayload { OpCode = GatewayOpCode.Resume, Data = resume }; var resumestr = JsonConvert.SerializeObject(resumePayload); this.GatewayUri = new Uri(this._resumeGatewayUrl); this.Logger.LogDebug(LoggerEvents.ConnectionClose, "Request to resume via {gw}", this.GatewayUri.AbsoluteUri); await this.WsSendAsync(resumestr).ConfigureAwait(false); } /// /// Internals the update gateway async. /// /// A Task. internal async Task InternalUpdateGatewayAsync() { var info = await this.GetGatewayInfoAsync().ConfigureAwait(false); this.GatewayInfo = info; this.GatewayUri = new Uri(info.Url); } /// /// Sends a websocket message. /// /// The payload to send. internal async Task WsSendAsync(string payload) { this.Logger.LogTrace(LoggerEvents.GatewayWsTx, payload); await this.WebSocketClient.SendMessageAsync(payload).ConfigureAwait(false); } #endregion #region Semaphore Methods /// /// Gets the socket lock. /// /// The added socket lock. private SocketLock GetSocketLock() => s_socketLocks.GetOrAdd(this.CurrentApplication.Id, appId => new SocketLock(appId, this.GatewayInfo.SessionBucket.MaxConcurrency)); #endregion } diff --git a/DisCatSharp/Clients/DiscordClient.cs b/DisCatSharp/Clients/DiscordClient.cs index 847b89b4e..5f2e856a4 100644 --- a/DisCatSharp/Clients/DiscordClient.cs +++ b/DisCatSharp/Clients/DiscordClient.cs @@ -1,1655 +1,1660 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using DisCatSharp.Attributes; using DisCatSharp.Common.Utilities; using DisCatSharp.Entities; using DisCatSharp.Enums; using DisCatSharp.EventArgs; using DisCatSharp.Exceptions; using DisCatSharp.Net; using DisCatSharp.Net.Abstractions; using DisCatSharp.Net.Models; using DisCatSharp.Net.Serialization; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; +using Sentry; + namespace DisCatSharp; /// /// A Discord API wrapper. /// public sealed partial class DiscordClient : BaseDiscordClient { #region Internal Fields/Properties internal bool IsShard = false; /// /// Gets the message cache. /// internal RingBuffer MessageCache { get; } private List _extensions = new(); private StatusUpdate _status; /// /// Gets the connection lock. /// private readonly ManualResetEventSlim _connectionLock = new(true); #endregion #region Public Fields/Properties /// /// Gets the gateway protocol version. /// public int GatewayVersion { get; internal set; } /// /// Gets the gateway session information for this client. /// public GatewayInfo GatewayInfo { get; internal set; } /// /// Gets the gateway URL. /// public Uri GatewayUri { get; internal set; } /// /// Gets the total number of shards the bot is connected to. /// public int ShardCount => this.GatewayInfo != null ? this.GatewayInfo.ShardCount : this.Configuration.ShardCount; /// /// Gets the currently connected shard ID. /// public int ShardId => this.Configuration.ShardId; /// /// Gets the intents configured for this client. /// public DiscordIntents Intents => this.Configuration.Intents; /// /// Gets a dictionary of guilds that this client is in. The dictionary's key is the guild ID. Note that the /// guild objects in this dictionary will not be filled in if the specific guilds aren't available (the /// or events haven't been fired yet) /// public override IReadOnlyDictionary Guilds { get; } internal ConcurrentDictionary GuildsInternal = new(); /// /// Gets the websocket latency for this client. /// public int Ping => Volatile.Read(ref this._ping); private int _ping; /// /// Gets the collection of presences held by this client. /// public IReadOnlyDictionary Presences => this._presencesLazy.Value; internal Dictionary PresencesInternal = new(); private Lazy> _presencesLazy; /// /// Gets the collection of presences held by this client. /// public IReadOnlyDictionary EmbeddedActivities => this._embeddedActivitiesLazy.Value; internal Dictionary EmbeddedActivitiesInternal = new(); private Lazy> _embeddedActivitiesLazy; #endregion #region Constructor/Internal Setup /// /// Initializes a new instance of . /// /// Specifies configuration parameters. public DiscordClient(DiscordConfiguration config) : base(config) { if (this.Configuration.MessageCacheSize > 0) { var intents = this.Configuration.Intents; this.MessageCache = intents.HasIntent(DiscordIntents.GuildMessages) || intents.HasIntent(DiscordIntents.DirectMessages) ? new RingBuffer(this.Configuration.MessageCacheSize) : null; } this.InternalSetup(); this.Guilds = new ReadOnlyConcurrentDictionary(this.GuildsInternal); } /// /// Internal setup of the Client. /// internal void InternalSetup() { this._clientErrored = new AsyncEvent("CLIENT_ERRORED", EventExecutionLimit, this.Goof); this._socketErrored = new AsyncEvent("SOCKET_ERRORED", EventExecutionLimit, this.Goof); this._socketOpened = new AsyncEvent("SOCKET_OPENED", EventExecutionLimit, this.EventErrorHandler); this._socketClosed = new AsyncEvent("SOCKET_CLOSED", EventExecutionLimit, this.EventErrorHandler); this._ready = new AsyncEvent("READY", EventExecutionLimit, this.EventErrorHandler); this._resumed = new AsyncEvent("RESUMED", EventExecutionLimit, this.EventErrorHandler); this._channelCreated = new AsyncEvent("CHANNEL_CREATED", EventExecutionLimit, this.EventErrorHandler); this._channelUpdated = new AsyncEvent("CHANNEL_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._channelDeleted = new AsyncEvent("CHANNEL_DELETED", EventExecutionLimit, this.EventErrorHandler); this._dmChannelDeleted = new AsyncEvent("DM_CHANNEL_DELETED", EventExecutionLimit, this.EventErrorHandler); this._channelPinsUpdated = new AsyncEvent("CHANNEL_PINS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildCreated = new AsyncEvent("GUILD_CREATED", EventExecutionLimit, this.EventErrorHandler); this._guildAvailable = new AsyncEvent("GUILD_AVAILABLE", EventExecutionLimit, this.EventErrorHandler); this._guildUpdated = new AsyncEvent("GUILD_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildDeleted = new AsyncEvent("GUILD_DELETED", EventExecutionLimit, this.EventErrorHandler); this._guildUnavailable = new AsyncEvent("GUILD_UNAVAILABLE", EventExecutionLimit, this.EventErrorHandler); this._guildDownloadCompletedEv = new AsyncEvent("GUILD_DOWNLOAD_COMPLETED", EventExecutionLimit, this.EventErrorHandler); this._inviteCreated = new AsyncEvent("INVITE_CREATED", EventExecutionLimit, this.EventErrorHandler); this._inviteDeleted = new AsyncEvent("INVITE_DELETED", EventExecutionLimit, this.EventErrorHandler); this._messageCreated = new AsyncEvent("MESSAGE_CREATED", EventExecutionLimit, this.EventErrorHandler); this._presenceUpdated = new AsyncEvent("PRESENCE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildBanAdded = new AsyncEvent("GUILD_BAN_ADD", EventExecutionLimit, this.EventErrorHandler); this._guildBanRemoved = new AsyncEvent("GUILD_BAN_REMOVED", EventExecutionLimit, this.EventErrorHandler); this._guildEmojisUpdated = new AsyncEvent("GUILD_EMOJI_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildStickersUpdated = new AsyncEvent("GUILD_STICKER_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildIntegrationsUpdated = new AsyncEvent("GUILD_INTEGRATIONS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildMemberAdded = new AsyncEvent("GUILD_MEMBER_ADD", EventExecutionLimit, this.EventErrorHandler); this._guildMemberRemoved = new AsyncEvent("GUILD_MEMBER_REMOVED", EventExecutionLimit, this.EventErrorHandler); this._guildMemberUpdated = new AsyncEvent("GUILD_MEMBER_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildRoleCreated = new AsyncEvent("GUILD_ROLE_CREATED", EventExecutionLimit, this.EventErrorHandler); this._guildRoleUpdated = new AsyncEvent("GUILD_ROLE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildRoleDeleted = new AsyncEvent("GUILD_ROLE_DELETED", EventExecutionLimit, this.EventErrorHandler); this._messageAcknowledged = new AsyncEvent("MESSAGE_ACKNOWLEDGED", EventExecutionLimit, this.EventErrorHandler); this._messageUpdated = new AsyncEvent("MESSAGE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._messageDeleted = new AsyncEvent("MESSAGE_DELETED", EventExecutionLimit, this.EventErrorHandler); this._messagesBulkDeleted = new AsyncEvent("MESSAGE_BULK_DELETED", EventExecutionLimit, this.EventErrorHandler); this._interactionCreated = new AsyncEvent("INTERACTION_CREATED", EventExecutionLimit, this.EventErrorHandler); this._componentInteractionCreated = new AsyncEvent("COMPONENT_INTERACTED", EventExecutionLimit, this.EventErrorHandler); this._contextMenuInteractionCreated = new AsyncEvent("CONTEXT_MENU_INTERACTED", EventExecutionLimit, this.EventErrorHandler); this._typingStarted = new AsyncEvent("TYPING_STARTED", EventExecutionLimit, this.EventErrorHandler); this._userSettingsUpdated = new AsyncEvent("USER_SETTINGS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._userUpdated = new AsyncEvent("USER_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._voiceStateUpdated = new AsyncEvent("VOICE_STATE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._voiceServerUpdated = new AsyncEvent("VOICE_SERVER_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildMembersChunked = new AsyncEvent("GUILD_MEMBERS_CHUNKED", EventExecutionLimit, this.EventErrorHandler); this._unknownEvent = new AsyncEvent("UNKNOWN_EVENT", EventExecutionLimit, this.EventErrorHandler); this._messageReactionAdded = new AsyncEvent("MESSAGE_REACTION_ADDED", EventExecutionLimit, this.EventErrorHandler); this._messageReactionRemoved = new AsyncEvent("MESSAGE_REACTION_REMOVED", EventExecutionLimit, this.EventErrorHandler); this._messageReactionsCleared = new AsyncEvent("MESSAGE_REACTIONS_CLEARED", EventExecutionLimit, this.EventErrorHandler); this._messageReactionRemovedEmoji = new AsyncEvent("MESSAGE_REACTION_REMOVED_EMOJI", EventExecutionLimit, this.EventErrorHandler); this._webhooksUpdated = new AsyncEvent("WEBHOOKS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._heartbeated = new AsyncEvent("HEARTBEATED", EventExecutionLimit, this.EventErrorHandler); this._applicationCommandCreated = new AsyncEvent("APPLICATION_COMMAND_CREATED", EventExecutionLimit, this.EventErrorHandler); this._applicationCommandUpdated = new AsyncEvent("APPLICATION_COMMAND_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._applicationCommandDeleted = new AsyncEvent("APPLICATION_COMMAND_DELETED", EventExecutionLimit, this.EventErrorHandler); this._guildApplicationCommandCountUpdated = new AsyncEvent("GUILD_APPLICATION_COMMAND_COUNTS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._applicationCommandPermissionsUpdated = new AsyncEvent("APPLICATION_COMMAND_PERMISSIONS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildIntegrationCreated = new AsyncEvent("INTEGRATION_CREATED", EventExecutionLimit, this.EventErrorHandler); this._guildIntegrationUpdated = new AsyncEvent("INTEGRATION_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildIntegrationDeleted = new AsyncEvent("INTEGRATION_DELETED", EventExecutionLimit, this.EventErrorHandler); this._stageInstanceCreated = new AsyncEvent("STAGE_INSTANCE_CREATED", EventExecutionLimit, this.EventErrorHandler); this._stageInstanceUpdated = new AsyncEvent("STAGE_INSTANCE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._stageInstanceDeleted = new AsyncEvent("STAGE_INSTANCE_DELETED", EventExecutionLimit, this.EventErrorHandler); this._threadCreated = new AsyncEvent("THREAD_CREATED", EventExecutionLimit, this.EventErrorHandler); this._threadUpdated = new AsyncEvent("THREAD_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._threadDeleted = new AsyncEvent("THREAD_DELETED", EventExecutionLimit, this.EventErrorHandler); this._threadListSynced = new AsyncEvent("THREAD_LIST_SYNCED", EventExecutionLimit, this.EventErrorHandler); this._threadMemberUpdated = new AsyncEvent("THREAD_MEMBER_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._threadMembersUpdated = new AsyncEvent("THREAD_MEMBERS_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._zombied = new AsyncEvent("ZOMBIED", EventExecutionLimit, this.EventErrorHandler); this._payloadReceived = new AsyncEvent("PAYLOAD_RECEIVED", EventExecutionLimit, this.EventErrorHandler); this._guildScheduledEventCreated = new AsyncEvent("GUILD_SCHEDULED_EVENT_CREATED", EventExecutionLimit, this.EventErrorHandler); this._guildScheduledEventUpdated = new AsyncEvent("GUILD_SCHEDULED_EVENT_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildScheduledEventDeleted = new AsyncEvent("GUILD_SCHEDULED_EVENT_DELETED", EventExecutionLimit, this.EventErrorHandler); this._guildScheduledEventUserAdded = new AsyncEvent("GUILD_SCHEDULED_EVENT_USER_ADDED", EventExecutionLimit, this.EventErrorHandler); this._guildScheduledEventUserRemoved = new AsyncEvent("GUILD_SCHEDULED_EVENT_USER_REMOVED", EventExecutionLimit, this.EventErrorHandler); this._embeddedActivityUpdated = new AsyncEvent("EMBEDDED_ACTIVITY_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildMemberTimeoutAdded = new AsyncEvent("GUILD_MEMBER_TIMEOUT_ADDED", EventExecutionLimit, this.EventErrorHandler); this._guildMemberTimeoutChanged = new AsyncEvent("GUILD_MEMBER_TIMEOUT_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._guildMemberTimeoutRemoved = new AsyncEvent("GUILD_MEMBER_TIMEOUT_REMOVED", EventExecutionLimit, this.EventErrorHandler); this._rateLimitHit = new AsyncEvent("RATELIMIT_HIT", EventExecutionLimit, this.EventErrorHandler); this._automodRuleCreated = new AsyncEvent("AUTO_MODERATION_RULE_CREATED", EventExecutionLimit, this.EventErrorHandler); this._automodRuleUpdated = new AsyncEvent("AUTO_MODERATION_RULE_UPDATED", EventExecutionLimit, this.EventErrorHandler); this._automodRuleDeleted = new AsyncEvent("AUTO_MODERATION_RULE_DELETED", EventExecutionLimit, this.EventErrorHandler); this._automodActionExecuted = new AsyncEvent("AUTO_MODERATION_ACTION_EXECUTED", EventExecutionLimit, this.EventErrorHandler); this._guildAuditLogEntryCreated = new AsyncEvent("GUILD_AUDIT_LOG_ENTRY_CREATED", EventExecutionLimit, this.EventErrorHandler); this.GuildsInternal.Clear(); this._presencesLazy = new Lazy>(() => new ReadOnlyDictionary(this.PresencesInternal)); this._embeddedActivitiesLazy = new Lazy>(() => new ReadOnlyDictionary(this.EmbeddedActivitiesInternal)); } #endregion #region Client Extension Methods /// /// Registers an extension with this client. /// /// Extension to register. public void AddExtension(BaseExtension ext) { ext.Setup(this); this._extensions.Add(ext); } /// /// Retrieves a previously registered extension from this client. /// /// The type of extension to retrieve. /// The requested extension. public T GetExtension() where T : BaseExtension => this._extensions.FirstOrDefault(x => x.GetType() == typeof(T)) as T; #endregion #region Public Connection Methods /// /// Connects to the gateway. /// /// The activity to set. Defaults to null. /// The optional status to set. Defaults to null. /// Since when is the client performing the specified activity. Defaults to null. /// Thrown when an invalid token was provided. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task ConnectAsync(DiscordActivity activity = null, UserStatus? status = null, DateTimeOffset? idlesince = null) { // Check if connection lock is already set, and set it if it isn't if (!this._connectionLock.Wait(0)) throw new InvalidOperationException("This client is already connected."); this._connectionLock.Set(); var w = 7500; var i = 5; var s = false; Exception cex = null; if (activity == null && status == null && idlesince == null) this._status = null; else { var sinceUnix = idlesince != null ? (long?)Utilities.GetUnixTime(idlesince.Value) : null; this._status = new StatusUpdate() { Activity = new TransportActivity(activity), Status = status ?? UserStatus.Online, IdleSince = sinceUnix, IsAfk = idlesince != null, ActivityInternal = activity }; } if (!this.IsShard) { if (this.Configuration.TokenType != TokenType.Bot) this.Logger.LogWarning(LoggerEvents.Misc, "You are logging in with a token that is not a bot token. This is not officially supported by Discord, and can result in your account being terminated if you aren't careful."); this.Logger.LogInformation(LoggerEvents.Startup, "Lib {0}, version {1}", this.BotLibrary, this.VersionString); } while (i-- > 0 || this.Configuration.ReconnectIndefinitely) { try { await this.InternalConnectAsync().ConfigureAwait(false); s = true; break; } catch (UnauthorizedException e) { FailConnection(this._connectionLock); throw new Exception("Authentication failed. Check your token and try again.", e); } catch (PlatformNotSupportedException) { FailConnection(this._connectionLock); throw; } catch (NotImplementedException) { FailConnection(this._connectionLock); throw; } catch (Exception ex) { FailConnection(null); cex = ex; if (i <= 0 && !this.Configuration.ReconnectIndefinitely) break; this.Logger.LogError(LoggerEvents.ConnectionFailure, ex, "Connection attempt failed, retrying in {0}s", w / 1000); await Task.Delay(w).ConfigureAwait(false); if (i > 0) w *= 2; } } if (!s && cex != null) { this._connectionLock.Set(); throw new Exception("Could not connect to Discord.", cex); } // non-closure, hence args static void FailConnection(ManualResetEventSlim cl) => // unlock this (if applicable) so we can let others attempt to connect cl?.Set(); } /// /// Reconnects to the gateway. /// /// Whether to start a new session. public Task ReconnectAsync(bool startNewSession = true) => this.InternalReconnectAsync(startNewSession, code: startNewSession ? 1000 : 4002); /// /// Disconnects from the gateway. /// public async Task DisconnectAsync() { this.Configuration.AutoReconnect = false; if (this.WebSocketClient != null) await this.WebSocketClient.DisconnectAsync().ConfigureAwait(false); } #endregion #region Public REST Methods /// /// Gets a user. /// /// Id of the user /// Whether to ignore the cache. Defaults to false. /// The requested user. /// Thrown when the user does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task GetUserAsync(ulong userId, bool fetch = false) { if (!fetch) { return this.TryGetCachedUserInternal(userId, out var usr) ? usr : new DiscordUser { Id = userId, Discord = this }; } else { var usr = await this.ApiClient.GetUserAsync(userId).ConfigureAwait(false); usr = this.UserCache.AddOrUpdate(userId, usr, (id, old) => { old.Username = usr.Username; old.Discriminator = usr.Discriminator; old.AvatarHash = usr.AvatarHash; old.BannerHash = usr.BannerHash; old.BannerColorInternal = usr.BannerColorInternal; old.AvatarDecorationHash = usr.AvatarDecorationHash; old.ThemeColorsInternal = usr.ThemeColorsInternal; old.Pronouns = usr.Pronouns; old.GlobalName = usr.GlobalName; return old; }); return usr; } } /// /// Gets a applications rpc information. /// /// Id of the application /// The requested application. /// Thrown when the application does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task GetRpcApplicationAsync(ulong applicationId) => await this.ApiClient.GetApplicationRpcInfoAsync(applicationId); public async Task GetCurrentApplicationInfoAsync() { var tapp = await this.ApiClient.GetCurrentApplicationInfoAsync(); var app = new DiscordApplication { Discord = this, Id = tapp.Id, Name = tapp.Name, Description = tapp.Description, Summary = tapp.Summary, IconHash = tapp.IconHash, RpcOrigins = tapp.RpcOrigins != null ? new ReadOnlyCollection(tapp.RpcOrigins) : null, Flags = tapp.Flags, IsHook = tapp.IsHook, Type = tapp.Type, PrivacyPolicyUrl = tapp.PrivacyPolicyUrl, TermsOfServiceUrl = tapp.TermsOfServiceUrl, CustomInstallUrl = tapp.CustomInstallUrl, InstallParams = tapp.InstallParams, RoleConnectionsVerificationUrl = tapp.RoleConnectionsVerificationUrl, Tags = (tapp.Tags ?? Enumerable.Empty()).ToArray(), GuildId = tapp.GuildId.ValueOrDefault(), Slug = tapp.Slug.ValueOrDefault(), PrimarySkuId = tapp.PrimarySkuId.ValueOrDefault(), VerifyKey = tapp.VerifyKey.ValueOrDefault(), CoverImageHash = tapp.CoverImageHash.ValueOrDefault(), Guild = tapp.Guild.ValueOrDefault(), ApproximateGuildCount = tapp.ApproximateGuildCount.ValueOrDefault(), RequiresCodeGrant = tapp.BotRequiresCodeGrant.ValueOrDefault(), IsPublic = tapp.IsPublicBot.ValueOrDefault(), RedirectUris = tapp.RedirectUris.ValueOrDefault(), InteractionsEndpointUrl = tapp.InteractionsEndpointUrl.ValueOrDefault() }; return app; } /// /// Tries to get a user. /// /// Id of the user. /// Whether to ignore the cache. Defaults to true. /// The requested user or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetUserAsync(ulong userId, bool fetch = true) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetUserAsync(userId, fetch).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Tries to get the published store sku listings (premium application subscription). /// /// The application id to fetch the listenings for. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. /// A list of published listings with s. public async Task> TryGetPublishedListingsAsync(ulong applicationId) { try { return await this.ApiClient.GetPublishedListingsAsync(applicationId); } catch (NotFoundException) { return null; } } /// /// Gets the applications role connection metadata. /// /// A list of metadata records or . public async Task> GetRoleConnectionMetadata() => await this.ApiClient.GetRoleConnectionMetadataRecords(this.CurrentApplication.Id); /// /// Updates the applications role connection metadata. /// /// A list of metadata objects. Max 5. public async Task> UpdateRoleConnectionMetadata(IEnumerable metadata) => await this.ApiClient.UpdateRoleConnectionMetadataRecords(this.CurrentApplication.Id, metadata); /// /// Removes all global application commands. /// public async Task RemoveGlobalApplicationCommandsAsync() => await this.ApiClient.BulkOverwriteGlobalApplicationCommandsAsync(this.CurrentApplication.Id, Array.Empty()); /// /// Removes all global application commands for a specific guild id. /// /// The target guild id. public async Task RemoveGuildApplicationCommandsAsync(ulong guildId) => await this.ApiClient.BulkOverwriteGuildApplicationCommandsAsync(this.CurrentApplication.Id, guildId, Array.Empty()); /// /// Removes all global application commands for a specific guild. /// /// The target guild. public async Task RemoveGuildApplicationCommandsAsync(DiscordGuild guild) => await this.RemoveGuildApplicationCommandsAsync(guild.Id); /// /// Gets a channel. /// /// The id of the channel to get. /// Whether to ignore the cache. Defaults to false. /// The requested channel. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task GetChannelAsync(ulong id, bool fetch = false) => (fetch ? null : this.InternalGetCachedChannel(id)) ?? await this.ApiClient.GetChannelAsync(id).ConfigureAwait(false); /// /// Tries to get a channel. /// /// The id of the channel to get. /// Whether to ignore the cache. Defaults to true. /// The requested channel or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetChannelAsync(ulong id, bool fetch = true) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetChannelAsync(id, fetch).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets a thread. /// /// The id of the thread to get. /// Whether to ignore the cache. Defaults to false. /// The requested thread. /// Thrown when the thread does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task GetThreadAsync(ulong id, bool fetch = false) => (fetch ? null : this.InternalGetCachedThread(id)) ?? await this.ApiClient.GetThreadAsync(id).ConfigureAwait(false); /// /// Tries to get a thread. /// /// The id of the thread to get. /// Whether to ignore the cache. Defaults to true. /// The requested thread or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetThreadAsync(ulong id, bool fetch = true) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetThreadAsync(id, fetch).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Sends a normal message. /// /// The channel to send to. /// The message content to send. /// The message that was sent. /// Thrown when the client does not have the permission. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task SendMessageAsync(DiscordChannel channel, string content) => this.ApiClient.CreateMessageAsync(channel.Id, content, embeds: null, sticker: null, replyMessageId: null, mentionReply: false, failOnInvalidReply: false); /// /// Sends a message with an embed. /// /// The channel to send to. /// The embed to attach to the message. /// The message that was sent. /// Thrown when the client does not have the permission. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task SendMessageAsync(DiscordChannel channel, DiscordEmbed embed) => this.ApiClient.CreateMessageAsync(channel.Id, null, embed != null ? new[] { embed } : null, sticker: null, replyMessageId: null, mentionReply: false, failOnInvalidReply: false); /// /// Sends a message with content and an embed. /// /// Channel to send to. /// The message content to send. /// The embed to attach to the message. /// The message that was sent. /// Thrown when the client does not have the permission. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task SendMessageAsync(DiscordChannel channel, string content, DiscordEmbed embed) => this.ApiClient.CreateMessageAsync(channel.Id, content, embed != null ? new[] { embed } : null, sticker: null, replyMessageId: null, mentionReply: false, failOnInvalidReply: false); /// /// Sends a message with the . /// /// The channel to send the message to. /// The message builder. /// The message that was sent. /// Thrown when the client does not have the permission if TTS is false and if TTS is true. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task SendMessageAsync(DiscordChannel channel, DiscordMessageBuilder builder) => this.ApiClient.CreateMessageAsync(channel.Id, builder); /// /// Sends a message with an . /// /// The channel to send the message to. /// The message builder. /// The message that was sent. /// Thrown when the client does not have the permission if TTS is false and if TTS is true. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task SendMessageAsync(DiscordChannel channel, Action action) { var builder = new DiscordMessageBuilder(); action(builder); return this.ApiClient.CreateMessageAsync(channel.Id, builder); } /// /// Creates a guild. This requires the bot to be in less than 10 guilds total. /// /// Name of the guild. /// Voice region of the guild. /// Stream containing the icon for the guild. /// Verification level for the guild. /// Default message notification settings for the guild. /// System channel flags for the guild. /// The created guild. /// Thrown when the channel does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task CreateGuildAsync(string name, string region = null, Optional icon = default, VerificationLevel? verificationLevel = null, DefaultMessageNotifications? defaultMessageNotifications = null, SystemChannelFlags? systemChannelFlags = null) { var iconb64 = ImageTool.Base64FromStream(icon); return this.ApiClient.CreateGuildAsync(name, region, iconb64, verificationLevel, defaultMessageNotifications, systemChannelFlags); } /// /// Creates a guild from a template. This requires the bot to be in less than 10 guilds total. /// /// The template code. /// Name of the guild. /// Stream containing the icon for the guild. /// The created guild. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task CreateGuildFromTemplateAsync(string code, string name, Optional icon = default) { var iconb64 = ImageTool.Base64FromStream(icon); return this.ApiClient.CreateGuildFromTemplateAsync(code, name, iconb64); } /// /// Gets a guild. /// Setting to true will make a REST request. /// /// The guild ID to search for. /// Whether to include approximate presence and member counts in the returned guild. /// Whether to ignore the cache. Defaults to false. /// The requested Guild. /// Thrown when the guild does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task GetGuildAsync(ulong id, bool? withCounts = null, bool fetch = false) { if (!fetch && this.GuildsInternal.TryGetValue(id, out var guild) && (!withCounts.HasValue || !withCounts.Value)) return guild; guild = await this.ApiClient.GetGuildAsync(id, withCounts).ConfigureAwait(false); var channels = await this.ApiClient.GetGuildChannelsAsync(guild.Id).ConfigureAwait(false); foreach (var channel in channels) guild.ChannelsInternal[channel.Id] = channel; return guild; } /// /// Tries to get a guild. /// Setting to true will make a REST request. /// /// The guild ID to search for. /// Whether to include approximate presence and member counts in the returned guild. /// Whether to ignore the cache. Defaults to true. /// The requested Guild or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetGuildAsync(ulong id, bool? withCounts = null, bool fetch = true) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetGuildAsync(id, withCounts, fetch).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets a guild preview. /// /// The guild ID. /// A preview of the requested guild. /// Thrown when the guild does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetGuildPreviewAsync(ulong id) => this.ApiClient.GetGuildPreviewAsync(id); /// /// Tries to get a guild preview. /// /// The guild ID. /// A preview of the requested guild or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetGuildPreviewAsync(ulong id) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.ApiClient.GetGuildPreviewAsync(id).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets a guild widget. /// /// The Guild Id. /// A guild widget. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetGuildWidgetAsync(ulong id) => this.ApiClient.GetGuildWidgetAsync(id); /// /// Tries to get a guild widget. /// /// The Guild Id. /// The requested guild widget or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetGuildWidgetAsync(ulong id) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.ApiClient.GetGuildWidgetAsync(id); } catch (NotFoundException) { return null; } } /// /// Gets an invite. /// /// The invite code. /// Whether to include presence and total member counts in the returned invite. /// Whether to include the expiration date in the returned invite. /// The scheduled event id. /// The requested invite. /// Thrown when the invite does not exists. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetInviteByCodeAsync(string code, bool? withCounts = null, bool? withExpiration = null, ulong? scheduledEventId = null) => this.ApiClient.GetInviteAsync(code, withCounts, withExpiration, scheduledEventId); /// /// Tries to get an invite. /// /// The invite code. /// Whether to include presence and total member counts in the returned invite. /// Whether to include the expiration date in the returned invite. /// The scheduled event id. /// The requested invite or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetInviteByCodeAsync(string code, bool? withCounts = null, bool? withExpiration = null, ulong? scheduledEventId = null) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetInviteByCodeAsync(code, withCounts, withExpiration, scheduledEventId).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets a list of user connections. /// /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task> GetConnectionsAsync() => this.ApiClient.GetUserConnectionsAsync(); /// /// Gets a sticker. /// /// The requested sticker. /// The id of the sticker. /// Thrown when the sticker does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetStickerAsync(ulong id) => this.ApiClient.GetStickerAsync(id); /// /// Tries to get a sticker. /// /// The requested sticker or null if not found. /// The id of the sticker. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetStickerAsync(ulong id) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetStickerAsync(id).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets all nitro sticker packs. /// /// List of sticker packs. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task> GetStickerPacksAsync() => this.ApiClient.GetStickerPacksAsync(); /// /// Gets the In-App OAuth Url. /// /// Defaults to . /// Redirect Uri. /// Defaults to . /// The OAuth Url public Uri GetInAppOAuth(Permissions permissions = Permissions.None, OAuthScopes scopes = OAuthScopes.BOT_DEFAULT, string redir = null) { permissions &= PermissionMethods.FullPerms; return new Uri(new QueryUriBuilder($"{DiscordDomain.GetDomain(CoreDomain.Discord).Url}{Endpoints.OAUTH2}{Endpoints.AUTHORIZE}") .AddParameter("client_id", this.CurrentApplication.Id.ToString(CultureInfo.InvariantCulture)) .AddParameter("scope", OAuth.ResolveScopes(scopes)) .AddParameter("permissions", ((long)permissions).ToString(CultureInfo.InvariantCulture)) .AddParameter("state", "") .AddParameter("redirect_uri", redir ?? "") .ToString()); } /// /// Generates an In-App OAuth Url. /// /// The bot to generate the url for. /// Defaults to . /// Redirect Uri. /// Defaults to . /// The OAuth Url public Uri GenerateInAppOauthFor(DiscordUser bot, Permissions permissions = Permissions.None, OAuthScopes scopes = OAuthScopes.BOT_DEFAULT, string redir = null) { if (!bot.IsBot) throw new ArgumentException("The user must be a bot.", nameof(bot)); permissions &= PermissionMethods.FullPerms; return new Uri(new QueryUriBuilder($"{DiscordDomain.GetDomain(CoreDomain.Discord).Url}{Endpoints.OAUTH2}{Endpoints.AUTHORIZE}") .AddParameter("client_id", bot.Id.ToString(CultureInfo.InvariantCulture)) .AddParameter("scope", OAuth.ResolveScopes(scopes)) .AddParameter("permissions", ((long)permissions).ToString(CultureInfo.InvariantCulture)) .AddParameter("state", "") .AddParameter("redirect_uri", redir ?? "") .ToString()); } /// /// Gets a webhook. /// /// The target webhook id. /// The requested webhook. /// Thrown when the webhook does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetWebhookAsync(ulong id) => this.ApiClient.GetWebhookAsync(id); /// /// Tries to get a webhook. /// /// The target webhook id. /// The requested webhook or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetWebhookAsync(ulong id) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetWebhookAsync(id).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Gets a webhook with a token. /// /// The target webhook id. /// The target webhook token. /// The requested webhook. /// Thrown when the webhook does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetWebhookWithTokenAsync(ulong id, string token) => this.ApiClient.GetWebhookWithTokenAsync(id, token); /// /// Tries to get a webhook with a token. /// /// The target webhook id. /// The target webhook token. /// The requested webhook or null if not found. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. public async Task TryGetWebhookWithTokenAsync(ulong id, string token) #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. { try { return await this.GetWebhookWithTokenAsync(id, token).ConfigureAwait(false); } catch (NotFoundException) { return null; } } /// /// Updates current user's activity and status. /// /// Activity to set. /// Status of the user. /// Since when is the client performing the specified activity. /// public Task UpdateStatusAsync(DiscordActivity activity = null, UserStatus? userStatus = null, DateTimeOffset? idleSince = null) => this.InternalUpdateStatusAsync(activity, userStatus, idleSince); /// /// Edits current user. /// /// New username. /// New avatar. /// The modified user. /// Thrown when the user does not exist. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public async Task UpdateCurrentUserAsync(string username = null, Optional avatar = default) { var av64 = ImageTool.Base64FromStream(avatar); var usr = await this.ApiClient.ModifyCurrentUserAsync(username, av64).ConfigureAwait(false); this.CurrentUser.Username = usr.Username; this.CurrentUser.Discriminator = usr.Discriminator; this.CurrentUser.AvatarHash = usr.AvatarHash; return this.CurrentUser; } /// /// Gets a guild template by the code. /// /// The code of the template. /// The guild template for the code. /// Thrown when an invalid parameter was provided. /// Thrown when Discord is unable to process the request. public Task GetTemplateAsync(string code) => this.ApiClient.GetTemplateAsync(code); /// /// Gets all the global application commands for this application. /// /// Whether to get the full localization dict. /// A list of global application commands. public Task> GetGlobalApplicationCommandsAsync(bool withLocalizations = false) => this.ApiClient.GetGlobalApplicationCommandsAsync(this.CurrentApplication.Id, withLocalizations); /// /// Overwrites the existing global application commands. New commands are automatically created and missing commands are automatically deleted. /// /// The list of commands to overwrite with. /// The list of global commands. public Task> BulkOverwriteGlobalApplicationCommandsAsync(IEnumerable commands) => this.ApiClient.BulkOverwriteGlobalApplicationCommandsAsync(this.CurrentApplication.Id, commands); /// /// Creates or overwrites a global application command. /// /// The command to create. /// The created command. public Task CreateGlobalApplicationCommandAsync(DiscordApplicationCommand command) => this.ApiClient.CreateGlobalApplicationCommandAsync(this.CurrentApplication.Id, command); /// /// Gets a global application command by its id. /// /// The id of the command to get. /// The command with the id. public Task GetGlobalApplicationCommandAsync(ulong commandId) => this.ApiClient.GetGlobalApplicationCommandAsync(this.CurrentApplication.Id, commandId); /// /// Edits a global application command. /// /// The id of the command to edit. /// Action to perform. /// The edited command. public async Task EditGlobalApplicationCommandAsync(ulong commandId, Action action) { var mdl = new ApplicationCommandEditModel(); action(mdl); var applicationId = this.CurrentApplication?.Id ?? (await this.GetCurrentApplicationAsync().ConfigureAwait(false)).Id; return await this.ApiClient.EditGlobalApplicationCommandAsync(applicationId, commandId, mdl.Name, mdl.Description, mdl.Options, mdl.NameLocalizations, mdl.DescriptionLocalizations, mdl.DefaultMemberPermissions, mdl.DmPermission, mdl.IsNsfw).ConfigureAwait(false); } /// /// Deletes a global application command. /// /// The id of the command to delete. public Task DeleteGlobalApplicationCommandAsync(ulong commandId) => this.ApiClient.DeleteGlobalApplicationCommandAsync(this.CurrentApplication.Id, commandId); /// /// Gets all the application commands for a guild. /// /// The id of the guild to get application commands for. /// Whether to get the full localization dict. /// A list of application commands in the guild. public Task> GetGuildApplicationCommandsAsync(ulong guildId, bool withLocalizations = false) => this.ApiClient.GetGuildApplicationCommandsAsync(this.CurrentApplication.Id, guildId, withLocalizations); /// /// Overwrites the existing application commands in a guild. New commands are automatically created and missing commands are automatically deleted. /// /// The id of the guild. /// The list of commands to overwrite with. /// The list of guild commands. public Task> BulkOverwriteGuildApplicationCommandsAsync(ulong guildId, IEnumerable commands) => this.ApiClient.BulkOverwriteGuildApplicationCommandsAsync(this.CurrentApplication.Id, guildId, commands); /// /// Creates or overwrites a guild application command. /// /// The id of the guild to create the application command in. /// The command to create. /// The created command. public Task CreateGuildApplicationCommandAsync(ulong guildId, DiscordApplicationCommand command) => this.ApiClient.CreateGuildApplicationCommandAsync(this.CurrentApplication.Id, guildId, command); /// /// Gets a application command in a guild by its id. /// /// The id of the guild the application command is in. /// The id of the command to get. /// The command with the id. public Task GetGuildApplicationCommandAsync(ulong guildId, ulong commandId) => this.ApiClient.GetGuildApplicationCommandAsync(this.CurrentApplication.Id, guildId, commandId); /// /// Edits a application command in a guild. /// /// The id of the guild the application command is in. /// The id of the command to edit. /// Action to perform. /// The edited command. public async Task EditGuildApplicationCommandAsync(ulong guildId, ulong commandId, Action action) { var mdl = new ApplicationCommandEditModel(); action(mdl); var applicationId = this.CurrentApplication?.Id ?? (await this.GetCurrentApplicationAsync().ConfigureAwait(false)).Id; return await this.ApiClient.EditGuildApplicationCommandAsync(applicationId, guildId, commandId, mdl.Name, mdl.Description, mdl.Options, mdl.NameLocalizations, mdl.DescriptionLocalizations, mdl.DefaultMemberPermissions, mdl.DmPermission, mdl.IsNsfw).ConfigureAwait(false); } /// /// Deletes a application command in a guild. /// /// The id of the guild to delete the application command in. /// The id of the command. public Task DeleteGuildApplicationCommandAsync(ulong guildId, ulong commandId) => this.ApiClient.DeleteGuildApplicationCommandAsync(this.CurrentApplication.Id, guildId, commandId); /// /// Gets all command permissions for a guild. /// /// The target guild. [DiscordDeprecated("This was removed 2022")] public Task> GetGuildApplicationCommandPermissionsAsync(ulong guildId) => null; /// /// Gets the permissions for a guild command. /// /// The target guild. /// The target command id. [DiscordDeprecated("This was removed 2022")] public Task GetApplicationCommandPermissionAsync(ulong guildId, ulong commandId) => null; #endregion #region Internal Caching Methods /// /// Gets the internal cached threads. /// /// The target thread id. /// The requested thread. internal DiscordThreadChannel InternalGetCachedThread(ulong threadId) { if (this.Guilds == null) return null; foreach (var guild in this.Guilds.Values) if (guild.Threads.TryGetValue(threadId, out var foundThread)) return foundThread; return null; } /// /// Gets the internal cached scheduled event. /// /// The target scheduled event id. /// The requested scheduled event. internal DiscordScheduledEvent InternalGetCachedScheduledEvent(ulong scheduledEventId) { if (this.Guilds == null) return null; foreach (var guild in this.Guilds.Values) if (guild.ScheduledEvents.TryGetValue(scheduledEventId, out var foundScheduledEvent)) return foundScheduledEvent; return null; } /// /// Gets the internal cached channel. /// /// The target channel id. /// The requested channel. internal DiscordChannel InternalGetCachedChannel(ulong channelId, ulong? guildId = null) { if (this.Guilds == null) return null; foreach (var guild in this.Guilds.Values) if (guild.Channels.TryGetValue(channelId, out var foundChannel)) { if (guildId.HasValue) foundChannel.GuildId = guildId; return foundChannel; } return null; } /// /// Gets the internal cached guild. /// /// The target guild id. /// The requested guild. internal DiscordGuild InternalGetCachedGuild(ulong? guildId) { if (this.GuildsInternal != null && guildId.HasValue) { if (this.GuildsInternal.TryGetValue(guildId.Value, out var guild)) return guild; } return null; } /// /// Updates a message. /// /// The message to update. /// The author to update. /// The guild to update. /// The member to update. private void UpdateMessage(DiscordMessage message, TransportUser author, DiscordGuild guild, TransportMember member) { if (author != null) { var usr = new DiscordUser(author) { Discord = this }; if (member != null) member.User = author; message.Author = this.UpdateUser(usr, guild?.Id, guild, member); } var channel = this.InternalGetCachedChannel(message.ChannelId); if (channel != null) return; channel = !message.GuildId.HasValue ? new DiscordDmChannel { Id = message.ChannelId, Discord = this, Type = ChannelType.Private } : new DiscordChannel { Id = message.ChannelId, Discord = this }; message.Channel = channel; } /// /// Updates a scheduled event. /// /// The scheduled event to update. /// The guild to update. /// The updated scheduled event. private DiscordScheduledEvent UpdateScheduledEvent(DiscordScheduledEvent scheduledEvent, DiscordGuild guild) { if (scheduledEvent != null) { _ = guild.ScheduledEventsInternal.AddOrUpdate(scheduledEvent.Id, scheduledEvent, (id, old) => { old.Discord = this; old.Description = scheduledEvent.Description; old.ChannelId = scheduledEvent.ChannelId; old.EntityId = scheduledEvent.EntityId; old.EntityType = scheduledEvent.EntityType; old.EntityMetadata = scheduledEvent.EntityMetadata; old.PrivacyLevel = scheduledEvent.PrivacyLevel; old.Name = scheduledEvent.Name; old.Status = scheduledEvent.Status; old.UserCount = scheduledEvent.UserCount; old.ScheduledStartTimeRaw = scheduledEvent.ScheduledStartTimeRaw; old.ScheduledEndTimeRaw = scheduledEvent.ScheduledEndTimeRaw; return old; }); } return scheduledEvent; } /// /// Updates a user. /// /// The user to update. /// The guild id to update. /// The guild to update. /// The member to update. /// The updated user. private DiscordUser UpdateUser(DiscordUser usr, ulong? guildId, DiscordGuild guild, TransportMember mbr) { if (mbr != null) { if (mbr.User != null) { usr = new DiscordUser(mbr.User) { Discord = this }; _ = this.UserCache.AddOrUpdate(usr.Id, usr, (id, old) => { old.Username = usr.Username; old.Discriminator = usr.Discriminator; old.AvatarHash = usr.AvatarHash; old.BannerHash = usr.BannerHash; old.BannerColorInternal = usr.BannerColorInternal; old.AvatarDecorationHash = usr.AvatarDecorationHash; old.ThemeColorsInternal = usr.ThemeColorsInternal; old.Pronouns = usr.Pronouns; old.Locale = usr.Locale; old.GlobalName = usr.GlobalName; return old; }); usr = new DiscordMember(mbr) { Discord = this, GuildId = guildId.Value }; } var intents = this.Configuration.Intents; DiscordMember member = default; if (!intents.HasAllPrivilegedIntents() || guild.IsLarge) // we have the necessary privileged intents, no need to worry about caching here unless guild is large. { if (guild?.MembersInternal.TryGetValue(usr.Id, out member) == false) { if (intents.HasIntent(DiscordIntents.GuildMembers) || this.Configuration.AlwaysCacheMembers) // member can be updated by events, so cache it { guild.MembersInternal.TryAdd(usr.Id, (DiscordMember)usr); } } else if (intents.HasIntent(DiscordIntents.GuildPresences) || this.Configuration.AlwaysCacheMembers) // we can attempt to update it if it's already in cache. { if (!intents.HasIntent(DiscordIntents.GuildMembers)) // no need to update if we already have the member events { _ = guild.MembersInternal.TryUpdate(usr.Id, (DiscordMember)usr, member); } } } } else if (usr.Username != null) // check if not a skeleton user { _ = this.UserCache.AddOrUpdate(usr.Id, usr, (id, old) => { old.Username = usr.Username; old.Discriminator = usr.Discriminator; old.AvatarHash = usr.AvatarHash; old.BannerHash = usr.BannerHash; old.BannerColorInternal = usr.BannerColorInternal; old.AvatarDecorationHash = usr.AvatarDecorationHash; old.ThemeColorsInternal = usr.ThemeColorsInternal; old.Pronouns = usr.Pronouns; old.Locale = usr.Locale; old.GlobalName = usr.GlobalName; return old; }); } return usr; } /// /// Updates the cached scheduled events in a guild. /// /// The guild. /// The raw events. private void UpdateCachedScheduledEvent(DiscordGuild guild, JArray rawEvents) { if (this._disposed) return; if (rawEvents != null) { guild.ScheduledEventsInternal.Clear(); foreach (var xj in rawEvents) { var xtm = xj.ToDiscordObject(); xtm.Discord = this; guild.ScheduledEventsInternal[xtm.Id] = xtm; } } } /// /// Updates the cached guild. /// /// The new guild. /// The raw members. private void UpdateCachedGuild(DiscordGuild newGuild, JArray rawMembers) { if (this._disposed) return; if (!this.GuildsInternal.ContainsKey(newGuild.Id)) this.GuildsInternal[newGuild.Id] = newGuild; var guild = this.GuildsInternal[newGuild.Id]; if (newGuild.ChannelsInternal != null && !newGuild.ChannelsInternal.IsEmpty) { foreach (var channel in newGuild.ChannelsInternal.Values) { if (guild.ChannelsInternal.TryGetValue(channel.Id, out _)) continue; channel.Initialize(this); guild.ChannelsInternal[channel.Id] = channel; } } if (newGuild.ThreadsInternal != null && !newGuild.ThreadsInternal.IsEmpty) { foreach (var thread in newGuild.ThreadsInternal.Values) { if (guild.ThreadsInternal.TryGetValue(thread.Id, out _)) continue; guild.ThreadsInternal[thread.Id] = thread; } } if (newGuild.ScheduledEventsInternal != null && !newGuild.ScheduledEventsInternal.IsEmpty) { foreach (var @event in newGuild.ScheduledEventsInternal.Values) { if (guild.ScheduledEventsInternal.TryGetValue(@event.Id, out _)) continue; guild.ScheduledEventsInternal[@event.Id] = @event; } } foreach (var newEmoji in newGuild.EmojisInternal.Values) _ = guild.EmojisInternal.GetOrAdd(newEmoji.Id, _ => newEmoji); foreach (var newSticker in newGuild.StickersInternal.Values) _ = guild.StickersInternal.GetOrAdd(newSticker.Id, _ => newSticker); foreach (var newStageInstance in newGuild.StageInstancesInternal.Values) _ = guild.StageInstancesInternal.GetOrAdd(newStageInstance.Id, _ => newStageInstance); if (rawMembers != null) { guild.MembersInternal.Clear(); foreach (var xj in rawMembers) { var xtm = xj.ToDiscordObject(); var xu = new DiscordUser(xtm.User) { Discord = this }; _ = this.UserCache.AddOrUpdate(xtm.User.Id, xu, (id, old) => { old.Username = xu.Username; old.Discriminator = xu.Discriminator; old.AvatarHash = xu.AvatarHash; old.PremiumType = xu.PremiumType; old.GlobalName = xu.GlobalName; return old; }); guild.MembersInternal[xtm.User.Id] = new DiscordMember(xtm) { Discord = this, GuildId = guild.Id }; } } foreach (var role in newGuild.RolesInternal.Values) { if (guild.RolesInternal.TryGetValue(role.Id, out _)) continue; role.GuildId = guild.Id; guild.RolesInternal[role.Id] = role; } guild.Name = newGuild.Name; guild.AfkChannelId = newGuild.AfkChannelId; guild.AfkTimeout = newGuild.AfkTimeout; guild.DefaultMessageNotifications = newGuild.DefaultMessageNotifications; guild.RawFeatures = newGuild.RawFeatures; guild.IconHash = newGuild.IconHash; guild.MfaLevel = newGuild.MfaLevel; guild.OwnerId = newGuild.OwnerId; guild.VoiceRegionId = newGuild.VoiceRegionId; guild.SplashHash = newGuild.SplashHash; guild.VerificationLevel = newGuild.VerificationLevel; guild.WidgetEnabled = newGuild.WidgetEnabled; guild.WidgetChannelId = newGuild.WidgetChannelId; guild.ExplicitContentFilter = newGuild.ExplicitContentFilter; guild.PremiumTier = newGuild.PremiumTier; guild.PremiumSubscriptionCount = newGuild.PremiumSubscriptionCount; guild.PremiumProgressBarEnabled = newGuild.PremiumProgressBarEnabled; guild.BannerHash = newGuild.BannerHash; guild.Description = newGuild.Description; guild.VanityUrlCode = newGuild.VanityUrlCode; guild.SystemChannelId = newGuild.SystemChannelId; guild.SystemChannelFlags = newGuild.SystemChannelFlags; guild.DiscoverySplashHash = newGuild.DiscoverySplashHash; guild.MaxMembers = newGuild.MaxMembers; guild.MaxPresences = newGuild.MaxPresences; guild.ApproximateMemberCount = newGuild.ApproximateMemberCount; guild.ApproximatePresenceCount = newGuild.ApproximatePresenceCount; guild.MaxVideoChannelUsers = newGuild.MaxVideoChannelUsers; guild.PreferredLocale = newGuild.PreferredLocale; guild.RulesChannelId = newGuild.RulesChannelId; guild.PublicUpdatesChannelId = newGuild.PublicUpdatesChannelId; guild.ApplicationId = newGuild.ApplicationId; // fields not sent for update: // - guild.Channels // - voice states // - guild.JoinedAt = new_guild.JoinedAt; // - guild.Large = new_guild.Large; // - guild.MemberCount = Math.Max(new_guild.MemberCount, guild._members.Count); // - guild.Unavailable = new_guild.Unavailable; } /// /// Populates the message reactions and cache. /// /// The message. /// The author. /// The member. private void PopulateMessageReactionsAndCache(DiscordMessage message, TransportUser author, TransportMember member) { var guild = message.Channel?.Guild ?? this.InternalGetCachedGuild(message.GuildId); this.UpdateMessage(message, author, guild, member); message.ReactionsInternal ??= new List(); foreach (var xr in message.ReactionsInternal) xr.Emoji.Discord = this; if (this.Configuration.MessageCacheSize > 0 && message.Channel != null) this.MessageCache?.Add(message); } #endregion #region Disposal ~DiscordClient() { this.Dispose(); } /// /// Whether the client is disposed. /// private bool _disposed; /// /// Disposes the client. /// public override void Dispose() { if (this._disposed) return; this._disposed = true; GC.SuppressFinalize(this); this.DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); this.ApiClient.Rest.Dispose(); this.CurrentUser = null; var extensions = this._extensions; // prevent _extensions being modified during dispose this._extensions = null; foreach (var extension in extensions) if (extension is IDisposable disposable) disposable.Dispose(); try { this._cancelTokenSource?.Cancel(); this._cancelTokenSource?.Dispose(); } catch { } this.GuildsInternal = null; this._heartbeatTask = null; + + if (this.Configuration.EnableSentry) + SentrySdk.EndSession(); } #endregion } diff --git a/DisCatSharp/DiscordConfiguration.cs b/DisCatSharp/DiscordConfiguration.cs index 0ea857acf..fdca61dd3 100644 --- a/DisCatSharp/DiscordConfiguration.cs +++ b/DisCatSharp/DiscordConfiguration.cs @@ -1,327 +1,357 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Net; using DisCatSharp.Attributes; using DisCatSharp.Entities; using DisCatSharp.Enums; using DisCatSharp.Net.Udp; using DisCatSharp.Net.WebSocket; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace DisCatSharp; /// /// Represents configuration for and . /// public sealed class DiscordConfiguration { /// /// Sets the token used to identify the client. /// public string Token { internal get => this._token; set { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentNullException(nameof(value), "Token cannot be null, empty, or all whitespace."); this._token = value.Trim(); } } private string _token = ""; /// /// Sets the type of the token used to identify the client. /// Defaults to . /// public TokenType TokenType { internal get; set; } = TokenType.Bot; /// /// Sets the minimum logging level for messages. /// Typically, the default value of is ok for most uses. /// public LogLevel MinimumLogLevel { internal get; set; } = LogLevel.Information; /// /// Overwrites the api version. /// Defaults to 10. /// public string ApiVersion { internal get; set; } = "10"; /// /// Sets whether to rely on Discord for NTP (Network Time Protocol) synchronization with the "X-Ratelimit-Reset-After" header. /// If the system clock is unsynced, setting this to true will ensure ratelimits are synced with Discord and reduce the risk of hitting one. /// This should only be set to false if the system clock is synced with NTP. /// Defaults to . /// public bool UseRelativeRatelimit { internal get; set; } = true; /// /// Allows you to overwrite the time format used by the internal debug logger. /// Only applicable when is set left at default value. Defaults to ISO 8601-like format. /// public string LogTimestampFormat { internal get; set; } = "yyyy-MM-dd HH:mm:ss zzz"; /// /// Sets the member count threshold at which guilds are considered large. /// Defaults to 250. /// public int LargeThreshold { internal get; set; } = 250; /// /// Sets whether to automatically reconnect in case a connection is lost. /// Defaults to . /// public bool AutoReconnect { internal get; set; } = true; /// /// Sets the ID of the shard to connect to. /// If not sharding, or sharding automatically, this value should be left with the default value of 0. /// - public int ShardId { internal get; set; } + public int ShardId { internal get; set; } = 0; /// /// Sets the total number of shards the bot is on. If not sharding, this value should be left with a default value of 1. /// If sharding automatically, this value will indicate how many shards to boot. If left default for automatic sharding, the client will determine the shard count automatically. /// public int ShardCount { internal get; set; } = 1; /// /// Sets the level of compression for WebSocket traffic. /// Disabling this option will increase the amount of traffic sent via WebSocket. Setting will enable compression for READY and GUILD_CREATE payloads. Setting will enable compression for the entire WebSocket stream, drastically reducing amount of traffic. /// Defaults to . /// public GatewayCompressionLevel GatewayCompressionLevel { internal get; set; } = GatewayCompressionLevel.Stream; /// /// Sets the size of the global message cache. /// Setting this to 0 will disable message caching entirely. /// Defaults to 1024. /// public int MessageCacheSize { internal get; set; } = 1024; /// /// Sets the proxy to use for HTTP and WebSocket connections to Discord. /// Defaults to . /// - public IWebProxy Proxy { internal get; set; } + public IWebProxy Proxy { internal get; set; } = null!; /// /// Sets the timeout for HTTP requests. /// Set to to disable timeouts. /// Defaults to 20 seconds. /// public TimeSpan HttpTimeout { internal get; set; } = TimeSpan.FromSeconds(20); /// /// Defines that the client should attempt to reconnect indefinitely. /// This is typically a very bad idea to set to true, as it will swallow all connection errors. /// Defaults to . /// - public bool ReconnectIndefinitely { internal get; set; } + public bool ReconnectIndefinitely { internal get; set; } = false; /// /// Sets whether the client should attempt to cache members if exclusively using unprivileged intents. /// /// This will only take effect if there are no or /// intents specified. Otherwise, this will always be overwritten to true. /// /// Defaults to . /// public bool AlwaysCacheMembers { internal get; set; } = true; /// /// Sets the gateway intents for this client. /// If set, the client will only receive events that they specify with intents. /// Defaults to . /// public DiscordIntents Intents { internal get; set; } = DiscordIntents.AllUnprivileged; /// /// Sets the factory method used to create instances of WebSocket clients. /// Use and equivalents on other implementations to switch out client implementations. /// Defaults to . /// public WebSocketClientFactoryDelegate WebSocketClientFactory { internal get => this._webSocketClientFactory; set { if (value == null) throw new InvalidOperationException("You need to supply a valid WebSocket client factory method."); this._webSocketClientFactory = value; } } private WebSocketClientFactoryDelegate _webSocketClientFactory = WebSocketClient.CreateNew; /// /// Sets the factory method used to create instances of UDP clients. /// Use and equivalents on other implementations to switch out client implementations. /// Defaults to . /// public UdpClientFactoryDelegate UdpClientFactory { internal get => this._udpClientFactory; set => this._udpClientFactory = value ?? throw new InvalidOperationException("You need to supply a valid UDP client factory method."); } private UdpClientFactoryDelegate _udpClientFactory = DcsUdpClient.CreateNew; /// /// Sets the logger implementation to use. /// To create your own logger, implement the instance. /// Defaults to built-in implementation. /// public ILoggerFactory LoggerFactory { internal get; set; } /// /// Sets if the bot's status should show the mobile icon. /// Defaults to . /// - public bool MobileStatus { internal get; set; } + public bool MobileStatus { internal get; set; } = false; /// /// Whether to use canary. has to be false. /// Defaults to . /// [Deprecated("Use ApiChannel instead.")] public bool UseCanary { internal get => this.ApiChannel == ApiChannel.Canary; set { if (value) this.ApiChannel = ApiChannel.Canary; } } /// /// Whether to use ptb. has to be false. /// Defaults to . /// [Deprecated("Use ApiChannel instead.")] public bool UsePtb { internal get => this.ApiChannel == ApiChannel.PTB; set { if (value) this.ApiChannel = ApiChannel.PTB; } } /// /// Which api channel to use. /// Defaults to . /// public ApiChannel ApiChannel { internal get; set; } = ApiChannel.Stable; /// /// Refresh full guild channel cache. /// Defaults to . /// - public bool AutoRefreshChannelCache { internal get; set; } + public bool AutoRefreshChannelCache { internal get; set; } = false; /// /// Do not use, this is meant for DisCatSharp Devs. /// Defaults to . /// - public string Override { internal get; set; } + public string Override { internal get; set; } = null!; /// /// Sets your preferred API language. See for valid locales. /// public string Locale { internal get; set; } = DiscordLocales.AMERICAN_ENGLISH; /// /// Whether to report missing fields for discord object. /// Useful for library development. /// Defaults to . /// public bool ReportMissingFields { internal get; set; } = false; /// /// Sets the service provider. /// This allows passing data around without resorting to static members. /// Defaults to an empty service provider. /// public IServiceProvider ServiceProvider { internal get; set; } = new ServiceCollection().BuildServiceProvider(true); + + /// + /// Whether to report missing fields for discord object. + /// This helps us to track missing data and library bugs better. + /// Defaults to . + /// public bool EnableSentry { get; internal set; } = false; + /// + /// Whether to attach the bots username and id to sentry reports. + /// This helps us to pinpoint problems. + /// Defaults to . + /// + public bool AttachUserInfo { get; internal set; } = false; + + /// + /// Your email address we can reach out when your bot encounters library bugs. + /// Will only be transmitted if is . + /// Defaults to . + /// + public string? FeedbackEmail { get; internal set; } = null; + + /// + /// Your discord user id we can reach out when your bot encounters library bugs. + /// Will only be transmitted if is . + /// Defaults to . + /// + public ulong? DeveloperUserId { get; internal set; } = null; + /// /// Creates a new configuration with default values. /// public DiscordConfiguration() { } /// /// Utilized via Dependency Injection Pipeline /// /// [ActivatorUtilitiesConstructor] public DiscordConfiguration(IServiceProvider provider) { this.ServiceProvider = provider; } /// /// Creates a clone of another discord configuration. /// /// Client configuration to clone. public DiscordConfiguration(DiscordConfiguration other) { this.Token = other.Token; this.TokenType = other.TokenType; this.MinimumLogLevel = other.MinimumLogLevel; this.UseRelativeRatelimit = other.UseRelativeRatelimit; this.LogTimestampFormat = other.LogTimestampFormat; this.LargeThreshold = other.LargeThreshold; this.AutoReconnect = other.AutoReconnect; this.ShardId = other.ShardId; this.ShardCount = other.ShardCount; this.GatewayCompressionLevel = other.GatewayCompressionLevel; this.MessageCacheSize = other.MessageCacheSize; this.WebSocketClientFactory = other.WebSocketClientFactory; this.UdpClientFactory = other.UdpClientFactory; this.Proxy = other.Proxy; this.HttpTimeout = other.HttpTimeout; this.ReconnectIndefinitely = other.ReconnectIndefinitely; this.Intents = other.Intents; this.LoggerFactory = other.LoggerFactory; this.MobileStatus = other.MobileStatus; this.UseCanary = other.UseCanary; this.UsePtb = other.UsePtb; this.AutoRefreshChannelCache = other.AutoRefreshChannelCache; this.ApiVersion = other.ApiVersion; this.ServiceProvider = other.ServiceProvider; this.Override = other.Override; this.Locale = other.Locale; this.ReportMissingFields = other.ReportMissingFields; this.EnableSentry = other.EnableSentry; + this.AttachUserInfo = other.AttachUserInfo; + this.FeedbackEmail = other.FeedbackEmail; + this.DeveloperUserId = other.DeveloperUserId; } } diff --git a/DisCatSharp/Net/Rest/RestClient.cs b/DisCatSharp/Net/Rest/RestClient.cs index 0d13f2d30..7f6a6b4fe 100644 --- a/DisCatSharp/Net/Rest/RestClient.cs +++ b/DisCatSharp/Net/Rest/RestClient.cs @@ -1,888 +1,891 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using DisCatSharp.Exceptions; using Microsoft.Extensions.Logging; using Sentry; namespace DisCatSharp.Net; /// /// Represents a client used to make REST requests. /// internal sealed class RestClient : IDisposable { /// /// Gets the route argument regex. /// private static Regex s_routeArgumentRegex { get; } = new(@":([a-z_]+)"); /// /// Gets the http client. /// internal HttpClient HttpClient { get; } /// /// Gets the discord client. /// private readonly BaseDiscordClient _discord; /// /// Gets a value indicating whether debug is enabled. /// internal bool Debug { get; set; } /// /// Gets the logger. /// private readonly ILogger _logger; /// /// Gets the routes to hashes. /// private readonly ConcurrentDictionary _routesToHashes; /// /// Gets the hashes to buckets. /// private readonly ConcurrentDictionary _hashesToBuckets; /// /// Gets the request queue. /// private readonly ConcurrentDictionary _requestQueue; /// /// Gets the global rate limit event. /// private readonly AsyncManualResetEvent _globalRateLimitEvent; /// /// Gets a value indicating whether use reset after. /// private readonly bool _useResetAfter; private CancellationTokenSource _bucketCleanerTokenSource; private readonly TimeSpan _bucketCleanupDelay = TimeSpan.FromSeconds(60); private volatile bool _cleanerRunning; private Task _cleanerTask; private volatile bool _disposed; /// /// Initializes a new instance of the class. /// /// The client. internal RestClient(BaseDiscordClient client) : this(client.Configuration.Proxy, client.Configuration.HttpTimeout, client.Configuration.UseRelativeRatelimit, client.Logger) { this._discord = client; this.HttpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", Utilities.GetFormattedToken(client)); if (client.Configuration.Override != null) { this.HttpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-super-properties", client.Configuration.Override); } } /// /// Initializes a new instance of the class. /// This is for meta-clients, such as the webhook client. /// /// The proxy. /// The timeout. /// Whether to use relative ratelimit. /// The logger. internal RestClient(IWebProxy proxy, TimeSpan timeout, bool useRelativeRatelimit, ILogger logger) { this._logger = logger; var httphandler = new HttpClientHandler { UseCookies = false, AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, UseProxy = proxy != null, Proxy = proxy }; this.HttpClient = new HttpClient(httphandler) { BaseAddress = new Uri(Utilities.GetApiBaseUri(this._discord?.Configuration)), Timeout = timeout }; this.HttpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", Utilities.GetUserAgent()); if (this._discord != null && this._discord.Configuration != null && this._discord.Configuration.Override != null) this.HttpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-super-properties", this._discord.Configuration.Override); this.HttpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Discord-Locale", this._discord?.Configuration?.Locale ?? "en-US"); this._routesToHashes = new ConcurrentDictionary(); this._hashesToBuckets = new ConcurrentDictionary(); this._requestQueue = new ConcurrentDictionary(); this._globalRateLimitEvent = new AsyncManualResetEvent(true); this._useResetAfter = useRelativeRatelimit; } /// /// Gets a ratelimit bucket. /// /// The method. /// The route. /// The route parameters. /// The url. /// A ratelimit bucket. public RateLimitBucket GetBucket(RestRequestMethod method, string route, object routeParams, out string url) { var rparamsProps = routeParams.GetType() .GetTypeInfo() .DeclaredProperties; var rparams = new Dictionary(); foreach (var xp in rparamsProps) { var val = xp.GetValue(routeParams); rparams[xp.Name] = val is string xs ? xs : val is DateTime dt ? dt.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture) : val is DateTimeOffset dto ? dto.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture) : val is IFormattable xf ? xf.ToString(null, CultureInfo.InvariantCulture) : val.ToString(); } var guildId = rparams.TryGetValue("guild_id", out var rparam) ? rparam : ""; var channelId = rparams.TryGetValue("channel_id", out var rparam1) ? rparam1 : ""; var webhookId = rparams.TryGetValue("webhook_id", out var rparam2) ? rparam2 : ""; // Create a generic route (minus major params) key // ex: POST:/channels/channel_id/messages var hashKey = RateLimitBucket.GenerateHashKey(method, route); // We check if the hash is present, using our generic route (without major params) // ex: in POST:/channels/channel_id/messages, out 80c17d2f203122d936070c88c8d10f33 // If it doesn't exist, we create an unlimited hash as our initial key in the form of the hash key + the unlimited constant // and assign this to the route to hash cache // ex: this.RoutesToHashes[POST:/channels/channel_id/messages] = POST:/channels/channel_id/messages:unlimited var hash = this._routesToHashes.GetOrAdd(hashKey, RateLimitBucket.GenerateUnlimitedHash(method, route)); // Next we use the hash to generate the key to obtain the bucket. // ex: 80c17d2f203122d936070c88c8d10f33:guild_id:506128773926879242:webhook_id // or if unlimited: POST:/channels/channel_id/messages:unlimited:guild_id:506128773926879242:webhook_id var bucketId = RateLimitBucket.GenerateBucketId(hash, guildId, channelId, webhookId); // If it's not in cache, create a new bucket and index it by its bucket id. var bucket = this._hashesToBuckets.GetOrAdd(bucketId, new RateLimitBucket(hash, guildId, channelId, webhookId)); bucket.LastAttemptAt = DateTimeOffset.UtcNow; // Cache the routes for each bucket so it can be used for GC later. if (!bucket.RouteHashes.Contains(bucketId)) bucket.RouteHashes.Add(bucketId); // Add the current route to the request queue, which indexes the amount // of requests occurring to the bucket id. _ = this._requestQueue.TryGetValue(bucketId, out var count); // Increment by one atomically due to concurrency this._requestQueue[bucketId] = Interlocked.Increment(ref count); // Start bucket cleaner if not already running. if (!this._cleanerRunning) { this._cleanerRunning = true; this._bucketCleanerTokenSource = new CancellationTokenSource(); this._cleanerTask = Task.Run(this.CleanupBucketsAsync, this._bucketCleanerTokenSource.Token); this._logger.LogDebug(LoggerEvents.RestCleaner, "Bucket cleaner task started."); } url = s_routeArgumentRegex.Replace(route, xm => rparams[xm.Groups[1].Value]); return bucket; } /// /// Executes the request. /// /// The request to be executed. public Task ExecuteRequestAsync(BaseRestRequest request) => request == null ? throw new ArgumentNullException(nameof(request)) : this.ExecuteRequestAsync(request, null, null); /// /// Executes the request. /// This is to allow proper rescheduling of the first request from a bucket. /// /// The request to be executed. /// The bucket. /// The ratelimit task completion source. private async Task ExecuteRequestAsync(BaseRestRequest request, RateLimitBucket bucket, TaskCompletionSource ratelimitTcs) { if (this._disposed) return; HttpResponseMessage res = default; try { await this._globalRateLimitEvent.WaitAsync().ConfigureAwait(false); bucket ??= request.RateLimitBucket; ratelimitTcs ??= await this.WaitForInitialRateLimit(bucket).ConfigureAwait(false); if (ratelimitTcs == null) // check rate limit only if we are not the probe request { var now = DateTimeOffset.UtcNow; await bucket.TryResetLimitAsync(now).ConfigureAwait(false); // Decrement the remaining number of requests as there can be other concurrent requests before this one finishes and has a chance to update the bucket if (Interlocked.Decrement(ref bucket.RemainingInternal) < 0) { this._logger.LogWarning(LoggerEvents.RatelimitDiag, "Request for {bucket} is blocked. Url: {url}", bucket.ToString(), request.Url.AbsoluteUri); var delay = bucket.Reset - now; var resetDate = bucket.Reset; if (this._useResetAfter) { delay = bucket.ResetAfter.Value; resetDate = bucket.ResetAfterOffset; } if (delay < new TimeSpan(-TimeSpan.TicksPerMinute)) { this._logger.LogError(LoggerEvents.RatelimitDiag, "Failed to retrieve ratelimits - giving up and allowing next request for bucket"); bucket.RemainingInternal = 1; } if (delay < TimeSpan.Zero) delay = TimeSpan.FromMilliseconds(100); this._logger.LogWarning(LoggerEvents.RatelimitPreemptive, "Preemptive ratelimit triggered - waiting until {0:yyyy-MM-dd HH:mm:ss zzz} ({1:c}).", resetDate, delay); Task.Delay(delay) .ContinueWith(_ => this.ExecuteRequestAsync(request, null, null)) .LogTaskFault(this._logger, LogLevel.Error, LoggerEvents.RestError, "Error while executing request"); return; } this._logger.LogDebug(LoggerEvents.RatelimitDiag, "Request for {bucket} is allowed. Url: {url}", bucket.ToString(), request.Url.AbsoluteUri); } else this._logger.LogDebug(LoggerEvents.RatelimitDiag, "Initial request for {bucket} is allowed. Url: {url}", bucket.ToString(), request.Url.AbsoluteUri); var req = this.BuildRequest(request); if (this.Debug) this._logger.LogTrace(LoggerEvents.Misc, await req.Content.ReadAsStringAsync()); var response = new RestResponse(); try { if (this._disposed) return; res = await this.HttpClient.SendAsync(req, HttpCompletionOption.ResponseContentRead, CancellationToken.None).ConfigureAwait(false); var bts = await res.Content.ReadAsByteArrayAsync().ConfigureAwait(false); var txt = Utilities.UTF8.GetString(bts, 0, bts.Length); this._logger.LogTrace(LoggerEvents.RestRx, txt); response.Headers = res.Headers.ToDictionary(xh => xh.Key, xh => string.Join("\n", xh.Value), StringComparer.OrdinalIgnoreCase); response.Response = txt; response.ResponseCode = (int)res.StatusCode; } catch (HttpRequestException httpex) { this._logger.LogError(LoggerEvents.RestError, httpex, "Request to {0} triggered an HttpException", request.Url.AbsoluteUri); request.SetFaulted(httpex); this.FailInitialRateLimitTest(request, ratelimitTcs); return; } this.UpdateBucket(request, response, ratelimitTcs); Exception ex = null; Exception senex = null; switch (response.ResponseCode) { case 400: case 405: ex = new BadRequestException(request, response); senex = new Exception(ex.Message + "\nJson Response: " + (ex as BadRequestException).JsonMessage ?? "null", ex); break; case 401: case 403: ex = new UnauthorizedException(request, response); break; case 404: ex = new NotFoundException(request, response); break; case 413: ex = new RequestSizeException(request, response); break; case 429: ex = new RateLimitException(request, response); // check the limit info and requeue this.Handle429(response, out var wait, out var global); if (wait != null) { if (global) { bucket.IsGlobal = true; this._logger.LogError(LoggerEvents.RatelimitHit, "Global ratelimit hit, cooling down for {uri}", request.Url.AbsoluteUri); try { this._globalRateLimitEvent.Reset(); await wait.ConfigureAwait(false); } finally { // we don't want to wait here until all the blocked requests have been run, additionally Set can never throw an exception that could be suppressed here _ = this._globalRateLimitEvent.SetAsync(); } this.ExecuteRequestAsync(request, bucket, ratelimitTcs) .LogTaskFault(this._logger, LogLevel.Error, LoggerEvents.RestError, "Error while retrying request"); } else { if (this._discord is DiscordClient) { await (this._discord as DiscordClient)._rateLimitHit.InvokeAsync(this._discord as DiscordClient, new EventArgs.RateLimitExceptionEventArgs(this._discord.ServiceProvider) { Exception = ex as RateLimitException, ApiEndpoint = request.Url.AbsoluteUri }); } this._logger.LogError(LoggerEvents.RatelimitHit, "Ratelimit hit, requeuing request to {url}", request.Url.AbsoluteUri); await wait.ConfigureAwait(false); this.ExecuteRequestAsync(request, bucket, ratelimitTcs) .LogTaskFault(this._logger, LogLevel.Error, LoggerEvents.RestError, "Error while retrying request"); } return; } break; case 500: case 502: case 503: case 504: ex = new ServerErrorException(request, response); senex = new Exception(ex.Message + "\nJson Response: " + (ex as ServerErrorException).JsonMessage ?? "null", ex); break; } if (ex != null) { - if (senex != null) + if (this._discord.Configuration.EnableSentry) { - Dictionary debugInfo = new() + if (senex != null) { - { "route", request.Route }, - { "time", DateTimeOffset.UtcNow } - }; - senex.AddSentryContext("Request", debugInfo); - SentrySdk.CaptureException(senex); + Dictionary debugInfo = new() + { + { "route", request.Route }, + { "time", DateTimeOffset.UtcNow } + }; + senex.AddSentryContext("Request", debugInfo); + this._discord.Sentry.CaptureException(senex); + } } request.SetFaulted(ex); } else request.SetCompleted(response); } catch (Exception ex) { this._logger.LogError(LoggerEvents.RestError, ex, "Request to {0} triggered an exception", request.Url.AbsoluteUri); // if something went wrong and we couldn't get rate limits for the first request here, allow the next request to run if (bucket != null && ratelimitTcs != null && bucket.LimitTesting != 0) this.FailInitialRateLimitTest(request, ratelimitTcs); if (!request.TrySetFaulted(ex)) throw; } finally { res?.Dispose(); // Get and decrement active requests in this bucket by 1. _ = this._requestQueue.TryGetValue(bucket.BucketId, out var count); this._requestQueue[bucket.BucketId] = Interlocked.Decrement(ref count); // If it's 0 or less, we can remove the bucket from the active request queue, // along with any of its past routes. if (count <= 0) { foreach (var r in bucket.RouteHashes) { if (this._requestQueue.ContainsKey(r)) { _ = this._requestQueue.TryRemove(r, out _); } } } } } /// /// Fails the initial rate limit test. /// /// The request. /// The ratelimit task completion source. /// Whether to reset to initial values. private void FailInitialRateLimitTest(BaseRestRequest request, TaskCompletionSource ratelimitTcs, bool resetToInitial = false) { if (ratelimitTcs == null && !resetToInitial) return; var bucket = request.RateLimitBucket; bucket.LimitValid = false; bucket.LimitTestFinished = null; bucket.LimitTesting = 0; //Reset to initial values. if (resetToInitial) { this.UpdateHashCaches(request, bucket); bucket.Maximum = 0; bucket.RemainingInternal = 0; return; } // no need to wait on all the potentially waiting tasks _ = Task.Run(() => ratelimitTcs.TrySetResult(false)); } /// /// Waits for the initial rate limit. /// /// The bucket. private async Task> WaitForInitialRateLimit(RateLimitBucket bucket) { while (!bucket.LimitValid) { if (bucket.LimitTesting == 0) { if (Interlocked.CompareExchange(ref bucket.LimitTesting, 1, 0) == 0) { // if we got here when the first request was just finishing, we must not create the waiter task as it would signal ExecuteRequestAsync to bypass rate limiting if (bucket.LimitValid) return null; // allow exactly one request to go through without having rate limits available var ratelimitsTcs = new TaskCompletionSource(); bucket.LimitTestFinished = ratelimitsTcs.Task; return ratelimitsTcs; } } // it can take a couple of cycles for the task to be allocated, so wait until it happens or we are no longer probing for the limits Task waitTask = null; while (bucket.LimitTesting != 0 && (waitTask = bucket.LimitTestFinished) == null) await Task.Yield(); if (waitTask != null) await waitTask.ConfigureAwait(false); // if the request failed and the response did not have rate limit headers we have allow the next request and wait again, thus this is a loop here } return null; } /// /// Builds the request. /// /// The request. /// A http request message. private HttpRequestMessage BuildRequest(BaseRestRequest request) { var req = new HttpRequestMessage(new HttpMethod(request.Method.ToString()), request.Url); if (request.Headers != null && request.Headers.Any()) foreach (var kvp in request.Headers) req.Headers.Add(kvp.Key, kvp.Value); if (request is RestRequest nmprequest && !string.IsNullOrWhiteSpace(nmprequest.Payload)) { this._logger.LogTrace(LoggerEvents.RestTx, nmprequest.Payload); req.Content = new StringContent(nmprequest.Payload); req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); } if (request is MultipartWebRequest mprequest) { this._logger.LogTrace(LoggerEvents.RestTx, ""); var boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x"); req.Headers.Add("Connection", "keep-alive"); req.Headers.Add("Keep-Alive", "600"); var content = new MultipartFormDataContent(boundary); if (mprequest.Values != null && mprequest.Values.Any()) foreach (var kvp in mprequest.Values) content.Add(new StringContent(kvp.Value), kvp.Key); var fileId = mprequest.OverwriteFileIdStart ?? 0; if (mprequest.Files != null && mprequest.Files.Any()) { foreach (var f in mprequest.Files) { var name = $"files[{fileId.ToString(CultureInfo.InvariantCulture)}]"; content.Add(new StreamContent(f.Value), name, f.Key); fileId++; } } req.Content = content; } if (request is MultipartStickerWebRequest mpsrequest) { this._logger.LogTrace(LoggerEvents.RestTx, ""); var boundary = "---------------------------" + DateTime.Now.Ticks.ToString("x"); req.Headers.Add("Connection", "keep-alive"); req.Headers.Add("Keep-Alive", "600"); var sc = new StreamContent(mpsrequest.File.Stream); if (mpsrequest.File.ContentType != null) sc.Headers.ContentType = new MediaTypeHeaderValue(mpsrequest.File.ContentType); var fileName = mpsrequest.File.FileName; if (mpsrequest.File.FileType != null) fileName += '.' + mpsrequest.File.FileType; var content = new MultipartFormDataContent(boundary) { { new StringContent(mpsrequest.Name), "name" }, { new StringContent(mpsrequest.Tags), "tags" }, { new StringContent(mpsrequest.Description), "description" }, { sc, "file", fileName } }; req.Content = content; } return req; } /// /// Handles the HTTP 429 status. /// /// The response. /// The wait task. /// If true, global. private void Handle429(RestResponse response, out Task waitTask, out bool global) { waitTask = null; global = false; if (response.Headers == null) return; var hs = response.Headers; // handle the wait if (hs.TryGetValue("Retry-After", out var retryAfterRaw)) { var retryAfter = TimeSpan.FromSeconds(int.Parse(retryAfterRaw, CultureInfo.InvariantCulture)); waitTask = Task.Delay(retryAfter); } // check if global b1nzy if (hs.TryGetValue("X-RateLimit-Global", out var isGlobal) && isGlobal.ToLowerInvariant() == "true") { // global global = true; } } /// /// Updates the bucket. /// /// The request. /// The response. /// The ratelimit task completion source. private void UpdateBucket(BaseRestRequest request, RestResponse response, TaskCompletionSource ratelimitTcs) { var bucket = request.RateLimitBucket; if (response.Headers == null) { if (response.ResponseCode != 429) // do not fail when ratelimit was or the next request will be scheduled hitting the rate limit again this.FailInitialRateLimitTest(request, ratelimitTcs); return; } var hs = response.Headers; if (hs.TryGetValue("X-RateLimit-Scope", out var scope)) { bucket.Scope = scope; } if (hs.TryGetValue("X-RateLimit-Global", out var isGlobal) && isGlobal.ToLowerInvariant() == "true") { if (response.ResponseCode != 429) { bucket.IsGlobal = true; this.FailInitialRateLimitTest(request, ratelimitTcs); } return; } var r1 = hs.TryGetValue("X-RateLimit-Limit", out var usesMax); var r2 = hs.TryGetValue("X-RateLimit-Remaining", out var usesLeft); var r3 = hs.TryGetValue("X-RateLimit-Reset", out var reset); var r4 = hs.TryGetValue("X-Ratelimit-Reset-After", out var resetAfter); var r5 = hs.TryGetValue("X-Ratelimit-Bucket", out var hash); if (!r1 || !r2 || !r3 || !r4) { //If the limits were determined before this request, make the bucket initial again. if (response.ResponseCode != 429) this.FailInitialRateLimitTest(request, ratelimitTcs, ratelimitTcs == null); return; } var clientTime = DateTimeOffset.UtcNow; var resetTime = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(double.Parse(reset, CultureInfo.InvariantCulture)); var serverTime = clientTime; if (hs.TryGetValue("Date", out var rawDate)) serverTime = DateTimeOffset.Parse(rawDate, CultureInfo.InvariantCulture).ToUniversalTime(); var resetDelta = resetTime - serverTime; //var difference = clientTime - serverTime; //if (Math.Abs(difference.TotalSeconds) >= 1) //// this.Logger.LogMessage(LogLevel.DebugBaseDiscordClient.RestEventId, $"Difference between machine and server time: {difference.TotalMilliseconds.ToString("#,##0.00", CultureInfo.InvariantCulture)}ms", DateTime.Now); //else // difference = TimeSpan.Zero; if (request.RateLimitWaitOverride.HasValue) resetDelta = TimeSpan.FromSeconds(request.RateLimitWaitOverride.Value); var newReset = clientTime + resetDelta; if (this._useResetAfter) { bucket.ResetAfter = TimeSpan.FromSeconds(double.Parse(resetAfter, CultureInfo.InvariantCulture)); newReset = clientTime + bucket.ResetAfter.Value + (request.RateLimitWaitOverride.HasValue ? resetDelta : TimeSpan.Zero); bucket.ResetAfterOffset = newReset; } else bucket.Reset = newReset; var maximum = int.Parse(usesMax, CultureInfo.InvariantCulture); var remaining = int.Parse(usesLeft, CultureInfo.InvariantCulture); if (ratelimitTcs != null) { // initial population of the ratelimit data bucket.SetInitialValues(maximum, remaining, newReset); _ = Task.Run(() => ratelimitTcs.TrySetResult(true)); } else { // only update the bucket values if this request was for a newer interval than the one // currently in the bucket, to avoid issues with concurrent requests in one bucket // remaining is reset by TryResetLimit and not the response, just allow that to happen when it is time if (bucket.NextReset == 0) bucket.NextReset = newReset.UtcTicks; } this.UpdateHashCaches(request, bucket, hash); } /// /// Updates the hash caches. /// /// The request. /// The bucket. /// The new hash. private void UpdateHashCaches(BaseRestRequest request, RateLimitBucket bucket, string newHash = null) { var hashKey = RateLimitBucket.GenerateHashKey(request.Method, request.Route); if (!this._routesToHashes.TryGetValue(hashKey, out var oldHash)) return; // This is an unlimited bucket, which we don't need to keep track of. if (newHash == null) { _ = this._routesToHashes.TryRemove(hashKey, out _); _ = this._hashesToBuckets.TryRemove(bucket.BucketId, out _); return; } // Only update the hash once, due to a bug on Discord's end. // This will cause issues if the bucket hashes are dynamically changed from the API while running, // in which case, Dispose will need to be called to clear the caches. if (bucket.IsUnlimited && newHash != oldHash) { this._logger.LogDebug(LoggerEvents.RestHashMover, "Updating hash in {0}: \"{1}\" -> \"{2}\"", hashKey, oldHash, newHash); var bucketId = RateLimitBucket.GenerateBucketId(newHash, bucket.GuildId, bucket.ChannelId, bucket.WebhookId); _ = this._routesToHashes.AddOrUpdate(hashKey, newHash, (key, oldHash) => { bucket.Hash = newHash; var oldBucketId = RateLimitBucket.GenerateBucketId(oldHash, bucket.GuildId, bucket.ChannelId, bucket.WebhookId); // Remove the old unlimited bucket. _ = this._hashesToBuckets.TryRemove(oldBucketId, out _); _ = this._hashesToBuckets.AddOrUpdate(bucketId, bucket, (key, oldBucket) => bucket); return newHash; }); } return; } /// /// Cleans the buckets. /// private async Task CleanupBucketsAsync() { while (!this._bucketCleanerTokenSource.IsCancellationRequested) { try { await Task.Delay(this._bucketCleanupDelay, this._bucketCleanerTokenSource.Token).ConfigureAwait(false); } catch { } if (this._disposed) return; //Check and clean request queue first in case it wasn't removed properly during requests. foreach (var key in this._requestQueue.Keys) { var bucket = this._hashesToBuckets.Values.FirstOrDefault(x => x.RouteHashes.Contains(key)); if (bucket == null || (bucket != null && bucket.LastAttemptAt.AddSeconds(5) < DateTimeOffset.UtcNow)) _ = this._requestQueue.TryRemove(key, out _); } var removedBuckets = 0; StringBuilder bucketIdStrBuilder = default; foreach (var kvp in this._hashesToBuckets) { bucketIdStrBuilder ??= new StringBuilder(); var key = kvp.Key; var value = kvp.Value; // Don't remove the bucket if it's currently being handled by the rest client, unless it's an unlimited bucket. if (this._requestQueue.ContainsKey(value.BucketId) && !value.IsUnlimited) continue; var resetOffset = this._useResetAfter ? value.ResetAfterOffset : value.Reset; // Don't remove the bucket if it's reset date is less than now + the additional wait time, unless it's an unlimited bucket. if (!value.IsUnlimited && (resetOffset > DateTimeOffset.UtcNow || DateTimeOffset.UtcNow - resetOffset < this._bucketCleanupDelay)) continue; _ = this._hashesToBuckets.TryRemove(key, out _); removedBuckets++; bucketIdStrBuilder.Append(value.BucketId + ", "); } if (removedBuckets > 0) this._logger.LogDebug(LoggerEvents.RestCleaner, "Removed {0} unused bucket{1}: [{2}]", removedBuckets, removedBuckets > 1 ? "s" : string.Empty, bucketIdStrBuilder.ToString().TrimEnd(',', ' ')); if (this._hashesToBuckets.IsEmpty) break; } if (!this._bucketCleanerTokenSource.IsCancellationRequested) this._bucketCleanerTokenSource.Cancel(); this._cleanerRunning = false; this._logger.LogDebug(LoggerEvents.RestCleaner, "Bucket cleaner task stopped."); } ~RestClient() { this.Dispose(); } /// /// Disposes the rest client. /// public void Dispose() { if (this._disposed) return; this._disposed = true; this._globalRateLimitEvent.Reset(); if (this._bucketCleanerTokenSource?.IsCancellationRequested == false) { this._bucketCleanerTokenSource?.Cancel(); this._logger.LogDebug(LoggerEvents.RestCleaner, "Bucket cleaner task stopped."); } try { this._cleanerTask?.Dispose(); this._bucketCleanerTokenSource?.Dispose(); this.HttpClient?.Dispose(); } catch { } this._routesToHashes.Clear(); this._hashesToBuckets.Clear(); this._requestQueue.Clear(); GC.SuppressFinalize(this); } } diff --git a/DisCatSharp/Net/Serialization/DiscordJson.cs b/DisCatSharp/Net/Serialization/DiscordJson.cs index 56a42e711..35547419b 100644 --- a/DisCatSharp/Net/Serialization/DiscordJson.cs +++ b/DisCatSharp/Net/Serialization/DiscordJson.cs @@ -1,187 +1,210 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2023 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using DisCatSharp.Entities; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Sentry; namespace DisCatSharp.Net.Serialization; /// /// Represents discord json. /// public static class DiscordJson { private static readonly JsonSerializer s_serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings { ContractResolver = new OptionalJsonContractResolver() }); /// Serializes the specified object to a JSON string. /// The object to serialize. /// A JSON string representation of the object. public static string SerializeObject(object value) => SerializeObjectInternal(value, null!, s_serializer); public static T DeserializeObject(string json, BaseDiscordClient discord) where T : ObservableApiObject => DeserializeObjectInternal(json, discord); public static T DeserializeIEnumerableObject(string json, BaseDiscordClient discord) where T : IEnumerable => DeserializeIEnumerableObjectInternal(json, discord); /// Populates an object with the values from a JSON node. /// The token to populate the object with. /// The object to populate. public static void PopulateObject(JToken value, object target) { using var reader = value.CreateReader(); s_serializer.Populate(reader, target); } /// /// Converts this token into an object, passing any properties through extra s if needed. /// /// The token to convert /// Type to convert to /// The converted token public static T ToDiscordObject(this JToken token) => token.ToObject(s_serializer)!; /// /// Serializes the object. /// /// The value. /// The type. /// The json serializer. private static string SerializeObjectInternal(object value, Type type, JsonSerializer jsonSerializer) { var stringWriter = new StringWriter(new(256), CultureInfo.InvariantCulture); using (var jsonTextWriter = new JsonTextWriter(stringWriter)) { jsonTextWriter.Formatting = jsonSerializer.Formatting; jsonSerializer.Serialize(jsonTextWriter, value, type); } return stringWriter.ToString(); } private static T DeserializeObjectInternal(string json, BaseDiscordClient discord) where T : ObservableApiObject { var obj = JsonConvert.DeserializeObject(json, new JsonSerializerSettings() { ContractResolver = new OptionalJsonContractResolver() })!; obj.Discord = discord; if (!discord.Configuration.ReportMissingFields || !obj.AdditionalProperties.Any()) return obj; var sentryMessage = "Found missing properties in api response for " + obj.GetType().Name; List sentryFields = new(); var vals = 0; foreach (var ap in obj.AdditionalProperties) { vals++; if (obj._ignoredJsonKeys.Count == 0 || !obj._ignoredJsonKeys.Any(x => x == ap.Key)) { if (vals == 1) { discord.Logger.LogInformation("{sentry}", sentryMessage); discord.Logger.LogDebug(json); } sentryFields.Add(ap.Key); discord.Logger.LogInformation("Found field {field} on {object}", ap.Key, obj.GetType().Name); } } if (!discord.Configuration.EnableSentry || sentryFields.Count == 0) return obj; var sentryJson = JsonConvert.SerializeObject(sentryFields); sentryMessage += "\n\nNew fields: " + sentryJson; SentryEvent sentryEvent = new() { Level = SentryLevel.Warning, Logger = nameof(DiscordJson), Message = sentryMessage }; sentryEvent.SetExtra("Found Fields", sentryJson); + if (discord.Configuration.AttachUserInfo && discord.CurrentUser != null) + sentryEvent.User = new() + { + Id = discord.CurrentUser.Id.ToString(), + Username = discord.CurrentUser.UsernameWithDiscriminator, + Other = new Dictionary () + { + { "developer", discord.Configuration.DeveloperUserId?.ToString() ?? "not_given" }, + { "email", discord.Configuration.FeedbackEmail ?? "not_given" } + } + }; var sid = discord.Sentry.CaptureEvent(sentryEvent); _ = Task.Run(discord.Sentry.FlushAsync); discord.Logger.LogInformation("Reported to sentry with id {sid}", sid.ToString()); return obj; } private static T DeserializeIEnumerableObjectInternal(string json, BaseDiscordClient discord) where T : IEnumerable { var obj = JsonConvert.DeserializeObject(json, new JsonSerializerSettings() { ContractResolver = new OptionalJsonContractResolver() })!; foreach (var ob in obj) ob.Discord = discord; if (!discord.Configuration.ReportMissingFields || !obj.Any(x => x.AdditionalProperties.Any())) return obj; var first = obj.First(); var sentryMessage = "Found missing properties in api response for " + first.GetType().Name; List sentryFields = new(); var vals = 0; foreach (var ap in first.AdditionalProperties) { vals++; if (first._ignoredJsonKeys.Count == 0 || !first._ignoredJsonKeys.Any(x => x == ap.Key)) { if (vals == 1) { discord.Logger.LogInformation("{sentry}", sentryMessage); discord.Logger.LogDebug(json); } sentryFields.Add(ap.Key); discord.Logger.LogInformation("Found field {field} on {object}", ap.Key, first.GetType().Name); } } if (!discord.Configuration.EnableSentry || sentryFields.Count == 0) return obj; var sentryJson = JsonConvert.SerializeObject(sentryFields); sentryMessage += "\n\nNew fields: " + sentryJson; SentryEvent sentryEvent = new() { Level = SentryLevel.Warning, Logger = nameof(DiscordJson), Message = sentryMessage }; sentryEvent.SetExtra("Found Fields", sentryJson); + if (discord.Configuration.AttachUserInfo && discord.CurrentUser != null) + sentryEvent.User = new() + { + Id = discord.CurrentUser.Id.ToString(), + Username = discord.CurrentUser.UsernameWithDiscriminator, + Other = new Dictionary() + { + { "developer", discord.Configuration.DeveloperUserId?.ToString() ?? "not_given" }, + { "email", discord.Configuration.FeedbackEmail ?? "not_given" } + } + }; var sid = discord.Sentry.CaptureEvent(sentryEvent); _ = Task.Run(discord.Sentry.FlushAsync); discord.Logger.LogInformation("Reported to sentry with id {sid}", sid.ToString()); return obj; } }