Add proactive selective auto user downloading

This amounts to being a workaround, as Discord.Net's option is still broken for large bots.
This commit is contained in:
Noi 2021-11-07 23:26:19 -08:00
parent 13e48c91b3
commit ad756ae83b
3 changed files with 75 additions and 12 deletions

View file

@ -15,6 +15,7 @@ internal class Commands {
const string ErrInvalidZone = ":x: Not a valid zone name." const string ErrInvalidZone = ":x: Not a valid zone name."
+ " To find your time zone, refer to: <https://kevinnovak.github.io/Time-Zone-Picker/>."; + " To find your time zone, refer to: <https://kevinnovak.github.io/Time-Zone-Picker/>.";
const string ErrTargetUserNotFound = ":x: Unable to find the target user."; 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 MaxSingleLineLength = 750;
const int MaxSingleOutputLength = 900; const int MaxSingleOutputLength = 900;
@ -102,6 +103,11 @@ internal class Commands {
} }
private async Task CmdList(SocketTextChannel channel, SocketGuildUser sender, SocketMessage message) { 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); var wspl = message.Content.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (wspl.Length == 2) { if (wspl.Length == 2) {
// Has parameter - do specific user lookup // 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); await channel.SendMessageAsync(":x: You must specify a time zone to apply to the user.").ConfigureAwait(false);
return; return;
} }
if (!await AreUsersDownloadedAsync(channel.Guild).ConfigureAwait(false)) {
await channel.SendMessageAsync(ErrNoUserCache).ConfigureAwait(false);
return;
}
var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); var targetuser = ResolveUserParameter(channel.Guild, wspl[1]);
if (targetuser == null) { if (targetuser == null) {
await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); 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); await channel.SendMessageAsync(":x: You must specify a user for whom to remove time zone data.").ConfigureAwait(false);
return; return;
} }
if (!await AreUsersDownloadedAsync(channel.Guild).ConfigureAwait(false)) {
await channel.SendMessageAsync(ErrNoUserCache).ConfigureAwait(false);
return;
}
var targetuser = ResolveUserParameter(channel.Guild, wspl[1]); var targetuser = ResolveUserParameter(channel.Guild, wspl[1]);
if (targetuser == null) { if (targetuser == null) {
await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false); await channel.SendMessageAsync(ErrTargetUserNotFound).ConfigureAwait(false);
@ -304,9 +320,6 @@ internal class Commands {
/// Given parameter input, attempts to find the corresponding SocketGuildUser. /// Given parameter input, attempts to find the corresponding SocketGuildUser.
/// </summary> /// </summary>
private static SocketGuildUser? ResolveUserParameter(SocketGuild guild, string input) { 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 // Try interpreting as ID
var match = _userMention.Match(input); var match = _userMention.Match(input);
string idcheckstr = match.Success ? match.Groups[1].Value : input; string idcheckstr = match.Success ? match.Groups[1].Value : input;
@ -333,5 +346,24 @@ internal class Commands {
return null; return null;
} }
/// <summary>
/// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>
/// True if the guild's members are already downloaded. If false, the command handler must notify the user.
/// </returns>
private static async Task<bool> 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 #endregion
} }

View file

@ -8,6 +8,7 @@ namespace WorldTime;
/// </summary> /// </summary>
internal class Database { internal class Database {
private const string UserDatabase = "userdata"; private const string UserDatabase = "userdata";
private const string CutoffInterval = "INTERVAL '30 days'"; // TODO make configurable?
private readonly string _connectionString; private readonly string _connectionString;
@ -38,6 +39,25 @@ internal class Database {
await c.ExecuteNonQueryAsync().ConfigureAwait(false); await c.ExecuteNonQueryAsync().ConfigureAwait(false);
} }
/// <summary>
/// Checks if a given guild contains at least one user data entry with recent enough activity.
/// </summary>
public async Task<bool> 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);
}
/// <summary> /// <summary>
/// Gets the number of unique time zones in the database. /// Gets the number of unique time zones in the database.
/// </summary> /// </summary>
@ -51,7 +71,8 @@ internal class Database {
/// <summary> /// <summary>
/// Updates the last activity field for the specified guild user, if existing in the database. /// Updates the last activity field for the specified guild user, if existing in the database.
/// </summary> /// </summary>
public async Task UpdateLastActivityAsync(SocketGuildUser user) { /// <returns>True if a value was updated, implying that the specified user exists in the database.</returns>
public async Task<bool> UpdateLastActivityAsync(SocketGuildUser user) {
using var db = await OpenConnectionAsync().ConfigureAwait(false); using var db = await OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand(); using var c = db.CreateCommand();
c.CommandText = $"UPDATE {UserDatabase} SET last_active = now() " + 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("@Gid", NpgsqlDbType.Bigint).Value = (long)user.Guild.Id;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id; c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)user.Id;
await c.PrepareAsync().ConfigureAwait(false); 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? // 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} SELECT zone, user_id FROM {UserDatabase}
WHERE WHERE
guild_id = @Gid 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"; ORDER BY RANDOM() -- Randomize results for display purposes";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
await c.PrepareAsync().ConfigureAwait(false); await c.PrepareAsync().ConfigureAwait(false);

View file

@ -44,7 +44,6 @@ internal class WorldTime : IDisposable {
LogLevel = LogSeverity.Info, LogLevel = LogSeverity.Info,
DefaultRetryMode = RetryMode.RetryRatelimit, DefaultRetryMode = RetryMode.RetryRatelimit,
MessageCacheSize = 0, // disable message cache MessageCacheSize = 0, // disable message cache
AlwaysDownloadUsers = true,
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
}); });
DiscordClient.Log += DiscordClient_Log; DiscordClient.Log += DiscordClient_Log;
@ -150,7 +149,14 @@ internal class WorldTime : IDisposable {
private Task DiscordClient_ShardReady(DiscordSocketClient arg) => arg.SetGameAsync(Commands.CommandPrefix + "help"); private Task DiscordClient_ShardReady(DiscordSocketClient arg) => arg.SetGameAsync(Commands.CommandPrefix + "help");
/// <summary>
/// Non-specific handler for incoming events.
/// </summary>
private async Task DiscordClient_MessageReceived(SocketMessage message) { 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: * 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 * "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. * 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. // 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; var hasMemberHint = await Database.UpdateLastActivityAsync((SocketGuildUser)message.Author).ConfigureAwait(false);
if (message.Type != MessageType.Default) return;
if (message.Channel is not SocketTextChannel) return;
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 #endregion
} }