From 74f876c4af64adacfb153dc1e91ddf7d0ef19f50 Mon Sep 17 00:00:00 2001 From: Noi Date: Mon, 21 Feb 2022 11:57:17 -0800 Subject: [PATCH] Implemented more commands --- ApplicationCommands/BotApplicationCommand.cs | 6 +- ApplicationCommands/HelpCommands.cs | 27 +- ApplicationCommands/ModCommands.cs | 317 +++++++++++++++++++ ApplicationCommands/QueryCommands.cs | 281 ++++++++++++++++ ShardManager.cs | 13 +- 5 files changed, 630 insertions(+), 14 deletions(-) create mode 100644 ApplicationCommands/ModCommands.cs create mode 100644 ApplicationCommands/QueryCommands.cs diff --git a/ApplicationCommands/BotApplicationCommand.cs b/ApplicationCommands/BotApplicationCommand.cs index cb169a8..0c78511 100644 --- a/ApplicationCommands/BotApplicationCommand.cs +++ b/ApplicationCommands/BotApplicationCommand.cs @@ -45,9 +45,9 @@ internal abstract class BotApplicationCommand { /// throwing a FormatException if the input is not recognized. /// protected static string ParseTimeZone(string tzinput) { - if (!TzNameMap.TryGetValue(tzinput, out string? tz)) throw new FormatException(":x: Unexpected time zone name." - + $" Refer to `INSERT COMMAND NAME HERE` to help determine the correct value."); // TODO fix!!!!!!!!!!!!!!!!!!! - // put link to tz finder -and- refer to command for elaborate info + if (!TzNameMap.TryGetValue(tzinput, out string? tz)) + throw new FormatException(":x: Unknown time zone name.\n" + + "To find your time zone, please refer to: https://kevinnovak.github.io/Time-Zone-Picker/"); return tz!; } diff --git a/ApplicationCommands/HelpCommands.cs b/ApplicationCommands/HelpCommands.cs index 5a6f56e..6713639 100644 --- a/ApplicationCommands/HelpCommands.cs +++ b/ApplicationCommands/HelpCommands.cs @@ -9,14 +9,23 @@ internal class HelpCommands : BotApplicationCommand { static HelpCommands() { _helpEmbedRegCommandsField = new EmbedFieldBuilder() { Name = "Commands", - Value = $"`/set-birthday` - {RegistrationCommands.HelpSet}\n" - + $"`/set-timezone` - {RegistrationCommands.HelpZone}\n" - + $"`/remove-timezone` - {RegistrationCommands.HelpZoneDel}\n" - + $"`/remove-birthday` - {RegistrationCommands.HelpDel}" + Value = $"`/set-birthday` - {RegistrationCommands.HelpSet}\n" + + $"`/set-timezone` - {RegistrationCommands.HelpZone}\n" + + $"`/remove-timezone` - {RegistrationCommands.HelpZoneDel}\n" + + $"`/remove-birthday` - {RegistrationCommands.HelpDel}\n" + + $"`/birthday` - {QueryCommands.HelpBirthdayFor}\n" + + $"`/recent`, `/upcoming` - {QueryCommands.HelpRecentUpcoming}" + }; _helpEmbedModCommandsField = new EmbedFieldBuilder() { Name = "Moderator commands", - Value = $"`/override` - {RegistrationOverrideCommands.HelpOverride}" + Value = + $"`/config` - {ModCommands.HelpConfig}\n" + + $"`/announce` - {ModCommands.HelpConfAnnounce}\n" + + $"`/blocking` - {ModCommands.HelpConfBlocking}\n" + + $"`/list-all` - {QueryCommands.HelpListAll}\n" + + $"`/override` - {RegistrationOverrideCommands.HelpOverride}\n" + + $"See also: `/config help`, `/announce help`, `/blocking help`." }; } @@ -40,10 +49,10 @@ internal class HelpCommands : BotApplicationCommand { .WithAuthor("Help & About") .WithFooter($"Birthday Bot {ver} - Shard {instance.ShardId:00} up {Program.BotUptime}", instance.DiscordClient.CurrentUser.GetAvatarUrl()) - .WithDescription("Support, data policy, etc: https://noithecat.dev/bots/BirthdayBot\n" - + "This bot is provided for free, without any paywalls or exclusive paid features. If this bot has been useful to you, " - + "please consider taking a look at the author's Ko-fi: https://ko-fi.com/noithecat.\n" - + "Thank you for using Birthday Bot!") + .WithDescription("Thank you for using Birthday Bot!\n" + + "Support, data policy, etc: https://noithecat.dev/bots/BirthdayBot\n" + + "This bot is provided for free, without any paywalls or exclusive paid features. If this bot has been useful to you, " + + "please consider taking a look at the author's Ko-fi: https://ko-fi.com/noithecat.") .AddField(_helpEmbedRegCommandsField) .AddField(_helpEmbedModCommandsField) .Build(); diff --git a/ApplicationCommands/ModCommands.cs b/ApplicationCommands/ModCommands.cs new file mode 100644 index 0000000..998598b --- /dev/null +++ b/ApplicationCommands/ModCommands.cs @@ -0,0 +1,317 @@ +using BirthdayBot.Data; +using System.Text; + +namespace BirthdayBot.ApplicationCommands; + +internal class ModCommands : BotApplicationCommand { + private readonly ShardManager _instance; + + private delegate Task SubCommandHandler(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam); + private static Embed HelpSubAnnounceEmbed { get; } = new EmbedBuilder() + .AddField("Subcommands for `/announce`", + $"`channel` - {HelpSAnChannel} {HelpPofxBlankUnset}\n" + + $"`ping` - {HelpSAnPing}\n" + + $"`message-single` - {HelpSAnSingle}\n" + + $"`message-multi` - {HelpSAnMulti}") + .AddField("Custom announcement messages", + "The `message-single` and `message-multi` subcommands allow moderators to edit the message sent into the announcement " + + "channel.\nThe first command `message-single` sets the message that is displayed when *one* user has a birthday. The second " + + "command `message-multi` sets the message used when *two or more* users have birthdays. If only one of the two messages " + + "have been set, this bot will use the same message in both cases.\n\n" + + "For further customization, you may use the token `%n` in your message to specify where the name(s) should appear.\n") + .Build(); + private static Embed HelpSubBlockingEmbed { get; } = new EmbedBuilder() + .AddField("Commands", "testtesttest").Build(); + + public const string HelpConfig = "Configure for essential bot settings."; + public const string HelpConfAnnounce = "Configuration regarding announcement messages."; + public const string HelpConfBlocking = "Configuration regarding limiting user access."; + const string HelpPofxBlankUnset = " Leave blank to unset."; + const string HelpOptChannelDefault = "The corresponding channel to use."; + const string HelpOptRoleDefault = "The corresponding role to use."; + private const string HelpSAnChannel = "Set the channel which to send birthday announcements."; + private const string HelpSAnPing = "Set whether to ping users mentioned in the announcement."; + private const string HelpSAnSingle = "Set the message announced when one user has a birthday."; + private const string HelpSAnMulti = "Set the message announced when two or more users have a birthday."; + + public ModCommands(ShardManager instance) => _instance = instance; + + public override IEnumerable GetCommands() => new ApplicationCommandProperties[] { + new SlashCommandBuilder() + .WithName("config") + .WithDescription(HelpPfxModOnly + HelpConfig) + .AddOption(new SlashCommandOptionBuilder() + .WithName("birthday-role") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Set or modify the role given to those having a birthday.") + .AddOption("role", ApplicationCommandOptionType.Role, HelpOptRoleDefault, isRequired: true) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("mod-role") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Allow a role to be able to use moderator commands." + HelpPofxBlankUnset) + .AddOption("role", ApplicationCommandOptionType.Role, HelpOptRoleDefault, isRequired: false) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("server-timezone") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Set the default time zone to be used in this server." + HelpPofxBlankUnset) + .AddOption("zone", ApplicationCommandOptionType.String, HelpOptZone, isRequired: false) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("check") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Give a configuration status report.") + ) + .Build(), + new SlashCommandBuilder() + .WithName("announce") + .WithDescription(HelpPfxModOnly + HelpConfAnnounce) + .AddOption("help", ApplicationCommandOptionType.SubCommand, + HelpPfxModOnly + "Display information regarding announcement messages.") + .AddOption(new SlashCommandOptionBuilder() + .WithName("channel") + .WithDescription(HelpPfxModOnly + HelpSAnChannel + HelpPofxBlankUnset) + .WithType(ApplicationCommandOptionType.SubCommand) + .AddOption("channel", ApplicationCommandOptionType.Channel, HelpOptChannelDefault, isRequired: false) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("ping") + .WithDescription(HelpPfxModOnly + HelpSAnPing) + .WithType(ApplicationCommandOptionType.SubCommand) + .AddOption("option", ApplicationCommandOptionType.Boolean, + "True to ping users or False to display names normally.", isRequired: true) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("message-single") + .WithDescription(HelpPfxModOnly + HelpSAnSingle) + .WithType(ApplicationCommandOptionType.SubCommand) + .AddOption("message", ApplicationCommandOptionType.String, "The new message to use.") + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("message-multi") + .WithDescription(HelpPfxModOnly + HelpSAnMulti) + .WithType(ApplicationCommandOptionType.SubCommand) + .AddOption("message", ApplicationCommandOptionType.String, "The new message to use.") + ) + .Build(), + new SlashCommandBuilder() + .WithName("blocking") + .WithDescription(HelpPfxModOnly + HelpConfBlocking) + .AddOption("help", ApplicationCommandOptionType.SubCommand, + HelpPfxModOnly + "Display information regarding user blocking.") + .AddOption(new SlashCommandOptionBuilder() + .WithName("moderated") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Set moderated mode on the server.") + .AddOption("enable", ApplicationCommandOptionType.Boolean, + "True to enable moderated mode, False to disable.", isRequired: true) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("block-user") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Add a user to the blocklist.") + .AddOption("user", ApplicationCommandOptionType.User, "The user to add to the blocklist.", isRequired: true) + ) + .AddOption(new SlashCommandOptionBuilder() + .WithName("unblock-user") + .WithType(ApplicationCommandOptionType.SubCommand) + .WithDescription(HelpPfxModOnly + "Remove a user from the blocklist.") + .AddOption("user", ApplicationCommandOptionType.User, "The user to remove from the blocklist.", isRequired: true) + ) + .Build() + }; + public override CommandResponder? GetHandlerFor(string commandName) => commandName switch { + "config" => CmdConfigDispatch, + "announce" => CmdConfigDispatch, + "blocking" => CmdConfigDispatch, + _ => null, + }; + + private Task CmdConfigDispatch(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) { + if (!gconf.IsBotModerator((SocketGuildUser)arg.User)) return arg.RespondAsync(ErrNotAllowed); + + var name = arg.Data.Options.First().Name; + if (name == "help") return HelpCommandHandler(arg, arg.CommandName); + + SubCommandHandler? subh = arg.Data.Options.First().Name switch { + "birthday-role" => CmdConfigSubBRole, + "mod-role" => CmdConfigSubMRole, + "server-timezone" => CmdConfigSubTz, + "check" => CmdConfigSubCheck, + "channel" => CmdAnnounceSubChannel, + "ping" => CmdAnnounceSubPing, + "message-single" => CmdAnnounceSubMsg, + "message-multi" => CmdAnnounceSubMsg, + "moderated" => CmdBlockSubModerated, + "block-user" => CmdBlockSubAddDel, + "unblock-user" => CmdBlockSubAddDel, + _ => null + }; + + if (subh == null) return arg.RespondAsync(ShardInstance.UnknownCommandError, ephemeral: true); + + var subparam = ((SocketSlashCommandDataOption)arg.Data.Options.First()).Options.ToDictionary(o => o.Name, o => o.Value); + return subh(gconf, arg, subparam); + } + + private static async Task HelpCommandHandler(SocketSlashCommand arg, string baseCommand) { + var answer = baseCommand switch { + "announce" => HelpSubAnnounceEmbed, + "blocking" => HelpSubBlockingEmbed, + _ => null + }; + if (answer == null) { + await arg.RespondAsync(ShardInstance.UnknownCommandError, ephemeral: true); + return; + } + await arg.RespondAsync(embed: answer); + } + + private async Task CmdConfigSubBRole(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + var role = (SocketRole)subparam["role"]; + gconf.RoleId = role.Id; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync($":white_check_mark: The birthday role has been set to **{role.Name}**.").ConfigureAwait(false); + } + + private async Task CmdConfigSubMRole(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + var role = subparam.GetValueOrDefault("role") as SocketRole; + gconf.ModeratorRole = role?.Id; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync(":white_check_mark: The moderator role has been " + + (role == null ? "unset." : $"set to **{role.Name}**.")); + } + + private async Task CmdConfigSubTz(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + const string Response = ":white_check_mark: The server's time zone has been "; + var inputtz = subparam.GetValueOrDefault("zone") as string; + + if (inputtz == null) { + gconf.TimeZone = null; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync(Response + "unset.").ConfigureAwait(false); + } else { + string zone; + try { + zone = ParseTimeZone(inputtz); + } catch (FormatException e) { + arg.RespondAsync(e.Message).Wait(); + return; + } + + gconf.TimeZone = zone; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync(Response + $"set to **{zone}**.").ConfigureAwait(false); + } + } + + private async Task CmdConfigSubCheck(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + static string DoTestFor(string label, Func test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }"; + var result = new StringBuilder(); + SocketTextChannel channel = (SocketTextChannel)arg.Channel; + var guild = channel.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.GetShardIdFor(guild.Id):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? announcech = null; + result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate { + if (conf == null) return false; + announcech = guild.GetTextChannel(conf.AnnounceChannelId ?? 0); + return announcech != null; + })); + string disp = announcech == null ? "announcement channel" : $"<#{announcech.Id}>"; + result.AppendLine(DoTestFor($"(Optional) Bot can send messages into { disp }", delegate { + if (announcech == null) return false; + return guild.CurrentUser.GetPermissions(announcech).SendMessages; + })); + + await arg.RespondAsync(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("Multi", prepareAnnouncePreview(conf.AnnounceMessages.Item2)); + } + await channel.SendMessageAsync(embed: em.Build()).ConfigureAwait(false); + } + } + + private async Task CmdAnnounceSubChannel(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + var channel = subparam.GetValueOrDefault("channel") as SocketTextChannel; + gconf.AnnounceChannelId = channel?.Id; + await gconf.UpdateAsync(); + await arg.RespondAsync(":white_check_mark: The announcement channel has been " + + (channel == null ? "unset." : $"set to **{channel.Name}**.")); + } + + private async Task CmdAnnounceSubPing(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + var setting = (bool)subparam["option"]; + gconf.AnnouncePing = setting; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync(":white_check_mark: Announcement pings are now " + (setting ? "**on**." : "**off**.")).ConfigureAwait(false); + } + + private async Task CmdAnnounceSubMsg(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + // Handles "message-single" and "message-multi" subcommands + await arg.RespondAsync("unimplemented"); + } + + private async Task CmdBlockSubModerated(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + var setting = (bool)subparam["option"]; + gconf.IsModerated = setting; + await gconf.UpdateAsync().ConfigureAwait(false); + await arg.RespondAsync(":white_check_mark: Moderated mode is now " + (setting ? "**on**." : "**off**.")).ConfigureAwait(false); + } + + private async Task CmdBlockSubAddDel(GuildConfiguration gconf, SocketSlashCommand arg, Dictionary subparam) { + // Handles "block-user" and "unblock-user" subcommands + await arg.RespondAsync("unimplemented"); + } +} diff --git a/ApplicationCommands/QueryCommands.cs b/ApplicationCommands/QueryCommands.cs new file mode 100644 index 0000000..ba8ecef --- /dev/null +++ b/ApplicationCommands/QueryCommands.cs @@ -0,0 +1,281 @@ +using BirthdayBot.Data; +using System.Text; + +namespace BirthdayBot.ApplicationCommands; + +internal class QueryCommands : BotApplicationCommand { + public const string HelpBirthdayFor = "Gets a user's birthday."; + public const string HelpListAll = "Show a full list of all known birthdays."; + public const string HelpRecentUpcoming = "Get a list of users who recently had or will have a birthday."; + + public override IEnumerable GetCommands() => new ApplicationCommandProperties[] { + new SlashCommandBuilder() + .WithName("birthday") + .WithDescription(HelpBirthdayFor) + .AddOption("user", ApplicationCommandOptionType.User, "The user whose birthday to check.", isRequired: false) + .Build(), + new SlashCommandBuilder() + .WithName("recent") + .WithDescription(HelpRecentUpcoming) + .Build(), + new SlashCommandBuilder() + .WithName("upcoming") + .WithDescription(HelpRecentUpcoming) + .Build(), + new SlashCommandBuilder() + .WithName("list-all") + .WithDescription(HelpPfxModOnly + HelpRecentUpcoming) + .AddOption("as-csv", ApplicationCommandOptionType.Boolean, "Whether to output the list in CSV format.") + .Build(), + }; + public override CommandResponder? GetHandlerFor(string commandName) => commandName switch { + "birthday-for" => CmdBirthdayFor, + "recent" => CmdRecentUpcoming, + "upcoming" => CmdRecentUpcoming, + "list-all" => CmdListAll, + _ => null, + }; + + private async Task CmdBirthdayFor(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) { + var searchtarget = arg.Data.Options.FirstOrDefault()?.Value as SocketGuildUser ?? (SocketGuildUser)arg.User; + var targetdata = await GuildUserConfiguration.LoadAsync(gconf.GuildId, searchtarget.Id); + + if (!targetdata.IsKnown) { + await arg.RespondAsync($"{Common.FormatName(searchtarget, false)} does not have their birthday registered."); + return; + } + await arg.RespondAsync($"{Common.FormatName(searchtarget, false)}: " + + $"`{targetdata.BirthDay:00}-{Common.MonthNames[targetdata.BirthMonth]}`" + + (targetdata.TimeZone == null ? "" : $" - {targetdata.TimeZone}")).ConfigureAwait(false); + } + + // "Recent and upcoming birthdays" + // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here + // TODO stop being lazy + private async Task CmdRecentUpcoming(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) { + var guild = ((SocketGuildChannel)arg.Channel).Guild; + if (!await HasMemberCacheAsync(guild).ConfigureAwait(false)) { + await arg.RespondAsync(MemberCacheEmptyError, ephemeral: true); + 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(guild).ConfigureAwait(false); + + // TODO pagination instead of this workaround + bool hasOutputOneLine = false; + // First output is shown as an interaction response, followed then as regular channel messages + async Task doOutput(string msg) { + if (!hasOutputOneLine) { + await arg.RespondAsync(msg).ConfigureAwait(false); + hasOutputOneLine = true; + } else { + await arg.Channel.SendMessageAsync(msg).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 doOutput(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 arg.RespondAsync( + "There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).") + .ConfigureAwait(false); + else + await doOutput(output.ToString()).ConfigureAwait(false); + } + + private async Task CmdListAll(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) { + var guild = ((SocketGuildChannel)arg.Channel).Guild; + // For now, we're restricting this command to moderators only. This may turn into an option later. + if (!gconf.IsBotModerator((SocketGuildUser)arg.User)) { + // Do not add detailed usage information to this error message. + await arg.RespondAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false); + return; + } + + if (!await HasMemberCacheAsync(guild)) { + await arg.RespondAsync(MemberCacheEmptyError).ConfigureAwait(false); + return; + } + + // Check for CSV option + var useCsv = arg.Data.Options.FirstOrDefault()?.Value as bool? ?? false; + + var bdlist = await GetSortedUsersAsync(guild).ConfigureAwait(false); + + var filepath = Path.GetTempPath() + "birthdaybot-" + guild.Id; + string fileoutput; + if (useCsv) { + fileoutput = ListExportCsv(guild, bdlist); + filepath += ".csv"; + } else { + fileoutput = ListExportNormal(guild, bdlist); + filepath += ".txt."; + } + await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false); + + try { + await arg.RespondWithFileAsync(filepath, "birthdaybot-" + guild.Id + (useCsv ? ".csv" : ".txt"), + $"Exported {bdlist.Count} birthdays to file.", + null, false, false, null, null, null, null); + } catch (Exception ex) { + Program.Log("Listing", ex.ToString()); + } finally { + File.Delete(filepath); + } + } + + /// + /// 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(SocketGuild guild, IEnumerable list) { + // Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" + var result = new StringBuilder(); + result.AppendLine("Birthdays in " + guild.Name); + result.AppendLine(); + foreach (var item in list) { + var user = 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(SocketGuild guild, 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 = 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/ShardManager.cs b/ShardManager.cs index d31866f..6f01b39 100644 --- a/ShardManager.cs +++ b/ShardManager.cs @@ -71,10 +71,11 @@ class ShardManager : IDisposable { foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2); _appCommands = new List() { - // TODO fill this out new HelpCommands(), new RegistrationCommands(), - new RegistrationOverrideCommands() + new RegistrationOverrideCommands(), + new QueryCommands(), + new ModCommands(this) }; // Allocate shards based on configuration @@ -130,6 +131,14 @@ class ShardManager : IDisposable { return newInstance; } + public int? GetShardIdFor(ulong guildId) { + foreach (var sh in _shards.Values) { + if (sh == null) continue; + if (sh.DiscordClient.GetGuild(guildId) != null) return sh.ShardId; + } + return null; + } + #region Status checking and display private struct GuildStatusData { public int GuildCount;