commit ddcde10e0900e088d83287dac5f6155042b00a39 Author: Noi Date: Thu Apr 2 11:27:55 2020 -0700 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. 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}`"); + } + } +}