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
}