diff --git a/BackgroundServices/BirthdayRoleUpdate.cs b/BackgroundServices/BirthdayRoleUpdate.cs index 26324a0..51e42c5 100644 --- a/BackgroundServices/BirthdayRoleUpdate.cs +++ b/BackgroundServices/BirthdayRoleUpdate.cs @@ -1,12 +1,6 @@ using BirthdayBot.Data; -using Discord.WebSocket; using NodaTime; -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace BirthdayBot.BackgroundServices; @@ -46,7 +40,7 @@ class BirthdayRoleUpdate : BackgroundService { /// private static async Task ProcessGuildAsync(SocketGuild guild) { // 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); if (gc == null) return; diff --git a/BackgroundServices/SelectiveAutoUserDownload.cs b/BackgroundServices/SelectiveAutoUserDownload.cs index aabe069..a042973 100644 --- a/BackgroundServices/SelectiveAutoUserDownload.cs +++ b/BackgroundServices/SelectiveAutoUserDownload.cs @@ -1,40 +1,22 @@ using BirthdayBot.Data; -using Discord; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace BirthdayBot.BackgroundServices; /// -/// Rather than use up unnecessary resources by auto-downloading the user list in -every- -/// server we're in, this service checks if fetching the user list is warranted for each -/// guild before proceeding to request it. +/// Proactively fills the user cache for guilds in which any birthday data already exists. /// class SelectiveAutoUserDownload : BackgroundService { - private readonly HashSet _fetchRequests = new(); - public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { } public override async Task OnTick(int tickCount, CancellationToken token) { - IEnumerable requests; - lock (_fetchRequests) { - requests = _fetchRequests.ToArray(); - _fetchRequests.Clear(); - } - foreach (var guild in ShardInstance.DiscordClient.Guilds) { // Has the potential to disconnect while in the middle of processing. if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return; // Determine if there is action to be taken... - if (guild.HasAllMembers) continue; - if (requests.Contains(guild.Id) || await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) { - await guild.DownloadUsersAsync().ConfigureAwait(false); - // Must delay after a download request. Seems to hang indefinitely otherwise. - await Task.Delay(300, CancellationToken.None).ConfigureAwait(false); + if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) { + await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run + await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang... } } } @@ -52,8 +34,4 @@ class SelectiveAutoUserDownload : BackgroundService { using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false); return r.Read(); } - - public void RequestDownload(ulong guildId) { - lock (_fetchRequests) _fetchRequests.Add(guildId); - } } diff --git a/BirthdayBot.csproj b/BirthdayBot.csproj index 7a05da7..52f6a69 100644 --- a/BirthdayBot.csproj +++ b/BirthdayBot.csproj @@ -5,7 +5,7 @@ net6.0 enable enable - 3.2.2 + 3.2.3 NoiTheCat diff --git a/Common.cs b/Common.cs index 3136a51..a9a741a 100644 --- a/Common.cs +++ b/Common.cs @@ -1,6 +1,4 @@ -using Discord.WebSocket; -using System.Collections.Generic; -using System.Text; +using System.Text; namespace BirthdayBot; @@ -33,21 +31,4 @@ static class Common { { 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" }, { 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" } }; - - /// - /// An alternative to . - /// Returns true if *most* members have been downloaded. - /// - 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; - } - } } diff --git a/ShardInstance.cs b/ShardInstance.cs index e69aa6f..449a58b 100644 --- a/ShardInstance.cs +++ b/ShardInstance.cs @@ -80,8 +80,6 @@ class ShardInstance : IDisposable { 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 private Task Client_Log(LogMessage arg) { // TODO revise this some time, filters might need to be modified by now diff --git a/UserInterface/CommandsCommon.cs b/UserInterface/CommandsCommon.cs index 6b7800d..93ab296 100644 --- a/UserInterface/CommandsCommon.cs +++ b/UserInterface/CommandsCommon.cs @@ -1,99 +1,94 @@ using BirthdayBot.Data; -using Discord.WebSocket; using NodaTime; -using System; -using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Text.RegularExpressions; -using System.Threading.Tasks; -namespace BirthdayBot.UserInterface -{ - /// - /// Common base class for common constants and variables. - /// - internal abstract class CommandsCommon - { +namespace BirthdayBot.UserInterface; + +/// +/// Common base class for common constants and variables. +/// +internal abstract class CommandsCommon { #if DEBUG - public const string CommandPrefix = "bt."; + public const string CommandPrefix = "bt."; #else public const string CommandPrefix = "bb."; #endif - public const string BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID."; - 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 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 BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID."; + 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 InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue."; + public const string MemberCacheEmptyError = ":warning: Please try the command again."; - public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); + public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf, + string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); - protected static Dictionary TzNameMap { - 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(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 UserMention { get; } = new Regex(@"\!?(\d+)>"); - private static Dictionary _tzNameMap; // Value set by getter property on first read + protected static ReadOnlyDictionary TzNameMap { get; } + protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>"); + protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>"); - protected Configuration BotConfig { get; } + protected Configuration BotConfig { get; } - protected CommandsCommon(Configuration db) - { - BotConfig = db; + protected CommandsCommon(Configuration db) { + BotConfig = db; + } + + static CommandsCommon() { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name); + TzNameMap = new(dict); + } + + /// + /// On command dispatcher initialization, it will retrieve all available commands through here. + /// + public abstract IEnumerable<(string, CommandHandler)> Commands { get; } + + /// + /// Checks given time zone input. Returns a valid string for use with NodaTime. + /// + protected static string ParseTimeZone(string tzinput) { + 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." + + $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value."); + return tz; + } + + /// + /// 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. + /// + protected static bool TryGetUserId(string input, out ulong result) { + string doParse; + var m = UserMention.Match(input); + if (m.Success) doParse = m.Groups[1].Value; + else doParse = input; + + if (ulong.TryParse(doParse, out ulong resultVal)) { + result = resultVal; + return true; } - /// - /// On command dispatcher initialization, it will retrieve all available commands through here. - /// - public abstract IEnumerable<(string, CommandHandler)> Commands { get; } + result = default; + return false; + } - /// - /// Checks given time zone input. Returns a valid string for use with NodaTime. - /// - protected static string ParseTimeZone(string tzinput) - { - string tz = null; - if (tzinput != null) - { - if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; - - // 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."); - } - } - return tz; - } - - /// - /// 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. - /// - protected static bool TryGetUserId(string input, out ulong result) - { - string doParse; - var m = UserMention.Match(input); - if (m.Success) doParse = m.Groups[1].Value; - else doParse = input; - - if (ulong.TryParse(doParse, out ulong resultVal)) { - result = resultVal; - return true; - } - - result = default; - return false; - } + /// + /// An alternative to to be called by command handlers needing a full member cache. + /// Creates a download request if necessary. + /// + /// + /// True if the member cache is already filled, false otherwise. + /// + /// + /// Any updates to the member cache aren't accessible until the event handler finishes execution, meaning proactive downloading + /// is necessary, and is handled by . 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. + /// + protected static async Task 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; } } diff --git a/UserInterface/ListingCommands.cs b/UserInterface/ListingCommands.cs index 0861569..c8210ef 100644 --- a/UserInterface/ListingCommands.cs +++ b/UserInterface/ListingCommands.cs @@ -1,367 +1,313 @@ using BirthdayBot.Data; -using Discord.WebSocket; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Text; -using System.Threading.Tasks; -namespace BirthdayBot.UserInterface -{ - /// - /// Commands for listing upcoming and all birthdays. - /// - internal class ListingCommands : CommandsCommon - { - public ListingCommands(Configuration db) : base(db) { } +namespace BirthdayBot.UserInterface; - public override IEnumerable<(string, CommandHandler)> Commands - => new List<(string, CommandHandler)>() - { +/// +/// Commands for listing upcoming and all birthdays. +/// +internal class ListingCommands : CommandsCommon { + public ListingCommands(Configuration db) : base(db) { } + + public override IEnumerable<(string, CommandHandler)> Commands + => new List<(string, CommandHandler)>() + { ("list", CmdList), ("upcoming", CmdUpcoming), ("recent", CmdUpcoming), ("when", CmdWhen) - }; + }; - #region Documentation - public static readonly CommandDocumentation DocList = - new(new string[] { "list" }, "Exports all birthdays to a file." - + " Accepts `csv` as an optional parameter.", null); - public static readonly CommandDocumentation DocUpcoming = - new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null); - public static readonly CommandDocumentation DocWhen = - new(new string[] { "when" }, "Displays the given user's birthday information.", null); - #endregion + #region Documentation + public static readonly CommandDocumentation DocList = + new(new string[] { "list" }, "Exports all birthdays to a file." + + " Accepts `csv` as an optional parameter.", null); + public static readonly CommandDocumentation DocUpcoming = + new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null); + public static readonly CommandDocumentation DocWhen = + new(new string[] { "when" }, "Displays the given user's birthday information.", null); + #endregion - private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) - { - if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) - { - instance.RequestDownloadUsers(reqChannel.Guild.Id); - await reqChannel.SendMessageAsync(UsersNotDownloadedError); - return; - } - - // Requires a parameter - if (param.Length == 1) - { - await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); - return; - } - - var search = param[1]; - if (param.Length == 3) - { - // param maxes out at 3 values. param[2] might contain part of the search string (if name has a space) - search += " " + param[2]; - } - - SocketGuildUser searchTarget = null; - - if (!TryGetUserId(search, out ulong searchId)) // ID lookup - { - // name lookup without discriminator - foreach (var searchuser in reqChannel.Guild.Users) - { - if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase)) - { - searchTarget = searchuser; - break; - } - } - } - else - { - searchTarget = reqChannel.Guild.GetUser(searchId); - } - if (searchTarget == null) - { - await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); - return; - } - - var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false); - if (!searchTargetData.IsKnown) - { - await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false); - return; - } - - string result = Common.FormatName(searchTarget, false); - result += ": "; - result += $"`{searchTargetData.BirthDay:00}-{Common.MonthNames[searchTargetData.BirthMonth]}`"; - result += searchTargetData.TimeZone == null ? "" : $" - `{searchTargetData.TimeZone}`"; - - await reqChannel.SendMessageAsync(result).ConfigureAwait(false); + private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf, + string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { + if (!await HasMemberCacheAsync(reqChannel.Guild)) { + await reqChannel.SendMessageAsync(MemberCacheEmptyError); + return; } - // Creates a file with all birthdays. - private async Task CmdList(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) - { - // For now, we're restricting this command to moderators only. This may turn into an option later. - if (!gconf.IsBotModerator(reqUser)) - { - // Do not add detailed usage information to this error message. - await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false); - return; - } - - if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) - { - instance.RequestDownloadUsers(reqChannel.Guild.Id); - await reqChannel.SendMessageAsync(UsersNotDownloadedError); - return; - } - - bool useCsv = false; - // Check for CSV option - if (param.Length == 2) - { - if (param[1].ToLower() == "csv") useCsv = true; - else - { - await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed) - .ConfigureAwait(false); - return; - } - } - else if (param.Length > 2) - { - await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false); - return; - } - - var bdlist = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false); - - var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id; - string fileoutput; - if (useCsv) - { - fileoutput = ListExportCsv(reqChannel, bdlist); - filepath += ".csv"; - } - else - { - fileoutput = ListExportNormal(reqChannel, bdlist); - filepath += ".txt."; - } - await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false); - - try - { - await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false); - } - catch (Discord.Net.HttpException) - { - reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait(); - } - catch (Exception ex) - { - Program.Log("Listing", ex.ToString()); - reqChannel.SendMessageAsync(InternalError).Wait(); - // TODO webhook report - } - finally - { - File.Delete(filepath); - } + // Requires a parameter + if (param.Length == 1) { + await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); + return; } - // "Recent and upcoming birthdays" - // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here - private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + var search = param[1]; + if (param.Length == 3) { + // param maxes out at 3 values. param[2] might contain part of the search string (if name has a space) + search += " " + param[2]; + } + + SocketGuildUser? searchTarget = null; + + if (!TryGetUserId(search, out ulong searchId)) // ID lookup { - if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) - { - instance.RequestDownloadUsers(reqChannel.Guild.Id); - await reqChannel.SendMessageAsync(UsersNotDownloadedError); - return; - } - - var now = DateTimeOffset.UtcNow; - var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC - if (search <= 0) search = 366 - Math.Abs(search); - - var query = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false); - - var output = new StringBuilder(); - var resultCount = 0; - output.AppendLine("Recent and upcoming birthdays:"); - for (int count = 0; count <= 21; count++) // cover 21 days total (7 prior, current day, 14 upcoming) - { - var results = from item in query - where item.DateIndex == search - select item; - - // push up search by 1 now, in case we back out early - search += 1; - if (search > 366) search = 1; // wrap to beginning of year - - if (!results.Any()) continue; // back out early - resultCount += results.Count(); - - // Build sorted name list - var names = new List(); - foreach (var item in results) - { - names.Add(item.DisplayName); - } - names.Sort(StringComparer.OrdinalIgnoreCase); - - var first = true; - output.AppendLine(); - output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); - foreach (var item in names) - { - // If the output is starting to fill up, send out this message and prepare a new one. - if (output.Length > 800) - { - await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false); - output.Clear(); - first = true; - output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); - } - - if (first) first = false; - else output.Append(", "); - output.Append(item); + // name lookup without discriminator + foreach (var searchuser in reqChannel.Guild.Users) { + if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase)) { + searchTarget = searchuser; + break; } } + } else { + searchTarget = reqChannel.Guild.GetUser(searchId); + } + if (searchTarget == null) { + await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false); + return; + } - if (resultCount == 0) - await reqChannel.SendMessageAsync( - "There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).") + var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false); + if (!searchTargetData.IsKnown) { + await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false); + return; + } + + string result = Common.FormatName(searchTarget, false); + result += ": "; + result += $"`{searchTargetData.BirthDay:00}-{Common.MonthNames[searchTargetData.BirthMonth]}`"; + result += searchTargetData.TimeZone == null ? "" : $" - `{searchTargetData.TimeZone}`"; + + await reqChannel.SendMessageAsync(result).ConfigureAwait(false); + } + + // Creates a file with all birthdays. + private async Task CmdList(ShardInstance instance, GuildConfiguration gconf, + string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { + // For now, we're restricting this command to moderators only. This may turn into an option later. + if (!gconf.IsBotModerator(reqUser)) { + // Do not add detailed usage information to this error message. + await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false); + return; + } + + if (!await HasMemberCacheAsync(reqChannel.Guild)) { + await reqChannel.SendMessageAsync(MemberCacheEmptyError); + return; + } + + bool useCsv = false; + // Check for CSV option + if (param.Length == 2) { + if (param[1].ToLower() == "csv") useCsv = true; + else { + await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed) .ConfigureAwait(false); - else - await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false); - } - - /// - /// Fetches all guild birthdays and places them into an easily usable structure. - /// Users currently not in the guild are not included in the result. - /// - private static async Task> GetSortedUsersAsync(SocketGuild guild) - { - using var db = await Database.OpenConnectionAsync(); - using var c = db.CreateCommand(); - c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable - + " where guild_id = @Gid order by birth_month, birth_day"; - c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id; - c.Prepare(); - using var r = await c.ExecuteReaderAsync(); - var result = new List(); - while (await r.ReadAsync()) - { - var id = (ulong)r.GetInt64(0); - var month = r.GetInt32(1); - var day = r.GetInt32(2); - - var guildUser = guild.GetUser(id); - if (guildUser == null) continue; // Skip user not in guild - - result.Add(new ListItem() - { - BirthMonth = month, - BirthDay = day, - DateIndex = DateIndex(month, day), - UserId = guildUser.Id, - DisplayName = Common.FormatName(guildUser, false) - }); + return; } - return result; + } else if (param.Length > 2) { + await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false); + return; } - private string ListExportNormal(SocketGuildChannel channel, IEnumerable list) - { - // Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" - var result = new StringBuilder(); - result.AppendLine("Birthdays in " + channel.Guild.Name); - result.AppendLine(); - foreach (var item in list) - { - var user = channel.Guild.GetUser(item.UserId); - 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(item.UserId); - result.Append(" " + user.Username + "#" + user.Discriminator); - if (user.Nickname != null) result.Append(" - Nickname: " + user.Nickname); - result.AppendLine(); - } - return result.ToString(); + var bdlist = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false); + + var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id; + string fileoutput; + if (useCsv) { + fileoutput = ListExportCsv(reqChannel, bdlist); + filepath += ".csv"; + } else { + fileoutput = ListExportNormal(reqChannel, bdlist); + filepath += ".txt."; } + await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false); - private string ListExportCsv(SocketGuildChannel channel, IEnumerable list) - { - // Output: User ID, Username, Nickname, Month-Day, Month, Day - var result = new StringBuilder(); - - // Conforming to RFC 4180; with header - result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day"); - result.Append("\r\n"); // crlf line break is specified by the standard - foreach (var item in list) - { - var user = channel.Guild.GetUser(item.UserId); - if (user == null) continue; // User disappeared in the instant between getting list and processing - result.Append(item.UserId); - result.Append(','); - result.Append(CsvEscape(user.Username + "#" + user.Discriminator)); - result.Append(','); - if (user.Nickname != null) result.Append(user.Nickname); - result.Append(','); - result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}"); - result.Append(','); - result.Append(item.BirthMonth); - result.Append(','); - result.Append(item.BirthDay); - result.Append("\r\n"); - } - return result.ToString(); - } - - private static string CsvEscape(string input) - { - var result = new StringBuilder(); - result.Append('"'); - foreach (var ch in input) - { - if (ch == '"') result.Append('"'); - result.Append(ch); - } - result.Append('"'); - return result.ToString(); - } - - private static int DateIndex(int month, int day) - { - var dateindex = 0; - // Add month offsets - if (month > 1) dateindex += 31; // Offset January - if (month > 2) dateindex += 29; // Offset February (incl. leap day) - if (month > 3) dateindex += 31; // etc - if (month > 4) dateindex += 30; - if (month > 5) dateindex += 31; - if (month > 6) dateindex += 30; - if (month > 7) dateindex += 31; - if (month > 8) dateindex += 31; - if (month > 9) dateindex += 30; - if (month > 10) dateindex += 31; - if (month > 11) dateindex += 30; - dateindex += day; - return dateindex; - } - - private struct ListItem - { - public int DateIndex; - public int BirthMonth; - public int BirthDay; - public ulong UserId; - public string DisplayName; + try { + await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false); + } catch (Discord.Net.HttpException) { + reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait(); + } catch (Exception ex) { + Program.Log("Listing", ex.ToString()); + reqChannel.SendMessageAsync(InternalError).Wait(); + // TODO webhook report + } finally { + File.Delete(filepath); } } + + // "Recent and upcoming birthdays" + // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here + private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf, + string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { + if (!await HasMemberCacheAsync(reqChannel.Guild)) { + await reqChannel.SendMessageAsync(MemberCacheEmptyError); + return; + } + + var now = DateTimeOffset.UtcNow; + var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC + if (search <= 0) search = 366 - Math.Abs(search); + + var query = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false); + + var output = new StringBuilder(); + var resultCount = 0; + output.AppendLine("Recent and upcoming birthdays:"); + for (int count = 0; count <= 21; count++) // cover 21 days total (7 prior, current day, 14 upcoming) + { + var results = from item in query + where item.DateIndex == search + select item; + + // push up search by 1 now, in case we back out early + search += 1; + if (search > 366) search = 1; // wrap to beginning of year + + if (!results.Any()) continue; // back out early + resultCount += results.Count(); + + // Build sorted name list + var names = new List(); + foreach (var item in results) { + names.Add(item.DisplayName); + } + names.Sort(StringComparer.OrdinalIgnoreCase); + + var first = true; + output.AppendLine(); + output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); + foreach (var item in names) { + // If the output is starting to fill up, send out this message and prepare a new one. + if (output.Length > 800) { + await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false); + output.Clear(); + first = true; + output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); + } + + if (first) first = false; + else output.Append(", "); + output.Append(item); + } + } + + if (resultCount == 0) + await reqChannel.SendMessageAsync( + "There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).") + .ConfigureAwait(false); + else + await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false); + } + + /// + /// Fetches all guild birthdays and places them into an easily usable structure. + /// Users currently not in the guild are not included in the result. + /// + private static async Task> GetSortedUsersAsync(SocketGuild guild) { + using var db = await Database.OpenConnectionAsync(); + using var c = db.CreateCommand(); + c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable + + " where guild_id = @Gid order by birth_month, birth_day"; + c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id; + c.Prepare(); + using var r = await c.ExecuteReaderAsync(); + var result = new List(); + while (await r.ReadAsync()) { + var id = (ulong)r.GetInt64(0); + var month = r.GetInt32(1); + var day = r.GetInt32(2); + + var guildUser = guild.GetUser(id); + if (guildUser == null) continue; // Skip user not in guild + + result.Add(new ListItem() { + BirthMonth = month, + BirthDay = day, + DateIndex = DateIndex(month, day), + UserId = guildUser.Id, + DisplayName = Common.FormatName(guildUser, false) + }); + } + return result; + } + + private string ListExportNormal(SocketGuildChannel channel, IEnumerable list) { + // Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" + var result = new StringBuilder(); + result.AppendLine("Birthdays in " + channel.Guild.Name); + result.AppendLine(); + foreach (var item in list) { + var user = channel.Guild.GetUser(item.UserId); + 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(item.UserId); + result.Append(" " + user.Username + "#" + user.Discriminator); + if (user.Nickname != null) result.Append(" - Nickname: " + user.Nickname); + result.AppendLine(); + } + return result.ToString(); + } + + private string ListExportCsv(SocketGuildChannel channel, IEnumerable list) { + // Output: User ID, Username, Nickname, Month-Day, Month, Day + var result = new StringBuilder(); + + // Conforming to RFC 4180; with header + result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day"); + result.Append("\r\n"); // crlf line break is specified by the standard + foreach (var item in list) { + var user = channel.Guild.GetUser(item.UserId); + if (user == null) continue; // User disappeared in the instant between getting list and processing + result.Append(item.UserId); + result.Append(','); + result.Append(CsvEscape(user.Username + "#" + user.Discriminator)); + result.Append(','); + if (user.Nickname != null) result.Append(user.Nickname); + result.Append(','); + result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}"); + result.Append(','); + result.Append(item.BirthMonth); + result.Append(','); + result.Append(item.BirthDay); + result.Append("\r\n"); + } + return result.ToString(); + } + + private static string CsvEscape(string input) { + var result = new StringBuilder(); + result.Append('"'); + foreach (var ch in input) { + if (ch == '"') result.Append('"'); + result.Append(ch); + } + result.Append('"'); + return result.ToString(); + } + + private static int DateIndex(int month, int day) { + var dateindex = 0; + // Add month offsets + if (month > 1) dateindex += 31; // Offset January + if (month > 2) dateindex += 29; // Offset February (incl. leap day) + if (month > 3) dateindex += 31; // etc + if (month > 4) dateindex += 30; + if (month > 5) dateindex += 31; + if (month > 6) dateindex += 30; + if (month > 7) dateindex += 31; + if (month > 8) dateindex += 31; + if (month > 9) dateindex += 30; + if (month > 10) dateindex += 31; + if (month > 11) dateindex += 30; + dateindex += day; + return dateindex; + } + + private struct ListItem { + public int DateIndex; + public int BirthMonth; + public int BirthDay; + public ulong UserId; + public string DisplayName; + } } diff --git a/UserInterface/ManagerCommands.cs b/UserInterface/ManagerCommands.cs index a98131d..ac8e8b6 100644 --- a/UserInterface/ManagerCommands.cs +++ b/UserInterface/ManagerCommands.cs @@ -1,11 +1,6 @@ using BirthdayBot.Data; -using Discord.WebSocket; -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace BirthdayBot.UserInterface; @@ -327,9 +322,8 @@ internal class ManagerCommands : CommandsCommon { // Moderators only. As with config, silently drop if this check fails. if (!gconf.IsBotModerator(reqUser)) return; - if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) { - instance.RequestDownloadUsers(reqChannel.Guild.Id); - await reqChannel.SendMessageAsync(UsersNotDownloadedError); + if (!await HasMemberCacheAsync(reqChannel.Guild)) { + await reqChannel.SendMessageAsync(MemberCacheEmptyError); return; } @@ -393,7 +387,7 @@ internal class ManagerCommands : CommandsCommon { var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); 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.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members."); int bdayCount = -1;