Split BackgroundWorker into various components

This commit is contained in:
Noikoio 2019-06-15 16:59:09 -07:00
parent 9608a29a8a
commit fe5bdb53f9
10 changed files with 188 additions and 133 deletions

View file

@ -0,0 +1,58 @@
Imports System.Threading
''' <summary>
''' Handles the execution of periodic background tasks.
''' </summary>
Class BackgroundServiceRunner
Const Interval = 45 ' Tick interval in seconds. Adjust as needed.
Private ReadOnly Property Workers As List(Of BackgroundService)
Private ReadOnly Property WorkerCancel As New CancellationTokenSource
Private _workerTask As Task
Private _tickCount As Integer
Sub New(instance As BirthdayBot)
Workers = New List(Of BackgroundService) From {
{New GuildStatistics(instance)},
{New Heartbeat(instance)},
{New BirthdayRoleUpdate(instance)}
}
End Sub
Public Sub Start()
_tickCount = 0
_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
''' <summary>
''' *The* background task. Executes service tasks and handles errors.
''' </summary>
Private Async Function WorkerLoop() As Task
While Not WorkerCancel.IsCancellationRequested
Try
' Delay a bit before we start (or continue) work.
Await Task.Delay(Interval * 1000, WorkerCancel.Token)
' Start background tasks.
Dim tasks As New List(Of Task)
For Each service In Workers
tasks.Add(service.OnTick(_tickCount))
Next
Await Task.WhenAll(tasks)
Catch ex As TaskCanceledException
Return
Catch ex As Exception
Log("Background task", "Unhandled exception in background task thread:")
Log("Background task", ex.ToString())
End Try
_tickCount += 1
End While
End Function
End Class

View file

@ -0,0 +1,17 @@
''' <summary>
''' Base class for background services.
''' Background services are called periodically by another class.
''' </summary>
MustInherit Class BackgroundService
Protected ReadOnly Property BotInstance As BirthdayBot
Sub New(instance As BirthdayBot)
BotInstance = instance
End Sub
Sub Log(message As String)
Program.Log(Me.GetType().Name, message)
End Sub
MustOverride Function OnTick(tick As Integer) As Task
End Class

View file

@ -1,77 +1,40 @@
Imports System.Net Imports System.Net
Imports System.Text Imports System.Text
Imports System.Threading
Imports Discord.WebSocket Imports Discord.WebSocket
Imports NodaTime Imports NodaTime
''' <summary> ''' <summary>
''' BirthdayBot's periodic task. Frequently wakes up to take various actions. ''' Periodically scans all known guilds and adjusts birthday role membership as necessary.
''' Also handles birthday announcements.
''' </summary> ''' </summary>
Class BackgroundWorker Class BirthdayRoleUpdate
Private ReadOnly _bot As BirthdayBot Inherits BackgroundService
Private ReadOnly Property WorkerCancel As New CancellationTokenSource Private ReadOnly Property Clock As IClock
Private _workerTask As Task
Const Interval = 45 ' How often the worker wakes up, in seconds. Adjust as needed.
Private ReadOnly _clock As IClock
Sub New(instance As BirthdayBot) Public Sub New(instance As BirthdayBot)
_bot = instance MyBase.New(instance)
_clock = SystemClock.Instance ' can replace with FakeClock here when testing Clock = SystemClock.Instance ' can be replaced with FakeClock during testing
End Sub 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
''' <summary> ''' <summary>
''' Background task. Kicks off many other tasks. ''' Initial processing: Sets up a task per guild and waits on all.
''' </summary> ''' </summary>
Private Async Function WorkerLoop() As Task Public Overrides Async Function OnTick(tick As Integer) As Task
While Not WorkerCancel.IsCancellationRequested
Try
' Delay a bit before we start (or continue) work.
Await Task.Delay(Interval * 1000, WorkerCancel.Token)
' Start background tasks.
Dim bgTasks As New List(Of Task) From {
ReportAsync(),
BirthdayAsync()
}
Await Task.WhenAll(bgTasks)
Catch ex As TaskCanceledException
Return
Catch ex As Exception
Log("Background task", "Unhandled exception in background task thread:")
Log("Background task", ex.ToString())
End Try
End While
End Function
#Region "Birthday handling"
''' <summary>
''' Birthday tasks processing. Sets up a task per guild and waits on them.
''' </summary>
Private Async Function BirthdayAsync() As Task
Dim tasks As New List(Of Task(Of Integer)) Dim tasks As New List(Of Task(Of Integer))
For Each guild In _bot.DiscordClient.Guilds For Each guild In BotInstance.DiscordClient.Guilds
Dim t = BirthdayGuildProcessAsync(guild) Dim t = ProcessGuildAsync(guild)
tasks.Add(t) tasks.Add(t)
Next Next
Try Try
Await Task.WhenAll(tasks) Await Task.WhenAll(tasks)
Catch ex As Exception Catch ex As Exception
Dim exs = From task In tasks Dim exs = From task In tasks
Where task.Exception IsNot Nothing Where task.Exception IsNot Nothing
Select task.Exception Select task.Exception
Log("Error", "Encountered one or more unhandled exceptions during birthday processing.") Log($"Encountered {exs.Count} errors during bulk guild processing.")
For Each iex In exs For Each iex In exs
Log("Error", iex.ToString()) Log(iex.ToString())
Next Next
End Try End Try
@ -84,14 +47,10 @@ Class BackgroundWorker
guilds += 1 guilds += 1
End If End If
Next Next
If announces > 0 Then Log("Birthday task", $"Announcing {announces} birthday(s) in {guilds} guild(s).") If announces > 0 Then Log($"Announcing {announces} birthday(s) in {guilds} guild(s).")
End Function End Function
''' <summary> Async Function ProcessGuildAsync(guild As SocketGuild) As Task(Of Integer)
''' Birthday processing for an individual guild.
''' </summary>
''' <returns>Number of birthdays announced.</returns>
Private Async Function BirthdayGuildProcessAsync(guild As SocketGuild) As Task(Of Integer)
' Gather required information ' Gather required information
Dim tz As String Dim tz As String
Dim users As IEnumerable(Of GuildUserSettings) Dim users As IEnumerable(Of GuildUserSettings)
@ -99,9 +58,9 @@ Class BackgroundWorker
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
SyncLock _bot.KnownGuilds SyncLock BotInstance.KnownGuilds
If Not _bot.KnownGuilds.ContainsKey(guild.Id) Then Return 0 If Not BotInstance.KnownGuilds.ContainsKey(guild.Id) Then Return 0
Dim gs = _bot.KnownGuilds(guild.Id) Dim gs = BotInstance.KnownGuilds(guild.Id)
tz = gs.TimeZone tz = gs.TimeZone
users = gs.Users users = gs.Users
announce = gs.AnnounceMessages announce = gs.AnnounceMessages
@ -117,15 +76,15 @@ Class BackgroundWorker
End SyncLock End SyncLock
' Determine who's currently having a birthday ' Determine who's currently having a birthday
Dim birthdays = BirthdayCalculate(users, tz) Dim birthdays = GetGuildCurrentBirthdays(users, tz)
' Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply. ' Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
' Set birthday roles, get list of users that had the role added ' 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. ' But first check if we are able to do so. Letting all requests fail instead will lead to rate limiting.
Dim announceNames As IEnumerable(Of SocketGuildUser) Dim announceNames As IEnumerable(Of SocketGuildUser)
If BirthdayHasGoodRolePermissions(guild, role) Then If HasCorrectRolePermissions(guild, role) Then
Try Try
announceNames = Await BirthdayApplyAsync(guild, role, birthdays) announceNames = Await UpdateGuildBirthdayRoles(guild, role, birthdays)
Catch ex As Discord.Net.HttpException Catch ex As Discord.Net.HttpException
If ex.HttpCode = HttpStatusCode.Forbidden Then If ex.HttpCode = HttpStatusCode.Forbidden Then
announceNames = Nothing announceNames = Nothing
@ -138,16 +97,16 @@ Class BackgroundWorker
End If End If
If announceNames Is Nothing Then If announceNames Is Nothing Then
SyncLock _bot.KnownGuilds SyncLock BotInstance.KnownGuilds
' Nothing on announceNAmes signals failure to apply roles. Set the warning message. ' Nothing on announceNAmes signals failure to apply roles. Set the warning message.
_bot.KnownGuilds(guild.Id).RoleWarning = True BotInstance.KnownGuilds(guild.Id).RoleWarning = True
End SyncLock End SyncLock
Return 0 Return 0
End If End If
If announceNames.Count <> 0 Then If announceNames.Count <> 0 Then
' Send out announcement message ' Send out announcement message
Await BirthdayAnnounceAsync(announce, announceping, channel, announceNames) Await AnnounceBirthdaysAsync(announce, announceping, channel, announceNames)
End If End If
Return announceNames.Count Return announceNames.Count
End Function End Function
@ -155,7 +114,7 @@ Class BackgroundWorker
''' <summary> ''' <summary>
''' Checks if the bot may be allowed to alter roles. ''' Checks if the bot may be allowed to alter roles.
''' </summary> ''' </summary>
Private Function BirthdayHasGoodRolePermissions(guild As SocketGuild, role As SocketRole) As Boolean Private Function HasCorrectRolePermissions(guild As SocketGuild, role As SocketRole) As Boolean
If Not guild.CurrentUser.GuildPermissions.ManageRoles Then If Not guild.CurrentUser.GuildPermissions.ManageRoles Then
' Bot user cannot manage roles ' Bot user cannot manage roles
Return False Return False
@ -174,7 +133,8 @@ Class BackgroundWorker
''' Gets all known users from the given guild and returns a list including only those who are ''' 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. ''' currently experiencing a birthday in the respective time zone.
''' </summary> ''' </summary>
Private Function BirthdayCalculate(guildUsers As IEnumerable(Of GuildUserSettings), defaultTzStr As String) As HashSet(Of ULong) Private Function GetGuildCurrentBirthdays(guildUsers As IEnumerable(Of GuildUserSettings),
defaultTzStr As String) As HashSet(Of ULong)
Dim birthdayUsers As New HashSet(Of ULong) Dim birthdayUsers As New HashSet(Of ULong)
Dim defaultTz As DateTimeZone = Nothing Dim defaultTz As DateTimeZone = Nothing
@ -196,7 +156,7 @@ Class BackgroundWorker
Dim targetMonth = item.BirthMonth Dim targetMonth = item.BirthMonth
Dim targetDay = item.BirthDay Dim targetDay = item.BirthDay
Dim checkNow = _clock.GetCurrentInstant().InZone(tz) 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 ' 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 Date.IsLeapYear(checkNow.Year) Then If targetMonth = 2 And targetDay = 29 And Not Date.IsLeapYear(checkNow.Year) Then
targetMonth = 3 targetMonth = 3
@ -214,7 +174,7 @@ Class BackgroundWorker
''' Sets the birthday role to all applicable users. Unsets it from all others who may have it. ''' Sets the birthday role to all applicable users. Unsets it from all others who may have it.
''' </summary> ''' </summary>
''' <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 BirthdayApplyAsync(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))
' 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.
@ -255,7 +215,7 @@ Class BackgroundWorker
''' Makes (or attempts to make) an announcement in the specified channel that includes all users ''' Makes (or attempts to make) an announcement in the specified channel that includes all users
''' who have just had their birthday role added. ''' who have just had their birthday role added.
''' </summary> ''' </summary>
Private Async Function BirthdayAnnounceAsync(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)) As Task names As IEnumerable(Of SocketGuildUser)) As Task
@ -294,56 +254,4 @@ Class BackgroundWorker
' TODO keep tabs on this somehow for troubleshooting purposes ' TODO keep tabs on this somehow for troubleshooting purposes
End Try End Try
End Function End Function
#End Region
#Region "Activity reporting"
''' <summary>
''' Increasing value for regulating how often certain tasks are done.
''' For anything relying on this value, also be mindful of the interval value.
''' </summary>
Private _reportTick As Integer = 0
''' <summary>
''' Handles various periodic reporting tasks.
''' </summary>
Private Async Function ReportAsync() As Task
ReportHeartbeat(_reportTick)
Await ReportGuildCount(_reportTick)
_reportTick += 1
End Function
Private Sub ReportHeartbeat(tick As Integer)
' Roughly every 15 minutes (interval: 45)
If tick Mod 20 = 0 Then
Log("Background task", $"Still alive! Tick: {_reportTick}.")
End If
End Sub
Private Async Function ReportGuildCount(tick As Integer) As Task
' Roughly every 5 hours (interval: 45)
If tick Mod 400 <> 2 Then Return
Dim count = _bot.DiscordClient.Guilds.Count
Log("Report", $"Currently in {count} guild(s).")
Dim dtok = _bot.Config.DBotsToken
If dtok IsNot Nothing Then
Const dUrl As String = "https://discord.bots.gg/api/v1/bots/{0}/stats"
Using client As New WebClient()
Dim uri = New Uri(String.Format(dUrl, CType(_bot.DiscordClient.CurrentUser.Id, String)))
Dim data = "{ ""guildCount"": " + CType(count, String) + " }"
client.Headers(HttpRequestHeader.Authorization) = dtok
client.Headers(HttpRequestHeader.ContentType) = "application/json"
Try
Await client.UploadStringTaskAsync(uri, data)
Log("Server Count", "Count sent to Discord Bots.")
Catch ex As WebException
Log("Server Count", "Encountered error on sending to Discord Bots: " + ex.Message)
End Try
End Using
End If
End Function
#End Region
End Class End Class

View file

@ -0,0 +1,47 @@
Imports System.Net
Class GuildStatistics
Inherits BackgroundService
Private ReadOnly Property DBotsToken As String
Public Sub New(instance As BirthdayBot)
MyBase.New(instance)
DBotsToken = instance.Config.DBotsToken
End Sub
Public Overrides Async Function OnTick(tick As Integer) As Task
' Activate roughly every 5 hours (interval: 45)
If tick Mod 400 <> 2 Then Return
Dim count = BotInstance.DiscordClient.Guilds.Count
Log($"Currently in {count} guild(s).")
Await SendExternalStatistics(count)
End Function
''' <summary>
''' Send statistical information to external services.
''' </summary>
''' <remarks>
''' Only Discord Bots is currently supported. No plans to support others any time soon.
''' </remarks>
Async Function SendExternalStatistics(guildCount As Integer) As Task
Dim rptToken = BotInstance.Config.DBotsToken
If rptToken Is Nothing Then Return
Const apiUrl As String = "https://discord.bots.gg/api/v1/bots/{0}/stats"
Using client As New WebClient()
Dim uri = New Uri(String.Format(apiUrl, CType(BotInstance.DiscordClient.CurrentUser.Id, String)))
Dim data = "{ ""guildCount"": " + CType(guildCount, String) + " }"
client.Headers(HttpRequestHeader.Authorization) = rptToken
client.Headers(HttpRequestHeader.ContentType) = "application/json"
Try
Await client.UploadStringTaskAsync(uri, data)
Log("Discord Bots: Report sent successfully.")
Catch ex As WebException
Log("Discord Bots: Encountered an error. " + ex.Message)
End Try
End Using
End Function
End Class

View file

@ -0,0 +1,20 @@
''' <summary>
''' Basic heartbeat function - indicates that the background task is still functioning.
''' </summary>
Class Heartbeat
Inherits BackgroundService
Public Sub New(instance As BirthdayBot)
MyBase.New(instance)
End Sub
Public Overrides Function OnTick(tick As Integer) As Task
' Print a message roughly every 15 minutes (assuming 45s per tick).
If tick Mod 20 = 0 Then
Dim uptime = DateTimeOffset.UtcNow - Program.BotStartTime
Log($"Tick {tick:00000} - Bot uptime: {BotUptime()}")
End If
Return Task.CompletedTask
End Function
End Class

View file

@ -15,7 +15,7 @@ Class BirthdayBot
Private ReadOnly _cmdsMods As ManagerCommands Private ReadOnly _cmdsMods As ManagerCommands
Private WithEvents _client As DiscordSocketClient Private WithEvents _client As DiscordSocketClient
Private ReadOnly _worker As BackgroundWorker Private ReadOnly _worker As BackgroundServiceRunner
Friend ReadOnly Property Config As Configuration Friend ReadOnly Property Config As Configuration
@ -33,7 +33,7 @@ Class BirthdayBot
_client = dc _client = dc
KnownGuilds = New Dictionary(Of ULong, GuildSettings) KnownGuilds = New Dictionary(Of ULong, GuildSettings)
_worker = New BackgroundWorker(Me) _worker = New BackgroundServiceRunner(Me)
' Command dispatch set-up ' Command dispatch set-up
_dispatchCommands = New Dictionary(Of String, CommandHandler)(StringComparer.InvariantCultureIgnoreCase) _dispatchCommands = New Dictionary(Of String, CommandHandler)(StringComparer.InvariantCultureIgnoreCase)

View file

@ -4,8 +4,8 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<RootNamespace>BirthdayBot</RootNamespace> <RootNamespace>BirthdayBot</RootNamespace>
<TargetFramework>netcoreapp2.0</TargetFramework> <TargetFramework>netcoreapp2.0</TargetFramework>
<Version>1.0.5</Version> <Version>1.1.0</Version>
<AssemblyVersion>1.0.4.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<Authors>Noiiko</Authors> <Authors>Noiiko</Authors>
<Company /> <Company />
<Description>Discord bot for birthday reminders.</Description> <Description>Discord bot for birthday reminders.</Description>

View file

@ -30,4 +30,8 @@ Module Common
{1, "Jan"}, {2, "Feb"}, {3, "Mar"}, {4, "Apr"}, {5, "May"}, {6, "Jun"}, {1, "Jan"}, {2, "Feb"}, {3, "Mar"}, {4, "Apr"}, {5, "May"}, {6, "Jun"},
{7, "Jul"}, {8, "Aug"}, {9, "Sep"}, {10, "Oct"}, {11, "Nov"}, {12, "Dec"} {7, "Jul"}, {8, "Aug"}, {9, "Sep"}, {10, "Oct"}, {11, "Nov"}, {12, "Dec"}
} }
Public Function BotUptime() As String
BotUptime = (DateTimeOffset.UtcNow - BotStartTime).ToString("d' days, 'hh':'mm':'ss")
End Function
End Module End Module

View file

@ -14,7 +14,9 @@ Module Program
End Set End Set
End Property End Property
Sub Main(args As String()) Sub Main()
Log("Birthday Bot", $"Version {System.Reflection.Assembly.GetExecutingAssembly().GetName.Version.ToString(3)} is starting.")
BotStartTime = DateTimeOffset.UtcNow BotStartTime = DateTimeOffset.UtcNow
Dim cfg As New Configuration() Dim cfg As New Configuration()

View file

@ -136,7 +136,7 @@ Friend Class HelpInfoCommands
}) })
embed.AddField(New EmbedFieldBuilder() With { embed.AddField(New EmbedFieldBuilder() With {
.Name = "Examples", .Name = "Examples",
.Value = String.Format(msghelp2, BackgroundWorker.DefaultAnnounce, BackgroundWorker.DefaultAnnouncePl) .Value = String.Format(msghelp2, BirthdayRoleUpdate.DefaultAnnounce, BirthdayRoleUpdate.DefaultAnnouncePl)
}) })
Await reqChannel.SendMessageAsync(embed:=embed.Build()) Await reqChannel.SendMessageAsync(embed:=embed.Build())
End Function End Function
@ -145,10 +145,9 @@ Friend Class HelpInfoCommands
' Bot status field ' Bot status field
Dim strStatus As New StringBuilder Dim strStatus As New StringBuilder
Dim asmnm = Reflection.Assembly.GetExecutingAssembly.GetName() Dim asmnm = Reflection.Assembly.GetExecutingAssembly.GetName()
strStatus.AppendLine("Birthday Bot version " + asmnm.Version.ToString(3)) strStatus.AppendLine("Birthday Bot v" + asmnm.Version.ToString(3))
strStatus.AppendLine("Server count: " + _discordClient.Guilds.Count.ToString()) strStatus.AppendLine("Server count: " + _discordClient.Guilds.Count.ToString())
strStatus.AppendLine("Uptime: " + (DateTimeOffset.UtcNow - Program.BotStartTime).ToString("d' days, 'hh':'mm':'ss")) strStatus.AppendLine("Uptime: " + BotUptime())
strStatus.Append("More info will be shown here soon.")
' TODO fun stats ' TODO fun stats
' current birthdays, total names registered, unique time zones ' current birthdays, total names registered, unique time zones