Rework member cache downloading

Fill it quicker when immediately needed, removing the need for a request list and all relating to it in the corresponding background service.

Additionally, updated usings, style, nullables in all affected files.
This commit is contained in:
Noi 2021-11-22 13:40:08 -08:00
parent 488ebfa163
commit 5e4d030467
8 changed files with 373 additions and 487 deletions

View file

@ -1,12 +1,6 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime; using NodaTime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
@ -46,7 +40,7 @@ class BirthdayRoleUpdate : BackgroundService {
/// </summary> /// </summary>
private static async Task ProcessGuildAsync(SocketGuild guild) { private static async Task ProcessGuildAsync(SocketGuild guild) {
// Load guild information - stop if local cache is unavailable. // Load guild information - stop if local cache is unavailable.
if (!Common.HasMostMembersDownloaded(guild)) return; if (!guild.HasAllMembers) return;
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return; if (gc == null) return;

View file

@ -1,40 +1,22 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
/// <summary> /// <summary>
/// Rather than use up unnecessary resources by auto-downloading the user list in -every- /// Proactively fills the user cache for guilds in which any birthday data already exists.
/// server we're in, this service checks if fetching the user list is warranted for each
/// guild before proceeding to request it.
/// </summary> /// </summary>
class SelectiveAutoUserDownload : BackgroundService { class SelectiveAutoUserDownload : BackgroundService {
private readonly HashSet<ulong> _fetchRequests = new();
public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { } public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { }
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
IEnumerable<ulong> requests;
lock (_fetchRequests) {
requests = _fetchRequests.ToArray();
_fetchRequests.Clear();
}
foreach (var guild in ShardInstance.DiscordClient.Guilds) { foreach (var guild in ShardInstance.DiscordClient.Guilds) {
// Has the potential to disconnect while in the middle of processing. // Has the potential to disconnect while in the middle of processing.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return; if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
// Determine if there is action to be taken... // Determine if there is action to be taken...
if (guild.HasAllMembers) continue; if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) {
if (requests.Contains(guild.Id) || await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) { await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run
await guild.DownloadUsersAsync().ConfigureAwait(false); await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
// Must delay after a download request. Seems to hang indefinitely otherwise.
await Task.Delay(300, CancellationToken.None).ConfigureAwait(false);
} }
} }
} }
@ -52,8 +34,4 @@ class SelectiveAutoUserDownload : BackgroundService {
using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false); using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false);
return r.Read(); return r.Read();
} }
public void RequestDownload(ulong guildId) {
lock (_fetchRequests) _fetchRequests.Add(guildId);
}
} }

View file

@ -5,7 +5,7 @@
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>3.2.2</Version> <Version>3.2.3</Version>
<Authors>NoiTheCat</Authors> <Authors>NoiTheCat</Authors>
</PropertyGroup> </PropertyGroup>

View file

@ -1,6 +1,4 @@
using Discord.WebSocket; using System.Text;
using System.Collections.Generic;
using System.Text;
namespace BirthdayBot; namespace BirthdayBot;
@ -33,21 +31,4 @@ static class Common {
{ 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" }, { 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" },
{ 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" } { 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" }
}; };
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/>.
/// Returns true if *most* members have been downloaded.
/// </summary>
public 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)
int threshold = (int)(guild.MemberCount * 0.85);
return guild.DownloadedMemberCount >= threshold;
} else {
// For smaller guilds, fail if two or more members are missing
return guild.MemberCount - guild.DownloadedMemberCount <= 2;
}
}
} }

View file

@ -80,8 +80,6 @@ class ShardInstance : IDisposable {
public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message);
public void RequestDownloadUsers(ulong guildId) => _background.UserDownloader.RequestDownload(guildId);
#region Event handling #region Event handling
private Task Client_Log(LogMessage arg) { private Task Client_Log(LogMessage arg) {
// TODO revise this some time, filters might need to be modified by now // TODO revise this some time, filters might need to be modified by now

View file

@ -1,18 +1,14 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime; using NodaTime;
using System; using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface namespace BirthdayBot.UserInterface;
{
/// <summary> /// <summary>
/// Common base class for common constants and variables. /// Common base class for common constants and variables.
/// </summary> /// </summary>
internal abstract class CommandsCommon internal abstract class CommandsCommon {
{
#if DEBUG #if DEBUG
public const string CommandPrefix = "bt."; public const string CommandPrefix = "bt.";
#else #else
@ -22,35 +18,27 @@ namespace BirthdayBot.UserInterface
public const string ParameterError = ":x: Invalid usage. Refer to how to use the command and try again."; public const string ParameterError = ":x: Invalid usage. Refer to how to use the command and try again.";
public const string NoParameterError = ":x: This command does not accept any parameters."; public const string NoParameterError = ":x: This command does not accept any parameters.";
public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue."; public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue.";
public const string UsersNotDownloadedError = ":eight_spoked_asterisk: Still catching up... Please try the command again in a few minutes."; public const string MemberCacheEmptyError = ":warning: Please try the command again.";
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf, public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
protected static Dictionary<string, string> TzNameMap { protected static ReadOnlyDictionary<string, string> TzNameMap { get; }
get {
// Because IDateTimeZoneProvider.GetZoneOrNull is not case sensitive:
// Getting every existing zone name and mapping it onto a dictionary. Now a case-insensitive
// search can be made with the accepted value retrieved as a result.
if (_tzNameMap == null)
{
_tzNameMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) _tzNameMap.Add(name, name);
}
return _tzNameMap;
}
}
protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>"); protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>");
protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>"); protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
private static Dictionary<string, string> _tzNameMap; // Value set by getter property on first read
protected Configuration BotConfig { get; } protected Configuration BotConfig { get; }
protected CommandsCommon(Configuration db) protected CommandsCommon(Configuration db) {
{
BotConfig = db; BotConfig = db;
} }
static CommandsCommon() {
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name);
TzNameMap = new(dict);
}
/// <summary> /// <summary>
/// On command dispatcher initialization, it will retrieve all available commands through here. /// On command dispatcher initialization, it will retrieve all available commands through here.
/// </summary> /// </summary>
@ -59,20 +47,10 @@ namespace BirthdayBot.UserInterface
/// <summary> /// <summary>
/// Checks given time zone input. Returns a valid string for use with NodaTime. /// Checks given time zone input. Returns a valid string for use with NodaTime.
/// </summary> /// </summary>
protected static string ParseTimeZone(string tzinput) protected static string ParseTimeZone(string tzinput) {
{
string tz = null;
if (tzinput != null)
{
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
if (!TzNameMap.TryGetValue(tzinput, out string? tz)) throw new FormatException(":x: Unexpected time zone name."
// Just check if the input exists in the map. Get the "true" value, or reject it altogether.
if (!TzNameMap.TryGetValue(tzinput, out tz))
{
throw new FormatException(":x: Unexpected time zone name."
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value."); + $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value.");
}
}
return tz; return tz;
} }
@ -80,8 +58,7 @@ namespace BirthdayBot.UserInterface
/// Given user input where a user-like parameter is expected, attempts to resolve to an ID value. /// Given user input where a user-like parameter is expected, attempts to resolve to an ID value.
/// Input must be a mention or explicit ID. No name resolution is done here. /// Input must be a mention or explicit ID. No name resolution is done here.
/// </summary> /// </summary>
protected static bool TryGetUserId(string input, out ulong result) protected static bool TryGetUserId(string input, out ulong result) {
{
string doParse; string doParse;
var m = UserMention.Match(input); var m = UserMention.Match(input);
if (m.Success) doParse = m.Groups[1].Value; if (m.Success) doParse = m.Groups[1].Value;
@ -95,5 +72,23 @@ namespace BirthdayBot.UserInterface
result = default; result = default;
return false; return false;
} }
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/> to be called by command handlers needing a full member cache.
/// Creates a download request if necessary.
/// </summary>
/// <returns>
/// True if the member cache is already filled, false otherwise.
/// </returns>
/// <remarks>
/// Any updates to the member cache aren't accessible until the event handler finishes execution, meaning proactive downloading
/// is necessary, and is handled by <seealso cref="BackgroundServices.SelectiveAutoUserDownload"/>. In situations where
/// this approach fails, this is to be called, and the user must be asked to attempt the command again if this returns false.
/// </remarks>
protected static async Task<bool> HasMemberCacheAsync(SocketGuild guild) {
if (guild.HasAllMembers) return true;
// Event handling thread hangs if awaited normally or used with Task.Run
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
return false;
} }
} }

View file

@ -1,19 +1,12 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface namespace BirthdayBot.UserInterface;
{
/// <summary> /// <summary>
/// Commands for listing upcoming and all birthdays. /// Commands for listing upcoming and all birthdays.
/// </summary> /// </summary>
internal class ListingCommands : CommandsCommon internal class ListingCommands : CommandsCommon {
{
public ListingCommands(Configuration db) : base(db) { } public ListingCommands(Configuration db) : base(db) { }
public override IEnumerable<(string, CommandHandler)> Commands public override IEnumerable<(string, CommandHandler)> Commands
@ -36,56 +29,45 @@ namespace BirthdayBot.UserInterface
#endregion #endregion
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf, private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
{ if (!await HasMemberCacheAsync(reqChannel.Guild)) {
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) await reqChannel.SendMessageAsync(MemberCacheEmptyError);
{
instance.RequestDownloadUsers(reqChannel.Guild.Id);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return; return;
} }
// Requires a parameter // Requires a parameter
if (param.Length == 1) if (param.Length == 1) {
{
await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return; return;
} }
var search = param[1]; var search = param[1];
if (param.Length == 3) if (param.Length == 3) {
{
// param maxes out at 3 values. param[2] might contain part of the search string (if name has a space) // param maxes out at 3 values. param[2] might contain part of the search string (if name has a space)
search += " " + param[2]; search += " " + param[2];
} }
SocketGuildUser searchTarget = null; SocketGuildUser? searchTarget = null;
if (!TryGetUserId(search, out ulong searchId)) // ID lookup if (!TryGetUserId(search, out ulong searchId)) // ID lookup
{ {
// name lookup without discriminator // name lookup without discriminator
foreach (var searchuser in reqChannel.Guild.Users) foreach (var searchuser in reqChannel.Guild.Users) {
{ if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase)) {
if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase))
{
searchTarget = searchuser; searchTarget = searchuser;
break; break;
} }
} }
} } else {
else
{
searchTarget = reqChannel.Guild.GetUser(searchId); searchTarget = reqChannel.Guild.GetUser(searchId);
} }
if (searchTarget == null) if (searchTarget == null) {
{
await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return; return;
} }
var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false); var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false);
if (!searchTargetData.IsKnown) if (!searchTargetData.IsKnown) {
{
await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false); await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false);
return; return;
} }
@ -100,37 +82,29 @@ namespace BirthdayBot.UserInterface
// Creates a file with all birthdays. // Creates a file with all birthdays.
private async Task CmdList(ShardInstance instance, GuildConfiguration gconf, private async Task CmdList(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
{
// For now, we're restricting this command to moderators only. This may turn into an option later. // For now, we're restricting this command to moderators only. This may turn into an option later.
if (!gconf.IsBotModerator(reqUser)) if (!gconf.IsBotModerator(reqUser)) {
{
// Do not add detailed usage information to this error message. // Do not add detailed usage information to this error message.
await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false); await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false);
return; return;
} }
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) if (!await HasMemberCacheAsync(reqChannel.Guild)) {
{ await reqChannel.SendMessageAsync(MemberCacheEmptyError);
instance.RequestDownloadUsers(reqChannel.Guild.Id);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return; return;
} }
bool useCsv = false; bool useCsv = false;
// Check for CSV option // Check for CSV option
if (param.Length == 2) if (param.Length == 2) {
{
if (param[1].ToLower() == "csv") useCsv = true; if (param[1].ToLower() == "csv") useCsv = true;
else else {
{
await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed) await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed)
.ConfigureAwait(false); .ConfigureAwait(false);
return; return;
} }
} } else if (param.Length > 2) {
else if (param.Length > 2)
{
await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false); await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false);
return; return;
} }
@ -139,34 +113,24 @@ namespace BirthdayBot.UserInterface
var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id; var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id;
string fileoutput; string fileoutput;
if (useCsv) if (useCsv) {
{
fileoutput = ListExportCsv(reqChannel, bdlist); fileoutput = ListExportCsv(reqChannel, bdlist);
filepath += ".csv"; filepath += ".csv";
} } else {
else
{
fileoutput = ListExportNormal(reqChannel, bdlist); fileoutput = ListExportNormal(reqChannel, bdlist);
filepath += ".txt."; filepath += ".txt.";
} }
await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false); await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false);
try try {
{
await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false); await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false);
} } catch (Discord.Net.HttpException) {
catch (Discord.Net.HttpException)
{
reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait(); reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
} } catch (Exception ex) {
catch (Exception ex)
{
Program.Log("Listing", ex.ToString()); Program.Log("Listing", ex.ToString());
reqChannel.SendMessageAsync(InternalError).Wait(); reqChannel.SendMessageAsync(InternalError).Wait();
// TODO webhook report // TODO webhook report
} } finally {
finally
{
File.Delete(filepath); File.Delete(filepath);
} }
} }
@ -174,12 +138,9 @@ namespace BirthdayBot.UserInterface
// "Recent and upcoming birthdays" // "Recent and upcoming birthdays"
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf, private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
{ if (!await HasMemberCacheAsync(reqChannel.Guild)) {
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) await reqChannel.SendMessageAsync(MemberCacheEmptyError);
{
instance.RequestDownloadUsers(reqChannel.Guild.Id);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return; return;
} }
@ -207,8 +168,7 @@ namespace BirthdayBot.UserInterface
// Build sorted name list // Build sorted name list
var names = new List<string>(); var names = new List<string>();
foreach (var item in results) foreach (var item in results) {
{
names.Add(item.DisplayName); names.Add(item.DisplayName);
} }
names.Sort(StringComparer.OrdinalIgnoreCase); names.Sort(StringComparer.OrdinalIgnoreCase);
@ -216,11 +176,9 @@ namespace BirthdayBot.UserInterface
var first = true; var first = true;
output.AppendLine(); output.AppendLine();
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
foreach (var item in names) foreach (var item in names) {
{
// If the output is starting to fill up, send out this message and prepare a new one. // If the output is starting to fill up, send out this message and prepare a new one.
if (output.Length > 800) if (output.Length > 800) {
{
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false); await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
output.Clear(); output.Clear();
first = true; first = true;
@ -245,8 +203,7 @@ namespace BirthdayBot.UserInterface
/// Fetches all guild birthdays and places them into an easily usable structure. /// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result. /// Users currently not in the guild are not included in the result.
/// </summary> /// </summary>
private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild) private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild) {
{
using var db = await Database.OpenConnectionAsync(); using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand(); using var c = db.CreateCommand();
c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable
@ -255,8 +212,7 @@ namespace BirthdayBot.UserInterface
c.Prepare(); c.Prepare();
using var r = await c.ExecuteReaderAsync(); using var r = await c.ExecuteReaderAsync();
var result = new List<ListItem>(); var result = new List<ListItem>();
while (await r.ReadAsync()) while (await r.ReadAsync()) {
{
var id = (ulong)r.GetInt64(0); var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1); var month = r.GetInt32(1);
var day = r.GetInt32(2); var day = r.GetInt32(2);
@ -264,8 +220,7 @@ namespace BirthdayBot.UserInterface
var guildUser = guild.GetUser(id); var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip user not in guild if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem() result.Add(new ListItem() {
{
BirthMonth = month, BirthMonth = month,
BirthDay = day, BirthDay = day,
DateIndex = DateIndex(month, day), DateIndex = DateIndex(month, day),
@ -276,14 +231,12 @@ namespace BirthdayBot.UserInterface
return result; return result;
} }
private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) {
{
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" // Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]"
var result = new StringBuilder(); var result = new StringBuilder();
result.AppendLine("Birthdays in " + channel.Guild.Name); result.AppendLine("Birthdays in " + channel.Guild.Name);
result.AppendLine(); result.AppendLine();
foreach (var item in list) foreach (var item in list) {
{
var user = channel.Guild.GetUser(item.UserId); var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: "); result.Append($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: ");
@ -295,16 +248,14 @@ namespace BirthdayBot.UserInterface
return result.ToString(); return result.ToString();
} }
private string ListExportCsv(SocketGuildChannel channel, IEnumerable<ListItem> list) private string ListExportCsv(SocketGuildChannel channel, IEnumerable<ListItem> list) {
{
// Output: User ID, Username, Nickname, Month-Day, Month, Day // Output: User ID, Username, Nickname, Month-Day, Month, Day
var result = new StringBuilder(); var result = new StringBuilder();
// Conforming to RFC 4180; with header // Conforming to RFC 4180; with header
result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day"); result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day");
result.Append("\r\n"); // crlf line break is specified by the standard result.Append("\r\n"); // crlf line break is specified by the standard
foreach (var item in list) foreach (var item in list) {
{
var user = channel.Guild.GetUser(item.UserId); var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append(item.UserId); result.Append(item.UserId);
@ -323,12 +274,10 @@ namespace BirthdayBot.UserInterface
return result.ToString(); return result.ToString();
} }
private static string CsvEscape(string input) private static string CsvEscape(string input) {
{
var result = new StringBuilder(); var result = new StringBuilder();
result.Append('"'); result.Append('"');
foreach (var ch in input) foreach (var ch in input) {
{
if (ch == '"') result.Append('"'); if (ch == '"') result.Append('"');
result.Append(ch); result.Append(ch);
} }
@ -336,8 +285,7 @@ namespace BirthdayBot.UserInterface
return result.ToString(); return result.ToString();
} }
private static int DateIndex(int month, int day) private static int DateIndex(int month, int day) {
{
var dateindex = 0; var dateindex = 0;
// Add month offsets // Add month offsets
if (month > 1) dateindex += 31; // Offset January if (month > 1) dateindex += 31; // Offset January
@ -355,8 +303,7 @@ namespace BirthdayBot.UserInterface
return dateindex; return dateindex;
} }
private struct ListItem private struct ListItem {
{
public int DateIndex; public int DateIndex;
public int BirthMonth; public int BirthMonth;
public int BirthDay; public int BirthDay;
@ -364,4 +311,3 @@ namespace BirthdayBot.UserInterface
public string DisplayName; public string DisplayName;
} }
} }
}

View file

@ -1,11 +1,6 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface; namespace BirthdayBot.UserInterface;
@ -327,9 +322,8 @@ internal class ManagerCommands : CommandsCommon {
// Moderators only. As with config, silently drop if this check fails. // Moderators only. As with config, silently drop if this check fails.
if (!gconf.IsBotModerator(reqUser)) return; if (!gconf.IsBotModerator(reqUser)) return;
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) { if (!await HasMemberCacheAsync(reqChannel.Guild)) {
instance.RequestDownloadUsers(reqChannel.Guild.Id); await reqChannel.SendMessageAsync(MemberCacheEmptyError);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return; return;
} }
@ -393,7 +387,7 @@ internal class ManagerCommands : CommandsCommon {
var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
result.AppendLine($"Server ID: {guild.Id} | Bot shard ID: {instance.ShardId:00}"); result.AppendLine($"Server ID: {guild.Id} | Bot shard ID: {instance.ShardId:00}");
bool hasMembers = Common.HasMostMembersDownloaded(guild); bool hasMembers = guild.HasAllMembers;
result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers)); result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers));
result.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members."); result.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members.");
int bdayCount = -1; int bdayCount = -1;