First commit

This commit is contained in:
Noikoio 2018-07-22 13:57:18 -07:00
commit 8d44858d71
18 changed files with 1615 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
[Bb]in/
[Oo]bj/
.vs/
*.user

25
BirthdayBot.sln Normal file
View file

@ -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

View file

@ -0,0 +1,192 @@
Option Strict On
Option Explicit On
Imports System.Text
Imports System.Threading
Imports Discord.WebSocket
Imports NodaTime
''' <summary>
''' BirthdayBot's periodic task. Frequently wakes up to take various actions.
''' </summary>
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"
''' <summary>
''' All birthday checking happens here.
''' </summary>
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
''' <summary>
''' 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.
''' </summary>
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
''' <summary>
''' Sets the birthday role to all applicable users. Unsets it from all others who may have it.
''' </summary>
''' <returns>A list of users who had the birthday role applied. Use for the announcement message.</returns>
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
''' <summary>
''' Makes (or attempts to make) an announcement in the specified channel that includes all users
''' who have just had their birthday role added.
''' </summary>
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

122
BirthdayBot/BirthdayBot.vb Normal file
View file

@ -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
''' <summary>SyncLock when using. The lock object is itself.</summary>
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
''' <summary>
''' Called only by CancelKeyPress handler.
''' </summary>
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

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>BirthdayBot</RootNamespace>
<TargetFramework>netcoreapp2.0</TargetFramework>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<Authors>Noikoio</Authors>
<Company />
<Description>Discord bot for birthday reminders.</Description>
<StartupObject>Sub Main</StartupObject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="1.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="NodaTime" Version="2.3.0" />
<PackageReference Include="Npgsql" Version="3.2.7" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,36 @@
Option Strict On
Option Explicit On
Imports System.Reflection
Imports Newtonsoft.Json.Linq
Imports System.IO
''' <summary>
''' Loads and holds configuration values.
''' </summary>
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

View file

@ -0,0 +1,35 @@
Option Strict On
Option Explicit On
Imports Npgsql
''' <summary>
''' Some database abstractions.
''' </summary>
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

View file

@ -0,0 +1,293 @@
Option Strict On
Option Explicit On
Imports System.Data.Common
Imports Npgsql
Imports NpgsqlTypes
''' <summary>
''' 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.
''' </summary>
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)
''' <summary>
''' Gets a list of cached users. Use sparingly.
''' </summary>
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
''' <summary>
''' Gets the guild's designated Role ID.
''' </summary>
Public ReadOnly Property RoleId As ULong?
Get
Return _role
End Get
End Property
''' <summary>
''' Gets the designated announcement Channel ID.
''' </summary>
Public ReadOnly Property AnnounceChannelId As ULong?
Get
Return _channel
End Get
End Property
''' <summary>
''' Gets the guild's default time zone.
''' </summary>
Public ReadOnly Property TimeZone As String
Get
Return _tz
End Get
End Property
''' <summary>
''' Gets or sets if the server is in moderated mode.
''' Updating this value updates the database.
''' </summary>
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
''' <summary>
''' 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.
''' </summary>
''' <param name="userId"></param>
''' <returns></returns>
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
''' <summary>
''' Deletes the user from the backing database. Drops the locally cached entry.
''' </summary>
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
''' <summary>
''' Checks if the given user is banned from issuing commands.
''' If the server is in moderated mode, this always returns True.
''' </summary>
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
''' <summary>
''' Bans the specified user from issuing commands.
''' Does not check if the given user is already banned.
''' </summary>
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
''' <summary>
''' Removes the specified user from the ban list.
''' Does not check if the given user was not banned to begin with.
''' </summary>
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
''' <summary>
''' 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.
''' </summary>
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
''' <summary>
''' Updates the backing database with values from this instance
''' This is a non-asynchronous operation. That may be bad.
''' </summary>
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

View file

@ -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
''' <summary>
''' Month of birth as a numeric value. Range 1-12.
''' </summary>
Public ReadOnly Property BirthMonth As Integer
Get
Return _month
End Get
End Property
''' <summary>
''' Day of birth as a numeric value. Ranges between 1-31 or lower based on month value.
''' </summary>
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
''' <summary>
''' Creates a data-less instance without any useful information.
''' Calling <see cref="UpdateAsync(Integer, Integer, String)"/> will cause an actual database update.
''' </summary>
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
''' <summary>
''' Updates user with given information.
''' NOTE: If there exists a tz value and the update contains none, the old tz value is retained.
''' </summary>
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
''' <summary>
''' Deletes information of this user from the backing database.
''' The corresponding object reference should ideally be discarded after calling this.
''' </summary>
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
''' <summary>
''' Gets all known birthday records from the specified guild. No further filtering is done here.
''' </summary>
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

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<PublishProtocol>FileSystem</PublishProtocol>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<TargetFramework>netcoreapp2.0</TargetFramework>
<PublishDir>bin\Release\netcoreapp2.0\publish\</PublishDir>
<SelfContained>false</SelfContained>
<_IsPortable>true</_IsPortable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on and is
is designed to work with. Uncomment the appropriate elements and Windows will
automatically selected the most compatible environment. -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
</application>
</compatibility>
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. -->
<!--
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
-->
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
<!--
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
-->
</assembly>

58
BirthdayBot/Program.vb Normal file
View file

@ -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
''' <summary>
''' Sends a formatted message to console.
''' </summary>
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

View file

@ -0,0 +1,65 @@
Option Strict On
Option Explicit On
Imports System.Text.RegularExpressions
Imports Discord.WebSocket
Imports NodaTime
''' <summary>
''' Common base class for common constants and variables.
''' </summary>
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
''' <summary>
''' Checks given time zone input. Returns a valid string for use with NodaTime.
''' </summary>
''' <param name="tzinput"></param>
''' <returns></returns>
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
''' <summary>
''' On command dispatcher initialization, it will retrieve all available commands through here.
''' </summary>
Public MustOverride ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler))
End Class

View file

@ -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

View file

@ -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

View file

@ -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
''' <summary>
''' Parses date parameter. Strictly takes dd-MMM or MMM-dd only. Eliminates ambiguity over dd/mm vs mm/dd.
''' </summary>
''' <returns>Tuple: month, day</returns>
''' <exception cref="FormatException">Thrown for any parsing issue. Reason is expected to be sent to Discord as-is.</exception>
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, "^(?<day>\d{1,2})-(?<month>[A-Za-z]{3})$")
If Not m.Success Then
' Flip the fields around, try again
m = Regex.Match(dateInput, "^(?<month>[A-Za-z]{3})-(?<day>\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

9
License.txt Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2018 Noikoio <noikoio1@gmail.com>
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.

7
Readme.md Normal file
View file

@ -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.