diff --git a/BackgroundServices/BirthdayRoleUpdate.cs b/BackgroundServices/BirthdayRoleUpdate.cs index b63fb6a..e97906f 100644 --- a/BackgroundServices/BirthdayRoleUpdate.cs +++ b/BackgroundServices/BirthdayRoleUpdate.cs @@ -78,36 +78,6 @@ class BirthdayRoleUpdate : BackgroundService { else if (exceptions.Count == 1) throw new Exception("An unhandled exception occurred when processing a birthday.", exceptions[0]); } - /// - /// Gets all known users from the given guild and returns a list including only those who are - /// currently experiencing a birthday in the respective time zone. - /// - [Obsolete(Database.ObsoleteReason)] - public static HashSet GetGuildCurrentBirthdays(IEnumerable guildUsers, string? defaultTzStr) { - var tzdb = DateTimeZoneProviders.Tzdb; - DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null) - ?? tzdb.GetZoneOrNull("UTC")!; - - var birthdayUsers = new HashSet(); - foreach (var item in guildUsers) { - // Determine final time zone to use for calculation - DateTimeZone tz = (item.TimeZone != null ? tzdb.GetZoneOrNull(item.TimeZone) : null) ?? defaultTz; - - var targetMonth = item.BirthMonth; - var targetDay = item.BirthDay; - - var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz); - // Special case: If birthday is February 29 and it's not a leap year, recognize it on March 1st - if (targetMonth == 2 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year)) { - targetMonth = 3; - targetDay = 1; - } - if (targetMonth == checkNow.Month && targetDay == checkNow.Day) { - birthdayUsers.Add(item.UserId); - } - } - return birthdayUsers; - } /// /// Gets all known users from the given guild and returns a list including only those who are /// currently experiencing a birthday in the respective time zone. diff --git a/Data/Database.cs b/Data/Database.cs deleted file mode 100644 index 2dfd980..0000000 --- a/Data/Database.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Npgsql; - -namespace BirthdayBot.Data; - -[Obsolete(ObsoleteReason, error: false)] -internal static class Database { - public const string ObsoleteReason = "Will be removed in favor of EF6 stuff when text commands are removed"; - - public static string DBConnectionString { get; set; } = null!; - - /// - /// Sets up and opens a database connection. - /// - public static async Task OpenConnectionAsync() { - var db = new NpgsqlConnection(DBConnectionString); - await db.OpenAsync().ConfigureAwait(false); - return db; - } - - public static async Task DoInitialDatabaseSetupAsync() { - using var db = await OpenConnectionAsync().ConfigureAwait(false); - - // Refer to the methods being called for information on how the database is set up. - // Note: The order these are called is important. (Foreign reference constraints.) - await GuildConfiguration.DatabaseSetupAsync(db).ConfigureAwait(false); - await GuildUserConfiguration.DatabaseSetupAsync(db).ConfigureAwait(false); - } -} diff --git a/Data/GuildConfiguration.cs b/Data/GuildConfiguration.cs deleted file mode 100644 index b844fb2..0000000 --- a/Data/GuildConfiguration.cs +++ /dev/null @@ -1,253 +0,0 @@ -using Npgsql; -using NpgsqlTypes; -using System.Data.Common; - -namespace BirthdayBot.Data; - -/// -/// Represents guild-specific configuration as exists in the database. -/// Updating any property requires a call to for changes to take effect. -/// -[Obsolete(Database.ObsoleteReason, error: false)] -class GuildConfiguration { - /// - /// Gets this configuration's corresponding guild ID. - /// - public ulong GuildId { get; } - - /// - /// Gets or sets the guild's designated usable role ID. - /// Updating this value requires a call to . - /// - public ulong? RoleId { get; set; } - - /// - /// Gets or sets the announcement channel ID. - /// Updating this value requires a call to . - /// - public ulong? AnnounceChannelId { get; set; } - - /// - /// Gets or sets the guild's default time zone ztring. - /// Updating this value requires a call to . - /// - public string? TimeZone { get; set; } - - /// - /// Gets or sets the guild's moderated mode setting. - /// Updating this value requires a call to . - /// - public bool IsModerated { get; set; } - - /// - /// Gets or sets the guild's corresponding bot moderator role ID. - /// Updating this value requires a call to . - /// - public ulong? ModeratorRole { get; set; } - - /// - /// Gets or sets the guild-specific birthday announcement message. - /// Updating this value requires a call to . - /// - public (string?, string?) AnnounceMessages { get; set; } - - /// - /// Gets or sets the announcement ping setting. - /// Updating this value requires a call to . - /// - public bool AnnouncePing { get; set; } - - // Called by Load. Double-check ordinals when changes are made. - private GuildConfiguration(DbDataReader reader) { - GuildId = (ulong)reader.GetInt64(0); - if (!reader.IsDBNull(1)) RoleId = (ulong)reader.GetInt64(1); - if (!reader.IsDBNull(2)) AnnounceChannelId = (ulong)reader.GetInt64(2); - TimeZone = reader.IsDBNull(3) ? null : reader.GetString(3); - IsModerated = reader.GetBoolean(4); - if (!reader.IsDBNull(5)) ModeratorRole = (ulong)reader.GetInt64(5); - string? announceMsg = reader.IsDBNull(6) ? null : reader.GetString(6); - string? announceMsgPl = reader.IsDBNull(7) ? null : reader.GetString(7); - AnnounceMessages = (announceMsg, announceMsgPl); - AnnouncePing = reader.GetBoolean(8); - } - - /// - /// Checks if the specified user is blocked by current guild policy (block list or moderated mode). - /// - public async Task IsUserBlockedAsync(ulong userId) { - if (IsModerated) return true; - return await IsUserInBlocklistAsync(userId).ConfigureAwait(false); - } - - /// - /// Checks if the given user exists in the block list. - /// - public async Task IsUserInBlocklistAsync(ulong userId) { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"select * from {BackingTableBans} " - + "where guild_id = @Gid and user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId; - c.Prepare(); - using var r = await c.ExecuteReaderAsync().ConfigureAwait(false); - if (!await r.ReadAsync().ConfigureAwait(false)) return false; - return true; - } - - /// - /// Adds the specified user to the block list corresponding to this guild. - /// - public async Task BlockUserAsync(ulong userId) { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"insert into {BackingTableBans} (guild_id, user_id) " - + "values (@Gid, @Uid) " - + "on conflict (guild_id, user_id) do nothing"; - // There is no validation on whether the requested user is even in the guild. will this be a problem? - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId; - c.Prepare(); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - /// - /// Removes the specified user from the block list corresponding to this guild. - /// - /// True if a user has been removed, false if the requested user was not in this list. - public async Task UnblockUserAsync(ulong userId) { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"delete from {BackingTableBans} where " - + "guild_id = @Gid and user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId; - c.Prepare(); - var result = await c.ExecuteNonQueryAsync().ConfigureAwait(false); - return result != 0; - } - - /// - /// Checks if the given user can be considered a bot moderator. - /// Checks for either the Manage Guild permission or if the user is within a predetermined role. - /// - public bool IsBotModerator(SocketGuildUser user) - => user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value)); - - #region Database - public const string BackingTable = "settings"; - public const string BackingTableBans = "banned_users"; - - [Obsolete("DELETE THIS", error: true)] - internal static async Task DatabaseSetupAsync(NpgsqlConnection db) { - using (var c = db.CreateCommand()) { - c.CommandText = $"create table if not exists {BackingTable} (" - + "guild_id bigint primary key, " - + "role_id bigint null, " - + "channel_announce_id bigint null, " - + "time_zone text null, " - + "moderated boolean not null default FALSE, " - + "moderator_role bigint null, " - + "announce_message text null, " - + "announce_message_pl text null, " - + "announce_ping boolean not null default FALSE, " - + "last_seen timestamptz not null default NOW()" - + ")"; - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - using (var c = db.CreateCommand()) { - c.CommandText = $"create table if not exists {BackingTableBans} (" - + $"guild_id bigint not null references {BackingTable} ON DELETE CASCADE, " - + "user_id bigint not null, " - + "PRIMARY KEY (guild_id, user_id)" - + ")"; - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - } - - /// - /// Fetches guild settings from the database. If no corresponding entry exists, it will be created. - /// - /// - /// If true, this method shall not create a new entry and will return null if the guild does - /// not exist in the database. - /// - public static async Task LoadAsync(ulong guildId, bool nullIfUnknown) { - // TODO nullable static analysis problem: how to indicate non-null return when nullIfUnknown parameter is true? - using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) { - using (var c = db.CreateCommand()) { - // Take note of ordinals for the constructor - c.CommandText = "select guild_id, role_id, channel_announce_id, time_zone, " - + " moderated, moderator_role, announce_message, announce_message_pl, announce_ping " - + $"from {BackingTable} where guild_id = @Gid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; - c.Prepare(); - using var r = await c.ExecuteReaderAsync().ConfigureAwait(false); - if (await r.ReadAsync().ConfigureAwait(false)) return new GuildConfiguration(r); - } - if (nullIfUnknown) return null; - - // If we got here, no row exists. Create it with default values. - using (var c = db.CreateCommand()) { - c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; - c.Prepare(); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - } - // With a new row created, try this again - return await LoadAsync(guildId, nullIfUnknown).ConfigureAwait(false); - } - - /// - /// Updates values on the backing database with values from this object instance. - /// - public async Task UpdateAsync() { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"update {BackingTable} set " - + "role_id = @RoleId, " - + "channel_announce_id = @ChannelId, " - + "time_zone = @TimeZone, " - + "moderated = @Moderated, " - + "moderator_role = @ModRole, " - + "announce_message = @AnnounceMsg, " - + "announce_message_pl = @AnnounceMsgPl, " - + "announce_ping = @AnnouncePing " - + "where guild_id = @Gid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - NpgsqlParameter p; - - p = c.Parameters.Add("@RoleId", NpgsqlDbType.Bigint); - if (RoleId.HasValue) p.Value = (long)RoleId.Value; - else p.Value = DBNull.Value; - - p = c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint); - if (AnnounceChannelId.HasValue) p.Value = (long)AnnounceChannelId.Value; - else p.Value = DBNull.Value; - - p = c.Parameters.Add("@TimeZone", NpgsqlDbType.Text); - if (TimeZone != null) p.Value = TimeZone; - else p.Value = DBNull.Value; - - c.Parameters.Add("@Moderated", NpgsqlDbType.Boolean).Value = IsModerated; - - p = c.Parameters.Add("@ModRole", NpgsqlDbType.Bigint); - if (ModeratorRole.HasValue) p.Value = (long)ModeratorRole.Value; - else p.Value = DBNull.Value; - - p = c.Parameters.Add("@AnnounceMsg", NpgsqlDbType.Text); - if (AnnounceMessages.Item1 != null) p.Value = AnnounceMessages.Item1; - else p.Value = DBNull.Value; - - p = c.Parameters.Add("@AnnounceMsgPl", NpgsqlDbType.Text); - if (AnnounceMessages.Item2 != null) p.Value = AnnounceMessages.Item2; - else p.Value = DBNull.Value; - - c.Parameters.Add("@AnnouncePing", NpgsqlDbType.Boolean).Value = AnnouncePing; - - await c.PrepareAsync().ConfigureAwait(false); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - #endregion -} diff --git a/Data/GuildUserConfiguration.cs b/Data/GuildUserConfiguration.cs deleted file mode 100644 index 930b264..0000000 --- a/Data/GuildUserConfiguration.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Npgsql; -using NpgsqlTypes; -using System.Data.Common; - -namespace BirthdayBot.Data; - -/// -/// Represents configuration for a guild user as may exist in the database. -/// -[Obsolete(Database.ObsoleteReason, error: false)] -class GuildUserConfiguration { - public ulong GuildId { get; } - public ulong UserId { get; } - - /// - /// Month of birth as a numeric value. Range 1-12. - /// - public int BirthMonth { get; private set; } - /// - /// Day of birth as a numeric value. Ranges between 1-31 or lower based on month value. - /// - public int BirthDay { get; private set; } - - public string? TimeZone { get; private set; } - public bool IsKnown { get { return BirthMonth != 0 && BirthDay != 0; } } - - /// - /// Creates a new, data-less instance without a corresponding database entry. - /// Calling will create a real database enty - /// - private GuildUserConfiguration(ulong guildId, ulong userId) { - GuildId = guildId; - UserId = userId; - } - - // Called by GetGuildUsersAsync. Double-check ordinals when changes are made. - private GuildUserConfiguration(DbDataReader reader) { - GuildId = (ulong)reader.GetInt64(0); - UserId = (ulong)reader.GetInt64(1); - BirthMonth = reader.GetInt32(2); - BirthDay = reader.GetInt32(3); - if (!reader.IsDBNull(4)) TimeZone = reader.GetString(4); - } - - /// - /// Updates user with given information. - /// - public async Task UpdateAsync(int month, int day, string? newtz) { - using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) { - using var c = db.CreateCommand(); - c.CommandText = $"insert into {BackingTable} " - + "(guild_id, user_id, birth_month, birth_day, time_zone) values " - + "(@Gid, @Uid, @Month, @Day, @Tz) " - + "on conflict (guild_id, user_id) do update " - + "set birth_month = EXCLUDED.birth_month, birth_day = EXCLUDED.birth_day, time_zone = EXCLUDED.time_zone"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId; - c.Parameters.Add("@Month", NpgsqlDbType.Numeric).Value = month; - c.Parameters.Add("@Day", NpgsqlDbType.Numeric).Value = day; - var tzp = c.Parameters.Add("@Tz", NpgsqlDbType.Text); - if (newtz != null) tzp.Value = newtz; - else tzp.Value = DBNull.Value; - c.Prepare(); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - // Database update succeeded; update instance values - BirthMonth = month; - BirthDay = day; - TimeZone = newtz; - } - - /// - /// Deletes information of this user from the backing database. - /// The corresponding object reference should ideally be discarded after calling this. - /// - public async Task DeleteAsync() { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"delete from {BackingTable} " - + "where guild_id = @Gid and user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId; - c.Prepare(); - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - #region Database - public const string BackingTable = "user_birthdays"; - // Take note of ordinals for use in the constructor - private const string SelectFields = "guild_id, user_id, birth_month, birth_day, time_zone"; - - internal static async Task DatabaseSetupAsync(NpgsqlConnection db) { - using var c = db.CreateCommand(); - c.CommandText = $"create table if not exists {BackingTable} (" - + $"guild_id bigint not null references {GuildConfiguration.BackingTable} ON DELETE CASCADE, " - + "user_id bigint not null, " - + "birth_month integer not null, " - + "birth_day integer not null, " - + "time_zone text null, " - + "last_seen timestamptz not null default NOW(), " - + "PRIMARY KEY (guild_id, user_id)" // index automatically created with this - + ")"; - await c.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - /// - /// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found. - /// - public static async Task LoadAsync(ulong guildId, ulong userId) { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid and user_id = @Uid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; - c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId; - c.Prepare(); - - using var r = c.ExecuteReader(); - if (await r.ReadAsync().ConfigureAwait(false)) return new GuildUserConfiguration(r); - else return new GuildUserConfiguration(guildId, userId); - } - - /// - /// Gets all known user configuration records associated with the specified guild. - /// - public static async Task> LoadAllAsync(ulong guildId) { - using var db = await Database.OpenConnectionAsync().ConfigureAwait(false); - using var c = db.CreateCommand(); - c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid"; - c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; - c.Prepare(); - - using var r = await c.ExecuteReaderAsync().ConfigureAwait(false); - var result = new List(); - while (await r.ReadAsync().ConfigureAwait(false)) result.Add(new GuildUserConfiguration(r)); - return result; - } - #endregion -} diff --git a/Program.cs b/Program.cs index 05a548a..4581fb7 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,4 @@ -using BirthdayBot.Data; - -namespace BirthdayBot; - +namespace BirthdayBot; class Program { private static ShardManager? _bot; private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow; @@ -11,7 +8,7 @@ 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(); @@ -20,15 +17,6 @@ class Program { Environment.Exit((int)ExitCodes.ConfigError); } - Database.DBConnectionString = new Npgsql.NpgsqlConnectionStringBuilder() { - Host = cfg.SqlHost ?? "localhost", // default to localhost - Database = cfg.SqlDatabase, - Username = cfg.SqlUsername, - Password = cfg.SqlPassword, - ApplicationName = cfg.SqlApplicationName, - MaxPoolSize = Math.Max((int)Math.Ceiling(cfg.ShardAmount * 2 * 0.6), 8) - }.ToString(); - Console.CancelKeyPress += OnCancelKeyPressed; _bot = new ShardManager(cfg); diff --git a/ShardInstance.cs b/ShardInstance.cs index f00313a..0ab8cad 100644 --- a/ShardInstance.cs +++ b/ShardInstance.cs @@ -1,21 +1,16 @@ using BirthdayBot.ApplicationCommands; using BirthdayBot.BackgroundServices; -using BirthdayBot.Data; using Discord.Interactions; -using Discord.Net; using Microsoft.Extensions.DependencyInjection; using System.Reflection; -using static BirthdayBot.TextCommands.CommandsCommon; namespace BirthdayBot; - /// /// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord. /// public sealed class ShardInstance : IDisposable { private readonly ShardManager _manager; private readonly ShardBackgroundWorker _background; - private readonly Dictionary _textDispatch; private readonly InteractionService _interactionService; private readonly IServiceProvider _services; @@ -36,15 +31,13 @@ public sealed class ShardInstance : IDisposable { /// /// Prepares and configures the shard instances, but does not yet start its connection. /// - internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary textCmds) { + internal ShardInstance(ShardManager manager, IServiceProvider services) { _manager = manager; _services = services; - _textDispatch = textCmds; DiscordClient = _services.GetRequiredService(); DiscordClient.Log += Client_Log; DiscordClient.Ready += Client_Ready; - DiscordClient.MessageReceived += Client_MessageReceived; _interactionService = _services.GetRequiredService(); DiscordClient.InteractionCreated += DiscordClient_InteractionCreated; @@ -105,14 +98,7 @@ public sealed class ShardInstance : IDisposable { return Task.CompletedTask; } - /// - /// Registers all available slash commands. - /// Additionally, sets the shard's status to display the help command. - /// private async Task Client_Ready() { - // TODO get rid of this eventually? or change it to something fun... - await DiscordClient.SetGameAsync("/help"); - #if !DEBUG // Update slash/interaction commands if (ShardId == 0) { @@ -133,48 +119,6 @@ public sealed class ShardInstance : IDisposable { #endif } -#pragma warning disable CS0618 - /// - /// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary. - /// - private async Task Client_MessageReceived(SocketMessage msg) { - if (msg.Channel is not SocketTextChannel channel) return; - if (msg.Author.IsBot || msg.Author.IsWebhook) return; - if (((IMessage)msg).Type != MessageType.Default) return; - var author = (SocketGuildUser)msg.Author; - - // Limit 3: - // For all cases: base command, 2 parameters. - // Except this case: "bb.config", subcommand name, subcommand parameters in a single string - var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries); - if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) { - // Determine if it's something we're listening for. - if (!_textDispatch.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler? command)) return; - - // Load guild information here - var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false); - - // Ban check - if (!gconf!.IsBotModerator(author)) // skip check if user is a moderator - { - if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore - } - - // Execute the command - try { - Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}"); - await command(this, gconf, csplit, channel, author); - } catch (Exception ex) { - if (ex is HttpException) return; - Log("Command", ex.ToString()); - try { - channel.SendMessageAsync(InternalError).Wait(); - } catch (HttpException) { } // Fail silently - } - } - } -#pragma warning restore CS0618 - // Slash command preparation and invocation private async Task DiscordClient_InteractionCreated(SocketInteraction arg) { var context = new SocketInteractionContext(DiscordClient, arg); @@ -207,7 +151,7 @@ public sealed class ShardInstance : IDisposable { // Specific responses to errors, if necessary if (result.Error == InteractionCommandError.UnmetPrecondition) { - string errReply = result.ErrorReason switch { + var errReply = result.ErrorReason switch { RequireBotModeratorAttribute.Error => RequireBotModeratorAttribute.Reply, EnforceBlockingAttribute.FailBlocked => EnforceBlockingAttribute.ReplyBlocked, EnforceBlockingAttribute.FailModerated => EnforceBlockingAttribute.ReplyModerated, diff --git a/ShardManager.cs b/ShardManager.cs index 00bf8c4..dda1d6b 100644 --- a/ShardManager.cs +++ b/ShardManager.cs @@ -1,14 +1,11 @@ global using Discord; global using Discord.WebSocket; using BirthdayBot.BackgroundServices; -using BirthdayBot.TextCommands; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; using System.Text; -using static BirthdayBot.TextCommands.CommandsCommon; namespace BirthdayBot; - /// /// More or less the main class for the program. Handles individual shards and provides frequent /// status reports regarding the overall health of the application. @@ -45,8 +42,6 @@ class ShardManager : IDisposable { /// private readonly Dictionary _shards; - private readonly Dictionary _textCommands; - private readonly Task _statusTask; private readonly CancellationTokenSource _mainCancel; private int _destroyedShards = 0; @@ -59,20 +54,9 @@ class ShardManager : IDisposable { Config = cfg; - // Command handler setup - _textCommands = new Dictionary(StringComparer.OrdinalIgnoreCase); - var cmdsUser = new UserCommands(cfg); - foreach (var item in cmdsUser.Commands) _textCommands.Add(item.Item1, item.Item2); - var cmdsListing = new ListingCommands(cfg); - foreach (var item in cmdsListing.Commands) _textCommands.Add(item.Item1, item.Item2); - var cmdsHelp = new TextCommands.HelpInfoCommands(cfg); - foreach (var item in cmdsHelp.Commands) _textCommands.Add(item.Item1, item.Item2); - var cmdsMods = new ManagerCommands(cfg, cmdsUser.Commands); - foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2); - // Allocate shards based on configuration _shards = new Dictionary(); - for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) { + for (var i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) { _shards.Add(i, null); } @@ -114,12 +98,12 @@ class ShardManager : IDisposable { TotalShards = Config.ShardTotal, LogLevel = LogSeverity.Info, DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts, - GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages, + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers, SuppressUnknownDispatchWarnings = true, LogGatewayIntentWarnings = false }; var services = new ServiceCollection() - .AddSingleton(s => new ShardInstance(this, s, _textCommands)) + .AddSingleton(s => new ShardInstance(this, s)) .AddSingleton(s => new DiscordSocketClient(clientConf)) .AddSingleton(s => new InteractionService(s.GetRequiredService())) .BuildServiceProvider(); @@ -144,7 +128,7 @@ class ShardManager : IDisposable { public string? ExecutingTask; } - private string StatusDisplay(IEnumerable guildList, Dictionary guildInfo, bool showDetail) { + private static string StatusDisplay(IEnumerable guildList, Dictionary guildInfo, bool showDetail) { if (!guildList.Any()) return "--"; var result = new StringBuilder(); foreach (var item in guildList) { @@ -223,7 +207,7 @@ class ShardManager : IDisposable { Program.ProgramStop(); } else { // Start up any missing shards - int startAllowance = MaxConcurrentOperations; + var startAllowance = MaxConcurrentOperations; foreach (var id in nullShards) { // To avoid possible issues with resources strained over so many shards starting at once, // initialization is spread out by only starting a few at a time. diff --git a/TextCommands/CommandDocumentation.cs b/TextCommands/CommandDocumentation.cs deleted file mode 100644 index a0af16d..0000000 --- a/TextCommands/CommandDocumentation.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text; - -namespace BirthdayBot.TextCommands; - -internal class CommandDocumentation { - public string[] Commands { get; } - public string Usage { get; } - public string? Examples { get; } - - public CommandDocumentation(IEnumerable commands, string usage, string? examples) { - var cmds = new List(); - foreach (var item in commands) cmds.Add(CommandsCommon.CommandPrefix + item); - if (cmds.Count == 0) throw new ArgumentException(null, nameof(commands)); - Commands = cmds.ToArray(); - Usage = usage ?? throw new ArgumentException(null, nameof(usage)); - Examples = examples; - } - - /// - /// Returns a string that can be inserted into a help or usage message. - /// - public string Export() { - var result = new StringBuilder(); - foreach (var item in Commands) result.Append(", `" + item + "`"); - result.Remove(0, 2); - result.Insert(0, '●'); - result.AppendLine(); - result.Append("» " + Usage); - if (Examples != null) { - result.AppendLine(); - result.Append("» Examples: " + Examples); - } - return result.ToString(); - } - - /// - /// Creates an embeddable message containing the command documentation. - /// - public Embed UsageEmbed => new EmbedBuilder() { - Author = new EmbedAuthorBuilder() { Name = "Usage" }, - Description = Export() - }.Build(); -} diff --git a/TextCommands/CommandsCommon.cs b/TextCommands/CommandsCommon.cs deleted file mode 100644 index 1cf9d5f..0000000 --- a/TextCommands/CommandsCommon.cs +++ /dev/null @@ -1,93 +0,0 @@ -#pragma warning disable CS0618 -using BirthdayBot.Data; -using NodaTime; -using System.Collections.ObjectModel; -using System.Text.RegularExpressions; - -namespace BirthdayBot.TextCommands; - -/// -/// Common base class for common constants and variables. -/// -internal abstract class CommandsCommon { -#if DEBUG - 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 MemberCacheEmptyError = ":warning: Please try the command again."; - - public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); - - 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 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 (!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; - } - - 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 (Common.HasMostMembersDownloaded(guild)) 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/TextCommands/HelpInfoCommands.cs b/TextCommands/HelpInfoCommands.cs deleted file mode 100644 index 22314c5..0000000 --- a/TextCommands/HelpInfoCommands.cs +++ /dev/null @@ -1,164 +0,0 @@ -#pragma warning disable CS0618 -using BirthdayBot.Data; -using System.Text; - -namespace BirthdayBot.TextCommands; - -internal class HelpInfoCommands : CommandsCommon { - private readonly Embed _helpEmbed; - private readonly Embed _helpConfigEmbed; - - public HelpInfoCommands(Configuration cfg) : base(cfg) { - var embeds = BuildHelpEmbeds(); - _helpEmbed = embeds.Item1; - _helpConfigEmbed = embeds.Item2; - } - - public override IEnumerable<(string, CommandHandler)> Commands => - new List<(string, CommandHandler)>() { - ("help", CmdHelp), - ("help-config", CmdHelpConfig), - ("helpconfig", CmdHelpConfig), - ("help-tzdata", CmdHelpTzdata), - ("helptzdata", CmdHelpTzdata), - ("help-message", CmdHelpMessage), - ("helpmessage", CmdHelpMessage), - ("info", CmdInfo), - ("about", CmdInfo), - ("invite", CmdInfo) - }; - - private static (Embed, Embed) BuildHelpEmbeds() { - var cpfx = $"●`{CommandPrefix}"; - - // Normal section - var cmdField = new EmbedFieldBuilder() { - Name = "Commands", - Value = "----\n**Notice**: Text commands will soon be __removed__. " - + "For a list of new commands, see this bot's `/help` command.\n----\n" - + $"{cpfx}help`, `{CommandPrefix}info`, `{CommandPrefix}help-tzdata`\n" - + $" » Help and informational messages.\n" - + ListingCommands.DocUpcoming.Export() + "\n" - + UserCommands.DocSet.Export() + "\n" - + UserCommands.DocZone.Export() + "\n" - + UserCommands.DocRemove.Export() + "\n" - + ListingCommands.DocWhen.Export() - }; - var cmdModField = new EmbedFieldBuilder() { - Name = "Moderator actions", - Value = $"{cpfx}config`\n" - + $" » Edit bot configuration. See `{CommandPrefix}help-config`.\n" - + ListingCommands.DocList.Export() + "\n" - + ManagerCommands.DocOverride.Export() - }; - var helpRegular = new EmbedBuilder().AddField(cmdField).AddField(cmdModField); - - // Manager section - var mpfx = cpfx + "config "; - var configField1 = new EmbedFieldBuilder() { - Name = "Basic settings", - Value = $"{mpfx}role (role name or ID)`\n" - + " » Sets the role to apply to users having birthdays.\n" - + $"{mpfx}channel (channel name or ID)`\n" - + " » Sets the announcement channel. Leave blank to disable.\n" - + $"{mpfx}message (message)`, `{CommandPrefix}config messagepl (message)`\n" - + $" » Sets a custom announcement message. See `{CommandPrefix}help-message`.\n" - + $"{mpfx}ping (off|on)`\n" - + $" » Sets whether to ping the respective users in the announcement message.\n" - + $"{mpfx}zone (time zone name)`\n" - + $" » Sets the default server time zone. See `{CommandPrefix}help-tzdata`." - }; - var configField2 = new EmbedFieldBuilder() { - Name = "Access management", - Value = $"{mpfx}modrole (role name, role ping, or ID)`\n" - + " » Establishes a role for bot moderators. Grants access to `bb.config` and `bb.override`.\n" - + $"{mpfx}block/unblock (user ping or ID)`\n" - + " » Prevents or allows usage of bot commands to the given user.\n" - + $"{mpfx}moderated on/off`\n" - + " » Prevents or allows using commands for all members excluding moderators." - }; - - var helpConfig = new EmbedBuilder() { - Author = new EmbedAuthorBuilder() { Name = $"{CommandPrefix} config subcommands" }, - Description = "All the following subcommands are only usable by moderators and server managers." - }.AddField(configField1).AddField(configField2); - - return (helpRegular.Build(), helpConfig.Build()); - } - - private async Task CmdHelp(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) - => await reqChannel.SendMessageAsync(embed: _helpEmbed).ConfigureAwait(false); - - private async Task CmdHelpConfig(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) - => await reqChannel.SendMessageAsync(embed: _helpConfigEmbed).ConfigureAwait(false); - - private async Task CmdHelpTzdata(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - const string tzhelp = "You may specify a time zone in order to have your birthday recognized with respect to your local time. " - + "This bot only accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database).\n\n" - + "To find your zone: https://xske.github.io/tz/" + "\n" - + "Interactive map: https://kevinnovak.github.io/Time-Zone-Picker/" + "\n" - + "Complete list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"; - var embed = new EmbedBuilder(); - embed.AddField(new EmbedFieldBuilder() { - Name = "Time Zone Support", - Value = tzhelp - }); - await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - } - - private async Task CmdHelpMessage(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - const string msghelp = "The `message` and `messagepl` subcommands allow for editing the message sent into the announcement " - + "channel (defined with `{0}config channel`). This feature is separated across two commands:\n" - + "●`{0}config message`\n" - + "●`{0}config messagepl`\n" - + "The first command sets the message to be displayed when *one* user is having a birthday. The second command sets the " - + "message for when *two or more* users are having birthdays ('pl' means plural). If only one of the two custom messages " - + "are defined, it will be used for both cases.\n\n" - + "To further allow customization, you may place the token `%n` in your message to specify where the name(s) should appear.\n" - + "Leave the parameter blank to clear or reset the message to its default value."; - const string msghelp2 = "As examples, these are the default announcement messages used by this bot:\n" - + "`message`: {0}\n" + "`messagepl`: {1}"; - var embed = new EmbedBuilder().AddField(new EmbedFieldBuilder() { - Name = "Custom announcement message", - Value = string.Format(msghelp, CommandPrefix) - }).AddField(new EmbedFieldBuilder() { - Name = "Examples", - Value = string.Format(msghelp2, - BackgroundServices.BirthdayRoleUpdate.DefaultAnnounce, BackgroundServices.BirthdayRoleUpdate.DefaultAnnouncePl) - }); - await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - } - - private async Task CmdInfo(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - var strStats = new StringBuilder(); - var asmnm = System.Reflection.Assembly.GetExecutingAssembly().GetName(); - strStats.AppendLine("BirthdayBot v" + asmnm.Version!.ToString(3)); - //strStats.AppendLine("Server count: " + Discord.Guilds.Count.ToString()); // TODO restore this statistic - strStats.AppendLine("Shard #" + instance.ShardId.ToString("00")); - strStats.AppendLine("Uptime: " + Program.BotUptime); - - // TODO fun stats - // current birthdays, total names registered, unique time zones - - var embed = new EmbedBuilder() { - Author = new EmbedAuthorBuilder() { - Name = "Thank you for using Birthday Bot!", - IconUrl = instance.DiscordClient.CurrentUser.GetAvatarUrl() - }, - Description = "For more information regarding support, data retention, privacy, and other details, please refer to: " - + "https://github.com/NoiTheCat/BirthdayBot/blob/master/Readme.md" + "\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." - }.AddField(new EmbedFieldBuilder() { - Name = "Statistics", - Value = strStats.ToString() - }); - await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false); - } -} diff --git a/TextCommands/ListingCommands.cs b/TextCommands/ListingCommands.cs deleted file mode 100644 index 6bc870f..0000000 --- a/TextCommands/ListingCommands.cs +++ /dev/null @@ -1,313 +0,0 @@ -#pragma warning disable CS0618 -using BirthdayBot.Data; -using System.Text; - -namespace BirthdayBot.TextCommands; - -/// -/// 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 - - private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - if (!await HasMemberCacheAsync(reqChannel.Guild)) { - await reqChannel.SendMessageAsync(MemberCacheEmptyError); - 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); - } - - // 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); - 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(ShardInstance.InternalError).Wait(); - } 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 14 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/TextCommands/ManagerCommands.cs b/TextCommands/ManagerCommands.cs deleted file mode 100644 index 696972c..0000000 --- a/TextCommands/ManagerCommands.cs +++ /dev/null @@ -1,487 +0,0 @@ -#pragma warning disable CS0618 -using BirthdayBot.Data; -using System.Text; -using System.Text.RegularExpressions; - -namespace BirthdayBot.TextCommands; - -internal class ManagerCommands : CommandsCommon { - private static readonly string ConfErrorPostfix = - $" Refer to the `{CommandPrefix}help-config` command for information on this command's usage."; - private delegate Task ConfigSubcommand(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel); - - private readonly Dictionary _subcommands; - private readonly Dictionary _usercommands; - - public ManagerCommands(Configuration db, IEnumerable<(string, CommandHandler)> userCommands) : base(db) { - _subcommands = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "role", ScmdRole }, - { "channel", ScmdChannel }, - { "modrole", ScmdModRole }, - { "message", ScmdAnnounceMsg }, - { "messagepl", ScmdAnnounceMsg }, - { "ping", ScmdPing }, - { "zone", ScmdZone }, - { "block", ScmdBlock }, - { "unblock", ScmdBlock }, - { "moderated", ScmdModerated } - }; - - // Set up local copy of all user commands accessible by the override command - _usercommands = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var item in userCommands) _usercommands.Add(item.Item1, item.Item2); - } - - public override IEnumerable<(string, CommandHandler)> Commands - => new List<(string, CommandHandler)>() - { - ("config", CmdConfigDispatch), - ("override", CmdOverride), - ("check", CmdCheck), - ("test", CmdCheck) - }; - - #region Documentation - public static readonly CommandDocumentation DocOverride = - new(new string[] { "override (user ping or ID) (command w/ parameters)" }, - "Perform certain commands on behalf of another user.", null); - #endregion - - private async Task CmdConfigDispatch(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - // Ignore those without the proper permissions. - if (!gconf.IsBotModerator(reqUser)) { - await reqChannel.SendMessageAsync(":x: This command may only be used by bot moderators.").ConfigureAwait(false); - return; - } - - if (param.Length < 2) { - await reqChannel.SendMessageAsync($":x: See `{CommandPrefix}help-config` for information on how to use this command.") - .ConfigureAwait(false); - return; - } - - // Special case: Restrict 'modrole' to only guild managers, not mods - if (string.Equals(param[1], "modrole", StringComparison.OrdinalIgnoreCase) && !reqUser.GuildPermissions.ManageGuild) { - await reqChannel.SendMessageAsync(":x: This command may only be used by those with the `Manage Server` permission.") - .ConfigureAwait(false); - return; - } - - // Subcommands get a subset of the parameters, to make things a little easier. - var confparam = new string[param.Length - 1]; - Array.Copy(param, 1, confparam, 0, param.Length - 1); - - if (_subcommands.TryGetValue(confparam[0], out var h)) { - await h(confparam, gconf, reqChannel).ConfigureAwait(false); - } - } - - #region Configuration sub-commands - // Birthday role set - private async Task ScmdRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length != 2) { - await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified.") - .ConfigureAwait(false); - return; - } - var guild = reqChannel.Guild; - var role = FindUserInputRole(param[1], guild); - - if (role == null) { - await reqChannel.SendMessageAsync(RoleInputError).ConfigureAwait(false); - } else if (role.Id == reqChannel.Guild.EveryoneRole.Id) { - await reqChannel.SendMessageAsync(":x: You cannot set that as the birthday role.").ConfigureAwait(false); - } else { - gconf.RoleId = role.Id; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**.") - .ConfigureAwait(false); - } - } - - // Ping setting - private async Task ScmdPing(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - const string InputErr = ":x: You must specify either `off` or `on` in this setting."; - if (param.Length != 2) { - await reqChannel.SendMessageAsync(InputErr).ConfigureAwait(false); - return; - } - - var input = param[1].ToLower(); - bool setting; - string result; - if (input == "off") { - setting = false; - result = ":white_check_mark: Announcement pings are now **off**."; - } else if (input == "on") { - setting = true; - result = ":white_check_mark: Announcement pings are now **on**."; - } else { - await reqChannel.SendMessageAsync(InputErr).ConfigureAwait(false); - return; - } - - gconf.AnnouncePing = setting; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync(result).ConfigureAwait(false); - } - - // Announcement channel set - private async Task ScmdChannel(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length == 1) // No extra parameter. Unset announcement channel. - { - // Extra detail: Show a unique message if a channel hadn't been set prior. - if (!gconf.AnnounceChannelId.HasValue) { - await reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset.") - .ConfigureAwait(false); - return; - } - - gconf.AnnounceChannelId = null; - await gconf.UpdateAsync(); - await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset.") - .ConfigureAwait(false); - } else { - // Determine channel from input - ulong chId = 0; - - // Try channel mention - var m = ChannelMention.Match(param[1]); - if (m.Success) { - chId = ulong.Parse(m.Groups[1].Value); - } else if (ulong.TryParse(param[1], out chId)) { - // Continue... - } else { - // Try text-based search - var res = reqChannel.Guild.TextChannels - .FirstOrDefault(ch => string.Equals(ch.Name, param[1], StringComparison.OrdinalIgnoreCase)); - if (res != null) { - chId = res.Id; // Yep, we're throwing the full result away only to go look for it again later... - } - } - - // Attempt to find channel in guild - SocketTextChannel? chTt = null; - if (chId != 0) chTt = reqChannel.Guild.GetTextChannel(chId); - if (chTt == null) { - await reqChannel.SendMessageAsync(":x: Unable to find the specified channel.").ConfigureAwait(false); - return; - } - - // Update the value - gconf.AnnounceChannelId = chId; - await gconf.UpdateAsync().ConfigureAwait(false); - - // Report the success - await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>.") - .ConfigureAwait(false); - } - } - - // Moderator role set - private async Task ScmdModRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length != 2) { - await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified.") - .ConfigureAwait(false); - return; - } - var guild = reqChannel.Guild; - var role = FindUserInputRole(param[1], guild); - - if (role == null) { - await reqChannel.SendMessageAsync(RoleInputError).ConfigureAwait(false); - } else { - gconf.ModeratorRole = role.Id; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync($":white_check_mark: The moderator role is now **{role.Name}**.") - .ConfigureAwait(false); - } - } - - // Guild default time zone set/unset - private async Task ScmdZone(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length == 1) // No extra parameter. Unset guild default time zone. - { - // Extra detail: Show a unique message if there is no set zone. - if (!gconf.AnnounceChannelId.HasValue) { - await reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset.").ConfigureAwait(false); - return; - } - - gconf.TimeZone = null; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed.") - .ConfigureAwait(false); - } else { - // Parameter check. - string zone; - try { - zone = ParseTimeZone(param[1]); - } catch (FormatException ex) { - reqChannel.SendMessageAsync(ex.Message).Wait(); - return; - } - - // Update value - gconf.TimeZone = zone; - await gconf.UpdateAsync().ConfigureAwait(false); - - // Report the success - await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**.") - .ConfigureAwait(false); - } - } - - // Block/unblock individual non-manager users from using commands. - private async Task ScmdBlock(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length != 2) { - await reqChannel.SendMessageAsync(ParameterError + ConfErrorPostfix).ConfigureAwait(false); - return; - } - - bool doBan = param[0].ToLower() == "block"; // true = block, false = unblock - - if (!TryGetUserId(param[1], out ulong inputId)) { - await reqChannel.SendMessageAsync(BadUserError).ConfigureAwait(false); - return; - } - - var isBanned = await gconf.IsUserBlockedAsync(inputId).ConfigureAwait(false); - if (doBan) { - if (!isBanned) { - await gconf.BlockUserAsync(inputId).ConfigureAwait(false); - await reqChannel.SendMessageAsync(":white_check_mark: User has been blocked.").ConfigureAwait(false); - } else { - // TODO bug: this is incorrectly always displayed when in moderated mode - await reqChannel.SendMessageAsync(":white_check_mark: User is already blocked.").ConfigureAwait(false); - } - } else { - if (await gconf.UnblockUserAsync(inputId).ConfigureAwait(false)) { - await reqChannel.SendMessageAsync(":white_check_mark: User is now unblocked.").ConfigureAwait(false); - } else { - await reqChannel.SendMessageAsync(":white_check_mark: The specified user is not blocked.").ConfigureAwait(false); - } - } - } - - // "moderated on/off" - Sets/unsets moderated mode. - private async Task ScmdModerated(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - if (param.Length != 2) { - await reqChannel.SendMessageAsync(ParameterError + ConfErrorPostfix).ConfigureAwait(false); - return; - } - - var parameter = param[1].ToLower(); - bool modSet; - if (parameter == "on") modSet = true; - else if (parameter == "off") modSet = false; - else { - await reqChannel.SendMessageAsync(":x: Expecting `on` or `off` as a parameter." + ConfErrorPostfix) - .ConfigureAwait(false); - return; - } - - if (gconf.IsModerated == modSet) { - await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode is already {parameter}.") - .ConfigureAwait(false); - } else { - gconf.IsModerated = modSet; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode has been turned {parameter}.") - .ConfigureAwait(false); - } - } - - // Sets/unsets custom announcement message. - private async Task ScmdAnnounceMsg(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) { - var plural = param[0].ToLower().EndsWith("pl"); - - string? newmsg; - bool clear; - if (param.Length == 2) { - newmsg = param[1]; - clear = false; - } else { - newmsg = null; - clear = true; - } - - (string?, string?) update; - if (!plural) update = (newmsg, gconf.AnnounceMessages.Item2); - else update = (gconf.AnnounceMessages.Item1, newmsg); - gconf.AnnounceMessages = update; - await gconf.UpdateAsync().ConfigureAwait(false); - await reqChannel.SendMessageAsync(string.Format(":white_check_mark: The {0} birthday announcement message has been {1}.", - plural ? "plural" : "singular", clear ? "reset" : "updated")).ConfigureAwait(false); - } - #endregion - - // Execute command as another user - private async Task CmdOverride(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - // Moderators only. As with config, silently drop if this check fails. - if (!gconf.IsBotModerator(reqUser)) return; - - if (!await HasMemberCacheAsync(reqChannel.Guild)) { - await reqChannel.SendMessageAsync(MemberCacheEmptyError); - return; - } - - if (param.Length != 3) { - await reqChannel.SendMessageAsync(ParameterError, embed: DocOverride.UsageEmbed).ConfigureAwait(false); - return; - } - - // Second parameter: determine the user to act as - if (!TryGetUserId(param[1], out ulong user)) { - await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed).ConfigureAwait(false); - return; - } - var overuser = reqChannel.Guild.GetUser(user); - if (overuser == null) { - await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed).ConfigureAwait(false); - return; - } - - // Third parameter: determine command to invoke. - // Reminder that we're only receiving a param array of size 3 at maximum. String must be split again. - var overparam = param[2].Split(" ", 3, StringSplitOptions.RemoveEmptyEntries); - var cmdsearch = overparam[0]; - if (cmdsearch.StartsWith(CommandPrefix)) { - // Strip command prefix to search for the given command. - cmdsearch = cmdsearch[CommandPrefix.Length..]; - } else { - // Add command prefix to input, just in case. - overparam[0] = CommandPrefix + overparam[0].ToLower(); - } - if (!_usercommands.TryGetValue(cmdsearch, out var action)) { - await reqChannel.SendMessageAsync( - $":x: `{cmdsearch}` is not an overridable command.", embed: DocOverride.UsageEmbed) - .ConfigureAwait(false); - return; - } - - // Preparations complete. Run the command. - await reqChannel.SendMessageAsync( - $"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:") - .ConfigureAwait(false); - await action.Invoke(instance, gconf, overparam, reqChannel, overuser).ConfigureAwait(false); - } - - // Troubleshooting tool: Check for common problems regarding typical background operation. - private async Task CmdCheck(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - // Moderators only. As with config, silently drop if this check fails. - if (!gconf.IsBotModerator(reqUser)) return; - - if (param.Length != 1) { - // Too many parameters - // Note: Non-standard error display - await reqChannel.SendMessageAsync(NoParameterError).ConfigureAwait(false); - return; - } - - static string DoTestFor(string label, Func test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }"; - var result = new StringBuilder(); - var guild = reqChannel.Guild; - var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); - var userbdays = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false); - - result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{instance.ShardId:00}`"); - result.AppendLine($"Number of registered birthdays: `{ userbdays.Count() }`"); - result.AppendLine($"Server time zone: `{ (conf?.TimeZone ?? "Not set - using UTC") }`"); - result.AppendLine(); - - bool hasMembers = Common.HasMostMembersDownloaded(guild); - result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers)); - result.AppendLine($" - Has `{guild.DownloadedMemberCount}` of `{guild.MemberCount}` members."); - int bdayCount = -1; - result.Append(DoTestFor("Birthday processing", delegate { - if (!hasMembers) return false; - bdayCount = BackgroundServices.BirthdayRoleUpdate.GetGuildCurrentBirthdays(userbdays, conf?.TimeZone).Count; - return true; - })); - if (hasMembers) result.AppendLine($" - `{bdayCount}` user(s) currently having a birthday."); - else result.AppendLine(" - Previous step failed."); - result.AppendLine(); - - result.AppendLine(DoTestFor("Birthday role set with `bb.config role`", delegate { - if (conf == null) return false; - SocketRole? role = guild.GetRole(conf.RoleId ?? 0); - return role != null; - })); - result.AppendLine(DoTestFor("Birthday role can be managed by bot", delegate { - if (conf == null) return false; - SocketRole? role = guild.GetRole(conf.RoleId ?? 0); - if (role == null) return false; - return guild.CurrentUser.GuildPermissions.ManageRoles && role.Position < guild.CurrentUser.Hierarchy; - })); - result.AppendLine(); - - SocketTextChannel? channel = null; - result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate { - if (conf == null) return false; - channel = guild.GetTextChannel(conf.AnnounceChannelId ?? 0); - return channel != null; - })); - string disp = channel == null ? "announcement channel" : $"<#{channel.Id}>"; - result.AppendLine(DoTestFor($"(Optional) Bot can send messages into { disp }", delegate { - if (channel == null) return false; - return guild.CurrentUser.GetPermissions(channel).SendMessages; - })); - - await reqChannel.SendMessageAsync(embed: new EmbedBuilder() { - Author = new EmbedAuthorBuilder() { Name = "Status and config check" }, - Description = result.ToString() - }.Build()).ConfigureAwait(false); - - const int announceMsgPreviewLimit = 350; - static string prepareAnnouncePreview(string announce) { - string trunc = announce.Length > announceMsgPreviewLimit ? announce[..announceMsgPreviewLimit] + "`(...)`" : announce; - var result = new StringBuilder(); - foreach (var line in trunc.Split('\n')) - result.AppendLine($"> {line}"); - return result.ToString(); - } - if (conf != null && (conf.AnnounceMessages.Item1 != null || conf.AnnounceMessages.Item2 != null)) { - var em = new EmbedBuilder().WithAuthor(new EmbedAuthorBuilder() { Name = "Custom announce messages:" }); - var dispAnnounces = new StringBuilder("Custom announcement message(s):\n"); - if (conf.AnnounceMessages.Item1 != null) { - em = em.AddField("Single", prepareAnnouncePreview(conf.AnnounceMessages.Item1)); - } - if (conf.AnnounceMessages.Item2 != null) { - em = em.AddField("Plural", prepareAnnouncePreview(conf.AnnounceMessages.Item2)); - } - await reqChannel.SendMessageAsync(embed: em.Build()).ConfigureAwait(false); - } - } - - #region Common/helper methods - private const string RoleInputError = ":x: Unable to determine the given role."; - private static readonly Regex RoleMention = new(@"<@?&(?\d+)>", RegexOptions.Compiled); - - private static SocketRole? FindUserInputRole(string inputStr, SocketGuild guild) { - // Resembles a role mention? Strip it to the pure number - var input = inputStr; - var rmatch = RoleMention.Match(input); - if (rmatch.Success) input = rmatch.Groups["snowflake"].Value; - - // Attempt to get role by ID, or null - if (ulong.TryParse(input, out ulong rid)) { - return guild.GetRole(rid); - } else { - // Reset the search value on the off chance there's a role name that actually resembles a role ping. - input = inputStr; - } - - // If not already found, attempt to search role by string name - foreach (var search in guild.Roles) { - if (string.Equals(search.Name, input, StringComparison.OrdinalIgnoreCase)) return search; - } - - return null; - } - #endregion -} diff --git a/TextCommands/UserCommands.cs b/TextCommands/UserCommands.cs deleted file mode 100644 index 9fd8d52..0000000 --- a/TextCommands/UserCommands.cs +++ /dev/null @@ -1,180 +0,0 @@ -#pragma warning disable CS0618 -using BirthdayBot.Data; -using System.Text.RegularExpressions; - -namespace BirthdayBot.TextCommands; - -internal class UserCommands : CommandsCommon { - public UserCommands(Configuration db) : base(db) { } - - public override IEnumerable<(string, CommandHandler)> Commands - => new List<(string, CommandHandler)>() - { - ("set", CmdSet), - ("zone", CmdZone), - ("remove", CmdRemove) - }; - - #region Date parsing - const string FormatError = ":x: Unrecognized date format. The following formats are accepted, as examples: " - + "`15-jan`, `jan-15`, `15 jan`, `jan 15`, `15 January`, `January 15`."; - - private static readonly Regex DateParse1 = new(@"^(?\d{1,2})[ -](?[A-Za-z]+)$", RegexOptions.Compiled); - private static readonly Regex DateParse2 = new(@"^(?[A-Za-z]+)[ -](?\d{1,2})$", RegexOptions.Compiled); - - /// - /// Parses a date input. - /// - /// Tuple: month, day - /// - /// Thrown for any parsing issue. Reason is expected to be sent to Discord as-is. - /// - private static (int, int) ParseDate(string dateInput) { - var m = DateParse1.Match(dateInput); - if (!m.Success) { - // Flip the fields around, try again - m = DateParse2.Match(dateInput); - if (!m.Success) throw new FormatException(FormatError); - } - - int day, month; - string monthVal; - try { - day = int.Parse(m.Groups["day"].Value); - } catch (FormatException) { - throw new Exception(FormatError); - } - monthVal = m.Groups["month"].Value; - - int dayUpper; // upper day of month check - (month, dayUpper) = GetMonth(monthVal); - - if (day == 0 || day > dayUpper) throw new FormatException(":x: The date you specified is not a valid calendar date."); - - return (month, day); - } - - /// - /// Returns information for a given month input. - /// - /// - /// Tuple: Month value, upper limit of days in the month - /// - /// Thrown on error. Send out to Discord as-is. - /// - private static (int, int) GetMonth(string input) { - return input.ToLower() switch { - "jan" or "january" => (1, 31), - "feb" or "february" => (2, 29), - "mar" or "march" => (3, 31), - "apr" or "april" => (4, 30), - "may" => (5, 31), - "jun" or "june" => (6, 30), - "jul" or "july" => (7, 31), - "aug" or "august" => (8, 31), - "sep" or "september" => (9, 30), - "oct" or "october" => (10, 31), - "nov" or "november" => (11, 30), - "dec" or "december" => (12, 31), - _ => throw new FormatException($":x: Can't determine month name `{input}`. Check your spelling and try again."), - }; - } - #endregion - - #region Documentation - public static readonly CommandDocumentation DocSet = - new(new string[] { "set (date)" }, "Registers your birth month and day.", - $"`{CommandPrefix}set jan-31`, `{CommandPrefix}set 15 may`."); - public static readonly CommandDocumentation DocZone = - new(new string[] { "zone (zone)" }, "Sets your local time zone. " - + $"See also `{CommandPrefix}help-tzdata`.", null); - public static readonly CommandDocumentation DocRemove = - new(new string[] { "remove" }, "Removes your birthday information from this bot.", null); - #endregion - - private async Task CmdSet(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - if (param.Length < 2) { - await reqChannel.SendMessageAsync(ParameterError, embed: DocSet.UsageEmbed).ConfigureAwait(false); - return; - } - - // Date format accepts spaces. Must coalesce parameters to a single string. - var fullinput = ""; - foreach (var p in param[1..]) fullinput += " " + p; - fullinput = fullinput[1..]; // trim leading space - - int bmonth, bday; - try { - (bmonth, bday) = ParseDate(fullinput); - } catch (FormatException ex) { - // Our parse method's FormatException has its message to send out to Discord. - reqChannel.SendMessageAsync(ex.Message, embed: DocSet.UsageEmbed).Wait(); - return; - } - - // Parsing successful. Update user information. - bool known; // Extra detail: Bot's response changes if the user was previously unknown. - try { - var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false); - known = user.IsKnown; - await user.UpdateAsync(bmonth, bday, user.TimeZone).ConfigureAwait(false); - } catch (Exception ex) { - Program.Log("Error", ex.ToString()); - reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait(); - return; - } - await reqChannel.SendMessageAsync($":white_check_mark: Your birthday has been { (known ? "updated" : "recorded") }.") - .ConfigureAwait(false); - } - - private async Task CmdZone(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - if (param.Length != 2) { - await reqChannel.SendMessageAsync(ParameterError, embed: DocZone.UsageEmbed).ConfigureAwait(false); - return; - } - - var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false); - if (!user.IsKnown) { - await reqChannel.SendMessageAsync(":x: You may only update your time zone when you have a birthday registered." - + $" Refer to the `{CommandPrefix}set` command.", embed: DocZone.UsageEmbed) - .ConfigureAwait(false); - return; - } - - string btz; - try { - btz = ParseTimeZone(param[1]); - } catch (Exception ex) { - reqChannel.SendMessageAsync(ex.Message, embed: DocZone.UsageEmbed).Wait(); - return; - } - await user.UpdateAsync(user.BirthMonth, user.BirthDay, btz).ConfigureAwait(false); - - await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**.") - .ConfigureAwait(false); - } - - private async Task CmdRemove(ShardInstance instance, GuildConfiguration gconf, - string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) { - // Parameter count check - if (param.Length != 1) { - await reqChannel.SendMessageAsync(NoParameterError, embed: DocRemove.UsageEmbed).ConfigureAwait(false); - return; - } - - // Extra detail: Send a notification if the user isn't actually known by the bot. - bool known; - var u = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false); - known = u.IsKnown; - await u.DeleteAsync().ConfigureAwait(false); - if (!known) { - await reqChannel.SendMessageAsync(":white_check_mark: This bot already does not contain your information.") - .ConfigureAwait(false); - } else { - await reqChannel.SendMessageAsync(":white_check_mark: Your information has been removed.") - .ConfigureAwait(false); - } - } -}