diff --git a/.vscode/launch.json b/.vscode/launch.json index 146f5ab..8669443 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. "program": "${workspaceFolder}/bin/Debug/net6.0/WorldTime.dll", - "args": [], + "args": [ "-c", "${workspaceFolder}/bin/Debug/net6.0/settings.json" ], "cwd": "${workspaceFolder}", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "internalConsole", diff --git a/ApplicationCommands.cs b/ApplicationCommands.cs deleted file mode 100644 index 6847fc2..0000000 --- a/ApplicationCommands.cs +++ /dev/null @@ -1,305 +0,0 @@ -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."; - - const string EmbedHelpField1 = $"`/help` - {HelpHelp}\n" - + $"`/list` - {HelpList}\n" - + $"`/set` - {HelpSet}\n" - + $"`/remove` - {HelpRemove}"; - const string EmbedHelpField2 = $"`/set-for` - {HelpSetFor}\n`/remove-for` - {HelpRemoveFor}"; - - #region Help strings - const string HelpHelp = "Displays a list of available bot commands."; - const string HelpList = "Shows the current time for all recently active known users."; - const string HelpSet = "Adds or updates your time zone to the bot."; - const string HelpSetFor = "Sets/updates time zone for a given user."; - const string HelpRemove = "Removes your time zone information from this bot."; - const string HelpRemoveFor = "Removes time zone for a given user."; - #endregion - private const string ErrInvalidZone = ":x: Not a valid zone name." - + " To find your time zone, refer to: ."; - 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() { - var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); - var guildct = ShardedClient.Guilds.Count; - using var db = DbContext; - var uniquetz = db.GetDistinctZoneCount(); - await RespondAsync(embed: new EmbedBuilder() { - Title = "Help & About", - Description = $"World Time v{version} - Serving {guildct} communities across {uniquetz} time zones.\n\n" - + "This bot is provided for free, without any paywalled 'premium' features. " - + "If you've found this bot useful, please consider contributing via the " - + "bot author's page on Ko-fi: https://ko-fi.com/noithecat.", - Footer = new EmbedFooterBuilder() { - IconUrl = Context.Client.CurrentUser.GetAvatarUrl(), - Text = "World Time" - } - }.AddField(inline: false, name: "Commands", value: EmbedHelpField1 - ).AddField(inline: false, name: "Admin commands", value: EmbedHelpField2 - ).AddField(inline: false, name: "Zones", value: - "This bot accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database). " + - "A useful tool to determine yours can be found at: https://kevinnovak.github.io/Time-Zone-Picker/" - ).Build()); - } - - [RequireGuildContext] - [SlashCommand("list", HelpList)] - public async Task CmdList([Summary(description: "A specific user whose time to look up.")]SocketGuildUser? user = null) { - if (!await AreUsersDownloadedAsync(Context.Guild)) { - await RespondAsync(ErrNoUserCache, ephemeral: true); - return; - } - - if (user == null) { - // No parameter - full listing - await CmdListWithoutParamAsync(); - } else { - // Has parameter - do single user listing - await CmdListWithUserParamAsync(user); - } - } - - private async Task CmdListWithoutParamAsync() { - // Called by CmdList - using var db = DbContext; - var userlist = db.GetGuildZones(Context.Guild.Id); - if (userlist.Count == 0) { - await RespondAsync(":x: Nothing to show. Register your time zones with the bot using the `/set` command."); - return; - } - // Order times by popularity to limit how many are shown, group by printed name - var sortedlist = new SortedDictionary>(); - foreach ((string area, List users) in userlist.OrderByDescending(o => o.Value.Count).Take(20)) { - // Filter further to top 20 distinct timezones, even if they are not displayed in the final result - var areaprint = TzPrint(area); - if (!sortedlist.ContainsKey(areaprint)) sortedlist.Add(areaprint, new List()); - sortedlist[areaprint].AddRange(users); - } - - const int MaxSingleLineLength = 750; - const int MaxSingleOutputLength = 900; - - // Build zone listings with users - var outputlines = new List(); - foreach ((string area, List users) in sortedlist) { - var buffer = new StringBuilder(); - buffer.Append(area[4..] + ": "); - bool empty = true; - foreach (var userid in users) { - var userinstance = Context.Guild.GetUser(userid); - if (userinstance == null) continue; - if (empty) empty = !empty; - else buffer.Append(", "); - var useradd = FormatName(userinstance); - if (buffer.Length + useradd.Length > MaxSingleLineLength) { - buffer.Append("others..."); - break; - } else buffer.Append(useradd); - } - if (!empty) outputlines.Add(buffer.ToString()); - } - - // Prepare for output - send buffers out if they become too large - outputlines.Sort(); - bool hasOutputOneLine = false; - // First output is shown as an interaction response, followed then as regular channel messages - async Task doOutput(Embed msg) { - if (!hasOutputOneLine) { - await RespondAsync(embed: msg); - hasOutputOneLine = true; - } else { - await ReplyAsync(embed: msg); - } - } - - var resultout = new StringBuilder(); - foreach (var line in outputlines) { - if (resultout.Length + line.Length > MaxSingleOutputLength) { - await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build()); - resultout.Clear(); - } - if (resultout.Length > 0) resultout.AppendLine(); // avoids trailing newline by adding to the previous line - resultout.Append(line); - } - if (resultout.Length > 0) { - await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build()); - } - } - - private async Task CmdListWithUserParamAsync(SocketGuildUser parameter) { - // Called by CmdList - using var db = DbContext; - var result = db.GetUserZone(parameter); - if (result == null) { - bool isself = Context.User.Id == parameter.Id; - if (isself) await RespondAsync(":x: You do not have a time zone. Set it with `tz.set`.", ephemeral: true); - else await RespondAsync(":x: The given user does not have a time zone set.", ephemeral: true); - return; - } - - var resulttext = TzPrint(result)[4..] + ": " + FormatName(parameter); - await RespondAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build()); - } - - [SlashCommand("set", HelpSet)] - public async Task CmdSet([Summary(description: "The new time zone to set.")]string zone) { - var parsedzone = ParseTimeZone(zone); - if (parsedzone == null) { - await RespondAsync(ErrInvalidZone, ephemeral: true); - return; - } - using var db = DbContext; - db.UpdateUser((SocketGuildUser)Context.User, parsedzone); - await RespondAsync($":white_check_mark: Your time zone has been set to **{parsedzone}**."); - } - - [RequireGuildContext] - [SlashCommand("set-for", HelpSetFor)] - public async Task CmdSetFor([Summary(description: "The user whose time zone to modify.")] SocketGuildUser user, - [Summary(description: "The new time zone to set.")] string zone) { - if (!IsUserAdmin((SocketGuildUser)Context.User)) { - await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false); - return; - } - - // Extract parameters - var newtz = ParseTimeZone(zone); - if (newtz == null) { - await RespondAsync(ErrInvalidZone); - return; - } - - using var db = DbContext; - db.UpdateUser(user, newtz); - await RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**."); - } - - [RequireGuildContext] - [SlashCommand("remove", HelpRemove)] - public async Task CmdRemove() { - using var db = DbContext; - var success = db.DeleteUser((SocketGuildUser)Context.User); - if (success) await RespondAsync(":white_check_mark: Your zone has been removed."); - else await RespondAsync(":x: You don't have a time zone set."); - } - - [RequireGuildContext] - [SlashCommand("remove-for", HelpRemoveFor)] - public async Task CmdRemoveFor([Summary(description: "The user whose time zone to remove.")] SocketGuildUser user) { - if (!IsUserAdmin((SocketGuildUser)Context.User)) { - await RespondAsync(ErrNotAllowed, ephemeral: true).ConfigureAwait(false); - return; - } - - using var db = DbContext; - if (db.DeleteUser(user)) - await RespondAsync($":white_check_mark: Removed zone information for {user}."); - else - await RespondAsync($":white_check_mark: No time zone is set for {user}."); - } - - #region Helper methods - /// - /// 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..cc25cf8 --- /dev/null +++ b/Commands/CommandsBase.cs @@ -0,0 +1,105 @@ +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 six numbers for sorting purposes. Must be trimmed before output. + /// + protected static string TzPrint(string zone, bool use12hr) { + 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("MMddHH", DateTimeFormatInfo.InvariantInfo); + string fullstr; + if (use12hr) { + var ap = now.ToString("tt", DateTimeFormatInfo.InvariantInfo).ToLowerInvariant(); + fullstr = now.ToString($"MMM' 'dd', 'hh':'mm'{ap} 'x' (UTC'o')'", DateTimeFormatInfo.InvariantInfo); + } else 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 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/Commands/ConfigCommands.cs b/Commands/ConfigCommands.cs new file mode 100644 index 0000000..8c81f8e --- /dev/null +++ b/Commands/ConfigCommands.cs @@ -0,0 +1,49 @@ +using Discord.Interactions; + +namespace WorldTime.Commands; +[Group("config", "Configuration commands for World Time.")] +[DefaultMemberPermissions(GuildPermission.ManageGuild)] +[EnabledInDm(false)] +public class ConfigCommands : CommandsBase { + internal const string HelpUse12 = "Sets whether to use the 12-hour (AM/PM) format in time zone listings."; + internal const string HelpSetFor = "Sets/updates time zone for a given user."; + internal const string HelpRemoveFor = "Removes time zone for a given user."; + + [SlashCommand("use-12hour", HelpUse12)] + public async Task Cmd12Hour([Summary(description: "True to enable, False to disable.")] bool setting) { + using var db = DbContext; + var gs = db.GuildSettings.Where(r => r.GuildId == Context.Guild.Id).SingleOrDefault(); + if (gs == null) { + gs = new() { GuildId = Context.Guild.Id }; + db.Add(gs); + } + + gs.Use12HourTime = setting; + await db.SaveChangesAsync(); + await RespondAsync($":white_check_mark: Time listing set to **{(setting ? "AM/PM" : "24 hour")}** format."); + } + + [SlashCommand("set-for", HelpSetFor)] + public async Task CmdSetFor([Summary(description: "The user whose time zone to modify.")] SocketGuildUser user, + [Summary(description: "The new time zone to set.")] string zone) { + // Extract parameters + var newtz = ParseTimeZone(zone); + if (newtz == null) { + await RespondAsync(ErrInvalidZone); + return; + } + + using var db = DbContext; + db.UpdateUser(user, newtz); + await RespondAsync($":white_check_mark: Time zone for **{user}** set to **{newtz}**."); + } + + [SlashCommand("remove-for", HelpRemoveFor)] + public async Task CmdRemoveFor([Summary(description: "The user whose time zone to remove.")] SocketGuildUser user) { + using var db = DbContext; + if (db.DeleteUser(user)) + await RespondAsync($":white_check_mark: Removed zone information for {user}."); + else + await RespondAsync($":white_check_mark: No time zone is set for {user}."); + } +} \ No newline at end of file diff --git a/Commands/UserCommands.cs b/Commands/UserCommands.cs new file mode 100644 index 0000000..8e0345a --- /dev/null +++ b/Commands/UserCommands.cs @@ -0,0 +1,171 @@ +using Discord.Interactions; +using System.Text; + +namespace WorldTime.Commands; +public class UserCommands : CommandsBase { + const string EmbedHelpField1 = + $"`/help` - {HelpHelp}\n" + + $"`/list` - {HelpList}\n" + + $"`/set` - {HelpSet}\n" + + $"`/remove` - {HelpRemove}"; + const string EmbedHelpField2 = + $"`/config use-12hour` - {ConfigCommands.HelpUse12}\n" + + $"`/set-for` - {ConfigCommands.HelpSetFor}\n" + + $"`/remove-for` - {ConfigCommands.HelpRemoveFor}"; + + #region Help strings + const string HelpHelp = "Displays a list of available bot commands."; + const string HelpList = "Shows the current time for all recently active known users."; + const string HelpSet = "Adds or updates your time zone to the bot."; + const string HelpRemove = "Removes your time zone information from this bot."; + + #endregion + + [SlashCommand("help", HelpHelp)] + public async Task CmdHelp() { + var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); + var guildct = ShardedClient.Guilds.Count; + using var db = DbContext; + var uniquetz = db.GetDistinctZoneCount(); + await RespondAsync(embed: new EmbedBuilder() { + Title = "Help & About", + Description = $"World Time v{version} - Serving {guildct} communities across {uniquetz} time zones.\n\n" + + "This bot is provided for free, without any paywalled 'premium' features. " + + "If you've found this bot useful, please consider contributing via the " + + "bot author's page on Ko-fi: https://ko-fi.com/noithecat.", + Footer = new EmbedFooterBuilder() { + IconUrl = Context.Client.CurrentUser.GetAvatarUrl(), + Text = "World Time" + } + }.AddField(inline: false, name: "Commands", value: EmbedHelpField1 + ).AddField(inline: false, name: "Admin commands", value: EmbedHelpField2 + ).AddField(inline: false, name: "Zones", value: + "This bot accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database). " + + "A useful tool to determine yours can be found at: https://kevinnovak.github.io/Time-Zone-Picker/" + ).Build()); + } + + [SlashCommand("list", HelpList)] + [EnabledInDm(false)] + public async Task CmdList([Summary(description: "A specific user whose time to look up.")]SocketGuildUser? user = null) { + if (!await AreUsersDownloadedAsync(Context.Guild)) { + await RespondAsync(ErrNoUserCache, ephemeral: true); + return; + } + + if (user == null) { + // No parameter - full listing + await CmdListWithoutParamAsync(); + } else { + // Has parameter - do single user listing + await CmdListWithUserParamAsync(user); + } + } + + private async Task CmdListWithoutParamAsync() { + // Called by CmdList + using var db = DbContext; + var userlist = db.GetGuildZones(Context.Guild.Id); + if (userlist.Count == 0) { + await RespondAsync(":x: Nothing to show. Register your time zones with the bot using the `/set` command."); + return; + } + + // Order times by popularity to limit how many are shown, group by printed name + var sortedlist = new SortedDictionary>(); + var ampm = db.GuildSettings.Where(s => s.GuildId == Context.Guild.Id).SingleOrDefault()?.Use12HourTime ?? false; + foreach ((string area, List users) in userlist.OrderByDescending(o => o.Value.Count).Take(20)) { + // Filter further to top 20 distinct timezones, even if they are not displayed in the final result + var areaprint = TzPrint(area, ampm); + if (!sortedlist.ContainsKey(areaprint)) sortedlist.Add(areaprint, new List()); + sortedlist[areaprint].AddRange(users); + } + + const int MaxSingleLineLength = 750; + const int MaxSingleOutputLength = 3000; + + // Build zone listings with users + var outputlines = new List(); + foreach ((string area, List users) in sortedlist) { + var buffer = new StringBuilder(); + buffer.Append(area[6..] + ": "); + bool empty = true; + foreach (var userid in users) { + var userinstance = Context.Guild.GetUser(userid); + if (userinstance == null) continue; + if (empty) empty = !empty; + else buffer.Append(", "); + var useradd = FormatName(userinstance); + if (buffer.Length + useradd.Length > MaxSingleLineLength) { + buffer.Append("others..."); + break; + } else buffer.Append(useradd); + } + if (!empty) outputlines.Add(buffer.ToString()); + } + + // Prepare for output - send buffers out if they become too large + outputlines.Sort(); + bool hasOutputOneLine = false; + // First output is shown as an interaction response, followed then as regular channel messages + async Task doOutput(Embed msg) { + if (!hasOutputOneLine) { + await RespondAsync(embed: msg); + hasOutputOneLine = true; + } else { + await ReplyAsync(embed: msg); + } + } + + var resultout = new StringBuilder(); + foreach (var line in outputlines) { + if (resultout.Length + line.Length > MaxSingleOutputLength) { + await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build()); + resultout.Clear(); + } + if (resultout.Length > 0) resultout.AppendLine(); // avoids trailing newline by adding to the previous line + resultout.Append(line); + } + if (resultout.Length > 0) { + await doOutput(new EmbedBuilder().WithDescription(resultout.ToString()).Build()); + } + } + + private async Task CmdListWithUserParamAsync(SocketGuildUser parameter) { + // Called by CmdList + using var db = DbContext; + var result = db.GetUserZone(parameter); + if (result == null) { + bool isself = Context.User.Id == parameter.Id; + if (isself) await RespondAsync(":x: You do not have a time zone. Set it with `tz.set`.", ephemeral: true); + else await RespondAsync(":x: The given user does not have a time zone set.", ephemeral: true); + return; + } + + var ampm = db.GuildSettings.Where(s => s.GuildId == Context.Guild.Id).SingleOrDefault()?.Use12HourTime ?? false; + var resulttext = TzPrint(result, ampm)[6..] + ": " + FormatName(parameter); + await RespondAsync(embed: new EmbedBuilder().WithDescription(resulttext).Build()); + } + + [SlashCommand("set", HelpSet)] + [EnabledInDm(false)] + public async Task CmdSet([Summary(description: "The new time zone to set.")]string zone) { + var parsedzone = ParseTimeZone(zone); + if (parsedzone == null) { + await RespondAsync(ErrInvalidZone, ephemeral: true); + return; + } + using var db = DbContext; + db.UpdateUser((SocketGuildUser)Context.User, parsedzone); + await RespondAsync($":white_check_mark: Your time zone has been set to **{parsedzone}**."); + } + + [SlashCommand("remove", HelpRemove)] + [EnabledInDm(false)] + public async Task CmdRemove() { + using var db = DbContext; + var success = db.DeleteUser((SocketGuildUser)Context.User); + if (success) await RespondAsync(":white_check_mark: Your zone has been removed."); + else await RespondAsync(":x: You don't have a time zone set."); + } +} diff --git a/Configuration.cs b/Configuration.cs index 571c1be..2568669 100644 --- a/Configuration.cs +++ b/Configuration.cs @@ -1,56 +1,52 @@ using CommandLine; -using CommandLine.Text; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Npgsql; using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace WorldTime; - /// /// Loads and holds configuration values. /// class Configuration { - const string KeySqlHost = "SqlHost"; - const string KeySqlUsername = "SqlUsername"; - const string KeySqlPassword = "SqlPassword"; - const string KeySqlDatabase = "SqlDatabase"; - - public string DbConnectionString { get; } public string BotToken { get; } public string? DBotsToken { get; } public int ShardTotal { get; } - public Configuration(string[] args) { - var cmdline = CmdLineOpts.Parse(args); + public string? SqlHost { get; } + public string? SqlDatabase { get; } + public string SqlUsername { get; } + public string SqlPassword { get; } + + public Configuration() { + var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs()); + var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + + Path.DirectorySeparatorChar + "." + Path.DirectorySeparatorChar + "settings.json"; // Looks for configuration file - var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + Path.DirectorySeparatorChar; - confPath += cmdline.Config!; - if (!File.Exists(confPath)) throw new Exception("Settings file not found in path: " + confPath); + JObject jc; + try { + var conftxt = File.ReadAllText(path); + jc = JObject.Parse(conftxt); + } catch (Exception ex) { + string pfx; + if (ex is JsonException) pfx = "Unable to parse configuration: "; + else pfx = "Unable to access configuration: "; - var jc = JObject.Parse(File.ReadAllText(confPath)); + throw new Exception(pfx + ex.Message, ex); + } BotToken = ReadConfKey(jc, nameof(BotToken), true); DBotsToken = ReadConfKey(jc, nameof(DBotsToken), false); - ShardTotal = cmdline.ShardTotal ?? ReadConfKey(jc, nameof(ShardTotal), false) ?? 1; + ShardTotal = args.ShardTotal ?? ReadConfKey(jc, nameof(ShardTotal), false) ?? 1; if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer."); - var sqlhost = ReadConfKey(jc, KeySqlHost, false) ?? "localhost"; // Default to localhost - var sqluser = ReadConfKey(jc, KeySqlUsername, false); - var sqlpass = ReadConfKey(jc, KeySqlPassword, false); - if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass)) - throw new Exception("'SqlUsername', 'SqlPassword' must be specified."); - var csb = new NpgsqlConnectionStringBuilder() { - Host = sqlhost, - Username = sqluser, - Password = sqlpass - }; - var sqldb = ReadConfKey(jc, KeySqlDatabase, false); - if (sqldb != null) csb.Database = sqldb; // Optional database setting - DbConnectionString = csb.ToString(); + SqlHost = ReadConfKey(jc, nameof(SqlHost), false); + SqlDatabase = ReadConfKey(jc, nameof(SqlDatabase), false); + SqlUsername = ReadConfKey(jc, nameof(SqlUsername), true); + SqlPassword = ReadConfKey(jc, nameof(SqlPassword), true); } private static T? ReadConfKey(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { @@ -59,29 +55,24 @@ class Configuration { return default; } - private class CmdLineOpts { - [Option('c', "config", Default = "settings.json", - HelpText = "Custom path to instance configuration, relative from executable directory.")] - public string? Config { get; set; } + class CommandLineParameters { + [Option('c', "config")] + public string? ConfigFile { get; set; } - [Option("shardtotal", - HelpText = "Total number of shards online. MUST be the same for all instances.\n" - + "This value overrides the config file value.")] + [Option("shardtotal")] public int? ShardTotal { get; set; } - public static CmdLineOpts Parse(string[] args) { - // Do not automatically print help message - var clp = new Parser(c => c.HelpWriter = null); + public static CommandLineParameters? Parse(string[] args) { + CommandLineParameters? result = null; - CmdLineOpts? result = null; - var r = clp.ParseArguments(args); - r.WithParsed(parsed => result = parsed); - r.WithNotParsed(err => { - var ht = HelpText.AutoBuild(r); - Console.WriteLine(ht.ToString()); - Environment.Exit((int)Program.ExitCodes.BadCommand); - }); - return result!; + new Parser(settings => { + settings.IgnoreUnknownArguments = true; + settings.AutoHelp = false; + settings.AutoVersion = false; + }).ParseArguments(args) + .WithParsed(p => result = p) + .WithNotParsed(e => { /* ignore */ }); + return result; } } } diff --git a/Data/BotDatabaseContext.cs b/Data/BotDatabaseContext.cs index 03ac6d1..a3fab44 100644 --- a/Data/BotDatabaseContext.cs +++ b/Data/BotDatabaseContext.cs @@ -1,35 +1,32 @@ using Microsoft.EntityFrameworkCore; +using Npgsql; namespace WorldTime.Data; public class BotDatabaseContext : DbContext { - private static string? _npgsqlConnectionString; - internal static string NpgsqlConnectionString { -#if DEBUG - get { - if (_npgsqlConnectionString != null) return _npgsqlConnectionString; - Program.Log(nameof(BotDatabaseContext), "Using hardcoded connection string!"); - return _npgsqlConnectionString ?? "Host=localhost;Username=worldtime;Password=wt"; - } -#else - get => _npgsqlConnectionString!; -#endif - set => _npgsqlConnectionString ??= value; + private static readonly string _connectionString; + + static BotDatabaseContext() { + // Get our own config loaded just for the SQL stuff + var conf = new Configuration(); + _connectionString = new NpgsqlConnectionStringBuilder() { + Host = conf.SqlHost ?? "localhost", // default to localhost + Database = conf.SqlDatabase, + Username = conf.SqlUsername, + Password = conf.SqlPassword + }.ToString(); } public DbSet UserEntries { get; set; } = null!; + public DbSet GuildSettings { get; set; } = null!; protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder - .UseNpgsql(NpgsqlConnectionString) -#if DEBUG - .LogTo((string line) => Program.Log("EF", line), Microsoft.Extensions.Logging.LogLevel.Information) -#endif + .UseNpgsql(_connectionString) .UseSnakeCaseNamingConvention(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(entity => { - entity.HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey"); - }); + modelBuilder.Entity().HasKey(e => new { e.GuildId, e.UserId }).HasName("userdata_pkey"); + modelBuilder.Entity().Property(p => p.Use12HourTime).HasDefaultValue(false); } #region Helper methods / abstractions diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs new file mode 100644 index 0000000..6339bd7 --- /dev/null +++ b/Data/GuildConfiguration.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace WorldTime.Data; +public class GuildConfiguration { + [Key] + public ulong GuildId { get; set; } + + public bool Use12HourTime { get; set; } +} \ No newline at end of file diff --git a/Data/Migrations/20230115041447_Add12HrSetting.Designer.cs b/Data/Migrations/20230115041447_Add12HrSetting.Designer.cs new file mode 100644 index 0000000..92939fe --- /dev/null +++ b/Data/Migrations/20230115041447_Add12HrSetting.Designer.cs @@ -0,0 +1,69 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using WorldTime.Data; + +#nullable disable + +namespace WorldTime.Data.Migrations +{ + [DbContext(typeof(BotDatabaseContext))] + [Migration("20230115041447_Add12HrSetting")] + partial class Add12HrSetting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("WorldTime.Data.GuildConfiguration", b => + { + b.Property("GuildId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("guild_id"); + + b.Property("Use12HourTime") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("use12hour_time"); + + b.HasKey("GuildId") + .HasName("pk_guild_settings"); + + b.ToTable("guild_settings", (string)null); + }); + + modelBuilder.Entity("WorldTime.Data.UserEntry", b => + { + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zone"); + + b.HasKey("GuildId", "UserId") + .HasName("userdata_pkey"); + + b.ToTable("userdata", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20230115041447_Add12HrSetting.cs b/Data/Migrations/20230115041447_Add12HrSetting.cs new file mode 100644 index 0000000..0953a8c --- /dev/null +++ b/Data/Migrations/20230115041447_Add12HrSetting.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WorldTime.Data.Migrations +{ + /// + public partial class Add12HrSetting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "guild_settings", + columns: table => new + { + guildid = table.Column(name: "guild_id", type: "numeric(20,0)", nullable: false), + use12hourtime = table.Column(name: "use12hour_time", type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("pk_guild_settings", x => x.guildid); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "guild_settings"); + } + } +} diff --git a/Data/Migrations/BotDatabaseContextModelSnapshot.cs b/Data/Migrations/BotDatabaseContextModelSnapshot.cs index a8cfd57..63704a5 100644 --- a/Data/Migrations/BotDatabaseContextModelSnapshot.cs +++ b/Data/Migrations/BotDatabaseContextModelSnapshot.cs @@ -16,11 +16,30 @@ namespace WorldTime.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("ProductVersion", "7.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("WorldTime.Data.GuildConfiguration", b => + { + b.Property("GuildId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("guild_id"); + + b.Property("Use12HourTime") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("use12hour_time"); + + b.HasKey("GuildId") + .HasName("pk_guild_settings"); + + b.ToTable("guild_settings", (string)null); + }); + modelBuilder.Entity("WorldTime.Data.UserEntry", b => { b.Property("GuildId") diff --git a/Program.cs b/Program.cs index 678058c..3562520 100644 --- a/Program.cs +++ b/Program.cs @@ -9,17 +9,15 @@ class Program { /// public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss"); - static async Task Main(string[] args) { + static async Task Main() { Configuration? cfg = null; try { - cfg = new Configuration(args); + cfg = new Configuration(); } catch (Exception ex) { Console.WriteLine(ex); Environment.Exit((int)ExitCodes.ConfigError); } - Data.BotDatabaseContext.NpgsqlConnectionString = cfg.DbConnectionString; - Console.CancelKeyPress += OnCancelKeyPressed; _bot = new WorldTime(cfg); await _bot.StartAsync().ConfigureAwait(false); @@ -31,10 +29,10 @@ class Program { /// Sends a formatted message to console. /// public static void Log(string source, string message) { - var ts = DateTime.UtcNow; + var ts = DateTime.Now; var ls = new string[] { "\r\n", "\n" }; foreach (var item in message.Split(ls, StringSplitOptions.None)) - Console.WriteLine($"{ts:u} [{source}] {item}"); + Console.WriteLine($"{ts:s} [{source}] {item}"); } private static void OnCancelKeyPressed(object? sender, ConsoleCancelEventArgs e) { diff --git a/RequireGuildContextAttribute.cs b/RequireGuildContextAttribute.cs deleted file mode 100644 index 806498d..0000000 --- a/RequireGuildContextAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Discord.Interactions; - -namespace WorldTime; -/// -/// 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. -///
-class RequireGuildContextAttribute : RequireContextAttribute { - public const string Error = "Command not received within a guild context."; - public const string Reply = ":x: This command is only available within a server."; - - public override string ErrorMessage => Error; - - public RequireGuildContextAttribute() : base(ContextType.Guild) { } -} diff --git a/WorldTime.cs b/WorldTime.cs index 904e243..074cd70 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. /// @@ -125,10 +124,7 @@ internal class WorldTime : IDisposable { private Task DiscordClient_Log(LogMessage arg) { // Suppress certain messages if (arg.Message != null) { - // These warnings appear often as of Discord.Net v3... - if (arg.Message.StartsWith("Unknown Dispatch ") || arg.Message.StartsWith("Unknown Channel")) return Task.CompletedTask; - switch (arg.Message) // Connection status messages replaced by ShardManager's output - { + switch (arg.Message) { // Connection status messages replaced by ShardManager's output case "Connecting": case "Connected": case "Ready": @@ -139,7 +135,6 @@ internal class WorldTime : IDisposable { case "Discord.WebSocket.GatewayReconnectException: Server requested a reconnect": return Task.CompletedTask; } - Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); } @@ -161,10 +156,14 @@ internal class WorldTime : IDisposable { } #else // Debug: Register our commands locally instead, in each guild we're in - var iasrv = _services.GetRequiredService(); - foreach (var g in arg.Guilds) { - await iasrv.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false); - Program.Log("Command registration", $"Updated DEBUG command registration in guild {g.Id}."); + if (arg.Guilds.Count > 5) { + Program.Log("Command registration", "Are you debugging in production?! Skipping DEBUG command registration."); + } else { + var iasrv = _services.GetRequiredService(); + foreach (var g in arg.Guilds) { + await iasrv.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false); + Program.Log("Command registration", $"Updated DEBUG command registration in guild {g.Id}."); + } } #endif } @@ -200,20 +199,11 @@ internal class WorldTime : IDisposable { // Additional log information with error detail logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason; - // Specific responses to errors, if necessary - if (result.Error == InteractionCommandError.UnmetPrecondition) { - string errReply = result.ErrorReason switch { - RequireGuildContextAttribute.Error => RequireGuildContextAttribute.Reply, - _ => result.ErrorReason - }; - await context.Interaction.RespondAsync(errReply, ephemeral: true); - } else { - // Generic error response - // TODO when implementing proper application error logging, see here - var ia = context.Interaction; - if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError); - else await ia.RespondAsync(InternalError); - } + // Generic error response + // TODO when implementing proper application error logging, see here + var ia = context.Interaction; + if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError); + else await ia.RespondAsync(InternalError); } Program.Log("Command", logresult); diff --git a/WorldTime.csproj b/WorldTime.csproj index a2da401..23fd9cf 100644 --- a/WorldTime.csproj +++ b/WorldTime.csproj @@ -5,24 +5,24 @@ net6.0 enable enable - 2.1.5 + 2.3.0 NoiTheCat - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - + + + + +