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/CommandsText.cs b/CommandsText.cs deleted file mode 100644 index 8fa5e67..0000000 --- a/CommandsText.cs +++ /dev/null @@ -1,398 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NodaTime; -using System.Collections.ObjectModel; -using System.Globalization; -using System.Text; -using System.Text.RegularExpressions; -using WorldTime.Data; - -namespace WorldTime; -[Obsolete("Text commands are deprecated and will be removed soon.")] -internal class CommandsText { -#if DEBUG - public const string CommandPrefix = "tt."; -#else - public const string CommandPrefix = "tz."; -#endif - delegate Task Command(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message); - - private readonly Dictionary _commands; - private readonly IServiceProvider _services; - private readonly WorldTime _instance; - - private BotDatabaseContext Database => _services.GetRequiredService(); - - private static readonly Regex _userExplicit; - private static readonly Regex _userMention; - private static readonly ReadOnlyDictionary _tzNameMap; - - private const string ErrInvalidZone = ":x: Not a valid zone name." - + " To find your time zone, refer to: ."; - private const string ErrTargetUserNotFound = ":x: Unable to find the target user."; - private const string ErrNoUserCache = ":warning: Please try the command again."; - private const int MaxSingleLineLength = 750; - private const int MaxSingleOutputLength = 900; - - static CommandsText() { - _userExplicit = new Regex(@"(.+)#(\d{4})", RegexOptions.Compiled); - _userMention = new Regex(@"\!?(\d+)>", RegexOptions.Compiled); - - Dictionary tzNameMap = new(StringComparer.OrdinalIgnoreCase); - foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name); - _tzNameMap = new(tzNameMap); - } - - public CommandsText(WorldTime inst, IServiceProvider services) { - _instance = inst; - _services = services; - _commands = new(StringComparer.OrdinalIgnoreCase) { - { "help", CmdHelp }, - { "list", CmdList }, - { "set", CmdSet }, - { "remove", CmdRemove }, - { "setfor", CmdSetFor }, - { "removefor", CmdRemoveFor } - }; - - inst.DiscordClient.MessageReceived += CommandDispatch; - } - - private async Task CommandDispatch(SocketMessage message) { - if (message.Author.IsBot || message.Author.IsWebhook) return; - if (message.Type != MessageType.Default) return; - if (message.Channel is not SocketTextChannel channel) return; // not handling DMs - - var msgsplit = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (msgsplit.Length == 0 || msgsplit[0].Length < 4) return; - if (msgsplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) { - var cmdBase = msgsplit[0][3..]; - if (_commands.ContainsKey(cmdBase)) { - Program.Log("Command invoked", $"{channel.Guild.Name}/{message.Author} {message.Content}"); - try { - await _commands[cmdBase](channel, (SocketGuildUser)message.Author, message).ConfigureAwait(false); - } catch (Exception ex) { - Program.Log("Command invoked", ex.ToString()); - } - } - } - } - - private async Task CmdHelp(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - using var db = Database; - var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); - var guildct = _instance.DiscordClient.Guilds.Count; - var uniquetz = db.GetDistinctZoneCount(); - await channel.SendMessageAsync(embed: new EmbedBuilder() { - Color = new Color(0xe0f2f7), - Title = "Help & About", - Description = $"World Time v{version} - Serving {guildct} communities across {uniquetz} time zones.\n\n" - + "This bot is provided for free, without any paywalled 'premium' features. " - + "If you've found this bot useful, please consider contributing via the " - + "bot author's page on Ko-fi: https://ko-fi.com/noithecat.", - Footer = new EmbedFooterBuilder() { - IconUrl = _instance.DiscordClient.CurrentUser.GetAvatarUrl(), - Text = "World Time" - } - }.AddField(inline: false, name: "Commands", value: - $"`{CommandPrefix}help` - This message.\n" + - $"`{CommandPrefix}list` - Displays current times for all recently active known users.\n" + - $"`{CommandPrefix}list [user]` - Displays the current time for the given *user*.\n" + - $"`{CommandPrefix}set [zone]` - Registers or updates your *zone* with the bot.\n" + - $"`{CommandPrefix}remove` - Removes your name from this bot." - ).AddField(inline: false, name: "Admin commands", value: - $"`{CommandPrefix}setFor [user] [zone]` - Sets the time zone for another user.\n" + - $"`{CommandPrefix}removeFor [user]` - Removes another user's information." - ).AddField(inline: false, name: "Zones", value: - "This bot accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database). " + - "A useful tool to determine yours can be found at: https://kevinnovak.github.io/Time-Zone-Picker/" - ).Build()); - } - - private async Task CmdList(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - if (!await AreUsersDownloadedAsync(channel.Guild).ConfigureAwait(false)) { - await channel.SendMessageAsync(ErrNoUserCache).ConfigureAwait(false); - return; - } - - var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (wspl.Length == 2) { - // Has parameter - do specific user lookup - var usersearch = ResolveUserParameter(channel.Guild, wspl[1]); - if (usersearch == null) { - await channel.SendMessageAsync(":x: Cannot find the specified user.").ConfigureAwait(false); - return; - } - - using var db = Database; - var result = db.GetUserZone(usersearch); - if (result == null) { - bool isself = sender.Id == usersearch.Id; - if (isself) await channel.SendMessageAsync(":x: You do not have a time zone. Set it with `tz.set`.").ConfigureAwait(false); - else await channel.SendMessageAsync(":x: The given user does not have a time zone set.").ConfigureAwait(false); - return; - } - - var resulttext = TzPrint(result)[4..] + ": " + FormatName(usersearch); - await channel.SendMessageAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build()).ConfigureAwait(false); - } else { - // Does not have parameter - build full list - using var db = Database; - var userlist = db.GetGuildZones(channel.Guild.Id); - if (userlist.Count == 0) { - await channel.SendMessageAsync(":x: Nothing to show. " + - $"To register time zones with the bot, use the `{CommandPrefix}set` command.").ConfigureAwait(false); - return; - } - - // Order times by popularity to limit how many are shown, group by printed name - var sortedlist = new SortedDictionary>(); - foreach ((string area, List users) in userlist.OrderByDescending(o => o.Value.Count).Take(20)) { - // Filter further to top 20 distinct timezones, even if they are not displayed in the final result - var areaprint = TzPrint(area); - if (!sortedlist.ContainsKey(areaprint)) sortedlist.Add(areaprint, new List()); - sortedlist[areaprint].AddRange(users); - } - - // Build zone listings with users - var outputlines = new List(); - foreach ((string area, List users) in sortedlist) { - var buffer = new StringBuilder(); - buffer.Append(area[4..] + ": "); - bool empty = true; - foreach (var userid in users) { - var userinstance = channel.Guild.GetUser(userid); - if (userinstance == null) continue; - if (empty) empty = !empty; - else buffer.Append(", "); - var useradd = FormatName(userinstance); - if (buffer.Length + useradd.Length > MaxSingleLineLength) { - buffer.Append("others..."); - break; - } else buffer.Append(useradd); - } - if (!empty) outputlines.Add(buffer.ToString()); - } - - // Prepare for output - send buffers out if they become too large - outputlines.Sort(); - var resultout = new StringBuilder(); - foreach (var line in outputlines) { - if (resultout.Length + line.Length > MaxSingleOutputLength) { - await channel.SendMessageAsync( - embed: new EmbedBuilder().WithDescription(resultout.ToString()).Build()).ConfigureAwait(false); - resultout.Clear(); - } - if (resultout.Length > 0) resultout.AppendLine(); // avoids trailing newline by adding to the previous line - resultout.Append(line); - } - if (resultout.Length > 0) { - await channel.SendMessageAsync( - embed: new EmbedBuilder().WithDescription(resultout.ToString()).Build()).ConfigureAwait(false); - } - } - } - - private async Task CmdSet(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (wspl.Length == 1) { - await channel.SendMessageAsync(":x: Zone parameter is required.").ConfigureAwait(false); - return; - } - var input = ParseTimeZone(wspl[1]); - if (input == null) { - await channel.SendMessageAsync(ErrInvalidZone).ConfigureAwait(false); - return; - } - using var db = Database; - db.UpdateUser(sender, input); - await channel.SendMessageAsync($":white_check_mark: Your time zone has been set to **{input}**.").ConfigureAwait(false); - } - - private async Task CmdSetFor(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - if (!IsUserAdmin(sender)) return; - - // Parameters: command, target, zone - var wspl = message.Content.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); - if (wspl.Length == 1) { - await channel.SendMessageAsync(":x: You must specify a user to set the time zone for.").ConfigureAwait(false); - return; - } - if (wspl.Length == 2) { - await channel.SendMessageAsync(":x: You must specify a time zone to apply to the user.").ConfigureAwait(false); - return; - } - - if (!await AreUsersDownloadedAsync(channel.Guild).ConfigureAwait(false)) { - await channel.SendMessageAsync(ErrNoUserCache).ConfigureAwait(false); - return; - } - var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); - if (targetuser == null) { - await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); - return; - } - var newtz = ParseTimeZone(wspl[2]); - if (newtz == null) { - await channel.SendMessageAsync(ErrInvalidZone).ConfigureAwait(false); - return; - } - - using var db = Database; - db.UpdateUser(targetuser, newtz); - await channel.SendMessageAsync($":white_check_mark: Time zone for **{targetuser}** set to **{newtz}**.").ConfigureAwait(false); - } - - private async Task CmdRemove(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - using var db = Database; - var success = db.DeleteUser(sender); - if (success) await channel.SendMessageAsync(":white_check_mark: Your zone has been removed.").ConfigureAwait(false); - else await channel.SendMessageAsync(":x: You don't have a time zone set."); - } - - private async Task CmdRemoveFor(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { - if (!IsUserAdmin(sender)) return; - - // Parameters: command, target - var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (wspl.Length == 1) { - await channel.SendMessageAsync(":x: You must specify a user for whom to remove time zone data.").ConfigureAwait(false); - return; - } - - if (!await AreUsersDownloadedAsync(channel.Guild).ConfigureAwait(false)) { - await channel.SendMessageAsync(ErrNoUserCache).ConfigureAwait(false); - return; - } - var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); - if (targetuser == null) { - await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); - return; - } - - using var db = Database; - db.DeleteUser(targetuser); - await channel.SendMessageAsync($":white_check_mark: Removed zone information for {targetuser}."); - } - - /// - /// Given parameter input, attempts to find the corresponding SocketGuildUser. - /// - private static SocketGuildUser? ResolveUserParameter(SocketGuild guild, string input) { - // Try interpreting as ID - var match = _userMention.Match(input); - string idcheckstr = match.Success ? match.Groups[1].Value : input; - if (ulong.TryParse(idcheckstr, out var value)) return guild.GetUser(value); - - // Prepare if input looks like Username#Discriminator - var @explicit = _userExplicit.Match(input); - - foreach (var user in guild.Users) { - // Explicit match search - if (@explicit.Success) { - var username = @explicit.Groups[1].Value; - var discriminator = @explicit.Groups[2].Value; - if (string.Equals(user.Username, username, StringComparison.OrdinalIgnoreCase) && user.Discriminator == discriminator) - return user; - } - - // Nickname search - if (user.Nickname != null && string.Equals(user.Nickname, input, StringComparison.OrdinalIgnoreCase)) return user; - - // Username search - if (string.Equals(user.Username, input, StringComparison.OrdinalIgnoreCase)) return user; - } - - return null; - } - - #region Helper methods - /// - /// Returns a string displaying the current time in the given time zone. - /// The result begins with four numbers for sorting purposes. Must be trimmed before output. - /// - private static string TzPrint(string zone) { - var tzdb = DateTimeZoneProviders.Tzdb; - DateTimeZone tz = tzdb.GetZoneOrNull(zone)!; - if (tz == null) throw new Exception("Encountered unknown time zone: " + zone); - - var now = SystemClock.Instance.GetCurrentInstant().InZone(tz); - var sortpfx = now.ToString("MMdd", DateTimeFormatInfo.InvariantInfo); - var fullstr = now.ToString("dd'-'MMM' 'HH':'mm' 'x' (UTC'o')'", DateTimeFormatInfo.InvariantInfo); - return $"{sortpfx}● `{fullstr}`"; - } - - /// - /// Checks given time zone input. Returns a valid string for use with NodaTime, or null. - /// - private static string? ParseTimeZone(string tzinput) { - if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; - if (_tzNameMap.TryGetValue(tzinput, out var name)) return name; - return null; - } - - /// - /// Formats a user's name to a consistent, readable format which makes use of their nickname. - /// - private static string FormatName(SocketGuildUser user) { - static string escapeFormattingCharacters(string input) { - var result = new StringBuilder(); - foreach (var c in input) { - if (c is '\\' or '_' or '~' or '*' or '@') { - result.Append('\\'); - } - result.Append(c); - } - return result.ToString(); - } - - var username = escapeFormattingCharacters(user.Username); - if (user.Nickname != null) { - return $"**{escapeFormattingCharacters(user.Nickname)}** ({username}#{user.Discriminator})"; - } - return $"**{username}**#{user.Discriminator}"; - } - - /// - /// Checks if the given user can be considered a guild admin ('Manage Server' is set). - /// - private static bool IsUserAdmin(SocketGuildUser user) - => user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild; - - /// - /// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed. - /// - /// - /// Due to a quirk in Discord.Net, the user cache cannot be filled until the command handler is no longer executing, - /// regardless of if the request runs on its own thread. - /// - /// - /// True if the guild's members are already downloaded. If false, the command handler must notify the user. - /// - private static async Task AreUsersDownloadedAsync(SocketGuild guild) { - if (HasMostMembersDownloaded(guild)) return true; - else { - // Event handler hangs if awaited normally or used with Task.Run - await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); - return false; - } - } - - /// - /// An alternative to . - /// Returns true if *most* members have been downloaded. - /// Used as a workaround check due to Discord.Net occasionally unable to actually download all members. - /// - /// Copied directly from BirthdayBot. Try to coordinate changes between projects... - private static bool HasMostMembersDownloaded(SocketGuild guild) { - if (guild.HasAllMembers) return true; - if (guild.MemberCount > 30) { - // For guilds of size over 30, require 85% or more of the members to be known - // (26/30, 42/50, 255/300, etc) - return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85); - } else { - // For smaller guilds, fail if two or more members are missing - return guild.MemberCount - guild.DownloadedMemberCount <= 2; - } - } - #endregion -} diff --git a/WorldTime.cs b/WorldTime.cs index 0c47c95..47557c1 100644 --- a/WorldTime.cs +++ b/WorldTime.cs @@ -16,22 +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 CommandsText _commandsTxt; + private readonly CancellationTokenSource _statusCancel; private readonly IServiceProvider _services; - private readonly HashSet _aotUserDownloadChecked = new(); + private readonly BackgroundUserListLoad _bgFetch; internal Configuration Config { get; } internal DiscordShardedClient DiscordClient => _services.GetRequiredService(); @@ -47,7 +40,7 @@ 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 }; @@ -58,17 +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; - _commandsTxt = new CommandsText(this, _services); - // 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() { @@ -78,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) { } } @@ -159,9 +149,6 @@ internal class WorldTime : IDisposable { } private async Task DiscordClient_ShardReady(DiscordSocketClient arg) { - // TODO get rid of this eventually? or change it to something fun... - await arg.SetGameAsync("/help"); - #if !DEBUG // Update slash/interaction commands if (arg.ShardId == 0) { @@ -182,27 +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 - lock (_aotUserDownloadChecked) { - if (!_aotUserDownloadChecked.Add(channel.Guild.Id)) return; // ...once. Just once. Not all the time. - } - 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 diff --git a/WorldTime.csproj b/WorldTime.csproj index 3f2181c..886138b 100644 --- a/WorldTime.csproj +++ b/WorldTime.csproj @@ -11,18 +11,18 @@ - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + +