Make diagnostic data user-accessible

This commit is contained in:
Noikoio 2019-12-15 23:42:48 -08:00
parent 3fc13efc57
commit 5012834073
11 changed files with 127 additions and 211 deletions

View file

@ -38,6 +38,9 @@ Class BirthdayRoleUpdate
End Try End Try
' TODO metrics for role sets, unsets, announcements - and how to do that for singles too? ' 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 End Function
''' <summary> ''' <summary>
@ -65,7 +68,6 @@ Class BirthdayRoleUpdate
Dim channel As SocketTextChannel = Nothing Dim channel As SocketTextChannel = Nothing
Dim announce As (String, String) Dim announce As (String, String)
Dim announceping As Boolean Dim announceping As Boolean
Dim op As OperationStatus
' Skip processing of guild if local info has not yet been loaded ' Skip processing of guild if local info has not yet been loaded
If Not BotInstance.GuildCache.ContainsKey(guild.Id) Then Return If Not BotInstance.GuildCache.ContainsKey(guild.Id) Then Return
@ -77,7 +79,6 @@ Class BirthdayRoleUpdate
users = .Users users = .Users
announce = .AnnounceMessages announce = .AnnounceMessages
announceping = .AnnouncePing announceping = .AnnouncePing
op = .OperationLog
If .AnnounceChannelId.HasValue Then channel = guild.GetTextChannel(gs.AnnounceChannelId.Value) If .AnnounceChannelId.HasValue Then channel = guild.GetTextChannel(gs.AnnounceChannelId.Value)
If .RoleId.HasValue Then role = guild.GetRole(gs.RoleId.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. ' 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) Dim roleCheck = CheckCorrectRoleSettings(guild, role)
If Not roleCheck.Item1 Then If Not roleCheck.Item1 Then
SyncLock op SyncLock gs
op(OperationType.BirthdayRole) = New OperationInfo(New Exception(roleCheck.Item2)) gs.OperationLog = New OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, roleCheck.Item2))
op(OperationType.BirthdayAnnounce) = Nothing
End SyncLock End SyncLock
Return Return
End If End If
Dim announcementList As IEnumerable(Of SocketGuildUser) Dim announcementList As IEnumerable(Of SocketGuildUser)
Dim roleResult As (Integer, Integer) ' Role additions, removals
' Do actual role updating ' Do actual role updating
Try Try
announcementList = Await UpdateGuildBirthdayRoles(guild, role, birthdays) Dim updateResult = Await UpdateGuildBirthdayRoles(guild, role, birthdays)
SyncLock op announcementList = updateResult.Item1
op(OperationType.BirthdayRole) = New OperationInfo() roleResult = updateResult.Item2
End SyncLock
Catch ex As Discord.Net.HttpException Catch ex As Discord.Net.HttpException
SyncLock op SyncLock gs
op(OperationType.BirthdayRole) = New OperationInfo(ex) gs.OperationLog = New OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, ex.Message))
op(OperationType.BirthdayAnnounce) = Nothing
End SyncLock End SyncLock
If ex.HttpCode <> HttpStatusCode.Forbidden Then If ex.HttpCode <> HttpStatusCode.Forbidden Then
' Send unusual exceptions to calling method ' Send unusual exceptions to calling method
@ -117,9 +116,20 @@ Class BirthdayRoleUpdate
Return Return
End Try 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 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 End If
SyncLock gs
gs.OperationLog = New OperationStatus(opResult1, opResult2)
End SyncLock
End Function End Function
''' <summary> ''' <summary>
@ -127,19 +137,19 @@ Class BirthdayRoleUpdate
''' </summary> ''' </summary>
Private Function CheckCorrectRoleSettings(guild As SocketGuild, role As SocketRole) As (Boolean, String) Private Function CheckCorrectRoleSettings(guild As SocketGuild, role As SocketRole) As (Boolean, String)
If role Is Nothing Then 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 End If
If Not guild.CurrentUser.GuildPermissions.ManageRoles Then 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 End If
' Check potential role order conflict ' Check potential role order conflict
If role.Position >= guild.CurrentUser.Hierarchy Then 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 End If
Return (True, "Success") Return (True, Nothing)
End Function End Function
''' <summary> ''' <summary>
@ -189,7 +199,7 @@ Class BirthdayRoleUpdate
''' <returns>A list of users who had the birthday role applied. Use for the announcement message.</returns> ''' <returns>A list of users who had the birthday role applied. Use for the announcement message.</returns>
Private Async Function UpdateGuildBirthdayRoles(g As SocketGuild, Private Async Function UpdateGuildBirthdayRoles(g As SocketGuild,
r As SocketRole, 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. ' Check members currently with the role. Figure out which users to remove it from.
Dim roleRemoves As New List(Of SocketGuildUser) Dim roleRemoves As New List(Of SocketGuildUser)
Dim roleKeeps As New HashSet(Of ULong) Dim roleKeeps As New HashSet(Of ULong)
@ -218,7 +228,7 @@ Class BirthdayRoleUpdate
newBirthdays.Add(member) newBirthdays.Add(member)
Next Next
Return newBirthdays Return (newBirthdays, (newBirthdays.Count, roleRemoves.Count))
End Function End Function
Public Const DefaultAnnounce = "Please wish a happy birthday to %n!" Public Const DefaultAnnounce = "Please wish a happy birthday to %n!"
@ -231,13 +241,9 @@ Class BirthdayRoleUpdate
Private Async Function AnnounceBirthdaysAsync(announce As (String, String), Private Async Function AnnounceBirthdaysAsync(announce As (String, String),
announcePing As Boolean, announcePing As Boolean,
c As SocketTextChannel, c As SocketTextChannel,
names As IEnumerable(Of SocketGuildUser), names As IEnumerable(Of SocketGuildUser)) As Task(Of String)
op As OperationStatus) As Task
If c Is Nothing Then If c Is Nothing Then
SyncLock op Return "Announcement channel is undefined."
op(OperationType.BirthdayAnnounce) = New OperationInfo("Announcement channel missing or undefined")
End SyncLock
Return
End If End If
Dim announceMsg As String Dim announceMsg As String
@ -268,13 +274,9 @@ Class BirthdayRoleUpdate
Try Try
Await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())) Await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString()))
SyncLock op Return $"Successfully announced {names.Count} name(s)"
op(OperationType.BirthdayAnnounce) = New OperationInfo()
End SyncLock
Catch ex As Discord.Net.HttpException Catch ex As Discord.Net.HttpException
SyncLock op Return ex.Message
op(OperationType.BirthdayAnnounce) = New OperationInfo(ex)
End SyncLock
End Try End Try
End Function End Function
End Class End Class

View file

@ -2,6 +2,7 @@
Imports BirthdayBot.CommandsCommon Imports BirthdayBot.CommandsCommon
Imports Discord Imports Discord
Imports Discord.Net Imports Discord.Net
Imports Discord.Webhook
Imports Discord.WebSocket Imports Discord.WebSocket
Class BirthdayBot Class BirthdayBot
@ -10,7 +11,6 @@ Class BirthdayBot
Private ReadOnly _cmdsListing As ListingCommands Private ReadOnly _cmdsListing As ListingCommands
Private ReadOnly _cmdsHelp As HelpInfoCommands Private ReadOnly _cmdsHelp As HelpInfoCommands
Private ReadOnly _cmdsMods As ManagerCommands Private ReadOnly _cmdsMods As ManagerCommands
Private ReadOnly _cmdsDiag As DiagnosticCommands
Private WithEvents Client As DiscordShardedClient Private WithEvents Client As DiscordShardedClient
Private ReadOnly _worker As BackgroundServiceRunner Private ReadOnly _worker As BackgroundServiceRunner
@ -22,12 +22,13 @@ Class BirthdayBot
Return Client Return Client
End Get End Get
End Property End Property
Friend ReadOnly Property GuildCache As ConcurrentDictionary(Of ULong, GuildStateInformation) Friend ReadOnly Property GuildCache As ConcurrentDictionary(Of ULong, GuildStateInformation)
Friend ReadOnly Property LogWebhook As DiscordWebhookClient
Public Sub New(conf As Configuration, dc As DiscordShardedClient) Public Sub New(conf As Configuration, dc As DiscordShardedClient)
Config = conf Config = conf
Client = dc Client = dc
LogWebhook = New DiscordWebhookClient(conf.LogWebhook)
GuildCache = New ConcurrentDictionary(Of ULong, GuildStateInformation) GuildCache = New ConcurrentDictionary(Of ULong, GuildStateInformation)
_worker = New BackgroundServiceRunner(Me) _worker = New BackgroundServiceRunner(Me)
@ -50,10 +51,6 @@ Class BirthdayBot
For Each item In _cmdsMods.Commands For Each item In _cmdsMods.Commands
_dispatchCommands.Add(item.Item1, item.Item2) _dispatchCommands.Add(item.Item1, item.Item2)
Next Next
_cmdsDiag = New DiagnosticCommands(Me, conf)
For Each item In _cmdsDiag.Commands
_dispatchCommands.Add(item.Item1, item.Item2)
Next
End Sub End Sub
Public Async Function Start() As Task Public Async Function Start() As Task

View file

@ -4,7 +4,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<RootNamespace>BirthdayBot</RootNamespace> <RootNamespace>BirthdayBot</RootNamespace>
<TargetFramework>netcoreapp2.0</TargetFramework> <TargetFramework>netcoreapp2.0</TargetFramework>
<Version>1.3.6</Version> <Version>1.4.0</Version>
<Authors>Noi</Authors> <Authors>Noi</Authors>
<Company /> <Company />
<Description>Discord bot for birthday reminders.</Description> <Description>Discord bot for birthday reminders.</Description>

View file

@ -8,7 +8,6 @@ Imports System.IO
Class Configuration Class Configuration
Public ReadOnly Property BotToken As String Public ReadOnly Property BotToken As String
Public ReadOnly Property LogWebhook As String Public ReadOnly Property LogWebhook As String
Public ReadOnly Property DiagnosticChannel As ULong
Public ReadOnly Property DBotsToken As String Public ReadOnly Property DBotsToken As String
Public ReadOnly Property DatabaseSettings As Database Public ReadOnly Property DatabaseSettings As Database
@ -36,8 +35,6 @@ Class Configuration
Throw New Exception("'LogWebhook' must be specified.") Throw New Exception("'LogWebhook' must be specified.")
End If End If
DiagnosticChannel = jc("DiagnosticChannel").Value(Of ULong)()
Dim dbj = jc("DBotsToken") Dim dbj = jc("DBotsToken")
If dbj IsNot Nothing Then If dbj IsNot Nothing Then
DBotsToken = dbj.Value(Of String)() DBotsToken = dbj.Value(Of String)()

View file

@ -19,7 +19,7 @@ Class GuildStateInformation
Private _announceMsgPl As String Private _announceMsgPl As String
Private _announcePing As Boolean Private _announcePing As Boolean
Private ReadOnly _userCache As Dictionary(Of ULong, GuildUserSettings) Private ReadOnly _userCache As Dictionary(Of ULong, GuildUserSettings)
Public ReadOnly Property OperationLog As OperationStatus Public Property OperationLog As OperationStatus
''' <summary> ''' <summary>
''' Gets a list of cached registered user information. ''' Gets a list of cached registered user information.

View file

@ -0,0 +1,48 @@
Imports System.Text
''' <summary>
''' Holds information regarding the previous updating operation done on a guild including success/error information.
''' </summary>
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
''' <summary>
''' Prepares known information in a displayable format.
''' </summary>
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
''' <summary>
''' Specifies the type of operation logged. These enum values are publicly displayed in the specified order.
''' </summary>
Public Enum OperationType
UpdateBirthdayRoleMembership
SendBirthdayAnnouncementMessage
End Enum
End Class

View file

@ -1,75 +0,0 @@
Imports Discord.WebSocket
Imports Discord.Webhook
Imports System.Text
''' <summary>
''' Implements the command used by global bot moderators to get operation info for each guild.
''' </summary>
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

View file

@ -1,37 +0,0 @@
''' <summary>
''' Information regarding a single type of operation.
''' </summary>
Class OperationInfo
''' <summary>
''' The time in which the respective operation was attempted.
''' </summary>
ReadOnly Property Timestamp As DateTimeOffset
''' <summary>
''' Any exception encountered during the respective operation.
''' </summary>
''' <returns>Nothing/null if the previous given operation was a success.</returns>
ReadOnly Property Exception As Exception
''' <summary>
''' Creates an instance containing a success status.
''' </summary>
Sub New()
Timestamp = DateTimeOffset.UtcNow
End Sub
''' <summary>
''' Creates an instance containing a captured exception
''' </summary>
Sub New(ex As Exception)
Me.New()
Exception = ex
End Sub
''' <summary>
''' Creates an instance containing a custom error message
''' </summary>
Sub New(message As String)
Me.New()
Exception = New Exception(message)
End Sub
End Class

View file

@ -1,49 +0,0 @@
Imports System.Text
''' <summary>
''' Holds information regarding previous operations done on a guild and their most recent success/error status.
''' </summary>
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
''' <summary>
''' Prepares known information in a displayable format.
''' </summary>
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

View file

@ -1,5 +0,0 @@
Enum OperationType
BirthdayRole
BirthdayAnnounce
CommandDispatch
End Enum

View file

@ -1,18 +1,22 @@
Imports System.Text.RegularExpressions Imports System.Text.RegularExpressions
Imports Discord
Imports Discord.WebSocket Imports Discord.WebSocket
Imports NodaTime
Friend Class ManagerCommands Friend Class ManagerCommands
Inherits CommandsCommon Inherits CommandsCommon
Private Delegate Function ConfigSubcommand(param As String(), reqChannel As SocketTextChannel) As Task Private Delegate Function ConfigSubcommand(param As String(), reqChannel As SocketTextChannel) As Task
Private _subcommands As Dictionary(Of String, ConfigSubcommand) Private ReadOnly _subcommands As Dictionary(Of String, ConfigSubcommand)
Private _usercommands As Dictionary(Of String, CommandHandler) Private ReadOnly _usercommands As Dictionary(Of String, CommandHandler)
Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler)) Public Overrides ReadOnly Property Commands As IEnumerable(Of (String, CommandHandler))
Get Get
Return New List(Of (String, CommandHandler)) From { Return New List(Of (String, CommandHandler)) From {
("config", AddressOf CmdConfigDispatch), ("config", AddressOf CmdConfigDispatch),
("override", AddressOf CmdOverride) ("override", AddressOf CmdOverride),
("status", AddressOf CmdStatus)
} }
End Get End Get
End Property End Property
@ -338,6 +342,40 @@ Friend Class ManagerCommands
Await action.Invoke(overparam, reqChannel, overuser) Await action.Invoke(overparam, reqChannel, overuser)
End Function 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" #Region "Common/helper methods"
Private Const RoleInputError = ":x: Unable to determine the given role." Private Const RoleInputError = ":x: Unable to determine the given role."
Private Shared ReadOnly RoleMention As New Regex("<@?&(?<snowflake>\d+)>", RegexOptions.Compiled) Private Shared ReadOnly RoleMention As New Regex("<@?&(?<snowflake>\d+)>", RegexOptions.Compiled)