diff --git a/ApplicationCommands.cs b/Commands/ApplicationCommands.cs similarity index 64% rename from ApplicationCommands.cs rename to Commands/ApplicationCommands.cs index 6847fc2..dfd4020 100644 --- a/ApplicationCommands.cs +++ b/Commands/ApplicationCommands.cs @@ -1,14 +1,8 @@ using Discord.Interactions; -using NodaTime; -using System.Collections.ObjectModel; -using System.Globalization; using System.Text; -using WorldTime.Data; - -namespace WorldTime; -public class ApplicationCommands : InteractionModuleBase { - const string ErrNotAllowed = ":x: Only server moderators may use this command."; +namespace WorldTime.Commands; +public class ApplicationCommands : CommandsBase { const string EmbedHelpField1 = $"`/help` - {HelpHelp}\n" + $"`/list` - {HelpList}\n" + $"`/set` - {HelpSet}\n" @@ -23,20 +17,6 @@ public class ApplicationCommands : InteractionModuleBase."; - private const string ErrNoUserCache = ":warning: Please try the command again."; - - private static readonly ReadOnlyDictionary _tzNameMap; - - public DiscordShardedClient ShardedClient { get; set; } = null!; - public BotDatabaseContext DbContext { get; set; } = null!; - - static ApplicationCommands() { - Dictionary tzNameMap = new(StringComparer.OrdinalIgnoreCase); - foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name); - _tzNameMap = new(tzNameMap); - } [SlashCommand("help", HelpHelp)] public async Task CmdHelp() { @@ -217,89 +197,4 @@ public class ApplicationCommands : InteractionModuleBase - /// Returns a string displaying the current time in the given time zone. - /// The result begins with four numbers for sorting purposes. Must be trimmed before output. - /// - private static string TzPrint(string zone) { - var tzdb = DateTimeZoneProviders.Tzdb; - DateTimeZone tz = tzdb.GetZoneOrNull(zone)!; - if (tz == null) throw new Exception("Encountered unknown time zone: " + zone); - - var now = SystemClock.Instance.GetCurrentInstant().InZone(tz); - var sortpfx = now.ToString("MMdd", DateTimeFormatInfo.InvariantInfo); - var fullstr = now.ToString("dd'-'MMM' 'HH':'mm' 'x' (UTC'o')'", DateTimeFormatInfo.InvariantInfo); - return $"{sortpfx}● `{fullstr}`"; - } - - /// - /// Checks given time zone input. Returns a valid string for use with NodaTime, or null. - /// - private static string? ParseTimeZone(string tzinput) { - if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; - if (_tzNameMap.TryGetValue(tzinput, out var name)) return name; - return null; - } - - /// - /// Formats a user's name to a consistent, readable format which makes use of their nickname. - /// - private static string FormatName(SocketGuildUser user) { - static string escapeFormattingCharacters(string input) { - var result = new StringBuilder(); - foreach (var c in input) { - if (c is '\\' or '_' or '~' or '*' or '@') { - result.Append('\\'); - } - result.Append(c); - } - return result.ToString(); - } - - var username = escapeFormattingCharacters(user.Username); - if (user.Nickname != null) { - return $"**{escapeFormattingCharacters(user.Nickname)}** ({username}#{user.Discriminator})"; - } - return $"**{username}**#{user.Discriminator}"; - } - - /// - /// Checks if the given user can be considered a guild admin ('Manage Server' is set). - /// - // TODO replace this with a precondition, or there's also a new permission scheme going around? - private static bool IsUserAdmin(SocketGuildUser user) - => user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild; - - /// - /// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed. - /// - /// - /// Due to a quirk in Discord.Net, the user cache cannot be filled until the command handler is no longer executing - /// regardless of if the request runs on its own thread, thus requiring the user to run the command again. - /// - /// - /// True if the guild's members are already downloaded. If false, the command handler must notify the user. - /// - private static async Task AreUsersDownloadedAsync(SocketGuild guild) { - 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) - return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85); - } else { - // For smaller guilds, fail if two or more members are missing - return guild.MemberCount - guild.DownloadedMemberCount <= 2; - } - } - if (HasMostMembersDownloaded(guild)) return true; - else { - // Event handler hangs if awaited normally or used with Task.Run - await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); - return false; - } - } - #endregion } diff --git a/Commands/CommandsBase.cs b/Commands/CommandsBase.cs new file mode 100644 index 0000000..9aea3f1 --- /dev/null +++ b/Commands/CommandsBase.cs @@ -0,0 +1,108 @@ +using Discord.Interactions; +using NodaTime; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using WorldTime.Data; + +namespace WorldTime.Commands; +public class CommandsBase : InteractionModuleBase { + protected const string ErrInvalidZone = ":x: Not a valid zone name." + + " To find your time zone, refer to: ."; + protected const string ErrNoUserCache = ":warning: Please try the command again."; + protected const string ErrNotAllowed = ":x: Only server moderators may use this command."; + + private static readonly ReadOnlyDictionary _tzNameMap; + + static CommandsBase() { + Dictionary tzNameMap = new(StringComparer.OrdinalIgnoreCase); + foreach (var name in DateTimeZoneProviders.Tzdb.Ids) tzNameMap.Add(name, name); + _tzNameMap = new(tzNameMap); + } + + public DiscordShardedClient ShardedClient { get; set; } = null!; + public BotDatabaseContext DbContext { get; set; } = null!; + + /// + /// Returns a string displaying the current time in the given time zone. + /// The result begins with four numbers for sorting purposes. Must be trimmed before output. + /// + protected static string TzPrint(string zone) { + var tzdb = DateTimeZoneProviders.Tzdb; + DateTimeZone tz = tzdb.GetZoneOrNull(zone)!; + if (tz == null) throw new Exception("Encountered unknown time zone: " + zone); + + var now = SystemClock.Instance.GetCurrentInstant().InZone(tz); + var sortpfx = now.ToString("MMdd", DateTimeFormatInfo.InvariantInfo); + var fullstr = now.ToString("dd'-'MMM' 'HH':'mm' 'x' (UTC'o')'", DateTimeFormatInfo.InvariantInfo); + return $"{sortpfx}● `{fullstr}`"; + } + + /// + /// Checks given time zone input. Returns a valid string for use with NodaTime, or null. + /// + protected static string? ParseTimeZone(string tzinput) { + if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; + if (_tzNameMap.TryGetValue(tzinput, out var name)) return name; + return null; + } + + /// + /// Formats a user's name to a consistent, readable format which makes use of their nickname. + /// + protected static string FormatName(SocketGuildUser user) { + static string escapeFormattingCharacters(string input) { + var result = new StringBuilder(); + foreach (var c in input) { + if (c is '\\' or '_' or '~' or '*' or '@') { + result.Append('\\'); + } + result.Append(c); + } + return result.ToString(); + } + + var username = escapeFormattingCharacters(user.Username); + if (user.Nickname != null) { + return $"**{escapeFormattingCharacters(user.Nickname)}** ({username}#{user.Discriminator})"; + } + return $"**{username}**#{user.Discriminator}"; + } + + /// + /// Checks if the given user can be considered a guild admin ('Manage Server' is set). + /// + // TODO replace this with a precondition, or there's also a new permission scheme going around? + protected static bool IsUserAdmin(SocketGuildUser user) + => user.GuildPermissions.Administrator || user.GuildPermissions.ManageGuild; + + /// + /// Checks if the member cache for the specified guild needs to be filled, and sends a request if needed. + /// + /// + /// Due to a quirk in Discord.Net, the user cache cannot be filled until the command handler is no longer executing + /// regardless of if the request runs on its own thread, thus requiring the user to run the command again. + /// + /// + /// True if the guild's members are already downloaded. If false, the command handler must notify the user. + /// + protected static async Task AreUsersDownloadedAsync(SocketGuild guild) { + 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) + return guild.DownloadedMemberCount >= (int)(guild.MemberCount * 0.85); + } else { + // For smaller guilds, fail if two or more members are missing + return guild.MemberCount - guild.DownloadedMemberCount <= 2; + } + } + if (HasMostMembersDownloaded(guild)) return true; + else { + // Event handler hangs if awaited normally or used with Task.Run + await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); + return false; + } + } +} \ No newline at end of file diff --git a/RequireGuildContextAttribute.cs b/Commands/RequireGuildContextAttribute.cs similarity index 69% rename from RequireGuildContextAttribute.cs rename to Commands/RequireGuildContextAttribute.cs index 806498d..5994111 100644 --- a/RequireGuildContextAttribute.cs +++ b/Commands/RequireGuildContextAttribute.cs @@ -1,9 +1,8 @@ using Discord.Interactions; -namespace WorldTime; +namespace WorldTime.Commands; /// -/// Implements the included precondition from Discord.Net, requiring a guild context while using our custom error message.

-/// Combining this with is redundant. If possible, only use the latter instead. +/// Implements the included precondition from Discord.Net, requiring a guild context while using our custom error message. ///
class RequireGuildContextAttribute : RequireContextAttribute { public const string Error = "Command not received within a guild context."; diff --git a/WorldTime.cs b/WorldTime.cs index 47557c1..07185a0 100644 --- a/WorldTime.cs +++ b/WorldTime.cs @@ -7,7 +7,6 @@ using System.Text; using WorldTime.Data; namespace WorldTime; - /// /// Main class for the program. Configures the client on start and occasionally prints status information. /// @@ -203,7 +202,7 @@ internal class WorldTime : IDisposable { // Specific responses to errors, if necessary if (result.Error == InteractionCommandError.UnmetPrecondition) { string errReply = result.ErrorReason switch { - RequireGuildContextAttribute.Error => RequireGuildContextAttribute.Reply, + Commands.RequireGuildContextAttribute.Error => Commands.RequireGuildContextAttribute.Reply, _ => result.ErrorReason }; await context.Interaction.RespondAsync(errReply, ephemeral: true);