commit 8d44858d71eb489daa8f9f0f293fd2a31ca97e81 Author: Noikoio Date: Sun Jul 22 13:57:18 2018 -0700 First commit 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/BirthdayBot.sln b/BirthdayBot.sln new file mode 100644 index 0000000..9e12a29 --- /dev/null +++ b/BirthdayBot.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27703.2026 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "BirthdayBot", "BirthdayBot\BirthdayBot.vbproj", "{B99DDA52-FB99-4ECD-9B3E-96E375B9B302}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B99DDA52-FB99-4ECD-9B3E-96E375B9B302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B99DDA52-FB99-4ECD-9B3E-96E375B9B302}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B99DDA52-FB99-4ECD-9B3E-96E375B9B302}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B99DDA52-FB99-4ECD-9B3E-96E375B9B302}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {07D2D9A5-B991-4CAA-9E45-7DBBC4746DD5} + EndGlobalSection +EndGlobal diff --git a/BirthdayBot/BackgroundWorker.vb b/BirthdayBot/BackgroundWorker.vb new file mode 100644 index 0000000..ffebe0d --- /dev/null +++ b/BirthdayBot/BackgroundWorker.vb @@ -0,0 +1,192 @@ +Option Strict On +Option Explicit On +Imports System.Text +Imports System.Threading +Imports Discord.WebSocket +Imports NodaTime + +''' +''' BirthdayBot's periodic task. Frequently wakes up to take various actions. +''' +Class BackgroundWorker + Private ReadOnly _bot As BirthdayBot + Private ReadOnly _db As Database + Private ReadOnly Property WorkerCancel As New CancellationTokenSource + Private _workerTask As Task + Const Interval = 30 ' How often the worker wakes up, in seconds + Private _clock As IClock + + Sub New(instance As BirthdayBot, dbsettings As Database) + _bot = instance + _db = dbsettings + _clock = SystemClock.Instance ' can replace with FakeClock here when testing + End Sub + + Public Sub Start() + _workerTask = Task.Factory.StartNew(AddressOf WorkerLoop, WorkerCancel.Token, + TaskCreationOptions.LongRunning, TaskScheduler.Default) + End Sub + + Public Async Function Cancel() As Task + WorkerCancel.Cancel() + Await _workerTask + End Function + + Private Async Function WorkerLoop() As Task + Try + While Not WorkerCancel.IsCancellationRequested + Await Task.Delay(Interval * 1000, WorkerCancel.Token) + WorkerCancel.Token.ThrowIfCancellationRequested() + Try + For Each guild In _bot.DiscordClient.Guilds + Dim b = BirthdayWorkAsync(guild) + Await b + Next + Catch ex As Exception + Log("Error", ex.ToString()) + End Try + End While + Catch ex As TaskCanceledException + Return + End Try + End Function + +#Region "Birthday handling" + ''' + ''' All birthday checking happens here. + ''' + Private Async Function BirthdayWorkAsync(guild As SocketGuild) As Task + ' Gather required information + Dim roleId, channelId As ULong? + Dim tz As String + Dim users As IEnumerable(Of GuildUserSettings) + SyncLock _bot.KnownGuilds + If Not _bot.KnownGuilds.ContainsKey(guild.Id) Then Return + Dim gs = _bot.KnownGuilds(guild.Id) + roleId = gs.RoleId + channelId = gs.AnnounceChannelId + tz = gs.TimeZone + users = gs.Users + End SyncLock + + ' Resolve snowflakes to Discord.Net classes + Dim role As SocketRole = Nothing + If roleId.HasValue Then role = guild.GetRole(roleId.Value) + If role Is Nothing Then Return ' Unable to work without it + + Dim channel As SocketTextChannel = Nothing + If channelId.HasValue Then channel = guild.GetTextChannel(channelId.Value) + + ' Determine who's currently having a birthday + Dim birthdays = BirthdayCalculate(users, tz) + ' Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply. + + ' Set birthday role, get list of users now having birthdays + Dim announceNames = Await BirthdayApplyAsync(guild, role, birthdays) + If announceNames.Count = 0 Then Return + + ' Send out announcement message + Await BirthdayAnnounceAsync(guild, channel, announceNames) + End Function + + ''' + ''' 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 Function BirthdayCalculate(guildUsers As IEnumerable(Of GuildUserSettings), defaultTzStr As String) As HashSet(Of ULong) + Dim birthdayUsers As New HashSet(Of ULong) + + Dim defaultTz As DateTimeZone = Nothing + If defaultTzStr IsNot Nothing Then + defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) + End If + defaultTz = If(defaultTz, DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC")) + ' TODO determine defaultTz from guild's voice region + + For Each item In guildUsers + ' Determine final time zone to use for calculation + Dim tz As DateTimeZone = Nothing + If item.TimeZone IsNot Nothing Then + ' Try user-provided time zone + tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone) + End If + tz = If(tz, defaultTz) + + Dim targetMonth = item.BirthMonth + Dim targetDay = item.BirthDay + + Dim checkNow = _clock.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 And targetDay = 29 And Not DateTime.IsLeapYear(checkNow.Year) Then + targetMonth = 3 + targetDay = 1 + End If + If targetMonth = checkNow.Month And targetDay = checkNow.Day Then + birthdayUsers.Add(item.UserId) + End If + Next + + Return birthdayUsers + End Function + + ''' + ''' 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 Function BirthdayApplyAsync(g As SocketGuild, r As SocketRole, names As HashSet(Of ULong)) As Task(Of IEnumerable(Of SocketGuildUser)) + If Not g.HasAllMembers Then Await g.DownloadUsersAsync() + Dim newBirthdays As New List(Of SocketGuildUser) + For Each user In g.Users + If names.Contains(user.Id) Then + ' User's in the list. Should have the role. Add and make note of if user does not. + If Not user.Roles.Contains(r) Then + Await user.AddRoleAsync(r) + newBirthdays.Add(user) + End If + Else + ' User's not in the list. Should remove the role. + If user.Roles.Contains(r) Then Await user.RemoveRoleAsync(r) + End If + Next + Return newBirthdays + End Function + + ''' + ''' 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 Function BirthdayAnnounceAsync(g As SocketGuild, c As SocketTextChannel, names As IEnumerable(Of SocketGuildUser)) As Task + If c Is Nothing Then Return + + Dim display As New StringBuilder() + + Dim multi = names.Count > 1 + For i = 0 To names.Count - 1 + If i <> 0 Then display.Append(", ") + If i > 0 And i Mod 5 = 0 Then + display.AppendLine() + display.Append(" - ") + End If + Dim user = names(i) + + If user.Nickname IsNot Nothing Then + display.Append($"{user.Nickname} ({user.Username}#{user.Discriminator})") + Else + display.Append($"{user.Username}#{user.Discriminator}") + End If + Next + + If multi Then + display.Insert(0, "Happy birthday to our wonderful members:" + vbLf + " - ") + Else + display.Insert(0, "Please wish a happy birthday to ") + End If + + Try + Await c.SendMessageAsync(display.ToString()) + Catch ex As Discord.Net.HttpException + ' Ignore + End Try + End Function +#End Region +End Class diff --git a/BirthdayBot/BirthdayBot.vb b/BirthdayBot/BirthdayBot.vb new file mode 100644 index 0000000..659e3a0 --- /dev/null +++ b/BirthdayBot/BirthdayBot.vb @@ -0,0 +1,122 @@ +Option Strict On +Option Explicit On +Imports BirthdayBot.CommandsCommon +Imports Discord +Imports Discord.WebSocket + +Class BirthdayBot + Private ReadOnly _dispatchCommands As Dictionary(Of String, CommandHandler) + Private ReadOnly _cmdsUser As UserCommands + Private ReadOnly _cmdsHelp As HelpCommands + Private ReadOnly _cmdsMods As ManagerCommands + + Private WithEvents _client As DiscordSocketClient + Private _cfg As Configuration + Private ReadOnly _worker As BackgroundWorker + + Friend ReadOnly Property DiscordClient As DiscordSocketClient + Get + Return _client + End Get + End Property + + ''' SyncLock when using. The lock object is itself. + Friend ReadOnly Property KnownGuilds As Dictionary(Of ULong, GuildSettings) + + Public Sub New(conf As Configuration, dc As DiscordSocketClient) + _cfg = conf + _client = dc + KnownGuilds = New Dictionary(Of ULong, GuildSettings) + + _worker = New BackgroundWorker(Me, conf.DatabaseSettings) + + ' Command dispatch set-up + _dispatchCommands = New Dictionary(Of String, CommandHandler)(StringComparer.InvariantCultureIgnoreCase) + _cmdsUser = New UserCommands(Me, conf) + For Each item In _cmdsUser.Commands + _dispatchCommands.Add(item.Item1, item.Item2) + Next + _cmdsHelp = New HelpCommands(Me, conf) + For Each item In _cmdsHelp.Commands + _dispatchCommands.Add(item.Item1, item.Item2) + Next + _cmdsMods = New ManagerCommands(Me, conf) + For Each item In _cmdsMods.Commands + _dispatchCommands.Add(item.Item1, item.Item2) + Next + End Sub + + Public Async Function Start() As Task + Await _client.LoginAsync(TokenType.Bot, _cfg.BotToken) + Await _client.StartAsync() + _worker.Start() + + Await Task.Delay(-1) + End Function + + ''' + ''' Called only by CancelKeyPress handler. + ''' + Public Async Function Shutdown() As Task + Await _worker.Cancel() + Await _client.LogoutAsync() + _client.Dispose() + End Function + + Private Function LoadGuild(g As SocketGuild) As Task Handles _client.JoinedGuild, _client.GuildAvailable + SyncLock KnownGuilds + If Not KnownGuilds.ContainsKey(g.Id) Then + Dim gi = GuildSettings.LoadSettingsAsync(_cfg.DatabaseSettings, g.Id).GetAwaiter().GetResult() + Log("Status", $"Load information for guild {g.Id} ({g.Name})") + KnownGuilds.Add(g.Id, gi) + End If + End SyncLock + Return Task.CompletedTask + End Function + + Private Function DiscardGuild(g As SocketGuild) As Task Handles _client.LeftGuild + SyncLock KnownGuilds + KnownGuilds.Remove(g.Id) + End SyncLock + Return Task.CompletedTask + End Function + + Private Async Function SetStatus() As Task Handles _client.Connected + Await _client.SetGameAsync(CommandPrefix + "help") + End Function + + Private Async Function Dispatch(msg As SocketMessage) As Task Handles _client.MessageReceived + If TypeOf msg.Channel Is IDMChannel Then Return + If msg.Author.IsBot Then Return + + ' Limit 3: + ' For all cases: base command, 2 parameters. + ' Except this case: "bb.config", subcommand name, subcommand parameters in a single string + Dim csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries) + If csplit.Length > 0 Then + If csplit(0).StartsWith(CommandPrefix, StringComparison.InvariantCultureIgnoreCase) Then + Dim channel = CType(msg.Channel, SocketTextChannel) + Dim author = CType(msg.Author, SocketGuildUser) + + ' Ban check - but bypass if the author is a manager. + If Not author.GuildPermissions.ManageGuild Then + SyncLock KnownGuilds + If KnownGuilds(channel.Guild.Id).IsUserBannedAsync(author.Id).GetAwaiter().GetResult() Then + Return + End If + End SyncLock + End If + + Dim h As CommandHandler = Nothing + If _dispatchCommands.TryGetValue(csplit(0).Substring(CommandPrefix.Length), h) Then + Try + Await h(csplit, channel, author) + Catch ex As Exception + channel.SendMessageAsync(":x: An unknown error occurred. It has been reported to the bot owner.").Wait() + Log("Error", ex.ToString()) + End Try + End If + End If + End If + End Function +End Class diff --git a/BirthdayBot/BirthdayBot.vbproj b/BirthdayBot/BirthdayBot.vbproj new file mode 100644 index 0000000..1e95a0b --- /dev/null +++ b/BirthdayBot/BirthdayBot.vbproj @@ -0,0 +1,22 @@ + + + + Exe + BirthdayBot + netcoreapp2.0 + 0.1.0 + 0.1.0.0 + Noikoio + + Discord bot for birthday reminders. + Sub Main + + + + + + + + + + diff --git a/BirthdayBot/Configuration.vb b/BirthdayBot/Configuration.vb new file mode 100644 index 0000000..f8f8932 --- /dev/null +++ b/BirthdayBot/Configuration.vb @@ -0,0 +1,36 @@ +Option Strict On +Option Explicit On +Imports System.Reflection +Imports Newtonsoft.Json.Linq +Imports System.IO + +''' +''' Loads and holds configuration values. +''' +Class Configuration + Public ReadOnly Property BotToken As String + Public ReadOnly Property DatabaseSettings As Database + + Sub New() + ' Looks for settings.json in the executable directory. + Dim confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + confPath += Path.DirectorySeparatorChar + "settings.json" + + If Not File.Exists(confPath) Then + Throw New Exception("Settings file not found. " _ + + "Create a file in the executable directory named 'settings.json'.") + End If + + Dim jc = JObject.Parse(File.ReadAllText(confPath)) + BotToken = jc("BotToken").Value(Of String)() + If String.IsNullOrWhiteSpace(BotToken) Then + Throw New Exception("'BotToken' must be specified.") + End If + + Dim sqlcs = jc("SqlConnectionString").Value(Of String)() + If String.IsNullOrWhiteSpace(sqlcs) Then + Throw New Exception("'SqlConnectionString' must be specified.") + End If + DatabaseSettings = New Database(sqlcs) + End Sub +End Class diff --git a/BirthdayBot/Data/Database.vb b/BirthdayBot/Data/Database.vb new file mode 100644 index 0000000..a0ecfe2 --- /dev/null +++ b/BirthdayBot/Data/Database.vb @@ -0,0 +1,35 @@ +Option Strict On +Option Explicit On +Imports Npgsql + +''' +''' 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 ReadOnly Property DBConnectionString As String + + Sub New(connString As String) + DBConnectionString = connString + + ' Database initialization happens here as well. + SetupTables() + End Sub + + Public Async Function OpenConnectionAsync() As Task(Of NpgsqlConnection) + Dim db As New NpgsqlConnection(DBConnectionString) + Await db.OpenAsync() + Return db + End Function + + Private Sub SetupTables() + Using db = OpenConnectionAsync().GetAwaiter().GetResult() + GuildSettings.SetUpDatabaseTable(db) ' Note: Call this first. (Foreign reference constraints.) + GuildUserSettings.SetUpDatabaseTable(db) + End Using + End Sub +End Class diff --git a/BirthdayBot/Data/GuildSettings.vb b/BirthdayBot/Data/GuildSettings.vb new file mode 100644 index 0000000..fd098f9 --- /dev/null +++ b/BirthdayBot/Data/GuildSettings.vb @@ -0,0 +1,293 @@ +Option Strict On +Option Explicit On +Imports System.Data.Common +Imports Npgsql +Imports NpgsqlTypes + +''' +''' Collection of GuildUserSettings instances. Holds cached information on guild users and overall +''' guild options, and provides some database abstractions regarding them all. +''' Object instances are loaded when entering a guild and discarded when the bot leaves the guild. +''' +Class GuildSettings + Public ReadOnly Property GuildId As ULong + Private ReadOnly _db As Database + Private _role As ULong? + Private _channel As ULong? + Private _tz As String + Private _modded As Boolean + Private _userCache As Dictionary(Of ULong, GuildUserSettings) + + ''' + ''' Gets a list of cached users. Use sparingly. + ''' + Friend ReadOnly Property Users As IEnumerable(Of GuildUserSettings) + Get + Dim items As New List(Of GuildUserSettings) + For Each item In _userCache.Values + items.Add(item) + Next + Return items + End Get + End Property + + ''' + ''' Gets the guild's designated Role ID. + ''' + Public ReadOnly Property RoleId As ULong? + Get + Return _role + End Get + End Property + + ''' + ''' Gets the designated announcement Channel ID. + ''' + Public ReadOnly Property AnnounceChannelId As ULong? + Get + Return _channel + End Get + End Property + + ''' + ''' Gets the guild's default time zone. + ''' + Public ReadOnly Property TimeZone As String + Get + Return _tz + End Get + End Property + + ''' + ''' Gets or sets if the server is in moderated mode. + ''' Updating this value updates the database. + ''' + Public Property IsModerated As Boolean + Get + Return _modded + End Get + Set(value As Boolean) + _modded = value + UpdateDatabaseAsync() + End Set + End Property + + ' Called by LoadSettingsAsync. Double-check ordinals when changes are made. + Private Sub New(reader As DbDataReader, dbconfig As Database) + _db = dbconfig + GuildId = CULng(reader.GetInt64(0)) + ' Weird: if using a ternary operator with a ULong?, Nothing resolves to 0 despite Option Strict On. + If Not reader.IsDBNull(1) Then _role = CULng(reader.GetInt64(1)) + If Not reader.IsDBNull(2) Then _channel = CULng(reader.GetInt64(2)) + _tz = If(reader.IsDBNull(3), Nothing, reader.GetString(3)) + _modded = reader.GetBoolean(4) + + ' Get user information loaded up. + Dim userresult = GuildUserSettings.GetGuildUsersAsync(dbconfig, GuildId) + _userCache = New Dictionary(Of ULong, GuildUserSettings) + For Each item In userresult + _userCache.Add(item.UserId, item) + Next + End Sub + + ''' + ''' Gets user information from this 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. + ''' + ''' + ''' + Public Function GetUser(userId As ULong) As GuildUserSettings + If _userCache.ContainsKey(userId) Then + Return _userCache(userId) + End If + + ' No result. Create a blank entry and add it to the list, in case it + ' gets referenced later regardless of if having been updated or not. + Dim blank As New GuildUserSettings(_GuildId, userId) + _userCache.Add(userId, blank) + Return blank + End Function + + ''' + ''' Deletes the user from the backing database. Drops the locally cached entry. + ''' + Public Async Function DeleteUserAsync(userId As ULong) As Task + Dim user As GuildUserSettings = Nothing + If _userCache.TryGetValue(userId, user) Then + Await user.DeleteAsync(_db) + Else + Return + End If + _userCache.Remove(userId) + End Function + + ''' + ''' Checks if the given user is banned from issuing commands. + ''' If the server is in moderated mode, this always returns True. + ''' + Public Async Function IsUserBannedAsync(userId As ULong) As Task(Of Boolean) + If IsModerated Then Return True + + Using db = Await _db.OpenConnectionAsync() + Using c = db.CreateCommand() + c.CommandText = $"select * from {BackingTableBans}" + + "where guild_id = @Gid and user_id = @Uid" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = GuildId + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = userId + c.Prepare() + Using r = Await c.ExecuteReaderAsync() + If Await r.ReadAsync() Then Return True + Return False + End Using + End Using + End Using + End Function + + ''' + ''' Bans the specified user from issuing commands. + ''' Does not check if the given user is already banned. + ''' + Public Async Function BanUserAsync(userId As ULong) As Task + Using db = Await _db.OpenConnectionAsync() + Using 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 = GuildId + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = userId + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + End Using + End Function + + ''' + ''' Removes the specified user from the ban list. + ''' Does not check if the given user was not banned to begin with. + ''' + Public Async Function UnbanUserAsync(userId As ULong) As Task + Using db = Await _db.OpenConnectionAsync() + Using c = db.CreateCommand() + c.CommandText = $"delete from {BackingTableBans} where " + + "guild_id = @Gid and user_id = @Uid" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = GuildId + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = userId + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + End Using + End Function + + Public Async Function UpdateRoleAsync(roleId As ULong) As Task + _role = roleId + Await UpdateDatabaseAsync() + End Function + + Public Async Function UpdateAnnounceChannelAsync(channelId As ULong?) As Task + _channel = channelId + Await UpdateDatabaseAsync() + End Function + + Public Async Function UpdateTimeZoneAsync(tzString As String) As Task + _tz = tzString + Await UpdateDatabaseAsync() + End Function + +#Region "Database" + Public Const BackingTable = "settings" + Public Const BackingTableBans = "banned_users" + + Friend Shared Sub SetUpDatabaseTable(db As NpgsqlConnection) + Using 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" + + ")" + c.ExecuteNonQuery() + End Using + Using 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)" + + ")" + End Using + End Sub + + ''' + ''' 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. + ''' + Friend Shared Async Function LoadSettingsAsync(dbsettings As Database, guild As ULong) As Task(Of GuildSettings) + Using db = Await dbsettings.OpenConnectionAsync() + Using 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 " + + $"from {BackingTable} where guild_id = @Gid" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guild + c.Prepare() + Using r = Await c.ExecuteReaderAsync() + If Await r.ReadAsync() Then + Return New GuildSettings(r, dbsettings) + End If + End Using + End Using + + ' If we got here, no row exists. Create it. + Using c = db.CreateCommand() + c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = guild + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + End Using + ' New row created. Try this again. + Return Await LoadSettingsAsync(dbsettings, guild) + End Function + + ''' + ''' Updates the backing database with values from this instance + ''' This is a non-asynchronous operation. That may be bad. + ''' + Private Async Function UpdateDatabaseAsync() As Task + Using db = Await _db.OpenConnectionAsync() + Using c = db.CreateCommand() + c.CommandText = $"update {BackingTable} set " + + "role_id = @RoleId, " + + "channel_announce_id = @ChannelId, " + + "time_zone = @TimeZone, " + + "moderated = @Moderated " + + "where guild_id = @Gid" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = GuildId + With c.Parameters.Add("@RoleId", NpgsqlDbType.Bigint) + If RoleId.HasValue Then + .Value = RoleId.Value + Else + .Value = DBNull.Value + End If + End With + With c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint) + If _channel.HasValue Then + .Value = _channel.Value + Else + .Value = DBNull.Value + End If + End With + With c.Parameters.Add("@TimeZone", NpgsqlDbType.Text) + If _tz IsNot Nothing Then + .Value = _tz + Else + .Value = DBNull.Value + End If + End With + c.Parameters.Add("@Moderated", NpgsqlDbType.Boolean).Value = _modded + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + End Using + End Function +#End Region +End Class diff --git a/BirthdayBot/Data/GuildUserSettings.vb b/BirthdayBot/Data/GuildUserSettings.vb new file mode 100644 index 0000000..f191fa1 --- /dev/null +++ b/BirthdayBot/Data/GuildUserSettings.vb @@ -0,0 +1,159 @@ +Option Strict On +Option Explicit On +Imports System.Data.Common +Imports Npgsql +Imports NpgsqlTypes + +Class GuildUserSettings + Private _month As Integer + Private _day As Integer + Private _tz As String + + Public ReadOnly Property GuildId As ULong + Public ReadOnly Property UserId As ULong + ''' + ''' Month of birth as a numeric value. Range 1-12. + ''' + Public ReadOnly Property BirthMonth As Integer + Get + Return _month + End Get + End Property + ''' + ''' Day of birth as a numeric value. Ranges between 1-31 or lower based on month value. + ''' + Public ReadOnly Property BirthDay As Integer + Get + Return _day + End Get + End Property + Public ReadOnly Property TimeZone As String + Get + Return _tz + End Get + End Property + Public ReadOnly Property IsKnown As Boolean + Get + Return _month <> 0 And _day <> 0 + End Get + End Property + + ''' + ''' Creates a data-less instance without any useful information. + ''' Calling will cause an actual database update. + ''' + Public Sub New(guildId As ULong, userId As ULong) + Me.GuildId = guildId + Me.UserId = userId + End Sub + + ' Called by GetGuildUsersAsync. Double-check ordinals when changes are made. + Private Sub New(reader As DbDataReader) + GuildId = CULng(reader.GetInt64(0)) + UserId = CULng(reader.GetInt64(1)) + _month = reader.GetInt32(2) + _day = reader.GetInt32(3) + If Not reader.IsDBNull(4) Then _tz = reader.GetString(4) + End Sub + + ''' + ''' 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 Function UpdateAsync(month As Integer, day As Integer, newtz As String, dbconfig As Database) As Task + Dim inserttz = If(newtz, TimeZone) + + Using db = Await dbconfig.OpenConnectionAsync() + ' Will do a delete/insert instead of insert...on conflict update. Because lazy. + Using t = db.BeginTransaction() + Await DoDeleteAsync(db) + Using 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 = GuildId + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = UserId + c.Parameters.Add("@Month", NpgsqlDbType.Numeric).Value = month + c.Parameters.Add("@Day", NpgsqlDbType.Numeric).Value = day + With c.Parameters.Add("@Tz", NpgsqlDbType.Text) + If inserttz IsNot Nothing Then + .Value = inserttz + Else + .Value = DBNull.Value + End If + End With + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + Await t.CommitAsync() + End Using + End Using + + ' We didn't crash! Get the new values stored locally. + _month = month + _day = day + _tz = inserttz + End Function + + ''' + ''' Deletes information of this user from the backing database. + ''' The corresponding object reference should ideally be discarded after calling this. + ''' + Public Async Function DeleteAsync(dbconfig As Database) As Task + Using db = Await dbconfig.OpenConnectionAsync() + Await DoDeleteAsync(db) + End Using + End Function + + ' Shared between UpdateAsync and DeleteAsync + Private Async Function DoDeleteAsync(dbconn As NpgsqlConnection) As Task + Using c = dbconn.CreateCommand() + c.CommandText = $"delete from {BackingTable}" + + " where guild_id = @Gid and user_id = @Uid" + c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = GuildId + c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = UserId + c.Prepare() + Await c.ExecuteNonQueryAsync() + End Using + End Function + +#Region "Database" + Public Const BackingTable = "user_birthdays" + + Friend Shared Sub SetUpDatabaseTable(db As NpgsqlConnection) + Using c = db.CreateCommand() + c.CommandText = $"create table if not exists {BackingTable} (" + + $"guild_id bigint not null references {GuildSettings.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() + End Using + End Sub + + ''' + ''' Gets all known birthday records from the specified guild. No further filtering is done here. + ''' + Shared Function GetGuildUsersAsync(dbsettings As Database, guildId As ULong) As IEnumerable(Of GuildUserSettings) + Using db = dbsettings.OpenConnectionAsync().GetAwaiter().GetResult() + Using 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 = guildId + c.Prepare() + Using r = c.ExecuteReader() + Dim result As New List(Of GuildUserSettings) + While r.Read() + result.Add(New GuildUserSettings(r)) + End While + Return result + End Using + End Using + End Using + End Function +#End Region +End Class diff --git a/BirthdayBot/My Project/PublishProfiles/FolderProfile.pubxml b/BirthdayBot/My Project/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..aef6406 --- /dev/null +++ b/BirthdayBot/My Project/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,15 @@ + + + + + FileSystem + Release + Any CPU + netcoreapp2.0 + bin\Release\netcoreapp2.0\publish\ + false + <_IsPortable>true + + \ No newline at end of file diff --git a/BirthdayBot/My Project/app.manifest b/BirthdayBot/My Project/app.manifest new file mode 100644 index 0000000..a6b46bb --- /dev/null +++ b/BirthdayBot/My Project/app.manifest @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BirthdayBot/Program.vb b/BirthdayBot/Program.vb new file mode 100644 index 0000000..9a132e0 --- /dev/null +++ b/BirthdayBot/Program.vb @@ -0,0 +1,58 @@ +Option Strict On +Option Explicit On +Imports Discord +Imports Discord.WebSocket + +Module Program + Private _bot As BirthdayBot + + Sub Main(args As String()) + Dim cfg As New Configuration() + + Dim dc As New DiscordSocketConfig() + With dc + .AlwaysDownloadUsers = True + .DefaultRetryMode = Discord.RetryMode.RetryRatelimit + .MessageCacheSize = 0 + End With + + Dim client As New DiscordSocketClient(dc) + AddHandler client.Log, AddressOf DNetLog + + _bot = New BirthdayBot(cfg, client) + + AddHandler Console.CancelKeyPress, AddressOf OnCancelKeyPressed + + _bot.Start().Wait() + End Sub + + ''' + ''' Sends a formatted message to console. + ''' + Sub Log(source As String, message As String) + ' Add file logging later? + Dim ts = DateTime.UtcNow + Dim ls = {vbCrLf, vbLf} + For Each item In message.Split(ls, StringSplitOptions.None) + Console.WriteLine($"{ts:u} [{source}] {item}") + Next + End Sub + + Private Function DNetLog(arg As LogMessage) As Task + If arg.Severity <= LogSeverity.Info Then + Log("Discord.Net", $"{arg.Severity}: {arg.Message}") + End If + Return Task.CompletedTask + End Function + + Private Sub OnCancelKeyPressed(sender As Object, e As ConsoleCancelEventArgs) + e.Cancel = True + Log("Shutdown", "Caught cancel key. Will shut down...") + Dim hang = Not _bot.Shutdown().Wait(10000) + If hang Then + Log("Shutdown", "Normal shutdown has not concluded after 10 seconds. Will force quit.") + End If + Environment.Exit(0) + End Sub + +End Module diff --git a/BirthdayBot/UserInterface/CommandsCommon.vb b/BirthdayBot/UserInterface/CommandsCommon.vb new file mode 100644 index 0000000..1d2082e --- /dev/null +++ b/BirthdayBot/UserInterface/CommandsCommon.vb @@ -0,0 +1,65 @@ +Option Strict On +Option Explicit On +Imports System.Text.RegularExpressions +Imports Discord.WebSocket +Imports NodaTime + +''' +''' Common base class for common constants and variables. +''' +Friend MustInherit Class CommandsCommon + Public Const CommandPrefix = "bb." + Public Const GenericError = ":x: Invalid usage. Consult the help command." + Public Const ExpectedNoParametersError = ":x: This command does not take parameters. Did you mean to use another?" + + Delegate Function CommandHandler(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + + Protected Shared ReadOnly Property TzNameMap As Dictionary(Of String, String) + Get + If _tzNameMap Is Nothing Then + ' 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. + _tzNameMap = New Dictionary(Of String, String)(StringComparer.InvariantCultureIgnoreCase) + For Each name In DateTimeZoneProviders.Tzdb.Ids + _tzNameMap.Add(name, name) + Next + End If + Return _tzNameMap + End Get + End Property + Protected Shared ReadOnly ChannelMention As New Regex("<#(\d+)>") + Protected Shared ReadOnly UserMention As New Regex("<@\!?(\d+)>") + Private Shared _tzNameMap As Dictionary(Of String, String) ' Value set by getter property on first read + + Protected ReadOnly Instance As BirthdayBot + Protected ReadOnly BotConfig As Configuration + Protected ReadOnly Discord As DiscordSocketClient + + Sub New(inst As BirthdayBot, db As Configuration) + Instance = inst + BotConfig = db + Discord = inst.DiscordClient + End Sub + + ''' + ''' Checks given time zone input. Returns a valid string for use with NodaTime. + ''' + ''' + ''' + Protected Function ParseTimeZone(tzinput As String) As String + Dim tz As String = Nothing + If tzinput IsNot Nothing Then + ' Just check if the input exists in the map. Get the "true" value, or reject it altogether. + If Not TzNameMap.TryGetValue(tzinput, tz) Then + Throw New FormatException(":x: Unknown or invalid time zone name.") + End If + End If + Return tz + End Function + + ''' + ''' On command dispatcher initialization, it will retrieve all available commands through here. + ''' + Public MustOverride ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) +End Class diff --git a/BirthdayBot/UserInterface/HelpCommands.vb b/BirthdayBot/UserInterface/HelpCommands.vb new file mode 100644 index 0000000..f5acb1f --- /dev/null +++ b/BirthdayBot/UserInterface/HelpCommands.vb @@ -0,0 +1,94 @@ +Option Strict On +Option Explicit On +Imports Discord +Imports Discord.WebSocket + +Friend Class HelpCommands + Inherits CommandsCommon + + Private _helpEmbed As EmbedBuilder ' Lazily generated in the help command handler + Private _helpManagerInfo As EmbedFieldBuilder ' Same + + Sub New(inst As BirthdayBot, db As Configuration) + MyBase.New(inst, db) + End Sub + + Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) + Get + Return New List(Of (String, CommandHandler)) From { + ("help", AddressOf CmdHelp) + } + End Get + End Property + + Private Async Function CmdHelp(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + Const FunctionMsg = "Attention server manager: A designated birthday role has not yet been set. " + + "This bot requires the ability to be able to set and unset the specified role onto all users. " + + "It cannot function without it." + vbLf + + "To designate a birthday role, issue the command `{0}config role (role name/ID)`." + + If _helpEmbed Is Nothing Then + Dim em As New EmbedBuilder + With em + .Footer = New EmbedFooterBuilder With { + .Text = Discord.CurrentUser.Username, + .IconUrl = Discord.CurrentUser.GetAvatarUrl() + } + .Title = "Help & About" + .Description = "Birthday Bot: A utility to assist with acknowledging birthdays and other annual events.\n" + + "**Currently a work in progress. There will be bugs. Features may change or be removed.**" + End With + Dim cpfx = $"●`{CommandPrefix}" + Dim cmdField As New EmbedFieldBuilder With { + .Name = "Commands", + .Value = + $"{cpfx}help`, `{CommandPrefix}info`, `{CommandPrefix}tzdata`" + vbLf + + $" » Various help messages." + vbLf + + $"{cpfx}set (date) [zone]`" + vbLf + + $" » Registers your birth date, with optional time zone." + vbLf + + $" »» Examples: `{CommandPrefix}set jan-31 America/New_York`, `{CommandPrefix}set 15-aug Europe/Stockholm`." + vbLf + + $"{cpfx}set-tz (zone)`" + vbLf + + $" » Sets your local time zone. Only accepts certain values. See `{CommandPrefix}tzdata`." + vbLf + + $"{cpfx}remove`" + vbLf + + $" » Removes all your information from this bot." + } + em.AddField(cmdField) + _helpEmbed = em + + Dim mpfx = cpfx + "config " + _helpManagerInfo = New EmbedFieldBuilder With { + .Name = "Commands for server managers", + .Value = + $"{mpfx}role (role name or ID)`" + vbLf + + " » Specifies which role to apply to users having birthdays." + vbLf + + $"{mpfx}channel (channel name or ID)`" + vbLf + + " » Sets the birthday and event announcement channel. Leave blank to disable announcements." + vbLf + + $"{mpfx}set-tz (time zone name)`" + vbLf + + " » Sets the default time zone to use with all dates. Leave blank to revert to default." + vbLf + + $" » Only accepts certain values. See `{CommandPrefix}tzdata`." + vbLf + + $"{mpfx}ban/unban (user mention or ID)`" + vbLf + + " » Restricts or reallows access to this bot for the given user." + vbLf + + $"{mpfx}ban-all/unban-all`" + vbLf + + " » Restricts or reallows access to this bot for all users. Server managers are exempt." + vbLf + + $"{cpfx}override (user ID) (regular command)`" + vbLf + + " » Performs a command on behalf of the given user." + } + End If + + ' Determine if an additional message about an invalid role should be added. + Dim useFunctionMessage = False + Dim gs As GuildSettings + SyncLock Instance.KnownGuilds + gs = Instance.KnownGuilds(reqChannel.Guild.Id) + End SyncLock + If Not gs.RoleId.HasValue Then + useFunctionMessage = True + End If + + ' Determine if the user asking is a manager + Dim showManagerCommands = reqUser.GuildPermissions.ManageGuild + + Await reqChannel.SendMessageAsync(If(useFunctionMessage, String.Format(FunctionMsg, CommandPrefix), ""), + embed:=If(showManagerCommands, _helpEmbed.AddField(_helpManagerInfo), _helpEmbed)) + End Function +End Class diff --git a/BirthdayBot/UserInterface/ManagerCommands.vb b/BirthdayBot/UserInterface/ManagerCommands.vb new file mode 100644 index 0000000..4d430c3 --- /dev/null +++ b/BirthdayBot/UserInterface/ManagerCommands.vb @@ -0,0 +1,229 @@ +Option Strict On +Option Explicit On +Imports Discord.WebSocket + +Friend Class ManagerCommands + Inherits CommandsCommon + Private Delegate Function ConfigSubcommand(param As String(), reqChannel As SocketTextChannel) As Task + + Private _subcommands As Dictionary(Of String, ConfigSubcommand) + + Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) + Get + Return New List(Of (String, CommandHandler)) From { + ("config", AddressOf CmdConfigDispatch), + ("override", AddressOf CmdOverride) + } + End Get + End Property + + Sub New(inst As BirthdayBot, db As Configuration) + MyBase.New(inst, db) + _subcommands = New Dictionary(Of String, ConfigSubcommand) From { + {"role", AddressOf ScmdRole}, + {"channel", AddressOf ScmdChannel}, + {"set-tz", AddressOf ScmdSetTz}, + {"ban", AddressOf ScmdBanUnban}, + {"unban", AddressOf ScmdBanUnban}, + {"ban-all", AddressOf ScmdSetModerated}, + {"unban-all", AddressOf ScmdSetModerated} + } + End Sub + + Private Async Function CmdConfigDispatch(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + ' Managers only past this point. (This may have already been checked.) + If Not reqUser.GuildPermissions.ManageGuild Then Return + + ' Subcommands get a subset of the parameters, to make things a little easier. + Dim confparam(param.Length - 2) As String ' subtract 2??? + Array.Copy(param, 1, confparam, 0, param.Length - 1) + ' confparam has at most 2 items: subcommand name, parameters in one string + + Dim h As ConfigSubcommand = Nothing + If _subcommands.TryGetValue(confparam(0), h) Then + Await h(confparam, reqChannel) + End If + End Function + +#Region "Configuration sub-commands" + ' Birthday role set + Private Async Function ScmdRole(param As String(), reqChannel As SocketTextChannel) As Task + If param.Length <> 2 Then + Await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified.") + Return + End If + + Dim guild = reqChannel.Guild + Dim input = param(1) + Dim role As SocketRole = Nothing + + ' Resembles a role mention? Strip it to the pure number + If input.StartsWith("<&") And input.EndsWith(">") Then + input = input.Substring(2, input.Length - 3) + End If + + ' Attempt to get role by ID + Dim rid As ULong + If ULong.TryParse(input, rid) Then + role = guild.GetRole(rid) + Else + ' Reset the search value on the off chance there's a role name actually starting with "<&" and ending with ">" + input = param(1) + End If + + ' If not already found, attempt to search role by string name + If role Is Nothing Then + For Each search In guild.Roles + If String.Equals(search.Name, input, StringComparison.InvariantCultureIgnoreCase) Then + role = search + Exit For + End If + Next + End If + + ' Final result + If role Is Nothing Then + Await reqChannel.SendMessageAsync(":x: Unable to determine the given role.") + Else + SyncLock Instance.KnownGuilds + Instance.KnownGuilds(guild.Id).UpdateRoleAsync(role.Id).Wait() + End SyncLock + Await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**.") + End If + End Function + + ' Announcement channel set + Private Async Function ScmdChannel(param As String(), reqChannel As SocketTextChannel) As Task + If param.Length = 1 Then + ' No extra parameter. Unset announcement channel. + SyncLock Instance.KnownGuilds + Dim gi = Instance.KnownGuilds(reqChannel.Guild.Id) + + ' Extra detail: Show a unique message if a channel hadn't been set prior. + If Not gi.AnnounceChannelId.HasValue Then + reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset.").Wait() + Return + End If + + gi.UpdateAnnounceChannelAsync(Nothing).Wait() + End SyncLock + + Await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset.") + Else + ' Parameter check: This needs a channel mention to function. + Dim m = ChannelMention.Match(param(1)) + If Not m.Success Then + Await reqChannel.SendMessageAsync(":x: The given parameter must be a channel. (The channel name must be clickable.)") + Return + End If + + Dim chId = ULong.Parse(m.Groups(1).Value) + ' Check if the channel isn't in the local guild. + Dim chInst = reqChannel.Guild.GetTextChannel(chId) + If chInst Is Nothing Then + Await reqChannel.SendMessageAsync(":x: Unable to find the specified channel on this server.") + Return + End If + + ' Update the value + SyncLock Instance.KnownGuilds + Dim gi = Instance.KnownGuilds(reqChannel.Guild.Id) + gi.UpdateAnnounceChannelAsync(chId).Wait() + End SyncLock + + ' Report the success + Await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>.") + End If + End Function + + ' Guild default time zone set/unset + Private Async Function ScmdSetTz(param As String(), reqChannel As SocketTextChannel) As Task + If param.Length = 1 Then + ' No extra parameter. Unset guild default time zone. + SyncLock Instance.KnownGuilds + Dim gi = Instance.KnownGuilds(reqChannel.Guild.Id) + + ' Extra detail: Show a unique message if there is no set zone. + If Not gi.AnnounceChannelId.HasValue Then + reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset.").Wait() + Return + End If + + gi.UpdateTimeZoneAsync(Nothing).Wait() + End SyncLock + + Await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed.") + Else + ' Parameter check. + Dim zone As String + Try + zone = ParseTimeZone(param(1)) + Catch ex As FormatException + reqChannel.SendMessageAsync(ex.Message).Wait() + Return + End Try + + ' Update value + SyncLock Instance.KnownGuilds + Dim gi = Instance.KnownGuilds(reqChannel.Guild.Id) + gi.UpdateTimeZoneAsync(zone).Wait() + End SyncLock + + ' Report the success + Await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**.") + End If + End Function + + ' Block/unblock individual non-manager users from using commands. + Private Async Function ScmdBanUnban(param As String(), reqChannel As SocketTextChannel) As Task + If param.Length <> 2 Then + Await reqChannel.SendMessageAsync(GenericError) + Return + End If + + Dim doBan As Boolean = param(0).ToLower() = "ban" ' True = ban, False = unban + + ' Parameter must be a mention or explicit ID. No name resolution. + Dim input = param(1) + Dim m = UserMention.Match(param(1)) + If m.Success Then input = m.Groups(1).Value + Dim inputId As ULong + If Not ULong.TryParse(input, inputId) Then + Await reqChannel.SendMessageAsync(":x: Unable to find user. Specify their `@` mention or their ID.") + Return + End If + + SyncLock Instance.KnownGuilds + Dim gi = Instance.KnownGuilds(reqChannel.Guild.Id) + Dim isBanned = gi.IsUserBannedAsync(inputId).GetAwaiter().GetResult() + + If doBan Then + If Not isBanned Then + gi.BanUserAsync(inputId).Wait() + reqChannel.SendMessageAsync(":white_check_mark: User has been banned from using the bot").Wait() + Else + reqChannel.SendMessageAsync(":white_check_mark: The specified user is already banned.").Wait() + End If + Else + If isBanned Then + gi.UnbanUserAsync(inputId).Wait() + reqChannel.SendMessageAsync(":white_check_mark: User may now use the bot").Wait() + Else + reqChannel.SendMessageAsync(":white_check_mark: The specified user is not banned.").Wait() + End If + End If + End SyncLock + End Function + + ' "ban/unban all" - Sets/unsets moderated mode. + Private Async Function ScmdSetModerated(param As String(), reqChannel As SocketTextChannel) As Task + Throw New NotImplementedException() + End Function +#End Region + + ' Execute command as another user + Private Async Function CmdOverride(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + Throw New NotImplementedException + ' obv. check if manager + End Function +End Class \ No newline at end of file diff --git a/BirthdayBot/UserInterface/UserCommands.vb b/BirthdayBot/UserInterface/UserCommands.vb new file mode 100644 index 0000000..ad6b731 --- /dev/null +++ b/BirthdayBot/UserInterface/UserCommands.vb @@ -0,0 +1,174 @@ +Option Strict On +Option Explicit On +Imports System.Text.RegularExpressions +Imports Discord.WebSocket +Imports NodaTime + +Class UserCommands + Inherits CommandsCommon + + Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) + Get + Return New List(Of (String, CommandHandler)) From { + ("set", AddressOf CmdSet), + ("set-tz", AddressOf CmdSetTz), + ("remove", AddressOf CmdRemove) + } + End Get + End Property + + Sub New(inst As BirthdayBot, db As Configuration) + MyBase.New(inst, db) + End Sub + + ''' + ''' 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 Function ParseDate(dateInput As String) As (Integer, Integer) + ' Not using 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. + Dim m = Regex.Match(dateInput, "^(?\d{1,2})-(?[A-Za-z]{3})$") + If Not m.Success Then + ' Flip the fields around, try again + m = Regex.Match(dateInput, "^(?[A-Za-z]{3})-(?\d{1,2})$") + If Not m.Success Then Throw New FormatException(GenericError) + End If + Dim day As Integer + Try + day = Integer.Parse(m.Groups("day").Value) + Catch ex As FormatException + Throw New FormatException(GenericError) + End Try + Dim monthVal = m.Groups("month").Value + Dim month As Integer + Dim dayUpper = 31 ' upper day of month check + Select Case monthVal.ToLower() + Case "jan" + month = 1 + Case "feb" + month = 2 + dayUpper = 29 + Case "mar" + month = 3 + Case "apr" + month = 4 + dayUpper = 30 + Case "may" + month = 5 + Case "jun" + month = 6 + dayUpper = 30 + Case "jul" + month = 7 + Case "aug" + month = 8 + Case "sep" + month = 9 + dayUpper = 30 + Case "oct" + month = 10 + Case "nov" + month = 11 + dayUpper = 30 + Case "dec" + month = 12 + Case Else + Throw New FormatException(":x: Invalid month name. Use a three-letter month abbreviation.") + End Select + If day = 0 Or day > dayUpper Then Throw New FormatException(":x: The date you specified is not a valid calendar date.") + + Return (month, day) + End Function + + Private Async Function CmdSet(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + ' Requires one parameter. Optionally two. + If param.Count < 2 Or param.Count > 3 Then + Await reqChannel.SendMessageAsync(GenericError) + Return + End If + + Dim bmonth, bday As Integer + Dim btz As String = Nothing + Try + Dim res = ParseDate(param(1)) + bmonth = res.Item1 + bday = res.Item2 + If param.Length = 3 Then + btz = ParseTimeZone(param(2)) + End If + Catch ex As FormatException + ' Our parse methods' FormatException has its message to send out to Discord. + reqChannel.SendMessageAsync(ex.Message).Wait() + Return + End Try + + ' Parsing successful. Update user information. + Dim known As Boolean ' Extra detail: Bot's response changes if the user was previously unknown. + Try + SyncLock Instance.KnownGuilds + Dim user = Instance.KnownGuilds(reqChannel.Guild.Id).GetUser(reqUser.Id) + known = user.IsKnown + user.UpdateAsync(bmonth, bday, btz, BotConfig.DatabaseSettings).Wait() + End SyncLock + Catch ex As Exception + Log("Error", ex.ToString()) + reqChannel.SendMessageAsync(":x: An unknown error occurred. The bot owner has been notified.").Wait() + Return + End Try + If known Then + Await reqChannel.SendMessageAsync(":white_check_mark: Your information has been updated.") + Else + Await reqChannel.SendMessageAsync(":white_check_mark: Your birthday has been recorded.") + End If + End Function + + Private Async Function CmdSetTz(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + If param.Count <> 2 Then + Await reqChannel.SendMessageAsync(GenericError) + Return + End If + + Dim btz As String = Nothing + SyncLock Instance.KnownGuilds + Dim user = Instance.KnownGuilds(reqChannel.Guild.Id).GetUser(reqUser.Id) + If Not user.IsKnown Then + reqChannel.SendMessageAsync(":x: Can't set your time zone if your birth date isn't registered.").Wait() + Return + End If + + Try + btz = ParseTimeZone(param(1)) + Catch ex As Exception + reqChannel.SendMessageAsync(ex.Message).Wait() + Return + End Try + user.UpdateAsync(user.BirthMonth, user.BirthDay, btz, BotConfig.DatabaseSettings).Wait() + End SyncLock + Await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**.") + End Function + + Private Async Function CmdRemove(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + ' Parameter count check + If param.Count <> 1 Then + Await reqChannel.SendMessageAsync(ExpectedNoParametersError) + Return + End If + + ' Extra detail: Send a notification if the user isn't actually known by the bot. + Dim known As Boolean + SyncLock Instance.KnownGuilds + Dim g = Instance.KnownGuilds(reqChannel.Guild.Id) + known = g.GetUser(reqUser.Id).IsKnown + If known Then + g.DeleteUserAsync(reqUser.Id).Wait() + End If + End SyncLock + If Not known Then + 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.") + End If + End Function +End Class diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..d129114 --- /dev/null +++ b/License.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2018 Noikoio + +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. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..49245bf --- /dev/null +++ b/Readme.md @@ -0,0 +1,7 @@ +# BirthdayBot + +Discord birthday reminder bot. + +Currently a major work in progress. Code may be restructured and features added, modified, or deleted. + +It is usable, however. A public instance exists. Contact me if you want it in your server. \ No newline at end of file