diff --git a/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb index 8d718c4..f262904 100644 --- a/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb +++ b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb @@ -18,7 +18,7 @@ Class BirthdayRoleUpdate ''' Does processing on all available guilds at once. ''' Public Overrides Async Function OnTick() As Task - Dim tasks As New List(Of Task(Of Integer)) + Dim tasks As New List(Of Task) For Each guild In BotInstance.DiscordClient.Guilds Dim t = ProcessGuildAsync(guild) tasks.Add(t) @@ -32,6 +32,7 @@ Class BirthdayRoleUpdate Select task.Exception Log($"Encountered {exs.Count} errors during bulk guild processing.") For Each iex In exs + ' TODO probably not a good idea Log(iex.ToString()) Next End Try @@ -53,7 +54,10 @@ Class BirthdayRoleUpdate ' TODO metrics for role sets, unsets, announcements - and I mentioned this above too End Function - Private Async Function ProcessGuildAsync(guild As SocketGuild) As Task(Of Integer) + ''' + ''' Main function where actual guild processing occurs. + ''' + Private Async Function ProcessGuildAsync(guild As SocketGuild) As Task ' Gather required information Dim tz As String Dim users As IEnumerable(Of GuildUserSettings) @@ -61,15 +65,19 @@ Class BirthdayRoleUpdate Dim channel As SocketTextChannel = Nothing Dim announce As (String, String) Dim announceping As Boolean + Dim op As OperationStatus - If Not BotInstance.GuildCache.ContainsKey(guild.Id) Then Return 0 ' guild not yet fully loaded; skip processing + ' Skip processing of guild if local info has not yet been loaded + If Not BotInstance.GuildCache.ContainsKey(guild.Id) Then Return + ' Lock once to grab all info Dim gs = BotInstance.GuildCache(guild.Id) With gs tz = .TimeZone users = .Users announce = .AnnounceMessages announceping = .AnnouncePing + op = .OperationLog If .AnnounceChannelId.HasValue Then channel = guild.GetTextChannel(gs.AnnounceChannelId.Value) If .RoleId.HasValue Then role = guild.GetRole(gs.RoleId.Value) @@ -81,55 +89,57 @@ Class BirthdayRoleUpdate ' Set birthday roles, get list of users that had the role added ' But first check if we are able to do so. Letting all requests fail instead will lead to rate limiting. - Dim correctRoleSettings = HasCorrectRoleSettings(guild, role) - Dim gotForbidden = False - - Dim announceNames As IEnumerable(Of SocketGuildUser) = Nothing - If correctRoleSettings Then - Try - announceNames = Await UpdateGuildBirthdayRoles(guild, role, birthdays) - Catch ex As Discord.Net.HttpException - If ex.HttpCode = HttpStatusCode.Forbidden Then - gotForbidden = True - Else - Throw - End If - End Try + Dim roleCheck = CheckCorrectRoleSettings(guild, role) + If Not roleCheck.Item1 Then + SyncLock op + op(OperationType.BirthdayRole) = New OperationInfo(New Exception(roleCheck.Item2)) + op(OperationType.BirthdayAnnounce) = Nothing + End SyncLock + Return End If - ' Update warning flag - Dim updateError = Not correctRoleSettings Or gotForbidden - ' Quit now if the warning flag was set. Announcement data is not available. - If updateError Then Return 0 + Dim announcementList As IEnumerable(Of SocketGuildUser) + ' Do actual role updating + Try + announcementList = Await UpdateGuildBirthdayRoles(guild, role, birthdays) + SyncLock op + op(OperationType.BirthdayRole) = New OperationInfo() + End SyncLock + Catch ex As Discord.Net.HttpException + SyncLock op + op(OperationType.BirthdayRole) = New OperationInfo(ex) + op(OperationType.BirthdayAnnounce) = Nothing + End SyncLock + If ex.HttpCode <> HttpStatusCode.Forbidden Then + ' Send unusual exceptions to calling method + Throw + End If + Return + End Try - If announceNames.Count <> 0 Then - ' Send out announcement message - Await AnnounceBirthdaysAsync(announce, announceping, channel, announceNames) + If announcementList.Count <> 0 Then + Await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList, op) End If - Return announceNames.Count End Function ''' ''' Checks if the bot may be allowed to alter roles. ''' - Private Function HasCorrectRoleSettings(guild As SocketGuild, role As SocketRole) As Boolean + Private Function CheckCorrectRoleSettings(guild As SocketGuild, role As SocketRole) As (Boolean, String) If role Is Nothing Then - ' Designated role not found or defined in guild - Return False + Return (False, "Designated role not found or defined in guild") End If If Not guild.CurrentUser.GuildPermissions.ManageRoles Then - ' Bot user cannot manage roles - Return False + Return (False, "Bot does not contain Manage Roles permission") End If ' Check potential role order conflict If role.Position >= guild.CurrentUser.Hierarchy Then - ' Target role is at or above bot's highest role. - Return False + Return (False, "Targeted role is at or above bot's highest rank") End If - Return True + Return (True, "Success") End Function ''' @@ -219,10 +229,16 @@ Class BirthdayRoleUpdate ''' who have just had their birthday role added. ''' Private Async Function AnnounceBirthdaysAsync(announce As (String, String), - announcePing As Boolean, - c As SocketTextChannel, - names As IEnumerable(Of SocketGuildUser)) As Task - If c Is Nothing Then Return + announcePing As Boolean, + c As SocketTextChannel, + names As IEnumerable(Of SocketGuildUser), + op As OperationStatus) As Task + If c Is Nothing Then + SyncLock op + op(OperationType.BirthdayAnnounce) = New OperationInfo("Announcement channel missing or undefined") + End SyncLock + Return + End If Dim announceMsg As String If names.Count = 1 Then @@ -252,9 +268,13 @@ Class BirthdayRoleUpdate Try Await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())) + SyncLock op + op(OperationType.BirthdayAnnounce) = New OperationInfo() + End SyncLock Catch ex As Discord.Net.HttpException - ' Ignore - ' TODO keep tabs on this somehow for troubleshooting purposes + SyncLock op + op(OperationType.BirthdayAnnounce) = New OperationInfo(ex) + End SyncLock End Try End Function End Class diff --git a/BirthdayBot/BirthdayBot.vb b/BirthdayBot/BirthdayBot.vb index 330ce79..f7ce32c 100644 --- a/BirthdayBot/BirthdayBot.vb +++ b/BirthdayBot/BirthdayBot.vb @@ -10,6 +10,7 @@ Class BirthdayBot Private ReadOnly _cmdsListing As ListingCommands Private ReadOnly _cmdsHelp As HelpInfoCommands Private ReadOnly _cmdsMods As ManagerCommands + Private ReadOnly _cmdsDiag As DiagnosticCommands Private WithEvents Client As DiscordShardedClient Private ReadOnly _worker As BackgroundServiceRunner @@ -49,15 +50,21 @@ Class BirthdayBot For Each item In _cmdsMods.Commands _dispatchCommands.Add(item.Item1, item.Item2) Next + _cmdsDiag = New DiagnosticCommands(Me, conf) + For Each item In _cmdsDiag.Commands + _dispatchCommands.Add(item.Item1, item.Item2) + Next End Sub Public Async Function Start() As Task Await Client.LoginAsync(TokenType.Bot, Config.BotToken) Await Client.StartAsync() +#If Not DEBUG Then Log("Background processing", "Delaying start") Await Task.Delay(90000) ' TODO don't keep doing this Log("Background processing", "Delay complete") +#End If _worker.Start() Await Task.Delay(-1) @@ -85,8 +92,8 @@ Class BirthdayBot Return Task.CompletedTask End Function - Private Async Function SetStatus() As Task Handles Client.ShardConnected - Await Client.SetGameAsync(CommandPrefix + "help") + Private Async Function SetStatus(shard As DiscordSocketClient) As Task Handles Client.ShardConnected + Await shard.SetGameAsync(CommandPrefix + "help") End Function Private Async Function Dispatch(msg As SocketMessage) As Task Handles Client.MessageReceived diff --git a/BirthdayBot/BirthdayBot.vbproj b/BirthdayBot/BirthdayBot.vbproj index 93b9408..9eef773 100644 --- a/BirthdayBot/BirthdayBot.vbproj +++ b/BirthdayBot/BirthdayBot.vbproj @@ -4,7 +4,7 @@ Exe BirthdayBot netcoreapp2.0 - 1.3.4 + 1.3.6 Noi Discord bot for birthday reminders. diff --git a/BirthdayBot/Configuration.vb b/BirthdayBot/Configuration.vb index 60ada1e..6c3920e 100644 --- a/BirthdayBot/Configuration.vb +++ b/BirthdayBot/Configuration.vb @@ -7,6 +7,8 @@ Imports System.IO ''' Class Configuration Public ReadOnly Property BotToken As String + Public ReadOnly Property LogWebhook As String + Public ReadOnly Property DiagnosticChannel As ULong Public ReadOnly Property DBotsToken As String Public ReadOnly Property DatabaseSettings As Database @@ -23,11 +25,19 @@ Class Configuration End If Dim jc = JObject.Parse(File.ReadAllText(confPath)) - BotToken = jc("BotToken").Value(Of String)() + + BotToken = jc("BotToken")?.Value(Of String)() If String.IsNullOrWhiteSpace(BotToken) Then Throw New Exception("'BotToken' must be specified.") End If + LogWebhook = jc("LogWebhook")?.Value(Of String)() + If String.IsNullOrWhiteSpace(LogWebhook) Then + Throw New Exception("'LogWebhook' must be specified.") + End If + + DiagnosticChannel = jc("DiagnosticChannel").Value(Of ULong)() + Dim dbj = jc("DBotsToken") If dbj IsNot Nothing Then DBotsToken = dbj.Value(Of String)() @@ -35,7 +45,7 @@ Class Configuration DBotsToken = Nothing End If - Dim sqlcs = jc("SqlConnectionString").Value(Of String)() + Dim sqlcs = jc("SqlConnectionString")?.Value(Of String)() If String.IsNullOrWhiteSpace(sqlcs) Then Throw New Exception("'SqlConnectionString' must be specified.") End If diff --git a/BirthdayBot/Data/GuildStateInformation.vb b/BirthdayBot/Data/GuildStateInformation.vb index 0b7122e..0d7b56e 100644 --- a/BirthdayBot/Data/GuildStateInformation.vb +++ b/BirthdayBot/Data/GuildStateInformation.vb @@ -7,7 +7,7 @@ Imports NpgsqlTypes ''' Holds various pieces of state information for a guild the bot is operating in. ''' Includes, among other things, a copy of the guild's settings and a list of all known users with birthdays. ''' -Friend Class GuildStateInformation +Class GuildStateInformation Public ReadOnly Property GuildId As ULong Private ReadOnly _db As Database Private _bdayRole As ULong? @@ -19,9 +19,10 @@ Friend Class GuildStateInformation Private _announceMsgPl As String Private _announcePing As Boolean Private ReadOnly _userCache As Dictionary(Of ULong, GuildUserSettings) + Public ReadOnly Property OperationLog As OperationStatus ''' - ''' Gets a list of cached users. Use sparingly. + ''' Gets a list of cached registered user information. ''' Public ReadOnly Property Users As IEnumerable(Of GuildUserSettings) Get @@ -115,6 +116,9 @@ Friend Class GuildStateInformation ' Called by LoadSettingsAsync. Double-check ordinals when changes are made. Private Sub New(reader As DbDataReader, dbconfig As Database) _db = dbconfig + + OperationLog = New OperationStatus() + 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 diff --git a/BirthdayBot/Diagnostics/DiagnosticCommands.vb b/BirthdayBot/Diagnostics/DiagnosticCommands.vb new file mode 100644 index 0000000..e03e149 --- /dev/null +++ b/BirthdayBot/Diagnostics/DiagnosticCommands.vb @@ -0,0 +1,75 @@ +Imports Discord.WebSocket +Imports Discord.Webhook +Imports System.Text +''' +''' Implements the command used by global bot moderators to get operation info for each guild. +''' +Class DiagnosticCommands + Inherits CommandsCommon + + Private ReadOnly _webhook As DiscordWebhookClient + Private ReadOnly _diagChannel As ULong + + Sub New(inst As BirthdayBot, db As Configuration) + MyBase.New(inst, db) + + _webhook = New DiscordWebhookClient(db.LogWebhook) + _diagChannel = db.DiagnosticChannel + End Sub + + Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) + Get + Return New List(Of (String, CommandHandler)) From { + ("diag", AddressOf CmdDiag), + ("checkme", AddressOf CmdCheckme) + } + End Get + End Property + + ' Dumps all known guild information to the given webhook + Private Async Function CmdDiag(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + ' Ignore if not in the correct channel + If reqChannel.Id <> _diagChannel Then Return + + ' Requires two parameters: (cmd) (guild id) + If param.Length <> 2 Then + Await reqChannel.SendMessageAsync(":x: Usage: (command) (guild ID)") + Return + End If + + Dim rgid As ULong + If Not ULong.TryParse(param(1), rgid) Then + Await reqChannel.SendMessageAsync(":x: Cannot parse numeric guild ID") + Return + End If + + Dim guild = Instance.DiscordClient.GetGuild(rgid) + If guild Is Nothing Then + Await reqChannel.SendMessageAsync(":x: Guild is not known to the bot") + End If + + Dim gi = Instance.GuildCache(rgid) + If gi Is Nothing Then + Await reqChannel.SendMessageAsync(":x: Guild is known, but information is not available.") + End If + + Await reqChannel.SendMessageAsync(":white_check_mark: Compiling info and sending to webhook.") + + Dim report As New StringBuilder + report.AppendLine("=-=-=-=-GUILD INFORMATION-=-=-=-=") + ' TODO dump config info + report.AppendLine($"{guild.Id}: {guild.Name}") + report.AppendLine($"User count: {guild.Users.Count}") + report.AppendLine("---") + SyncLock gi.OperationLog + report.Append(gi.OperationLog.GetDiagStrings()) + End SyncLock + report.AppendLine("**Note**: Full stack traces for captured exceptions are printed to console.") + + Await _webhook.SendMessageAsync(report.ToString()) + End Function + + Private Async Function CmdCheckme(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + Await _webhook.SendMessageAsync($"{reqUser.Username}#{reqUser.Discriminator}: {reqChannel.Guild.Id} checkme") + End Function +End Class diff --git a/BirthdayBot/Diagnostics/OperationInfo.vb b/BirthdayBot/Diagnostics/OperationInfo.vb new file mode 100644 index 0000000..fa25852 --- /dev/null +++ b/BirthdayBot/Diagnostics/OperationInfo.vb @@ -0,0 +1,37 @@ +''' +''' Information regarding a single type of operation. +''' +Class OperationInfo + ''' + ''' The time in which the respective operation was attempted. + ''' + ReadOnly Property Timestamp As DateTimeOffset + ''' + ''' Any exception encountered during the respective operation. + ''' + ''' Nothing/null if the previous given operation was a success. + ReadOnly Property Exception As Exception + + ''' + ''' Creates an instance containing a success status. + ''' + Sub New() + Timestamp = DateTimeOffset.UtcNow + End Sub + + ''' + ''' Creates an instance containing a captured exception + ''' + Sub New(ex As Exception) + Me.New() + Exception = ex + End Sub + + ''' + ''' Creates an instance containing a custom error message + ''' + Sub New(message As String) + Me.New() + Exception = New Exception(message) + End Sub +End Class \ No newline at end of file diff --git a/BirthdayBot/Diagnostics/OperationStatus.vb b/BirthdayBot/Diagnostics/OperationStatus.vb new file mode 100644 index 0000000..53bfc50 --- /dev/null +++ b/BirthdayBot/Diagnostics/OperationStatus.vb @@ -0,0 +1,49 @@ +Imports System.Text +''' +''' Holds information regarding previous operations done on a guild and their most recent success/error status. +''' +Class OperationStatus + Private ReadOnly _log As New Dictionary(Of OperationType, OperationInfo) + + Default Public Property Item(otype As OperationType) As OperationInfo + Get + Dim o As OperationInfo = Nothing + If Not _log.TryGetValue(otype, o) Then + Return Nothing + End If + Return o + End Get + Set(value As OperationInfo) + If value Is Nothing Then + _log.Remove(otype) + Else + _log(otype) = value + End If + End Set + End Property + + ''' + ''' Prepares known information in a displayable format. + ''' + Public Function GetDiagStrings() As String + Dim report As New StringBuilder + For Each otype As OperationType In [Enum].GetValues(GetType(OperationType)) + Dim prefix = $"`{[Enum].GetName(GetType(OperationType), otype)}`: " + + Dim info = Item(otype) + If info Is Nothing Then + report.AppendLine(prefix + "No data") + Continue For + End If + prefix += info.Timestamp.ToString("u") + " " + + If info.Exception Is Nothing Then + report.AppendLine(prefix + "Success") + Else + Log("OperationStatus", prefix + info.Exception.ToString()) + report.AppendLine(prefix + info.Exception.Message) + End If + Next + Return report.ToString() + End Function +End Class diff --git a/BirthdayBot/Diagnostics/OperationType.vb b/BirthdayBot/Diagnostics/OperationType.vb new file mode 100644 index 0000000..b752115 --- /dev/null +++ b/BirthdayBot/Diagnostics/OperationType.vb @@ -0,0 +1,5 @@ +Enum OperationType + BirthdayRole + BirthdayAnnounce + CommandDispatch +End Enum \ No newline at end of file