diff --git a/BackgroundUserListLoad.cs b/BackgroundUserListLoad.cs new file mode 100644 index 0000000..c5dcce2 --- /dev/null +++ b/BackgroundUserListLoad.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using WorldTime.Data; + +namespace WorldTime; +/// +/// Proactively fills the user cache for guilds in which any time zone configuration exists. +/// +/// Modeled after BirthdayBot's similar feature. +class BackgroundUserListLoad : IDisposable { + private readonly IServiceProvider _services; + private readonly Task _workerTask; + private readonly CancellationTokenSource _workerCancel; + + public BackgroundUserListLoad(IServiceProvider services) { + _services = services; + _workerCancel = new(); + _workerTask = Task.Factory.StartNew(Worker, _workerCancel.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + public void Dispose() { + _workerCancel.Cancel(); + _workerCancel.Dispose(); + _workerTask.Dispose(); + } + + private async Task Worker() { + while (!_workerCancel.IsCancellationRequested) { + // Interval same as status + await Task.Delay(WorldTime.StatusInterval * 1000, _workerCancel.Token); + + foreach (var shard in _services.GetRequiredService().Shards) { + try { + await ProcessShard(shard); + } catch (Exception ex) { + Program.Log(nameof(BackgroundUserListLoad), ex.ToString()); + } + } + } + } + + private async Task ProcessShard(DiscordSocketClient shard) { + using var db = _services.GetRequiredService(); + + // Check when a guild's cache is incomplete... + var incompleteCaches = shard.Guilds.Where(g => !g.HasAllMembers).Select(g => (long)g.Id).ToHashSet(); + // ...and contains any user data. + var mustFetch = db.UserEntries.Where(e => incompleteCaches.Contains(e.GuildId)).Select(e => e.GuildId).Distinct(); + + var processed = 0; + foreach (var item in mustFetch) { + // May cause a disconnect in certain situations. Cancel all further attempts until the next pass if it happens. + if (shard.ConnectionState != ConnectionState.Connected) break; + + var guild = shard.GetGuild((ulong)item); + if (guild == null) continue; // A guild disappeared...? + await guild.DownloadUsersAsync().ConfigureAwait(false); // We're already on a seperate thread, no need to use Task.Run + await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang... + processed++; + } + + if (processed > 100) Program.Log(nameof(BackgroundUserListLoad), $"Explicit user list request processed for {processed} guilds."); + } +} \ No newline at end of file diff --git a/WorldTime.cs b/WorldTime.cs index d8f7ab1..904e243 100644 --- a/WorldTime.cs +++ b/WorldTime.cs @@ -16,20 +16,15 @@ internal class WorldTime : IDisposable { /// Number of seconds between each time the status task runs, in seconds. /// #if DEBUG - private const int StatusInterval = 20; + internal const int StatusInterval = 20; #else - private const int StatusInterval = 300; + internal const int StatusInterval = 300; #endif - /// - /// Number of concurrent shard startups to happen on each check. - /// This value is also used in . - /// - public const int MaxConcurrentOperations = 5; - private readonly Task _statusTask; - private readonly CancellationTokenSource _mainCancel; + private readonly CancellationTokenSource _statusCancel; private readonly IServiceProvider _services; + private readonly BackgroundUserListLoad _bgFetch; internal Configuration Config { get; } internal DiscordShardedClient DiscordClient => _services.GetRequiredService(); @@ -45,7 +40,9 @@ internal class WorldTime : IDisposable { LogLevel = LogSeverity.Info, DefaultRetryMode = RetryMode.RetryRatelimit, MessageCacheSize = 0, // disable message cache - GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers, + SuppressUnknownDispatchWarnings = true, + LogGatewayIntentWarnings = false }; _services = new ServiceCollection() .AddSingleton(new DiscordShardedClient(clientConf)) @@ -54,15 +51,16 @@ internal class WorldTime : IDisposable { .BuildServiceProvider(); DiscordClient.Log += DiscordClient_Log; DiscordClient.ShardReady += DiscordClient_ShardReady; - DiscordClient.MessageReceived += DiscordClient_MessageReceived; var iasrv = _services.GetRequiredService(); DiscordClient.InteractionCreated += DiscordClient_InteractionCreated; iasrv.SlashCommandExecuted += InteractionService_SlashCommandExecuted; // Start status reporting thread - _mainCancel = new CancellationTokenSource(); - _statusTask = Task.Factory.StartNew(StatusLoop, _mainCancel.Token, + _statusCancel = new CancellationTokenSource(); + _statusTask = Task.Factory.StartNew(StatusLoop, _statusCancel.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + _bgFetch = new(_services); } public async Task StartAsync() { @@ -72,26 +70,24 @@ internal class WorldTime : IDisposable { } public void Dispose() { - _mainCancel.Cancel(); + _statusCancel.Cancel(); _statusTask.Wait(10000); if (!_statusTask.IsCompleted) Program.Log(nameof(WorldTime), "Warning: Main thread did not cleanly finish up in time. Continuing..."); - _mainCancel.Cancel(); - _statusTask.Wait(5000); - _mainCancel.Dispose(); + _statusCancel.Dispose(); Program.Log(nameof(WorldTime), $"Uptime: {Program.BotUptime}"); } private async Task StatusLoop() { try { - await Task.Delay(30000, _mainCancel.Token).ConfigureAwait(false); // initial 30 second delay - while (!_mainCancel.IsCancellationRequested) { + await Task.Delay(30000, _statusCancel.Token).ConfigureAwait(false); // initial 30 second delay + while (!_statusCancel.IsCancellationRequested) { Program.Log(nameof(WorldTime), $"Bot uptime: {Program.BotUptime}"); - await PeriodicReport(DiscordClient.CurrentUser.Id, DiscordClient.Guilds.Count, _mainCancel.Token).ConfigureAwait(false); - await Task.Delay(StatusInterval * 1000, _mainCancel.Token).ConfigureAwait(false); + await PeriodicReport(DiscordClient.CurrentUser.Id, DiscordClient.Guilds.Count, _statusCancel.Token).ConfigureAwait(false); + await Task.Delay(StatusInterval * 1000, _statusCancel.Token).ConfigureAwait(false); } } catch (TaskCanceledException) { } } @@ -173,25 +169,6 @@ internal class WorldTime : IDisposable { #endif } - /// - /// Non-specific handler for incoming events. - /// - private async Task DiscordClient_MessageReceived(SocketMessage message) { - if (message.Author.IsWebhook) return; - if (message.Type != MessageType.Default) return; - if (message.Channel is not SocketTextChannel channel) return; - - // Proactively fill guild user cache if the bot has any data for the respective guild - // Can skip an extra query if the last_seen update is known to have been successful, otherwise query for any users - if (!channel.Guild.HasAllMembers) { - using var db = _services.GetRequiredService(); - if (db.HasAnyUsers(channel.Guild)) { - // Event handler hangs if awaited normally or used with Task.Run - await Task.Factory.StartNew(channel.Guild.DownloadUsersAsync); - } - } - } - const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner."; // Slash command preparation and invocation