diff --git a/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb index f262904..3f78d51 100644 --- a/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb +++ b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb @@ -38,6 +38,9 @@ Class BirthdayRoleUpdate End Try ' TODO metrics for role sets, unsets, announcements - and how to do that for singles too? + + ' Running GC now. Many long-lasting items have likely been discarded by now. + GC.Collect() End Function ''' @@ -65,7 +68,6 @@ Class BirthdayRoleUpdate Dim channel As SocketTextChannel = Nothing Dim announce As (String, String) Dim announceping As Boolean - Dim op As OperationStatus ' Skip processing of guild if local info has not yet been loaded If Not BotInstance.GuildCache.ContainsKey(guild.Id) Then Return @@ -77,7 +79,6 @@ Class BirthdayRoleUpdate 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) @@ -91,24 +92,22 @@ Class BirthdayRoleUpdate ' But first check if we are able to do so. Letting all requests fail instead will lead to rate limiting. 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 + SyncLock gs + gs.OperationLog = New OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, roleCheck.Item2)) End SyncLock Return End If Dim announcementList As IEnumerable(Of SocketGuildUser) + Dim roleResult As (Integer, Integer) ' Role additions, removals ' Do actual role updating Try - announcementList = Await UpdateGuildBirthdayRoles(guild, role, birthdays) - SyncLock op - op(OperationType.BirthdayRole) = New OperationInfo() - End SyncLock + Dim updateResult = Await UpdateGuildBirthdayRoles(guild, role, birthdays) + announcementList = updateResult.Item1 + roleResult = updateResult.Item2 Catch ex As Discord.Net.HttpException - SyncLock op - op(OperationType.BirthdayRole) = New OperationInfo(ex) - op(OperationType.BirthdayAnnounce) = Nothing + SyncLock gs + gs.OperationLog = New OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, ex.Message)) End SyncLock If ex.HttpCode <> HttpStatusCode.Forbidden Then ' Send unusual exceptions to calling method @@ -117,9 +116,20 @@ Class BirthdayRoleUpdate Return End Try + Dim opResult1, opResult2 As (OperationStatus.OperationType, String) + opResult1 = (OperationStatus.OperationType.UpdateBirthdayRoleMembership, + $"Success: Added {roleResult.Item1} member(s), Removed {roleResult.Item2} member(s) from target role.") + If announcementList.Count <> 0 Then - Await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList, op) + Dim announceOpResult = Await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList) + opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, announceOpResult) + Else + opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, "Announcement not considered.") End If + + SyncLock gs + gs.OperationLog = New OperationStatus(opResult1, opResult2) + End SyncLock End Function ''' @@ -127,19 +137,19 @@ Class BirthdayRoleUpdate ''' Private Function CheckCorrectRoleSettings(guild As SocketGuild, role As SocketRole) As (Boolean, String) If role Is Nothing Then - Return (False, "Designated role not found or defined in guild") + Return (False, "Failed: Designated role not found or defined.") End If If Not guild.CurrentUser.GuildPermissions.ManageRoles Then - Return (False, "Bot does not contain Manage Roles permission") + Return (False, "Failed: Bot does not contain Manage Roles permission.") End If ' Check potential role order conflict If role.Position >= guild.CurrentUser.Hierarchy Then - Return (False, "Targeted role is at or above bot's highest rank") + Return (False, "Failed: Bot is beneath the designated role in the role hierarchy.") End If - Return (True, "Success") + Return (True, Nothing) End Function ''' @@ -189,7 +199,7 @@ Class BirthdayRoleUpdate ''' A list of users who had the birthday role applied. Use for the announcement message. Private Async Function UpdateGuildBirthdayRoles(g As SocketGuild, r As SocketRole, - names As HashSet(Of ULong)) As Task(Of IEnumerable(Of SocketGuildUser)) + names As HashSet(Of ULong)) As Task(Of (IEnumerable(Of SocketGuildUser), (Integer, Integer))) ' Check members currently with the role. Figure out which users to remove it from. Dim roleRemoves As New List(Of SocketGuildUser) Dim roleKeeps As New HashSet(Of ULong) @@ -218,7 +228,7 @@ Class BirthdayRoleUpdate newBirthdays.Add(member) Next - Return newBirthdays + Return (newBirthdays, (newBirthdays.Count, roleRemoves.Count)) End Function Public Const DefaultAnnounce = "Please wish a happy birthday to %n!" @@ -231,13 +241,9 @@ Class BirthdayRoleUpdate Private Async Function AnnounceBirthdaysAsync(announce As (String, String), announcePing As Boolean, c As SocketTextChannel, - names As IEnumerable(Of SocketGuildUser), - op As OperationStatus) As Task + names As IEnumerable(Of SocketGuildUser)) As Task(Of String) If c Is Nothing Then - SyncLock op - op(OperationType.BirthdayAnnounce) = New OperationInfo("Announcement channel missing or undefined") - End SyncLock - Return + Return "Announcement channel is undefined." End If Dim announceMsg As String @@ -268,13 +274,9 @@ Class BirthdayRoleUpdate Try Await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())) - SyncLock op - op(OperationType.BirthdayAnnounce) = New OperationInfo() - End SyncLock + Return $"Successfully announced {names.Count} name(s)" Catch ex As Discord.Net.HttpException - SyncLock op - op(OperationType.BirthdayAnnounce) = New OperationInfo(ex) - End SyncLock + Return ex.Message End Try End Function End Class diff --git a/BirthdayBot/BirthdayBot.vb b/BirthdayBot/BirthdayBot.vb index f7ce32c..bedb4b0 100644 --- a/BirthdayBot/BirthdayBot.vb +++ b/BirthdayBot/BirthdayBot.vb @@ -2,6 +2,7 @@ Imports BirthdayBot.CommandsCommon Imports Discord Imports Discord.Net +Imports Discord.Webhook Imports Discord.WebSocket Class BirthdayBot @@ -10,7 +11,6 @@ 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 @@ -22,12 +22,13 @@ Class BirthdayBot Return Client End Get End Property - Friend ReadOnly Property GuildCache As ConcurrentDictionary(Of ULong, GuildStateInformation) + Friend ReadOnly Property LogWebhook As DiscordWebhookClient Public Sub New(conf As Configuration, dc As DiscordShardedClient) Config = conf Client = dc + LogWebhook = New DiscordWebhookClient(conf.LogWebhook) GuildCache = New ConcurrentDictionary(Of ULong, GuildStateInformation) _worker = New BackgroundServiceRunner(Me) @@ -50,10 +51,6 @@ 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 diff --git a/BirthdayBot/BirthdayBot.vbproj b/BirthdayBot/BirthdayBot.vbproj index 9eef773..c390dc1 100644 --- a/BirthdayBot/BirthdayBot.vbproj +++ b/BirthdayBot/BirthdayBot.vbproj @@ -4,7 +4,7 @@ Exe BirthdayBot netcoreapp2.0 - 1.3.6 + 1.4.0 Noi Discord bot for birthday reminders. diff --git a/BirthdayBot/Configuration.vb b/BirthdayBot/Configuration.vb index 6c3920e..908bf73 100644 --- a/BirthdayBot/Configuration.vb +++ b/BirthdayBot/Configuration.vb @@ -8,7 +8,6 @@ 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 @@ -36,8 +35,6 @@ Class Configuration 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)() diff --git a/BirthdayBot/Data/GuildStateInformation.vb b/BirthdayBot/Data/GuildStateInformation.vb index 0d7b56e..35d1991 100644 --- a/BirthdayBot/Data/GuildStateInformation.vb +++ b/BirthdayBot/Data/GuildStateInformation.vb @@ -19,7 +19,7 @@ Class GuildStateInformation Private _announceMsgPl As String Private _announcePing As Boolean Private ReadOnly _userCache As Dictionary(Of ULong, GuildUserSettings) - Public ReadOnly Property OperationLog As OperationStatus + Public Property OperationLog As OperationStatus ''' ''' Gets a list of cached registered user information. diff --git a/BirthdayBot/Data/OperationStatus.vb b/BirthdayBot/Data/OperationStatus.vb new file mode 100644 index 0000000..e019988 --- /dev/null +++ b/BirthdayBot/Data/OperationStatus.vb @@ -0,0 +1,48 @@ +Imports System.Text +''' +''' Holds information regarding the previous updating operation done on a guild including success/error information. +''' +Class OperationStatus + Private ReadOnly _log As New Dictionary(Of OperationType, String) + + Public ReadOnly Property Timestamp As DateTimeOffset + + Sub New(ParamArray statuses() As (OperationType, String)) + Timestamp = DateTimeOffset.UtcNow + For Each status In statuses + _log(status.Item1) = status.Item2 + Next + End Sub + + ''' + ''' 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 As String = Nothing + + If Not _log.TryGetValue(otype, info) Then + report.AppendLine(prefix + "No data") + Continue For + End If + + If info Is Nothing Then + report.AppendLine(prefix + "Success") + Else + report.AppendLine(prefix + info) + End If + Next + Return report.ToString() + End Function + + ''' + ''' Specifies the type of operation logged. These enum values are publicly displayed in the specified order. + ''' + Public Enum OperationType + UpdateBirthdayRoleMembership + SendBirthdayAnnouncementMessage + End Enum +End Class diff --git a/BirthdayBot/Diagnostics/DiagnosticCommands.vb b/BirthdayBot/Diagnostics/DiagnosticCommands.vb deleted file mode 100644 index e03e149..0000000 --- a/BirthdayBot/Diagnostics/DiagnosticCommands.vb +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index fa25852..0000000 --- a/BirthdayBot/Diagnostics/OperationInfo.vb +++ /dev/null @@ -1,37 +0,0 @@ -''' -''' 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 deleted file mode 100644 index 53bfc50..0000000 --- a/BirthdayBot/Diagnostics/OperationStatus.vb +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index b752115..0000000 --- a/BirthdayBot/Diagnostics/OperationType.vb +++ /dev/null @@ -1,5 +0,0 @@ -Enum OperationType - BirthdayRole - BirthdayAnnounce - CommandDispatch -End Enum \ No newline at end of file diff --git a/BirthdayBot/UserInterface/ManagerCommands.vb b/BirthdayBot/UserInterface/ManagerCommands.vb index 8861b5b..98f0e4c 100644 --- a/BirthdayBot/UserInterface/ManagerCommands.vb +++ b/BirthdayBot/UserInterface/ManagerCommands.vb @@ -1,18 +1,22 @@ Imports System.Text.RegularExpressions +Imports Discord Imports Discord.WebSocket +Imports NodaTime Friend Class ManagerCommands Inherits CommandsCommon + Private Delegate Function ConfigSubcommand(param As String(), reqChannel As SocketTextChannel) As Task - Private _subcommands As Dictionary(Of String, ConfigSubcommand) - Private _usercommands As Dictionary(Of String, CommandHandler) + Private ReadOnly _subcommands As Dictionary(Of String, ConfigSubcommand) + Private ReadOnly _usercommands As Dictionary(Of String, CommandHandler) Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) Get Return New List(Of (String, CommandHandler)) From { ("config", AddressOf CmdConfigDispatch), - ("override", AddressOf CmdOverride) + ("override", AddressOf CmdOverride), + ("status", AddressOf CmdStatus) } End Get End Property @@ -338,6 +342,40 @@ Friend Class ManagerCommands Await action.Invoke(overparam, reqChannel, overuser) End Function + ' Prints a status report useful for troubleshooting operational issues within a guild + Private Async Function CmdStatus(param As String(), reqChannel As SocketTextChannel, reqUser As SocketGuildUser) As Task + ' Moderators only. As with config, silently drop if this check fails. + If Not Instance.GuildCache(reqUser.Guild.Id).IsUserModerator(reqUser) Then Return + + Dim result As New EmbedBuilder + Dim optime As DateTimeOffset + Dim optext As String + Dim zone As String + Dim gi = Instance.GuildCache(reqChannel.Guild.Id) + SyncLock gi + Dim opstat = gi.OperationLog + optext = opstat.GetDiagStrings() ' !!! Bulk of output handled by this method + optime = opstat.Timestamp + zone = If(gi.TimeZone, "UTC") + End SyncLock + Dim shard = Instance.DiscordClient.GetShardIdFor(reqChannel.Guild) + + ' Calculate timestamp in current zone + Dim ts As String = "Last update:" + Dim zonedTimeInstant = SystemClock.Instance.GetCurrentInstant().InZone(DateTimeZoneProviders.Tzdb.GetZoneOrNull(zone)) + Dim timeAgoEstimate = DateTimeOffset.UtcNow - optime + + With result + .Title = "Background operation status" + .Description = $"Shard: {shard}" + vbLf + + $"Operation time: {Math.Round(timeAgoEstimate.TotalSeconds)} second(s) ago at {zonedTimeInstant}" + vbLf + + "Report:" + vbLf + + optext.TrimEnd() + End With + + Await reqChannel.SendMessageAsync(embed:=result.Build()) + End Function + #Region "Common/helper methods" Private Const RoleInputError = ":x: Unable to determine the given role." Private Shared ReadOnly RoleMention As New Regex("<@?&(?\d+)>", RegexOptions.Compiled)