diff --git a/Commands.cs b/Commands.cs index 08718f3..4a4bfb8 100644 --- a/Commands.cs +++ b/Commands.cs @@ -15,6 +15,7 @@ internal class Commands { const string ErrInvalidZone = ":x: Not a valid zone name." + " To find your time zone, refer to: ."; const string ErrTargetUserNotFound = ":x: Unable to find the target user."; + const string ErrNoUserCache = ":warning: Please try the command again."; const int MaxSingleLineLength = 750; const int MaxSingleOutputLength = 900; @@ -102,6 +103,11 @@ internal class Commands { } 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 @@ -206,6 +212,11 @@ internal class Commands { 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); @@ -236,6 +247,11 @@ internal class Commands { 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); @@ -304,9 +320,6 @@ internal class Commands { /// Given parameter input, attempts to find the corresponding SocketGuildUser. /// private static SocketGuildUser? ResolveUserParameter(SocketGuild guild, string input) { - // if (!guild.HasAllMembers) await guild.DownloadUsersAsync().ConfigureAwait(false); - // TODO selective user downloading - see here when implementing - // Try interpreting as ID var match = _userMention.Match(input); string idcheckstr = match.Success ? match.Groups[1].Value : input; @@ -333,5 +346,24 @@ internal class Commands { return null; } + + /// + /// 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 (guild.HasAllMembers) return true; + else { + // Event handler hangs if awaited normally or used with Task.Run + await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); + return false; + } + } #endregion } diff --git a/Database.cs b/Database.cs index 73db51c..cbb0a7b 100644 --- a/Database.cs +++ b/Database.cs @@ -8,6 +8,7 @@ namespace WorldTime; /// internal class Database { private const string UserDatabase = "userdata"; + private const string CutoffInterval = "INTERVAL '30 days'"; // TODO make configurable? private readonly string _connectionString; @@ -38,6 +39,25 @@ internal class Database { await c.ExecuteNonQueryAsync().ConfigureAwait(false); } + /// + /// Checks if a given guild contains at least one user data entry with recent enough activity. + /// + public async Task HasAnyAsync(SocketGuild guild) { + using var db = await OpenConnectionAsync().ConfigureAwait(false); + using var c = db.CreateCommand(); + c.CommandText = $@" +SELECT true FROM {UserDatabase} +WHERE + guild_id = @Gid + AND last_active >= now() - {CutoffInterval} +LIMIT 1 +"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guild.Id; + await c.PrepareAsync().ConfigureAwait(false); + using var r = await c.ExecuteReaderAsync().ConfigureAwait(false); + return await r.ReadAsync().ConfigureAwait(false); + } + /// /// Gets the number of unique time zones in the database. /// @@ -51,7 +71,8 @@ internal class Database { /// /// Updates the last activity field for the specified guild user, if existing in the database. /// - public async Task UpdateLastActivityAsync(SocketGuildUser user) { + /// True if a value was updated, implying that the specified user exists in the database. + public async Task UpdateLastActivityAsync(SocketGuildUser user) { using var db = await OpenConnectionAsync().ConfigureAwait(false); using var c = db.CreateCommand(); c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " + @@ -59,7 +80,7 @@ internal class Database { c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id; c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; await c.PrepareAsync().ConfigureAwait(false); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); + return await c.ExecuteNonQueryAsync().ConfigureAwait(false) > 0; } // TODO remove data from users with very distant last activity. how long ago? @@ -124,7 +145,7 @@ internal class Database { SELECT zone, user_id FROM {UserDatabase} WHERE guild_id = @Gid - AND last_active >= now() - INTERVAL '30 days' -- TODO make configurable? + AND last_active >= now() - {CutoffInterval} ORDER BY RANDOM() -- Randomize results for display purposes"; c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; await c.PrepareAsync().ConfigureAwait(false); diff --git a/WorldTime.cs b/WorldTime.cs index 756ddb1..789247f 100644 --- a/WorldTime.cs +++ b/WorldTime.cs @@ -44,7 +44,6 @@ internal class WorldTime : IDisposable { LogLevel = LogSeverity.Info, DefaultRetryMode = RetryMode.RetryRatelimit, MessageCacheSize = 0, // disable message cache - AlwaysDownloadUsers = true, GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages }); DiscordClient.Log += DiscordClient_Log; @@ -150,7 +149,14 @@ internal class WorldTime : IDisposable { private Task DiscordClient_ShardReady(DiscordSocketClient arg) => arg.SetGameAsync(Commands.CommandPrefix + "help"); + /// + /// 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; + /* * From https://support-dev.discord.com/hc/en-us/articles/4404772028055: * "You will still receive the events and can call the same APIs, and you'll get other data about a message like @@ -159,13 +165,17 @@ internal class WorldTime : IDisposable { * * Assuming this stays true, it will be possible to maintain legacy behavior after this bot loses full access to incoming messages. */ - + // Attempt to update user's last_seen column // POTENTIAL BUG: If user does a list command, the list may be processed before their own time's refreshed, and they may be skipped. - if (message.Author.IsWebhook) return; - if (message.Type != MessageType.Default) return; - if (message.Channel is not SocketTextChannel) return; + var hasMemberHint = await Database.UpdateLastActivityAsync((SocketGuildUser)message.Author).ConfigureAwait(false); - await Database.UpdateLastActivityAsync((SocketGuildUser)message.Author).ConfigureAwait(false); + // 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 + var guild = channel.Guild; + if (!guild.HasAllMembers && (hasMemberHint || await Database.HasAnyAsync(guild).ConfigureAwait(false))) { + // Event handler hangs if awaited normally or used with Task.Run + await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); + } } #endregion }