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
' 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
''' <summary>
@ -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
''' <summary>
@ -127,19 +137,19 @@ Class BirthdayRoleUpdate
''' </summary>
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
''' <summary>
@ -189,7 +199,7 @@ Class BirthdayRoleUpdate
''' <returns>A list of users who had the birthday role applied. Use for the announcement message.</returns>
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

View file

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

View file

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

View file

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

View file

@ -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
''' <summary>
''' 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 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("<@?&(?<snowflake>\d+)>", RegexOptions.Compiled)