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;