From fe5bdb53f936d393820975cf096cc8cec362ae07 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 15 Jun 2019 16:59:09 -0700 Subject: [PATCH] Split BackgroundWorker into various components --- BirthdayBot/BackgroundServiceRunner.vb | 58 +++++++ .../BackgroundServices/BackgroundService.vb | 17 ++ .../BirthdayRoleUpdate.vb} | 156 ++++-------------- .../BackgroundServices/GuildStatistics.vb | 47 ++++++ BirthdayBot/BackgroundServices/Heartbeat.vb | 20 +++ BirthdayBot/BirthdayBot.vb | 4 +- BirthdayBot/BirthdayBot.vbproj | 4 +- BirthdayBot/Common.vb | 4 + BirthdayBot/Program.vb | 4 +- BirthdayBot/UserInterface/HelpInfoCommands.vb | 7 +- 10 files changed, 188 insertions(+), 133 deletions(-) create mode 100644 BirthdayBot/BackgroundServiceRunner.vb create mode 100644 BirthdayBot/BackgroundServices/BackgroundService.vb rename BirthdayBot/{BackgroundWorker.vb => BackgroundServices/BirthdayRoleUpdate.vb} (59%) create mode 100644 BirthdayBot/BackgroundServices/GuildStatistics.vb create mode 100644 BirthdayBot/BackgroundServices/Heartbeat.vb diff --git a/BirthdayBot/BackgroundServiceRunner.vb b/BirthdayBot/BackgroundServiceRunner.vb new file mode 100644 index 0000000..0d8c5d8 --- /dev/null +++ b/BirthdayBot/BackgroundServiceRunner.vb @@ -0,0 +1,58 @@ +Imports System.Threading + +''' +''' Handles the execution of periodic background tasks. +''' +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 + + ''' + ''' *The* background task. Executes service tasks and handles errors. + ''' + 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 diff --git a/BirthdayBot/BackgroundServices/BackgroundService.vb b/BirthdayBot/BackgroundServices/BackgroundService.vb new file mode 100644 index 0000000..86ab79f --- /dev/null +++ b/BirthdayBot/BackgroundServices/BackgroundService.vb @@ -0,0 +1,17 @@ +''' +''' Base class for background services. +''' Background services are called periodically by another class. +''' +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 diff --git a/BirthdayBot/BackgroundWorker.vb b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb similarity index 59% rename from BirthdayBot/BackgroundWorker.vb rename to BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb index 0fb653a..3afcd45 100644 --- a/BirthdayBot/BackgroundWorker.vb +++ b/BirthdayBot/BackgroundServices/BirthdayRoleUpdate.vb @@ -1,77 +1,40 @@ Imports System.Net Imports System.Text -Imports System.Threading Imports Discord.WebSocket Imports NodaTime ''' -''' 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. ''' -Class BackgroundWorker - Private ReadOnly _bot As BirthdayBot - Private ReadOnly Property WorkerCancel As New CancellationTokenSource - Private _workerTask As Task - Const Interval = 45 ' How often the worker wakes up, in seconds. Adjust as needed. - Private ReadOnly _clock As IClock +Class BirthdayRoleUpdate + Inherits BackgroundService + Private ReadOnly Property Clock As IClock - Sub New(instance As BirthdayBot) - _bot = instance - _clock = SystemClock.Instance ' can replace with FakeClock here when testing + Public Sub New(instance As BirthdayBot) + MyBase.New(instance) + Clock = SystemClock.Instance ' can be replaced with FakeClock during testing 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 - ''' - ''' Background task. Kicks off many other tasks. + ''' Initial processing: Sets up a task per guild and waits on all. ''' - 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 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" - ''' - ''' Birthday tasks processing. Sets up a task per guild and waits on them. - ''' - Private Async Function BirthdayAsync() As Task + Public Overrides Async Function OnTick(tick As Integer) As Task Dim tasks As New List(Of Task(Of Integer)) - For Each guild In _bot.DiscordClient.Guilds - Dim t = BirthdayGuildProcessAsync(guild) + For Each guild In BotInstance.DiscordClient.Guilds + Dim t = ProcessGuildAsync(guild) tasks.Add(t) Next + Try Await Task.WhenAll(tasks) Catch ex As Exception Dim exs = From task In tasks Where task.Exception IsNot Nothing 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 - Log("Error", iex.ToString()) + Log(iex.ToString()) Next End Try @@ -84,14 +47,10 @@ Class BackgroundWorker guilds += 1 End If 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 - ''' - ''' Birthday processing for an individual guild. - ''' - ''' Number of birthdays announced. - Private Async Function BirthdayGuildProcessAsync(guild As SocketGuild) As Task(Of Integer) + Async Function ProcessGuildAsync(guild As SocketGuild) As Task(Of Integer) ' Gather required information Dim tz As String Dim users As IEnumerable(Of GuildUserSettings) @@ -99,9 +58,9 @@ Class BackgroundWorker Dim channel As SocketTextChannel = Nothing Dim announce As (String, String) Dim announceping As Boolean - SyncLock _bot.KnownGuilds - If Not _bot.KnownGuilds.ContainsKey(guild.Id) Then Return 0 - Dim gs = _bot.KnownGuilds(guild.Id) + SyncLock BotInstance.KnownGuilds + If Not BotInstance.KnownGuilds.ContainsKey(guild.Id) Then Return 0 + Dim gs = BotInstance.KnownGuilds(guild.Id) tz = gs.TimeZone users = gs.Users announce = gs.AnnounceMessages @@ -117,15 +76,15 @@ Class BackgroundWorker End SyncLock ' 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. ' 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 announceNames As IEnumerable(Of SocketGuildUser) - If BirthdayHasGoodRolePermissions(guild, role) Then + If HasCorrectRolePermissions(guild, role) Then Try - announceNames = Await BirthdayApplyAsync(guild, role, birthdays) + announceNames = Await UpdateGuildBirthdayRoles(guild, role, birthdays) Catch ex As Discord.Net.HttpException If ex.HttpCode = HttpStatusCode.Forbidden Then announceNames = Nothing @@ -138,16 +97,16 @@ Class BackgroundWorker End If If announceNames Is Nothing Then - SyncLock _bot.KnownGuilds + SyncLock BotInstance.KnownGuilds ' 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 Return 0 End If If announceNames.Count <> 0 Then ' Send out announcement message - Await BirthdayAnnounceAsync(announce, announceping, channel, announceNames) + Await AnnounceBirthdaysAsync(announce, announceping, channel, announceNames) End If Return announceNames.Count End Function @@ -155,7 +114,7 @@ Class BackgroundWorker ''' ''' Checks if the bot may be allowed to alter roles. ''' - 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 ' Bot user cannot manage roles 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 ''' currently experiencing a birthday in the respective time zone. ''' - 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 defaultTz As DateTimeZone = Nothing @@ -196,7 +156,7 @@ Class BackgroundWorker Dim targetMonth = item.BirthMonth 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 If targetMonth = 2 And targetDay = 29 And Not Date.IsLeapYear(checkNow.Year) Then 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. ''' ''' A list of users who had the birthday role applied. Use for the announcement message. - Private Async Function BirthdayApplyAsync(g As SocketGuild, + Private Async Function UpdateGuildBirthdayRoles(g As SocketGuild, r As SocketRole, 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. @@ -255,7 +215,7 @@ Class BackgroundWorker ''' Makes (or attempts to make) an announcement in the specified channel that includes all users ''' who have just had their birthday role added. ''' - Private Async Function BirthdayAnnounceAsync(announce As (String, String), + Private Async Function AnnounceBirthdaysAsync(announce As (String, String), announcePing As Boolean, c As SocketTextChannel, names As IEnumerable(Of SocketGuildUser)) As Task @@ -294,56 +254,4 @@ Class BackgroundWorker ' TODO keep tabs on this somehow for troubleshooting purposes End Try End Function -#End Region - -#Region "Activity reporting" - ''' - ''' Increasing value for regulating how often certain tasks are done. - ''' For anything relying on this value, also be mindful of the interval value. - ''' - Private _reportTick As Integer = 0 - - ''' - ''' Handles various periodic reporting tasks. - ''' - 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 diff --git a/BirthdayBot/BackgroundServices/GuildStatistics.vb b/BirthdayBot/BackgroundServices/GuildStatistics.vb new file mode 100644 index 0000000..bd55f3d --- /dev/null +++ b/BirthdayBot/BackgroundServices/GuildStatistics.vb @@ -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 + + ''' + ''' Send statistical information to external services. + ''' + ''' + ''' Only Discord Bots is currently supported. No plans to support others any time soon. + ''' + 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 diff --git a/BirthdayBot/BackgroundServices/Heartbeat.vb b/BirthdayBot/BackgroundServices/Heartbeat.vb new file mode 100644 index 0000000..5b57438 --- /dev/null +++ b/BirthdayBot/BackgroundServices/Heartbeat.vb @@ -0,0 +1,20 @@ +''' +''' Basic heartbeat function - indicates that the background task is still functioning. +''' +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 diff --git a/BirthdayBot/BirthdayBot.vb b/BirthdayBot/BirthdayBot.vb index ed1ae96..a6a45aa 100644 --- a/BirthdayBot/BirthdayBot.vb +++ b/BirthdayBot/BirthdayBot.vb @@ -15,7 +15,7 @@ Class BirthdayBot Private ReadOnly _cmdsMods As ManagerCommands Private WithEvents _client As DiscordSocketClient - Private ReadOnly _worker As BackgroundWorker + Private ReadOnly _worker As BackgroundServiceRunner Friend ReadOnly Property Config As Configuration @@ -33,7 +33,7 @@ Class BirthdayBot _client = dc KnownGuilds = New Dictionary(Of ULong, GuildSettings) - _worker = New BackgroundWorker(Me) + _worker = New BackgroundServiceRunner(Me) ' Command dispatch set-up _dispatchCommands = New Dictionary(Of String, CommandHandler)(StringComparer.InvariantCultureIgnoreCase) diff --git a/BirthdayBot/BirthdayBot.vbproj b/BirthdayBot/BirthdayBot.vbproj index ee811d8..37cdd6a 100644 --- a/BirthdayBot/BirthdayBot.vbproj +++ b/BirthdayBot/BirthdayBot.vbproj @@ -4,8 +4,8 @@ Exe BirthdayBot netcoreapp2.0 - 1.0.5 - 1.0.4.0 + 1.1.0 + 1.1.0.0 Noiiko Discord bot for birthday reminders. diff --git a/BirthdayBot/Common.vb b/BirthdayBot/Common.vb index dc1bdac..b6a63f2 100644 --- a/BirthdayBot/Common.vb +++ b/BirthdayBot/Common.vb @@ -30,4 +30,8 @@ Module Common {1, "Jan"}, {2, "Feb"}, {3, "Mar"}, {4, "Apr"}, {5, "May"}, {6, "Jun"}, {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 diff --git a/BirthdayBot/Program.vb b/BirthdayBot/Program.vb index 9e4c98b..60c341d 100644 --- a/BirthdayBot/Program.vb +++ b/BirthdayBot/Program.vb @@ -14,7 +14,9 @@ Module Program End Set 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 Dim cfg As New Configuration() diff --git a/BirthdayBot/UserInterface/HelpInfoCommands.vb b/BirthdayBot/UserInterface/HelpInfoCommands.vb index 60ad3b5..b3b7304 100644 --- a/BirthdayBot/UserInterface/HelpInfoCommands.vb +++ b/BirthdayBot/UserInterface/HelpInfoCommands.vb @@ -136,7 +136,7 @@ Friend Class HelpInfoCommands }) embed.AddField(New EmbedFieldBuilder() With { .Name = "Examples", - .Value = String.Format(msghelp2, BackgroundWorker.DefaultAnnounce, BackgroundWorker.DefaultAnnouncePl) + .Value = String.Format(msghelp2, BirthdayRoleUpdate.DefaultAnnounce, BirthdayRoleUpdate.DefaultAnnouncePl) }) Await reqChannel.SendMessageAsync(embed:=embed.Build()) End Function @@ -145,10 +145,9 @@ Friend Class HelpInfoCommands ' Bot status field Dim strStatus As New StringBuilder 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("Uptime: " + (DateTimeOffset.UtcNow - Program.BotStartTime).ToString("d' days, 'hh':'mm':'ss")) - strStatus.Append("More info will be shown here soon.") + strStatus.AppendLine("Uptime: " + BotUptime()) ' TODO fun stats ' current birthdays, total names registered, unique time zones