From ddcde10e0900e088d83287dac5f6155042b00a39 Mon Sep 17 00:00:00 2001 From: Noi Date: Thu, 2 Apr 2020 11:27:55 -0700 Subject: [PATCH] First commit for C# rewrite All existing VB code was 'translated' to C# as closely as possible, with minor changes and additional notes. Currently untested and likely broken. Further commits will go toward making overall improvements until this version replaces the currently existing code. --- .gitignore | 4 + BackgroundServiceRunner.cs | 78 ++++ BackgroundServices/BackgroundService.cs | 15 + BackgroundServices/BirthdayRoleUpdate.cs | 328 +++++++++++++++ BackgroundServices/GuildStatistics.cs | 50 +++ BackgroundServices/Heartbeat.cs | 32 ++ BirthdayBot.cs | 156 ++++++++ BirthdayBot.csproj | 22 + BirthdayBot.sln | 25 ++ Common.cs | 47 +++ Configuration.cs | 69 ++++ Data/Database.cs | 42 ++ Data/GuildStateInformation.cs | 431 ++++++++++++++++++++ Data/GuildUserSettings.cs | 168 ++++++++ Data/OperationStatus.cs | 64 +++ DiscordBots.md | 18 + License.txt | 21 + Program.cs | 80 ++++ Readme.md | 10 + UserInterface/CommandsCommon.cs | 98 +++++ UserInterface/HelpInfoCommands.cs | 178 +++++++++ UserInterface/ListingCommands.cs | 271 +++++++++++++ UserInterface/ManagerCommands.cs | 488 +++++++++++++++++++++++ UserInterface/UserCommands.cs | 260 ++++++++++++ 24 files changed, 2955 insertions(+) create mode 100644 .gitignore create mode 100644 BackgroundServiceRunner.cs create mode 100644 BackgroundServices/BackgroundService.cs create mode 100644 BackgroundServices/BirthdayRoleUpdate.cs create mode 100644 BackgroundServices/GuildStatistics.cs create mode 100644 BackgroundServices/Heartbeat.cs create mode 100644 BirthdayBot.cs create mode 100644 BirthdayBot.csproj create mode 100644 BirthdayBot.sln create mode 100644 Common.cs create mode 100644 Configuration.cs create mode 100644 Data/Database.cs create mode 100644 Data/GuildStateInformation.cs create mode 100644 Data/GuildUserSettings.cs create mode 100644 Data/OperationStatus.cs create mode 100644 DiscordBots.md create mode 100644 License.txt create mode 100644 Program.cs create mode 100644 Readme.md create mode 100644 UserInterface/CommandsCommon.cs create mode 100644 UserInterface/HelpInfoCommands.cs create mode 100644 UserInterface/ListingCommands.cs create mode 100644 UserInterface/ManagerCommands.cs create mode 100644 UserInterface/UserCommands.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e04f816 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +[Bb]in/ +[Oo]bj/ +.vs/ +*.user \ No newline at end of file diff --git a/BackgroundServiceRunner.cs b/BackgroundServiceRunner.cs new file mode 100644 index 0000000..903acc6 --- /dev/null +++ b/BackgroundServiceRunner.cs @@ -0,0 +1,78 @@ +using BirthdayBot.BackgroundServices; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace BirthdayBot +{ + /// + /// Handles the execution of periodic background tasks. + /// + class BackgroundServiceRunner + { + const int Interval = 8 * 60; // Tick interval in seconds. Adjust as needed. + + private List _workers; + private readonly CancellationTokenSource _workerCancel; + private Task _workerTask; + + internal BirthdayRoleUpdate BirthdayUpdater { get; } + + public BackgroundServiceRunner(BirthdayBot instance) + { + _workerCancel = new CancellationTokenSource(); + BirthdayUpdater = new BirthdayRoleUpdate(instance); + _workers = new List() + { + {new GuildStatistics(instance)}, + {new Heartbeat(instance)}, + {BirthdayUpdater} + }; + } + + public void Start() + { + _workerTask = Task.Factory.StartNew(WorkerLoop, _workerCancel.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + public async Task Cancel() + { + _workerCancel.Cancel(); + await _workerTask; + } + + /// + /// *The* background task. Executes service tasks and handles errors. + /// + private async Task WorkerLoop() + { + while (!_workerCancel.IsCancellationRequested) + { + try + { + // Delay a bit before we start (or continue) work. + await Task.Delay(Interval * 1000, _workerCancel.Token); + + // Execute background tasks. + var tasks = new List(); + foreach (var service in _workers) + { + tasks.Add(service.OnTick()); + } + await Task.WhenAll(tasks); + } + catch (TaskCanceledException) + { + return; + } + catch (Exception ex) + { + Program.Log("Background task", "Unhandled exception during background task execution:"); + Program.Log("Background task", ex.ToString()); + } + } + } + } +} diff --git a/BackgroundServices/BackgroundService.cs b/BackgroundServices/BackgroundService.cs new file mode 100644 index 0000000..43fc17a --- /dev/null +++ b/BackgroundServices/BackgroundService.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace BirthdayBot.BackgroundServices +{ + abstract class BackgroundService + { + protected BirthdayBot BotInstance { get; } + + public BackgroundService(BirthdayBot instance) => BotInstance = instance; + + protected void Log(string message) => Program.Log(GetType().Name, message); + + public abstract Task OnTick(); + } +} diff --git a/BackgroundServices/BirthdayRoleUpdate.cs b/BackgroundServices/BirthdayRoleUpdate.cs new file mode 100644 index 0000000..ff276d2 --- /dev/null +++ b/BackgroundServices/BirthdayRoleUpdate.cs @@ -0,0 +1,328 @@ +using BirthdayBot.Data; +using Discord.WebSocket; +using NodaTime; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BirthdayBot.BackgroundServices +{ + /// + /// Core automatic functionality of the bot. Manages role memberships based on birthday information, + /// and optionally sends the announcement message to appropriate guilds. + /// + class BirthdayRoleUpdate : BackgroundService + { + public BirthdayRoleUpdate(BirthdayBot instance) : base(instance) { } + + /// + /// Does processing on all available guilds at once. + /// + public override async Task OnTick() + { + var tasks = new List(); + foreach (var guild in BotInstance.DiscordClient.Guilds) + { + var t = ProcessGuildAsync(guild); + tasks.Add(t); + } + + try + { + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + // TODO does this not actually work as might be expected? + var exs = from task in tasks + where task.Exception != null + select task.Exception; + Log($"Encountered {exs.Count()} errors during bulk guild processing."); + foreach (var iex in exs) + { + // TODO probably not a good idea + Log(iex.ToString()); + } + } + + // TODO metrics for role sets, unsets, announcements - and how to do that for singles too? + + // Running GC now. Many long-lasting items have likely been discarded by now. + GC.Collect(); + } + + public async Task SingleUpdateFor(SocketGuild guild) + { + try + { + await ProcessGuildAsync(guild); + } + catch (Exception ex) + { + Log("Encountered an error during guild processing:"); + Log(ex.ToString()); + } + + // TODO metrics for role sets, unsets, announcements - and I mentioned this above too + } + + /// + /// Main method where actual guild processing occurs. + /// + private async Task ProcessGuildAsync(SocketGuild guild) + { + // Gather required information + string tz; + IEnumerable users; + SocketRole role = null; + SocketTextChannel channel = null; + (string, string) announce; + bool announceping; + + // Skip processing of guild if local info has not yet been loaded + if (!BotInstance.GuildCache.ContainsKey(guild.Id)) return; + + // Lock once to grab all info + var gs = BotInstance.GuildCache[guild.Id]; + tz = gs.TimeZone; + users = gs.Users; + announce = gs.AnnounceMessages; + announceping = gs.AnnouncePing; + + if (gs.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gs.AnnounceChannelId.Value); + if (gs.RoleId.HasValue) role = guild.GetRole(gs.RoleId.Value); + + // Determine who's currently having a birthday + var birthdays = GetGuildCurrentBirthdays(users, tz); + // Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply. + + // Set birthday roles, get list of users that had the role added + // But first check if we are able to do so. Letting all requests fail instead will lead to rate limiting. + var roleCheck = CheckCorrectRoleSettings(guild, role); + if (!roleCheck.Item1) + { + lock (gs) + { + gs.OperationLog = new OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, roleCheck.Item2)); + } + return; + } + + IEnumerable announcementList; + (int, int) roleResult; // role additions, removals + // Do actual role updating + try + { + var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays); + announcementList = updateResult.Item1; + roleResult = updateResult.Item2; + } + catch (Discord.Net.HttpException ex) + { + lock (gs) + { + gs.OperationLog = new OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, ex.Message)); + } + if (ex.HttpCode != System.Net.HttpStatusCode.Forbidden) + { + // Send unusual exceptions to calling method + throw; + } + return; + } + + (OperationStatus.OperationType, string) opResult1, opResult2; + opResult1 = (OperationStatus.OperationType.UpdateBirthdayRoleMembership, + $"Success: Added {roleResult.Item1} member(s), Removed {roleResult.Item2} member(s) from target role."); + + if (announcementList.Count() != 0) + { + var announceOpResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList); + opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, announceOpResult); + } + else + { + opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, "Announcement not considered."); + } + + lock (gs) + { + gs.OperationLog = new OperationStatus(opResult1, opResult2); + } + } + + /// + /// Checks if the bot may be allowed to alter roles. + /// + private (bool, string) CheckCorrectRoleSettings(SocketGuild guild, SocketRole role) + { + if (role == null) + { + return (false, "Failed: Designated role not found or defined."); + } + + if (!guild.CurrentUser.GuildPermissions.ManageRoles) + { + return (false, "Failed: Bot does not contain Manage Roles permission."); + } + + // Check potential role order conflict + if (role.Position >= guild.CurrentUser.Hierarchy) + { + return (false, "Failed: Bot is beneath the designated role in the role hierarchy."); + } + + return (true, null); + } + + /// + /// 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. + /// + private HashSet GetGuildCurrentBirthdays(IEnumerable guildUsers, string defaultTzStr) + { + var birthdayUsers = new HashSet(); + + DateTimeZone defaultTz = null; + if (defaultTzStr != null) + { + defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr); + } + defaultTz = defaultTz ?? DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC"); + // TODO determine defaultTz from guild's voice region + + foreach (var item in guildUsers) + { + // Determine final time zone to use for calculation + DateTimeZone tz = null; + if (item.TimeZone != null) + { + // Try user-provided time zone + tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone); + } + tz = tz ?? 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; + } + + /// + /// Sets the birthday role to all applicable users. Unsets it from all others who may have it. + /// + /// A list of users who had the birthday role applied. Use for the announcement message. + private async Task<(IEnumerable, (int, int))> UpdateGuildBirthdayRoles( + SocketGuild g, SocketRole r, HashSet names) + { + // Check members currently with the role. Figure out which users to remove it from. + var roleRemoves = new List(); + var roleKeeps = new HashSet(); + var q = 0; + foreach (var member in r.Members) + { + if (!names.Contains(member.Id)) + { + roleRemoves.Add(member); + } + else + { + roleKeeps.Add(member.Id); + } + q += 1; + } + + // TODO Can we remove during the iteration instead of after? investigate later... + foreach (var user in roleRemoves) + { + await user.RemoveRoleAsync(r); + } + + // Apply role to members not already having it. Prepare announcement list. + var newBirthdays = new List(); + foreach (var target in names) + { + var member = g.GetUser(target); + if (member == null) continue; + if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing + await member.AddRoleAsync(r); + newBirthdays.Add(member); + } + + return (newBirthdays, (newBirthdays.Count, roleRemoves.Count)); + } + + public const string DefaultAnnounce = "Please wish a happy birthday to %n!"; + public const string DefaultAnnouncePl = "Please wish a happy birthday to our esteemed members: %n"; + + /// + /// Makes (or attempts to make) an announcement in the specified channel that includes all users + /// who have just had their birthday role added. + /// + private async Task AnnounceBirthdaysAsync( + (string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable names) + { + if (c == null) + { + return "Announcement channel is undefined."; + } + + string announceMsg; + if (names.Count() == 1) + { + announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce; + } + else + { + announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl; + } + announceMsg = announceMsg.TrimEnd(); + if (!announceMsg.Contains("%n")) announceMsg += " %n"; + + // Build sorted name list + var namestrings = new List(); + foreach (var item in names) + { + namestrings.Add(Common.FormatName(item, announcePing)); + } + namestrings.Sort(StringComparer.OrdinalIgnoreCase); + + var namedisplay = new StringBuilder(); + var first = true; + foreach (var item in namestrings) + { + if (!first) + { + namedisplay.Append(", "); + first = false; + } + namedisplay.Append(item); + } + + try + { + await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())); + return $"Successfully announced {names.Count()} name(s)"; + } + catch (Discord.Net.HttpException ex) + { + return ex.Message; + } + } + } +} diff --git a/BackgroundServices/GuildStatistics.cs b/BackgroundServices/GuildStatistics.cs new file mode 100644 index 0000000..eb5a6e3 --- /dev/null +++ b/BackgroundServices/GuildStatistics.cs @@ -0,0 +1,50 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace BirthdayBot.BackgroundServices +{ + class GuildStatistics : BackgroundService + { + private string DBotsToken { get; } + + public GuildStatistics(BirthdayBot instance) : base(instance) => DBotsToken = instance.Config.DBotsToken; + + public async override Task OnTick() + { + var count = BotInstance.DiscordClient.Guilds.Count; + Log($"Currently in {count} guild(s)."); + + await SendExternalStatistics(count); + } + + /// + /// Send statistical information to external services. + /// + /// + /// Only Discord Bots is currently supported. No plans to support others any time soon. + /// + async Task SendExternalStatistics(int guildCount) + { + var rptToken = BotInstance.Config.DBotsToken; + if (rptToken == null) return; + + const string apiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats"; + using (var client = new WebClient()) + { + var uri = new Uri(string.Format(apiUrl, BotInstance.DiscordClient.CurrentUser.Id)); + var data = $"{{ \"guildCount\": {guildCount} }}"; + client.Headers[HttpRequestHeader.Authorization] = rptToken; + client.Headers[HttpRequestHeader.ContentType] = "application/json"; + try + { + await client.UploadStringTaskAsync(uri, data); + Log("Discord Bots: Report sent successfully."); + } catch (WebException ex) + { + Log("Discord Bots: Encountered an error. " + ex.Message); + } + } + } + } +} diff --git a/BackgroundServices/Heartbeat.cs b/BackgroundServices/Heartbeat.cs new file mode 100644 index 0000000..1851d55 --- /dev/null +++ b/BackgroundServices/Heartbeat.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace BirthdayBot.BackgroundServices +{ + /// + /// Basic heartbeat function - hints that the background task is still alive. + /// + class Heartbeat : BackgroundService + { + public Heartbeat(BirthdayBot instance) : base(instance) { } + + public override Task OnTick() + { + var uptime = DateTimeOffset.UtcNow - Program.BotStartTime; + Log($"Bot uptime: {Common.BotUptime}"); + + // Disconnection warn + foreach (var shard in BotInstance.DiscordClient.Shards) + { + if (shard.ConnectionState == Discord.ConnectionState.Disconnected) + { + Log($"Shard {shard.ShardId} is disconnected! Restart the app if this persists."); + // The library alone cannot be restarted as it is in an unknown state. It was not designed to be restarted. + // TODO This is the part where we'd signal something to restart us if we were fancy. + } + } + + return Task.CompletedTask; + } + } +} diff --git a/BirthdayBot.cs b/BirthdayBot.cs new file mode 100644 index 0000000..a1c2ec2 --- /dev/null +++ b/BirthdayBot.cs @@ -0,0 +1,156 @@ +using BirthdayBot.Data; +using BirthdayBot.UserInterface; +using Discord; +using Discord.Net; +using Discord.Webhook; +using Discord.WebSocket; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using static BirthdayBot.UserInterface.CommandsCommon; + +namespace BirthdayBot +{ + class BirthdayBot + { + private readonly Dictionary _dispatchCommands; + private readonly UserCommands _cmdsUser; + private readonly ListingCommands _cmdsListing; + private readonly HelpInfoCommands _cmdsHelp; + private readonly ManagerCommands _cmdsMods; + + private BackgroundServiceRunner _worker; + + internal Configuration Config { get; } + internal DiscordShardedClient DiscordClient { get; } + internal ConcurrentDictionary GuildCache { get; } + internal DiscordWebhookClient LogWebhook { get; } + + public BirthdayBot(Configuration conf, DiscordShardedClient dc) + { + Config = conf; + DiscordClient = dc; + LogWebhook = new DiscordWebhookClient(conf.LogWebhook); + GuildCache = new ConcurrentDictionary(); + + _worker = new BackgroundServiceRunner(this); + + // Command dispatch set-up + _dispatchCommands = new Dictionary(StringComparer.OrdinalIgnoreCase); + _cmdsUser = new UserCommands(this, conf); + foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2); + _cmdsListing = new ListingCommands(this, conf); + foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2); + _cmdsHelp = new HelpInfoCommands(this, conf); + foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2); + _cmdsMods = new ManagerCommands(this, conf, _cmdsUser.Commands); + foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2); + + // Register event handlers + DiscordClient.JoinedGuild += LoadGuild; + DiscordClient.GuildAvailable += LoadGuild; + DiscordClient.LeftGuild += DiscardGuild; + DiscordClient.ShardConnected += SetStatus; + DiscordClient.MessageReceived += Dispatch; + } + + public async Task Start() + { + await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken); + await DiscordClient.StartAsync(); + +#if !DEBUG + Program.Log("Background processing", "Delaying start"); + await Task.Delay(90000); // TODO don't keep doing this + Program.Log("Background processing", "Delay complete"); +#endif + _worker.Start(); + + await Task.Delay(-1); + } + + /// + /// Called only by CancelKeyPress handler. + /// + public async Task Shutdown() + { + await _worker.Cancel(); + await DiscordClient.LogoutAsync(); + DiscordClient.Dispose(); + } + + private async Task LoadGuild(SocketGuild g) + { + if (!GuildCache.ContainsKey(g.Id)) + { + var gi = await GuildStateInformation.LoadSettingsAsync(Config.DatabaseSettings, g.Id); + GuildCache.TryAdd(g.Id, gi); + } + } + + private Task DiscardGuild(SocketGuild g) + { + GuildCache.TryRemove(g.Id, out _); + return Task.CompletedTask; + } + + private async Task SetStatus(DiscordSocketClient shard) + { + await shard.SetGameAsync(CommandPrefix + "help"); + } + + private async Task Dispatch(SocketMessage msg) + { + if (msg.Channel is IDMChannel) return; + if (msg.Author.IsBot) return; + // TODO determine message type (pin, join, etc) + + // 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) + { + var channel = (SocketTextChannel)msg.Channel; + var author = (SocketGuildUser)msg.Author; + + // Determine if it's something we're listening for. + // Doing this first before the block check because a block check triggers a database query. + CommandHandler command = null; + if (!_dispatchCommands.TryGetValue(csplit[0].Substring(CommandPrefix.Length), out command)) return; + + // Ban check + var gi = GuildCache[channel.Guild.Id]; + // Skip ban check if user is a manager + if (!gi.IsUserModerator(author)) + { + if (gi.IsUserBlockedAsync(author.Id).GetAwaiter().GetResult()) return; + } + + // Execute the command + try + { + Program.Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}"); + await command(csplit, channel, author); + } + catch (Exception ex) + { + if (ex is HttpException) return; + Program.Log("Error", ex.ToString()); + try + { + channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait(); + } catch (HttpException ex2) + { + // Fail silently. + } + } + + // Immediately check for role updates in the invoking guild + // TODO be smarter about when to call this + await _worker.BirthdayUpdater.SingleUpdateFor(channel.Guild); + } + } + } +} diff --git a/BirthdayBot.csproj b/BirthdayBot.csproj new file mode 100644 index 0000000..7b74126 --- /dev/null +++ b/BirthdayBot.csproj @@ -0,0 +1,22 @@ + + + + Exe + netcoreapp3.1 + 2.0.0 + BirthdayBot + NoiTheCat + BirthdayBot + Discord bot for birthday recognition and reminders. + BirthdayBot + BirthdayBot + + + + + + + + + + diff --git a/BirthdayBot.sln b/BirthdayBot.sln new file mode 100644 index 0000000..3a2bc4b --- /dev/null +++ b/BirthdayBot.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29806.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirthdayBot", "BirthdayBot.csproj", "{804EFB38-1D30-4082-B1F7-CD2594E9490E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {804EFB38-1D30-4082-B1F7-CD2594E9490E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {804EFB38-1D30-4082-B1F7-CD2594E9490E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {804EFB38-1D30-4082-B1F7-CD2594E9490E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {804EFB38-1D30-4082-B1F7-CD2594E9490E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2929174F-3E37-41FB-83CF-9EF38AFD225C} + EndGlobalSection +EndGlobal diff --git a/Common.cs b/Common.cs new file mode 100644 index 0000000..2139afc --- /dev/null +++ b/Common.cs @@ -0,0 +1,47 @@ +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Text; + +namespace BirthdayBot +{ + static class Common + { + /// + /// Formats a user's name to a consistent, readable format which makes use of their nickname. + /// + public static string FormatName(SocketGuildUser member, bool ping) + { + if (ping) return member.Mention; + + string escapeFormattingCharacters(string input) + { + var result = new StringBuilder(); + foreach (var c in input) + { + if (c == '\\' || c == '_' || c == '~' || c == '*') + { + result.Append('\\'); + } + result.Append(c); + } + return result.ToString(); + } + + var username = escapeFormattingCharacters(member.Username); + if (member.Nickname != null) + { + return $"**{escapeFormattingCharacters(member.Nickname)}** ({username}#{member.Discriminator})"; + } + return $"**{username}**#{member.Discriminator}"; + } + + public static readonly Dictionary MonthNames = new Dictionary() + { + {1, "Jan"}, {2, "Feb"}, {3, "Mar"}, {4, "Apr"}, {5, "May"}, {6, "Jun"}, + {7, "Jul"}, {8, "Aug"}, {9, "Sep"}, {10, "Oct"}, {11, "Nov"}, {12, "Dec"} + }; + + public static string BotUptime => (DateTimeOffset.UtcNow - Program.BotStartTime).ToString("d' days, 'hh':'mm':'ss"); + } +} diff --git a/Configuration.cs b/Configuration.cs new file mode 100644 index 0000000..efcc2cc --- /dev/null +++ b/Configuration.cs @@ -0,0 +1,69 @@ +using BirthdayBot.Data; +using Newtonsoft.Json.Linq; +using System; +using System.IO; +using System.Reflection; + +namespace BirthdayBot +{ + /// + /// Loads and holds configuration values. + /// + class Configuration + { + public string BotToken { get; } + public string LogWebhook { get; } + public string DBotsToken { get; } + public Database DatabaseSettings { get; } + public int ShardCount { get; } + + public Configuration() + { + // Looks for settings.json in the executable directory. + var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + confPath += Path.DirectorySeparatorChar + "settings.json"; + + if (!File.Exists(confPath)) + { + throw new Exception("Settings file not found." + + " Create a file in the executable directory named 'settings.json'."); + } + + var jc = JObject.Parse(File.ReadAllText(confPath)); + + BotToken = jc["BotToken"]?.Value(); + if (string.IsNullOrWhiteSpace(BotToken)) + throw new Exception("'BotToken' must be specified."); + + LogWebhook = jc["LogWebhook"]?.Value(); + if (string.IsNullOrWhiteSpace(LogWebhook)) + throw new Exception("'LogWebhook' must be specified."); + + var dbj = jc["DBotsToken"]; + if (dbj != null) + { + DBotsToken = dbj.Value(); + } + else + { + DBotsToken = null; + } + + var sqlcs = jc["SqlConnectionString"]?.Value(); + if (string.IsNullOrWhiteSpace(sqlcs)) + throw new Exception("'SqlConnectionString' must be specified."); + DatabaseSettings = new Database(sqlcs); + + int? sc = jc["ShardCount"]?.Value(); + if (!sc.HasValue) ShardCount = 1; + else + { + ShardCount = sc.Value; + if (ShardCount <= 0) + { + throw new Exception("'ShardCount' must be a positive integer."); + } + } + } + } +} diff --git a/Data/Database.cs b/Data/Database.cs new file mode 100644 index 0000000..90a82af --- /dev/null +++ b/Data/Database.cs @@ -0,0 +1,42 @@ +using Npgsql; +using System.Threading.Tasks; + +namespace BirthdayBot.Data +{ + /// + /// Some database abstractions. + /// + class Database + { + /* + * Database storage in this project, explained: + * Each guild gets a row in the settings table. This table is referred to when doing most things. + * Within each guild, each known user gets a row in the users table with specific information specified. + * Users can override certain settings in global, such as time zone. + */ + + private string DBConnectionString { get; } + + public Database(string connString) + { + DBConnectionString = connString; + + // Database initialization happens here as well. + SetupTables(); + } + + public async Task OpenConnectionAsync() + { + var db = new NpgsqlConnection(DBConnectionString); + await db.OpenAsync(); + return db; + } + + private void SetupTables() + { + using var db = OpenConnectionAsync().GetAwaiter().GetResult(); + GuildStateInformation.SetUpDatabaseTable(db); // Note: Call this first. (Foreign reference constraints.) + GuildUserSettings.SetUpDatabaseTable(db); + } + } +} diff --git a/Data/GuildStateInformation.cs b/Data/GuildStateInformation.cs new file mode 100644 index 0000000..96d016d --- /dev/null +++ b/Data/GuildStateInformation.cs @@ -0,0 +1,431 @@ +using Discord.WebSocket; +using Npgsql; +using NpgsqlTypes; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; + +namespace BirthdayBot.Data +{ + /// + /// Holds various pieces of state information for a guild the bot is operating in. + /// Includes, among other things, a copy of the guild's settings and a list of all known users with birthdays. + /// + class GuildStateInformation + { + private readonly Database _db; + private ulong? _bdayRole; + private ulong? _announceCh; + private ulong? _modRole; + private string _tz; + private bool _moderated; + private string _announceMsg; + private string _announceMsgPl; + private bool _announcePing; + private readonly Dictionary _userCache; + + public ulong GuildId { get; } + public OperationStatus OperationLog { get; set; } + + /// + /// Gets a list of cached registered user information. + /// + public IEnumerable Users { + get { + var items = new List(); + lock (this) + { + foreach (var item in _userCache.Values) items.Add(item); + } + return items; + } + } + + /// + /// Gets the guild's designated Role ID. + /// + public ulong? RoleId { get { lock (this) { return _bdayRole; } } } + + /// + /// Gets the designated announcement Channel ID. + /// + public ulong? AnnounceChannelId { get { lock (this) { return _announceCh; } } } + + /// + /// Gets the guild's default time zone. + /// + public string TimeZone { get { lock (this) { return _tz; } } } + + /// + /// Gets whether the guild is in moderated mode. + /// + public bool IsModerated { get { lock (this) { return _moderated; } } } + + /// + /// Gets the designated moderator role ID. + /// + public ulong? ModeratorRole { get { lock (this) { return _modRole; } } } + + /// + /// Gets the guild-specific birthday announcement message. + /// + public (string, string) AnnounceMessages { get { lock (this) { return (_announceMsg, _announceMsgPl); } } } + + /// + /// Gets whether to ping users in the announcement message instead of displaying their names. + /// + public bool AnnouncePing { get { lock (this) { return _announcePing; } } } + + // Called by LoadSettingsAsync. Double-check ordinals when changes are made. + private GuildStateInformation(DbDataReader reader, Database dbconfig) + { + _db = dbconfig; + + OperationLog = new OperationStatus(); + + GuildId = (ulong)reader.GetInt64(0); + if (!reader.IsDBNull(1)) + { + _bdayRole = (ulong)reader.GetInt64(1); + } + if (!reader.IsDBNull(2)) _announceCh = (ulong)reader.GetInt64(2); + _tz = reader.IsDBNull(3) ? null : reader.GetString(3); + _moderated = reader.GetBoolean(4); + if (!reader.IsDBNull(5)) _modRole = (ulong)reader.GetInt64(5); + _announceMsg = reader.IsDBNull(6) ? null : reader.GetString(6); + _announceMsgPl = reader.IsDBNull(7) ? null : reader.GetString(7); + _announcePing = reader.GetBoolean(8); + + // Get user information loaded up. + var userresult = GuildUserSettings.GetGuildUsersAsync(dbconfig, GuildId); + _userCache = new Dictionary(); + foreach (var item in userresult) + { + _userCache.Add(item.UserId, item); + } + } + + /// + /// Gets user information from th is guild. If the user doesn't exist in the backing database, + /// a new instance is created which is capable of adding the user to the database. + /// + /// + /// For users with the Known property set to false, be sure to call + /// if the resulting object is otherwise unused. + /// + public GuildUserSettings GetUser(ulong userId) + { + lock (this) + { + if (_userCache.ContainsKey(userId)) + { + return _userCache[userId]; + } + + // No result. Create a blank entry and add it to the list, + // in case it gets updated and then referenced later. + var blank = new GuildUserSettings(GuildId, userId); + _userCache.Add(userId, blank); + return blank; + } + } + + /// + /// Deletes the user from the backing database. Drops the locally cached entry. + /// + public async Task DeleteUserAsync(ulong userId) + { + GuildUserSettings user = null; + lock (this) + { + if (!_userCache.TryGetValue(userId, out user)) + { + return; + } + _userCache.Remove(userId); + } + await user.DeleteAsync(_db); + } + + /// + /// Checks if the given user is blocked from issuing commands. + /// If the server is in moderated mode, this always returns true. + /// Does not check if the user is a manager. + /// + public async Task IsUserBlockedAsync(ulong userId) + { + if (IsModerated) return true; + + // Block list is not cached, thus doing a database lookup + // TODO cache block list? + using (var db = await _db.OpenConnectionAsync()) + { + 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()) + { + if (await r.ReadAsync()) return true; + return false; + } + } + } + } + + /// + /// Checks if the given user is a moderator either by having the Manage Server permission or + /// being in the designated moderator role. + /// + public bool IsUserModerator(SocketGuildUser user) + { + if (user.GuildPermissions.ManageGuild) return true; + lock (this) + { + if (ModeratorRole.HasValue) + { + if (user.Roles.Where(r => r.Id == ModeratorRole.Value).Count() > 0) return true; + } + } + + return false; + } + + /// + /// Adds the specified user to the block list, preventing them from issuing commands. + /// + public async Task BlockUserAsync(ulong userId) + { + // TODO cache block list? + using (var db = await _db.OpenConnectionAsync()) + { + 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"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId; + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + } + } + + public async Task UnbanUserAsync(ulong userId) + { + // TODO cache block list? + using (var db = await _db.OpenConnectionAsync()) + { + 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(); + await c.ExecuteNonQueryAsync(); + } + } + } + + public void UpdateRole(ulong roleId) + { + lock (this) + { + _bdayRole = roleId; + UpdateDatabase(); + } + } + + public void UpdateAnnounceChannel(ulong? channelId) + { + lock (this) + { + _announceCh = channelId; + UpdateDatabase(); + } + } + + public void UpdateTimeZone(string tzString) + { + lock (this) + { + _tz = tzString; + UpdateDatabase(); + } + } + + public void UpdateModeratedMode(bool isModerated) + { + lock (this) + { + _moderated = isModerated; + UpdateDatabase(); + } + } + + public void UpdateModeratorRole(ulong? roleId) + { + lock (this) + { + _modRole = roleId; + UpdateDatabase(); + } + } + + public void UpdateAnnounceMessage(string message, bool plural) + { + lock (this) + { + if (plural) _announceMsgPl = message; + else _announceMsg = message; + + UpdateDatabase(); + } + } + + public void UpdateAnnouncePing(bool value) + { + lock (this) + { + _announcePing = value; + UpdateDatabase(); + } + } + + #region Database + public const string BackingTable = "settings"; + public const string BackingTableBans = "banned_users"; + + internal static void SetUpDatabaseTable(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" + + ")"; + c.ExecuteNonQuery(); + } + using (var c = db.CreateCommand()) + { + c.CommandText = $"create table if not exists {BackingTableBans} (" + + $"guild_id bigint not null references {BackingTable}, " + + "user_id bigint not null, " + + "PRIMARY KEY (guild_id, user_id)" + + ")"; + c.ExecuteNonQuery(); + } + } + + /// + /// Retrieves an object instance representative of guild settings for the specified guild. + /// If settings for the given guild do not yet exist, a new value is created. + /// + internal async static Task LoadSettingsAsync(Database dbsettings, ulong guild) + { + using (var db = await dbsettings.OpenConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + // Take note of ordinals for use in 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)guild; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) + { + return new GuildStateInformation(r, dbsettings); + } + } + } + + // If we got here, no row exists. Create it. + using (var c = db.CreateCommand()) + { + c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guild; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + } + + // New row created. Try this again. + return await LoadSettingsAsync(dbsettings, guild); + } + + /// + /// Updates the backing database with values from this instance + /// This is a non-asynchronous operation. That may be bad. + /// + private void UpdateDatabase() + { + using (var db = _db.OpenConnectionAsync().GetAwaiter().GetResult()) + { + 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 = (ulong)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 (_announceCh.HasValue) p.Value = (long)_announceCh.Value; + else p.Value = DBNull.Value; + + p = c.Parameters.Add("@TimeZone", NpgsqlDbType.Text); + if (_tz != null) p.Value = _tz; + else p.Value = DBNull.Value; + + c.Parameters.Add("@Moderated", NpgsqlDbType.Text).Value = _moderated; + + 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 (_announceMsg != null) p.Value = _announceMsg; + else p.Value = DBNull.Value; + + p = c.Parameters.Add("@AnnounceMsgPl", NpgsqlDbType.Text); + if (_announceMsgPl != null) p.Value = _announceMsgPl; + else p.Value = DBNull.Value; + + c.Parameters.Add("@AnnouncePing", NpgsqlDbType.Boolean).Value = _announcePing; + + c.Prepare(); + c.ExecuteNonQuery(); + } + } + } + #endregion + } +} diff --git a/Data/GuildUserSettings.cs b/Data/GuildUserSettings.cs new file mode 100644 index 0000000..8a8fa7a --- /dev/null +++ b/Data/GuildUserSettings.cs @@ -0,0 +1,168 @@ +using Npgsql; +using NpgsqlTypes; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; + +namespace BirthdayBot.Data +{ + /// + /// Representation of a user's birthday settings within a guild. + /// Instances are held and managed by . + /// + class GuildUserSettings + { + private int _month; + private int _day; + private string _tz; + + public ulong GuildId { get; } + public ulong UserId { get; } + + /// + /// Month of birth as a numeric value. Range 1-12. + /// + public int BirthMonth { get { return _month; } } + /// + /// Day of birth as a numeric value. Ranges between 1-31 or lower based on month value. + /// + public int BirthDay { get { return _day; } } + + public string TimeZone { get { return _tz; } } + public bool IsKnown { get { return _month != 0 && _day != 0; } } + + /// + /// Creates a data-less instance without any useful information. + /// Calling will create a real database enty + /// + public GuildUserSettings(ulong guildId, ulong userId) + { + GuildId = guildId; + UserId = userId; + } + + // Called by GetGuildUsersAsync. Double-check ordinals when changes are made. + private GuildUserSettings(DbDataReader reader) + { + GuildId = (ulong)reader.GetInt64(0); + UserId = (ulong)reader.GetInt64(1); + _month = reader.GetInt32(2); + _day = reader.GetInt32(3); + if (!reader.IsDBNull(4)) _tz = reader.GetString(4); + } + + /// + /// Updates user with given information. + /// NOTE: If there exists a tz value and the update contains none, the old tz value is retained. + /// + public async Task UpdateAsync(int month, int day, string newtz, Database dbconfig) + { + // TODO note from rewrite: huh? why are we doing this here? + var inserttz = newtz ?? TimeZone; + + using (var db = await dbconfig.OpenConnectionAsync()) + { + // Will do a delete/insert instead of insert...on conflict update. Because lazy. + using (var t = db.BeginTransaction()) + { + await DoDeleteAsync(db); + 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)"; + 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 p = c.Parameters.Add("@Tz", NpgsqlDbType.Text); + if (inserttz != null) p.Value = inserttz; + else p.Value = DBNull.Value; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + await t.CommitAsync(); + } + } + + // We didn't crash! Get the new values stored locally. + _month = month; + _day = day; + _tz = inserttz; + } + + /// + /// Deletes information of this user from the backing database. + /// The corresponding object reference should ideally be discarded after calling this. + /// + public async Task DeleteAsync(Database dbconfig) + { + using (var db = await dbconfig.OpenConnectionAsync()) + { + await DoDeleteAsync(db); + } + } + + // Shared between UpdateAsync and DeleteAsync + private async Task DoDeleteAsync(NpgsqlConnection dbconn) + { + using (var c = dbconn.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(); + } + } + + #region Database + public const string BackingTable = "user_birthdays"; + + internal static void SetUpDatabaseTable(NpgsqlConnection db) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"create table if not exists {BackingTable} (" + + $"guild_id bigint not null references {GuildStateInformation.BackingTable}, " + + "user_id bigint not null, " + + "birth_month integer not null, " + + "birth_day integer not null, " + + "time_zone text null, " + + "PRIMARY KEY (guild_id, user_id)" + + ")"; + c.ExecuteNonQuery(); + } + } + + /// + /// Gets all known birthday records from the specified guild. No further filtering is done here. + /// + internal static IEnumerable GetGuildUsersAsync(Database dbsettings, ulong guildId) + { + using (var db = dbsettings.OpenConnectionAsync().GetAwaiter().GetResult()) + { + using (var c = db.CreateCommand()) + { + // Take note of ordinals for use in the constructor + c.CommandText = "select guild_id, user_id, birth_month, birth_day, time_zone " + + $"from {BackingTable} where guild_id = @Gid"; + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId; + c.Prepare(); + using (var r = c.ExecuteReader()) + { + var result = new List(); + while (r.Read()) + { + result.Add(new GuildUserSettings(r)); + } + return result; + } + } + } + } + #endregion + } +} diff --git a/Data/OperationStatus.cs b/Data/OperationStatus.cs new file mode 100644 index 0000000..84c9b89 --- /dev/null +++ b/Data/OperationStatus.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BirthdayBot.Data +{ + /// + /// Holds information regarding the previous updating information done on a guild including success/error information. + /// + class OperationStatus + { + private readonly Dictionary _log = new Dictionary(); + + public DateTimeOffset Timestamp { get; } + + public OperationStatus (params (OperationType, string)[] statuses) + { + Timestamp = DateTimeOffset.UtcNow; + foreach (var status in statuses) + { + _log[status.Item1] = status.Item2; + } + } + + /// + /// Prepares known information in a displayable format. + /// + public string GetDiagStrings() + { + var report = new StringBuilder(); + foreach (OperationType otype in Enum.GetValues(typeof(OperationType))) + { + var prefix = $"`{Enum.GetName(typeof(OperationType), otype)}`: "; + + string info = null; + + if (!_log.TryGetValue(otype, out info)) + { + report.AppendLine(prefix + "No data"); + continue; + } + + if (info == null) + { + report.AppendLine(prefix + "Success"); + } + else + { + report.AppendLine(prefix + info); + } + } + return report.ToString(); + } + + /// + /// Specifies the type of operation logged. These enum values are publicly displayed in the specified order. + /// + public enum OperationType + { + UpdateBirthdayRoleMembership, + SendBirthdayAnnouncementMessage + } + } +} diff --git a/DiscordBots.md b/DiscordBots.md new file mode 100644 index 0000000..0699323 --- /dev/null +++ b/DiscordBots.md @@ -0,0 +1,18 @@ +## Recognize birthdays in your Discord community! + +Birthday Bot is a simple, single-purpose bot. It will set a role on your users for the duration of their birthdays and, if desired, can announce a message in a channel of your choosing. Server owners can further specify a default time zone, with individual users also setting their own to ensure everyone's birthdays are recognized precisely on time. + +#### Getting started +* Invite the bot. Be mindful that it requires role setting permissions. +* Create a dedicated birthday role to be used only by the bot. Ensure the new role is placed beneath the bot's own role. + * **Do not use an existing role!** This bot assumes exclusive control over it. Users that have the role but are not having a birthday *will* be removed from it! +* Instruct the bot to use the role: `bb.config role (role name)` + +#### Other tips +* Set the birthday announcement channel: `bb.config channel (channel)` +* Set a default time zone: `bb.config zone (time zone)` + * Use the command `bb.help-tzdata` for information on how to specify time zones. +* Customize the announcement message: See `bb.help-message` for more information. + +#### Note +Birthday information is not shared between servers. This is *by design*, as some people prefer to share their birthdays with select groups of people but keep it obscured from other communities. \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..84d0671 --- /dev/null +++ b/License.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-2019 Noi, a.k.a. Noiiko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..9568756 --- /dev/null +++ b/Program.cs @@ -0,0 +1,80 @@ +using Discord; +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace BirthdayBot +{ + class Program + { + private static BirthdayBot _bot; + + public static DateTimeOffset BotStartTime { get; private set; } + + static void Main() + { + var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + Log("Birthday Bot", $"Version {ver.ToString(3)} is starting."); + + BotStartTime = DateTimeOffset.UtcNow; + var cfg = new Configuration(); + + var dc = new DiscordSocketConfig() + { + AlwaysDownloadUsers = true, + DefaultRetryMode = Discord.RetryMode.RetryRatelimit, + MessageCacheSize = 0, + TotalShards = cfg.ShardCount, + ExclusiveBulkDelete = true + }; + + var client = new DiscordShardedClient(dc); + client.Log += DNetLog; + + _bot = new BirthdayBot(cfg, client); + + Console.CancelKeyPress += OnCancelKeyPressed; + + _bot.Start().Wait(); + } + + /// + /// Sends a formatted message to console. + /// + public static void Log(string source, string message) + { + // Add file logging later? + var ts = DateTime.UtcNow; + var ls = new string[]{ "\r\n", "\n" }; + foreach (var item in message.Split(ls, StringSplitOptions.None)) + Console.WriteLine($"{ts:u} [{source}] {item}"); + } + + private static Task DNetLog(LogMessage arg) + { + if (arg.Severity <= LogSeverity.Info) + { + Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); + } + + if (arg.Exception != null) + { + Log("Discord.Net", arg.Exception.ToString()); + } + + return Task.CompletedTask; + } + + private void OnCancelKeyPressed(Object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + Log("Shutdown", "Caught cancel key. Will shut down..."); + var hang = !_bot.Shutdown().Wait(10000); + if (hang) + { + Log("Shutdown", "Normal shutdown has not concluded after 10 seconds. Will force quit."); + } + Environment.Exit(0); + } + } +} diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..25a6f21 --- /dev/null +++ b/Readme.md @@ -0,0 +1,10 @@ +# Birthday Bot + +Recognize birthdays in your Discord community! + +* Info: https://discord.bots.gg/bots/470673087671566366 +* Invite: https://discordapp.com/oauth2/authorize?client_id=470673087671566366&scope=bot&permissions=268435456 + +This bot will automatically add a role to users during their birthdays. If desired, it will also announce birthdays in a channel of your choosing. Time zones are supported per-server and per-user to ensure that birthdays and events are recognized at appropriate times. + +For more information, see the `DiscordBots.md` file. \ No newline at end of file diff --git a/UserInterface/CommandsCommon.cs b/UserInterface/CommandsCommon.cs new file mode 100644 index 0000000..4988c52 --- /dev/null +++ b/UserInterface/CommandsCommon.cs @@ -0,0 +1,98 @@ +using Discord.WebSocket; +using NodaTime; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace BirthdayBot.UserInterface +{ + /// + /// 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 GenericError = ":x: Invalid usage. Consult the help command."; + public const string BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID."; + public const string ExpectedNoParametersError = ":x: This command does not take parameters. Did you mean to use another?"; + + public delegate Task CommandHandler(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); + + protected static Dictionary TzNameMap { + get { + // Because IDateTimeZoneProvider.GetZoneOrNull is not case sensitive: + // Getting every existing zone name and mapping it onto a dictionary. Now a case-insensitive + // search can be made with the accepted value retrieved as a result. + if (_tzNameMap == null) + { + _tzNameMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var name in DateTimeZoneProviders.Tzdb.Ids) _tzNameMap.Add(name, name); + } + return _tzNameMap; + } + } + protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>"); + protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>"); + private static Dictionary _tzNameMap; // Value set by getter property on first read + + protected BirthdayBot Instance { get; } + protected Configuration BotConfig { get; } + protected DiscordShardedClient Discord { get; } + + protected CommandsCommon(BirthdayBot inst, Configuration db) + { + Instance = inst; + BotConfig = db; + Discord = inst.DiscordClient; + } + + /// + /// 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 string ParseTimeZone(string tzinput) + { + string tz = null; + if (tzinput != null) + { + // Just check if the input exists in the map. Get the "true" value, or reject it altogether. + if (!TzNameMap.TryGetValue(tzinput, out tz)) + { + throw new FormatException(":x: Unknown or invalid time zone name."); + } + } + 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 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; + + ulong resultVal; + if (ulong.TryParse(doParse, out resultVal)) + { + result = resultVal; + return true; + } + + result = default; + return false; + } + } +} diff --git a/UserInterface/HelpInfoCommands.cs b/UserInterface/HelpInfoCommands.cs new file mode 100644 index 0000000..e719efd --- /dev/null +++ b/UserInterface/HelpInfoCommands.cs @@ -0,0 +1,178 @@ +using Discord; +using Discord.WebSocket; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace BirthdayBot.UserInterface +{ + internal class HelpInfoCommands : CommandsCommon + { + private readonly Embed _helpEmbed; + private readonly Embed _helpConfigEmbed; + + public HelpInfoCommands(BirthdayBot inst, Configuration db) : base(inst, db) + { + var embeds = BuildHelpEmbeds(); + _helpEmbed = embeds.Item1; + _helpConfigEmbed = embeds.Item2; + } + + public override IEnumerable<(string, CommandHandler)> Commands => + new List<(string, CommandHandler)>() { + ("help", CmdHelp), + ("help-config", CmdHelpConfig), + ("help-tzdata", CmdHelpTzdata), + ("help-message", CmdHelpMessage), + ("info", CmdInfo) + }; + + private (Embed, Embed) BuildHelpEmbeds() + { + var cpfx = $"●`{CommandPrefix}"; + + // Normal section + var cmdField = new EmbedFieldBuilder() + { + Name = "Commands", + Value = $"{cpfx}help`, `{CommandPrefix}info`, `{CommandPrefix}help-tzdata`\n" + + $" » Help and informational messages.\n" + + $"{cpfx}recent` and `{CommandPrefix}upcoming`\n" + + $" » Lists recent and upcoming birthdays.\n" + + $"{cpfx}set (date) [zone]`\n" + + $" » Registers your birth date. Time zone is optional.\n" + + $" »» Examples: `{CommandPrefix}set jan-31`, `{CommandPrefix}set 15-aug America/Los_Angeles`.\n" + + $"{cpfx}zone (zone)`\n" + + $" » Sets your local time zone. See `{CommandPrefix}help-tzdata`.\n" + + $"{cpfx}remove`\n" + + $" » Removes your birthday information from this bot.\n" + + $"{cpfx}when (user)`\n" + + $" » Displays birthday information of the given user." + }; + var cmdModField = new EmbedFieldBuilder() + { + Name = "Commands", + Value = $"{cpfx}config`\n" + + $" » Edit bot configuration. See `{CommandPrefix}help-config`.\n" + + $"{cpfx}list`\n" + + $" » Exports all birthdays to file. Accepts `csv` as a parameter.\n" + + $"{cpfx}override (user ping or ID) (command w/ parameters)`\n" + + " » Perform certain commands on behalf of another user." + }; + 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(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + => await reqChannel.SendMessageAsync(embed: _helpEmbed); + + private async Task CmdHelpConfig(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + => await reqChannel.SendMessageAsync(embed: _helpConfigEmbed); + + private async Task CmdHelpTzdata(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" + + "These names can be found at the following link, under the 'TZ database name' column: " + + "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()); + } + + private async Task CmdHelpMessage(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()); + } + + private async Task CmdInfo(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()); + strStats.AppendLine("Shard #" + Discord.GetShardIdFor(reqChannel.Guild).ToString()); + strStats.AppendLine("Uptime: " + Common.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 = Discord.CurrentUser.GetAvatarUrl() + }, + // TODO this message needs an overhaul + Description = "Suggestions and feedback are always welcome. Please refer to the listing on Discord Bots " + + "(discord.bots.gg) for information on reaching my personal server. I may not be available often, but I am happy to " + + "respond to feedback in due time." // TODO update this string + }.AddField(new EmbedFieldBuilder() + { + Name = "Statistics", + Value = strStats.ToString() + }); + await reqChannel.SendMessageAsync(embed: embed.Build()); + } + } +} diff --git a/UserInterface/ListingCommands.cs b/UserInterface/ListingCommands.cs new file mode 100644 index 0000000..514ad9c --- /dev/null +++ b/UserInterface/ListingCommands.cs @@ -0,0 +1,271 @@ +using BirthdayBot.Data; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BirthdayBot.UserInterface +{ + /// + /// Commands for listing upcoming and all birthdays. + /// + internal class ListingCommands : CommandsCommon + { + public ListingCommands(BirthdayBot inst, Configuration db) : base(inst, db) { } + + public override IEnumerable<(string, CommandHandler)> Commands + => new List<(string, CommandHandler)>() + { + ("list", CmdList), + ("upcoming", CmdUpcoming), + ("recent", CmdUpcoming) + }; + + // Creates a file with all birthdays. + private async Task CmdList(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // For now, we're restricting this command to moderators only. This may turn into an option later. + if (!Instance.GuildCache[reqChannel.Guild.Id].IsUserModerator(reqUser)) + { + await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command."); + 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."); + return; + } + } + else if (param.Length > 2) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + var bdlist = await LoadList(reqChannel.Guild, 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); + + try + { + await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file."); + } + 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(":x: An internal error occurred. It has been reported to the bot owner.").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(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + var now = DateTimeOffset.UtcNow; + var search = DateIndex(now.Month, now.Day) - 4; // begin search 4 days prior to current date UTC + if (search <= 0) search = 366 - Math.Abs(search); + + var query = await LoadList(reqChannel.Guild, true); + + var output = new StringBuilder(); + var resultCount = 0; + output.AppendLine("Recent and upcoming birthdays:"); + for (int count = 0; count <= 11; count++) // cover 11 days total (3 prior, current day, 7 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.Count() == 0) 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.ToString("00")}`: "); + foreach (var item in names) + { + 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 3 days and/or next 7 days)."); + else + await reqChannel.SendMessageAsync(output.ToString()); + } + + /// + /// 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 async Task> LoadList(SocketGuild guild, bool escapeFormat) + { + var ping = Instance.GuildCache[guild.Id].AnnouncePing; + + using (var db = await BotConfig.DatabaseSettings.OpenConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserSettings.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 users 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.ToString("00")}"); + result.Append(','); + result.Append(item.BirthMonth); + result.Append(','); + result.Append(item.BirthDay); + result.Append("\r\n"); + } + return result.ToString(); + } + + private 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 int DateIndex(int month, int day) + { + var dateindex = 0; + // Add month offsets + if (month > 1) dateindex += 31; // Offset January + if (month > 2) dateindex += 29; // Offset February (incl. leap day) + if (month > 3) dateindex += 31; // etc + if (month > 4) dateindex += 30; + if (month > 5) dateindex += 31; + if (month > 6) dateindex += 30; + if (month > 7) dateindex += 31; + if (month > 8) dateindex += 31; + if (month > 9) dateindex += 30; + if (month > 10) dateindex += 31; + if (month > 11) dateindex += 30; + dateindex += day; + return dateindex; + } + + private struct ListItem + { + public int DateIndex; + public int BirthMonth; + public int BirthDay; + public ulong UserId; + public string DisplayName; + } + } +} diff --git a/UserInterface/ManagerCommands.cs b/UserInterface/ManagerCommands.cs new file mode 100644 index 0000000..5400204 --- /dev/null +++ b/UserInterface/ManagerCommands.cs @@ -0,0 +1,488 @@ +using Discord; +using Discord.WebSocket; +using NodaTime; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace BirthdayBot.UserInterface +{ + internal class ManagerCommands : CommandsCommon + { + private delegate Task ConfigSubcommand(string[] param, SocketTextChannel reqChannel); + + private readonly Dictionary _subcommands; + private readonly Dictionary _usercommands; + + public ManagerCommands(BirthdayBot inst, Configuration db, IEnumerable<(string, CommandHandler)> userCommands) + : base(inst, 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), + ("status", CmdStatus) + }; + + private async Task CmdConfigDispatch(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Ignore those without the proper permissions. + // Requires either the manage guild permission or to be in the moderators role + if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser)) + { + await reqChannel.SendMessageAsync(":x: This command may only be used by bot moderators."); + return; + } + + if (param.Length < 2) + { + await reqChannel.SendMessageAsync($":x: See `{CommandPrefix}help-config` for information on how to use this command."); + return; + } + + // Special case: Restrict 'modrole' to only guild managers + 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."); + return; + } + + // Subcommands get a subset of the parameters, to make things a little easier. + var confparam = new string[param.Length - 2]; // subtract one extra??? TODO investigate after port to C# + Array.Copy(param, 1, confparam, 0, param.Length - 1); + + if (_subcommands.TryGetValue(confparam[0], out ConfigSubcommand h)) + { + await h(confparam, reqChannel); + } + } + + #region Configuration sub-commands + // Birthday role set + private async Task ScmdRole(string[] param, SocketTextChannel reqChannel) + { + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified."); + return; + } + var guild = reqChannel.Guild; + var role = FindUserInputRole(param[1], guild); + + if (role == null) + { + await reqChannel.SendMessageAsync(RoleInputError); + } + else if (role.Id == reqChannel.Guild.EveryoneRole.Id) + { + await reqChannel.SendMessageAsync(":x: You cannot set that as the birthday role."); + } + else + { + Instance.GuildCache[guild.Id].UpdateRole(role.Id); + await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**."); + } + } + + // Ping setting + private async Task ScmdPing(string[] param, SocketTextChannel reqChannel) + { + const string InputErr = ":x: You must specify either `off` or `on` in this setting."; + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(InputErr); + 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); + return; + } + + Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnouncePing(setting); + await reqChannel.SendMessageAsync(result); + } + + // Announcement channel set + private async Task ScmdChannel(string[] param, SocketTextChannel reqChannel) + { + if (param.Length == 1) + { + // No extra parameter. Unset announcement channel. + var gi = Instance.GuildCache[reqChannel.Guild.Id]; + + // Extra detail: Show a unique message if a channel hadn't been set prior. + if (!gi.AnnounceChannelId.HasValue) + { + await reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset."); + return; + } + + gi.UpdateAnnounceChannel(null); + await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset."); + } + 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."); + return; + } + + // Update the value + Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnounceChannel(chId); + + // Report the success + await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>."); + } + } + + // Moderator role set + private async Task ScmdModRole(string[] param, SocketTextChannel reqChannel) + { + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified."); + return; + } + var guild = reqChannel.Guild; + var role = FindUserInputRole(param[1], guild); + + if (role == null) + { + await reqChannel.SendMessageAsync(RoleInputError); + } + else + { + Instance.GuildCache[guild.Id].UpdateModeratorRole(role.Id); + await reqChannel.SendMessageAsync($":white_check_mark: The moderator role is now **{role.Name}**."); + } + } + + // Guild default time zone set/unset + private async Task ScmdZone(string[] param, SocketTextChannel reqChannel) + { + if (param.Length == 1) + { + // No extra parameter. Unset guild default time zone. + var gi = Instance.GuildCache[reqChannel.Guild.Id]; + + // Extra detail: Show a unique message if there is no set zone. + if (!gi.AnnounceChannelId.HasValue) + { + await reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset."); + return; + } + + gi.UpdateTimeZone(null); + await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed."); + } + else + { + // Parameter check. + string zone; + try + { + zone = ParseTimeZone(param[1]); + } + catch (FormatException ex) + { + reqChannel.SendMessageAsync(ex.Message).Wait(); + return; + } + + // Update value + Instance.GuildCache[reqChannel.Guild.Id].UpdateTimeZone(zone); + + // Report the success + await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**."); + } + } + + // Block/unblock individual non-manager users from using commands. + private async Task ScmdBlock(string[] param, SocketTextChannel reqChannel) + { + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + bool doBan = param[0].ToLower() == "block"; // true = block, false = unblock + + ulong inputId; + if (!TryGetUserId(param[1], out inputId)) + { + await reqChannel.SendMessageAsync(BadUserError); + return; + } + + var gi = Instance.GuildCache[reqChannel.Guild.Id]; + var isBanned = await gi.IsUserBlockedAsync(inputId); + if (doBan) + { + if (!isBanned) + { + await gi.BlockUserAsync(inputId); + await reqChannel.SendMessageAsync(":white_check_mark: User has been blocked."); + } + else + { + await reqChannel.SendMessageAsync(":white_check_mark: User is already blocked."); + } + } + else + { + if (isBanned) + { + await gi.UnbanUserAsync(inputId); + await reqChannel.SendMessageAsync(":white_check_mark: User is now unblocked."); + } + else + { + await reqChannel.SendMessageAsync(":white_check_mark: The specified user has not been blocked."); + } + } + } + + // "moderated on/off" - Sets/unsets moderated mode. + private async Task ScmdModerated(string[] param, SocketTextChannel reqChannel) + { + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + var parameter = param[1].ToLower(); + bool modSet; + if (parameter == "on") modSet = true; + else if (parameter == "off") modSet = false; + else + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + var gi = Instance.GuildCache[reqChannel.Guild.Id]; + var currentSet = gi.IsModerated; + gi.UpdateModeratedMode(modSet); + + if (currentSet == modSet) + { + await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode is already {parameter}."); + } + else + { + await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode has been turned {parameter}."); + } + } + + // Sets/unsets custom announcement message. + private async Task ScmdAnnounceMsg(string[] param, 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; + } + + Instance.GuildCache[reqChannel.Guild.Id].UpdateAnnounceMessage(newmsg, plural); + const string report = ":white_check_mark: The {0} birthday announcement message has been {1}."; + await reqChannel.SendMessageAsync(string.Format(report, plural ? "plural" : "singular", clear ? "reset" : "updated")); + } + #endregion + + // Execute command as another user + private async Task CmdOverride(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Moderators only. As with config, silently drop if this check fails. + if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser)) return; + + if (param.Length != 3) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + // Second parameter: determine the user to act as + ulong user = 0; + if (!TryGetUserId(param[1], out user)) + { + await reqChannel.SendMessageAsync(BadUserError); + return; + } + var overuser = reqChannel.Guild.GetUser(user); + if (overuser == null) + { + await reqChannel.SendMessageAsync(BadUserError); + 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.Substring(CommandPrefix.Length); + } + else + { + // Add command prefix to input, just in case. + overparam[0] = CommandPrefix + overparam[0].ToLower(); + } + CommandHandler action = null; + if (!_usercommands.TryGetValue(cmdsearch, out action)) + { + await reqChannel.SendMessageAsync($":x: `{cmdsearch}` is not an overridable command."); + return; + } + + // Preparations complete. Run the command. + await reqChannel.SendMessageAsync($"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:"); + await action.Invoke(overparam, reqChannel, overuser); + } + + // Prints a status report useful for troubleshooting operational issues within a guild + private async Task CmdStatus(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Moderators only. As with config, silently drop if this check fails. + if (!Instance.GuildCache[reqUser.Guild.Id].IsUserModerator(reqUser)) return; + + DateTimeOffset optime; + string optext; + string zone; + var gi = Instance.GuildCache[reqChannel.Guild.Id]; + lock (gi) + { + var opstat = gi.OperationLog; + optext = opstat.GetDiagStrings(); // !!! Bulk of output handled by this method + optime = opstat.Timestamp; + zone = gi.TimeZone ?? "UTC"; + } + var shard = Instance.DiscordClient.GetShardIdFor(reqChannel.Guild); + + // Calculate timestamp in current zone + var zonedTimeInstant = SystemClock.Instance.GetCurrentInstant().InZone(DateTimeZoneProviders.Tzdb.GetZoneOrNull(zone)); + var timeAgoEstimate = DateTimeOffset.UtcNow - optime; + + var result = new EmbedBuilder + { + Title = "Background operation status", + Description = $"Shard: {shard}\n" + + $"Operation time: {Math.Round(timeAgoEstimate.TotalSeconds)} second(s) ago at {zonedTimeInstant}\n" + + "Report:\n" + + optext.TrimEnd() + }; + + await reqChannel.SendMessageAsync(embed: result.Build()); + } + + #region Common/helper methods + private const string RoleInputError = ":x: Unable to determine the given role."; + private static readonly Regex RoleMention = new Regex(@"<@?&(?\d+)>", RegexOptions.Compiled); + + private 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 + ulong rid; + if (ulong.TryParse(input, out 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/UserInterface/UserCommands.cs b/UserInterface/UserCommands.cs new file mode 100644 index 0000000..050b8b2 --- /dev/null +++ b/UserInterface/UserCommands.cs @@ -0,0 +1,260 @@ +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace BirthdayBot.UserInterface +{ + internal class UserCommands : CommandsCommon + { + public UserCommands(BirthdayBot inst, Configuration db) : base(inst, db) { } + + public override IEnumerable<(string, CommandHandler)> Commands + => new List<(string, CommandHandler)>() + { + ("set", CmdSet), + ("zone", CmdZone), + ("remove", CmdRemove), + ("when", CmdWhen) + }; + + /// + /// Parses date parameter. Strictly takes dd-MMM or MMM-dd only. Eliminates ambiguity over dd/mm vs mm/dd. + /// + /// Tuple: month, day + /// Thrown for any parsing issue. Reason is expected to be sent to Discord as-is. + private (int, int) ParseDate(string dateInput) + { + // Not doing DateTime.Parse. Setting it up is rather complicated, and it's probably case sensitive. + // Admittedly, doing it the way it's being done here probably isn't any better. + var m = Regex.Match(dateInput, @"^(?\d{1,2})-(?[A-Za-z]{3})$"); + if (!m.Success) + { + // Flip the fields around, try again + m = Regex.Match(dateInput, @"^(?[A-Za-z]{3})-(?\d{1,2})$"); + if (!m.Success) throw new FormatException(GenericError); + } + int day; + try + { + day = int.Parse(m.Groups["day"].Value); + } + catch (FormatException) + { + throw new Exception(GenericError); + } + var monthVal = m.Groups["month"].Value; + int month; + var dayUpper = 31; // upper day of month check + switch (monthVal.ToLower()) + { + case "jan": + month = 1; + break; + case "feb": + month = 2; + dayUpper = 29; + break; + case "mar": + month = 3; + break; + case "apr": + month = 4; + dayUpper = 30; + break; + case "may": + month = 5; + break; + case "jun": + month = 6; + dayUpper = 30; + break; + case "jul": + month = 7; + break; + case "aug": + month = 8; + break; + case "sep": + month = 9; + dayUpper = 30; + break; + case "oct": + month = 10; + break; + case "nov": + month = 11; + dayUpper = 30; + break; + case "dec": + month = 12; + break; + default: + throw new FormatException(":x: Invalid month name. Use a three-letter month abbreviation."); + } + if (day == 0 || day > dayUpper) throw new FormatException(":x: The date you specified is not a valid calendar date."); + + return (month, day); + } + + private async Task CmdSet(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Requires one parameter. Optionally two. + if (param.Length < 2 || param.Length > 3) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + int bmonth, bday; + string btz = null; + try + { + var res = ParseDate(param[1]); + bmonth = res.Item1; + bday = res.Item2; + if (param.Length == 3) btz = ParseTimeZone(param[2]); + } + catch (FormatException ex) + { + // Our parse methods' FormatException has its message to send out to Discord. + reqChannel.SendMessageAsync(ex.Message).Wait(); + return; + } + + // Parsing successful. Update user information. + bool known; // Extra detail: Bot's response changes if the user was previously unknown. + try + { + var user = Instance.GuildCache[reqChannel.Guild.Id].GetUser(reqUser.Id); + known = user.IsKnown; + await user.UpdateAsync(bmonth, bday, btz, BotConfig.DatabaseSettings); + } + catch (Exception ex) + { + Program.Log("Error", ex.ToString()); + reqChannel.SendMessageAsync(":x: An unknown error occurred. The bot owner has been notified.").Wait(); + return; + } + if (known) + { + await reqChannel.SendMessageAsync(":white_check_mark: Your information has been updated."); + } + else + { + await reqChannel.SendMessageAsync(":white_check_mark: Your birthday has been recorded."); + } + } + + private async Task CmdZone(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + if (param.Length != 2) + { + await reqChannel.SendMessageAsync(GenericError); + return; + } + + string btz = null; + var user = Instance.GuildCache[reqChannel.Guild.Id].GetUser(reqUser.Id); + if (!user.IsKnown) + { + await reqChannel.SendMessageAsync(":x: Can't set your time zone if your birth date isn't registered."); + return; + } + + try + { + btz = ParseTimeZone(param[1]); + } + catch (Exception ex) + { + reqChannel.SendMessageAsync(ex.Message).Wait(); + return; + } + await user.UpdateAsync(user.BirthMonth, user.BirthDay, btz, BotConfig.DatabaseSettings); + + await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**."); + } + + private async Task CmdRemove(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Parameter count check + if (param.Length != 1) + { + await reqChannel.SendMessageAsync(ExpectedNoParametersError); + return; + } + + // Extra detail: Send a notification if the user isn't actually known by the bot. + bool known; + var g = Instance.GuildCache[reqChannel.Guild.Id]; + known = g.GetUser(reqUser.Id).IsKnown; + // Delete database and cache entry + await g.DeleteUserAsync(reqUser.Id); + if (!known) + { + await reqChannel.SendMessageAsync(":white_check_mark: I don't have your information. Nothing to remove."); + } + else + { + await reqChannel.SendMessageAsync(":white_check_mark: Your information has been removed."); + } + } + + private async Task CmdWhen(string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) + { + // Requires a parameter + if (param.Length == 1) + { + await reqChannel.SendMessageAsync(GenericError); + 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; + + ulong searchId = 0; + if (!TryGetUserId(search, out 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); + return; + } + + var users = Instance.GuildCache[reqChannel.Guild.Id].Users; + var searchTargetData = users.FirstOrDefault(u => u.UserId == searchTarget.Id); + if (searchTargetData == null) + { + await reqChannel.SendMessageAsync("The given user does not exist or has not set a birthday."); + return; + } + + await reqChannel.SendMessageAsync(Common.FormatName(searchTarget, false) + ": " + + $"`{ searchTargetData.BirthDay.ToString("00")}-{Common.MonthNames[searchTargetData.BirthMonth]}`" + + searchTargetData.TimeZone == null ? "" : $" - `{ searchTargetData.TimeZone}`"); + } + } +}