Modified all background services

-Removed a number of diagnostic messages
-Removed ConnectionStatus, connection scores, etc.
-Modified work intervals for certain background tasks
-Updated code style
This commit is contained in:
Noi 2021-10-14 18:55:04 -07:00
parent 8cff530a7c
commit 6f34fbe657
10 changed files with 452 additions and 612 deletions

View file

@ -1,16 +1,14 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices namespace BirthdayBot.BackgroundServices;
{
abstract class BackgroundService abstract class BackgroundService {
{
protected ShardInstance ShardInstance { get; } protected ShardInstance ShardInstance { get; }
public BackgroundService(ShardInstance instance) => ShardInstance = instance; public BackgroundService(ShardInstance instance) => ShardInstance = instance;
protected void Log(string message) => ShardInstance.Log(GetType().Name, message); protected void Log(string message) => ShardInstance.Log(GetType().Name, message);
public abstract Task OnTick(CancellationToken token); public abstract Task OnTick(int tickCount, CancellationToken token);
}
} }

View file

@ -8,38 +8,32 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices namespace BirthdayBot.BackgroundServices;
{
/// <summary> /// <summary>
/// Core automatic functionality of the bot. Manages role memberships based on birthday information, /// Core automatic functionality of the bot. Manages role memberships based on birthday information,
/// and optionally sends the announcement message to appropriate guilds. /// and optionally sends the announcement message to appropriate guilds.
/// </summary> /// </summary>
class BirthdayRoleUpdate : BackgroundService class BirthdayRoleUpdate : BackgroundService {
{
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { } public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
/// <summary> /// <summary>
/// Processes birthday updates for all available guilds synchronously. /// Processes birthday updates for all available guilds synchronously.
/// </summary> /// </summary>
public override async Task OnTick(CancellationToken token) public override async Task OnTick(int tickCount, CancellationToken token) {
{
var exs = new List<Exception>(); var exs = new List<Exception>();
foreach (var guild in ShardInstance.DiscordClient.Guilds) foreach (var guild in ShardInstance.DiscordClient.Guilds) {
{ if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) {
if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected)
{
Log("Client is not connected. Stopping early."); Log("Client is not connected. Stopping early.");
return; return;
} }
// Single guilds are fully processed and are not interrupted by task cancellation. // Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException(); if (token.IsCancellationRequested) throw new TaskCanceledException();
try
{ try {
await ProcessGuildAsync(guild).ConfigureAwait(false); await ProcessGuildAsync(guild).ConfigureAwait(false);
} } catch (Exception ex) {
catch (Exception ex)
{
// Catch all exceptions per-guild but continue processing, throw at end. // Catch all exceptions per-guild but continue processing, throw at end.
exs.Add(ex); exs.Add(ex);
} }
@ -57,11 +51,11 @@ namespace BirthdayBot.BackgroundServices
/// <summary> /// <summary>
/// Main method where actual guild processing occurs. /// Main method where actual guild processing occurs.
/// </summary> /// </summary>
private static async Task<PGDiagnostic> ProcessGuildAsync(SocketGuild guild) private static async Task<PGDiagnostic> ProcessGuildAsync(SocketGuild guild) {
{
var diag = new PGDiagnostic(); var diag = new PGDiagnostic();
// Load guild information - stop if there is none (bot never previously used in guild) // Load guild information - stop if local cache is unavailable.
if (!Common.HasMostMembersDownloaded(guild)) return diag;
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return diag; if (gc == null) return diag;
@ -80,14 +74,11 @@ namespace BirthdayBot.BackgroundServices
IEnumerable<SocketGuildUser> announcementList; IEnumerable<SocketGuildUser> announcementList;
// Update roles as appropriate // Update roles as appropriate
try try {
{
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false); var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
announcementList = updateResult.Item1; announcementList = updateResult.Item1;
diag.RoleApplyResult = updateResult.Item2; // statistics diag.RoleApplyResult = updateResult.Item2; // statistics
} } catch (Discord.Net.HttpException ex) {
catch (Discord.Net.HttpException ex)
{
diag.RoleApply = ex.Message; diag.RoleApply = ex.Message;
return diag; return diag;
} }
@ -98,14 +89,11 @@ namespace BirthdayBot.BackgroundServices
var announceping = gc.AnnouncePing; var announceping = gc.AnnouncePing;
SocketTextChannel channel = null; SocketTextChannel channel = null;
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value); if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Any()) if (announcementList.Any()) {
{
var announceResult = var announceResult =
await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false); await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false);
diag.Announcement = announceResult; diag.Announcement = announceResult;
} } else {
else
{
diag.Announcement = "No new role additions. Announcement not needed."; diag.Announcement = "No new role additions. Announcement not needed.";
} }
@ -115,19 +103,16 @@ namespace BirthdayBot.BackgroundServices
/// <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 static string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role) private static string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role) {
{ if (role == null) return "Birthday role is not configured, or has gone missing.";
if (role == null) return "Designated role is not set, or target role cannot be found.";
if (!guild.CurrentUser.GuildPermissions.ManageRoles) if (!guild.CurrentUser.GuildPermissions.ManageRoles) {
{
return "Bot does not have the 'Manage Roles' permission."; return "Bot does not have the 'Manage Roles' permission.";
} }
// Check potential role order conflict // Check potential role order conflict
if (role.Position >= guild.CurrentUser.Hierarchy) if (role.Position >= guild.CurrentUser.Hierarchy) {
{ return "Can't access the birthday role. Is it above the bot's permissions?";
return "Bot is unable to access the designated role due to permission hierarchy.";
} }
return null; return null;
@ -137,20 +122,17 @@ namespace BirthdayBot.BackgroundServices
/// 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 static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr) private static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr) {
{
var birthdayUsers = new HashSet<ulong>(); var birthdayUsers = new HashSet<ulong>();
DateTimeZone defaultTz = null; DateTimeZone defaultTz = null;
if (defaultTzStr != null) defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr); if (defaultTzStr != null) defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr);
defaultTz ??= DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC"); defaultTz ??= DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC");
foreach (var item in guildUsers) foreach (var item in guildUsers) {
{
// Determine final time zone to use for calculation // Determine final time zone to use for calculation
DateTimeZone tz = null; DateTimeZone tz = null;
if (item.TimeZone != null) if (item.TimeZone != null) {
{
// Try user-provided time zone // Try user-provided time zone
tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone); tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone);
} }
@ -161,13 +143,11 @@ namespace BirthdayBot.BackgroundServices
var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz); var checkNow = SystemClock.Instance.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 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year)) if (targetMonth == 2 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year)) {
{
targetMonth = 3; targetMonth = 3;
targetDay = 1; targetDay = 1;
} }
if (targetMonth == checkNow.Month && targetDay == checkNow.Day) if (targetMonth == checkNow.Month && targetDay == checkNow.Day) {
{
birthdayUsers.Add(item.UserId); birthdayUsers.Add(item.UserId);
} }
} }
@ -183,27 +163,23 @@ namespace BirthdayBot.BackgroundServices
/// Second item: Counts of users who have had roles added/removed, used for operation reporting. /// Second item: Counts of users who have had roles added/removed, used for operation reporting.
/// </returns> /// </returns>
private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles( private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles(
SocketGuild g, SocketRole r, HashSet<ulong> names) SocketGuild g, SocketRole r, HashSet<ulong> names) {
{
// 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.
var roleRemoves = new List<SocketGuildUser>(); var roleRemoves = new List<SocketGuildUser>();
var roleKeeps = new HashSet<ulong>(); var roleKeeps = new HashSet<ulong>();
foreach (var member in r.Members) foreach (var member in r.Members) {
{
if (!names.Contains(member.Id)) roleRemoves.Add(member); if (!names.Contains(member.Id)) roleRemoves.Add(member);
else roleKeeps.Add(member.Id); else roleKeeps.Add(member.Id);
} }
// TODO Can we remove during the iteration instead of after? investigate later... // TODO Can we remove during the iteration instead of after? investigate later...
foreach (var user in roleRemoves) foreach (var user in roleRemoves) {
{
await user.RemoveRoleAsync(r).ConfigureAwait(false); await user.RemoveRoleAsync(r).ConfigureAwait(false);
} }
// Apply role to members not already having it. Prepare announcement list. // Apply role to members not already having it. Prepare announcement list.
var newBirthdays = new List<SocketGuildUser>(); var newBirthdays = new List<SocketGuildUser>();
foreach (var target in names) foreach (var target in names) {
{
var member = g.GetUser(target); var member = g.GetUser(target);
if (member == null) continue; if (member == null) continue;
if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing
@ -223,9 +199,8 @@ namespace BirthdayBot.BackgroundServices
/// </summary> /// </summary>
/// <returns>The message to place into operation status log.</returns> /// <returns>The message to place into operation status log.</returns>
private static async Task<string> AnnounceBirthdaysAsync( private static async Task<string> AnnounceBirthdaysAsync(
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names) (string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names) {
{ if (c == null) return "Announcement channel is not configured, or has gone missing.";
if (c == null) return "Announcement channel is not set, or previous announcement channel has been deleted.";
string announceMsg; string announceMsg;
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce; if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce;
@ -240,27 +215,22 @@ namespace BirthdayBot.BackgroundServices
namestrings.Sort(StringComparer.OrdinalIgnoreCase); namestrings.Sort(StringComparer.OrdinalIgnoreCase);
var namedisplay = new StringBuilder(); var namedisplay = new StringBuilder();
foreach (var item in namestrings) foreach (var item in namestrings) {
{
namedisplay.Append(", "); namedisplay.Append(", ");
namedisplay.Append(item); namedisplay.Append(item);
} }
namedisplay.Remove(0, 2); // Remove initial comma and space namedisplay.Remove(0, 2); // Remove initial comma and space
try try {
{
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false); await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false);
return null; return null;
} } catch (Discord.Net.HttpException ex) {
catch (Discord.Net.HttpException ex)
{
// Directly use the resulting exception message in the operation status log // Directly use the resulting exception message in the operation status log
return ex.Message; return ex.Message;
} }
} }
private class PGDiagnostic private class PGDiagnostic {
{
const string DefaultValue = "--"; const string DefaultValue = "--";
public string RoleCheck = DefaultValue; public string RoleCheck = DefaultValue;
@ -269,8 +239,7 @@ namespace BirthdayBot.BackgroundServices
public (int, int)? RoleApplyResult; public (int, int)? RoleApplyResult;
public string Announcement = DefaultValue; public string Announcement = DefaultValue;
public string Export() public string Export() {
{
var result = new StringBuilder(); var result = new StringBuilder();
result.AppendLine("Test result:"); result.AppendLine("Test result:");
result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:")); result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:"));
@ -285,4 +254,3 @@ namespace BirthdayBot.BackgroundServices
} }
} }
} }
}

View file

@ -1,44 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices
{
/// <summary>
/// Keeps track of the connection status, assigning a score based on either the connection's
/// longevity or the amount of time it has remained persistently disconnected.
/// </summary>
class ConnectionStatus : BackgroundService
{
// About 3 minutes
public const int StableScore = 180 / ShardBackgroundWorker.Interval;
public bool Stable { get { return Score >= StableScore; } }
public int Score { get; private set; }
public ConnectionStatus(ShardInstance instance) : base(instance) { }
public override Task OnTick(CancellationToken token)
{
switch (ShardInstance.DiscordClient.ConnectionState)
{
case Discord.ConnectionState.Connected:
if (Score < 0) Score = 0;
Score++;
break;
default:
if (Score > 0) Score = 0;
Score--;
break;
}
return Task.CompletedTask;
}
/// <summary>
/// In response to a disconnection event, will immediately reset a positive score to zero.
/// </summary>
public void Disconnected()
{
if (Score > 0) Score = 0;
}
}
}

View file

@ -7,47 +7,35 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices namespace BirthdayBot.BackgroundServices;
{
/// <summary> /// <summary>
/// Data retention adherence service:
/// Automatically removes database information for guilds that have not been accessed in a long time. /// Automatically removes database information for guilds that have not been accessed in a long time.
/// </summary> /// </summary>
class DataRetention : BackgroundService class DataRetention : BackgroundService {
{
private static readonly SemaphoreSlim _updateLock = new(ShardManager.MaxConcurrentOperations); private static readonly SemaphoreSlim _updateLock = new(ShardManager.MaxConcurrentOperations);
const int ProcessInterval = 3600 / ShardBackgroundWorker.Interval; // Process about once per hour const int ProcessInterval = 3600 / ShardBackgroundWorker.Interval; // Process about once per hour
private int _tickCount = -1; const int Stagger = 3; // How many ticks in between each group of guilds to stagger processing.
public DataRetention(ShardInstance instance) : base(instance) { } public DataRetention(ShardInstance instance) : base(instance) { }
public override async Task OnTick(CancellationToken token) public override async Task OnTick(int tickCount, CancellationToken token) {
{ // On each tick, run only a set group of guilds, each group still processed every ProcessInterval ticks.
if ((++_tickCount + ShardInstance.ShardId * 3) % ProcessInterval != 0) if ((tickCount + ShardInstance.ShardId * Stagger) % ProcessInterval != 0) return;
{
// Do not process on every tick.
// Stagger processing based on shard ID, to not choke the background processing task.
return;
}
try try {
{
// A semaphore is used to restrict this work being done concurrently on other shards // A semaphore is used to restrict this work being done concurrently on other shards
// to avoid putting pressure on the SQL connection pool. Clearing old database information // to avoid putting pressure on the SQL connection pool. Clearing old database information
// ultimately is a low priority among other tasks. // ultimately is a low priority among other tasks.
await _updateLock.WaitAsync(token).ConfigureAwait(false); await _updateLock.WaitAsync(token).ConfigureAwait(false);
} } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) {
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) // Caller does not expect the exception that SemaphoreSlim throws...
{
// Calling thread does not expect the exception that SemaphoreSlim throws...
throw new TaskCanceledException(); throw new TaskCanceledException();
} }
try try {
{
// Build a list of all values to update // Build a list of all values to update
var updateList = new Dictionary<ulong, List<ulong>>(); var updateList = new Dictionary<ulong, List<ulong>>();
foreach (var g in ShardInstance.DiscordClient.Guilds) foreach (var g in ShardInstance.DiscordClient.Guilds) {
{
// Get list of IDs for all users who exist in the database and currently exist in the guild // Get list of IDs for all users who exist in the database and currently exist in the guild
var userList = GuildUserConfiguration.LoadAllAsync(g.Id); var userList = GuildUserConfiguration.LoadAllAsync(g.Id);
var guildUserIds = from gu in g.Users select gu.Id; var guildUserIds = from gu in g.Users select gu.Id;
@ -76,8 +64,7 @@ namespace BirthdayBot.BackgroundServices
// Do actual updates // Do actual updates
int updatedGuilds = 0; int updatedGuilds = 0;
int updatedUsers = 0; int updatedUsers = 0;
foreach (var item in updateList) foreach (var item in updateList) {
{
var guild = item.Key; var guild = item.Key;
var userlist = item.Value; var userlist = item.Value;
@ -85,8 +72,7 @@ namespace BirthdayBot.BackgroundServices
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
pUpdateGU_g.Value = (long)guild; pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist) foreach (var userid in userlist) {
{
pUpdateGU_u.Value = (long)userid; pUpdateGU_u.Value = (long)userid;
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
} }
@ -97,39 +83,31 @@ namespace BirthdayBot.BackgroundServices
// Delete all old values - expects referencing tables to have 'on delete cascade' // Delete all old values - expects referencing tables to have 'on delete cascade'
using var t = db.BeginTransaction(); using var t = db.BeginTransaction();
int staleGuilds, staleUsers; int staleGuilds, staleUsers;
using (var c = db.CreateCommand()) using (var c = db.CreateCommand()) {
{
// Delete data for guilds not seen in 4 weeks // Delete data for guilds not seen in 4 weeks
c.CommandText = $"delete from {GuildConfiguration.BackingTable} where (now() - interval '28 days') > last_seen"; c.CommandText = $"delete from {GuildConfiguration.BackingTable} where (now() - interval '28 days') > last_seen";
staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
} }
using (var c = db.CreateCommand()) using (var c = db.CreateCommand()) {
{
// Delete data for users not seen in 8 weeks // Delete data for users not seen in 8 weeks
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable} where (now() - interval '56 days') > last_seen"; c.CommandText = $"delete from {GuildUserConfiguration.BackingTable} where (now() - interval '56 days') > last_seen";
staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false); staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
} }
if (staleGuilds != 0 || staleUsers != 0) if (staleGuilds != 0 || staleUsers != 0) {
{
resultText.Append(" Discarded "); resultText.Append(" Discarded ");
if (staleGuilds != 0) if (staleGuilds != 0) {
{
resultText.Append($"{staleGuilds} guilds"); resultText.Append($"{staleGuilds} guilds");
if (staleUsers != 0) resultText.Append(", "); if (staleUsers != 0) resultText.Append(", ");
} }
if (staleUsers != 0) if (staleUsers != 0) {
{
resultText.Append($"{staleUsers} standalone users"); resultText.Append($"{staleUsers} standalone users");
} }
resultText.Append('.'); resultText.Append('.');
} }
t.Commit(); t.Commit();
Log(resultText.ToString()); Log(resultText.ToString());
} } finally {
finally
{
_updateLock.Release(); _updateLock.Release();
} }
} }
} }
}

View file

@ -4,55 +4,47 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices namespace BirthdayBot.BackgroundServices;
{
/// <summary> /// <summary>
/// Reports user count statistics to external services on a shard by shard basis. /// Reports user count statistics to external services on a shard by shard basis.
/// </summary> /// </summary>
class ExternalStatisticsReporting : BackgroundService class ExternalStatisticsReporting : BackgroundService {
{ const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes
const int ProcessInterval = 600 / ShardBackgroundWorker.Interval; // Process every ~5 minutes const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing 5 minutes after shard start
private int _tickCount = 0;
private static readonly HttpClient _httpClient = new(); private static readonly HttpClient _httpClient = new();
public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { } public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { }
public override async Task OnTick(CancellationToken token) public override async Task OnTick(int tickCount, CancellationToken token) {
{ if (tickCount < ProcessOffset) return;
if (++_tickCount % ProcessInterval != 0) return; if (tickCount % ProcessInterval != 0) return;
var botId = ShardInstance.DiscordClient.CurrentUser.Id; var botId = ShardInstance.DiscordClient.CurrentUser.Id;
if (botId == 0) return; if (botId == 0) return;
var count = ShardInstance.DiscordClient.Guilds.Count;
await SendDiscordBots(ShardInstance.DiscordClient.Guilds.Count, botId, token); var dbotsToken = ShardInstance.Config.DBotsToken;
if (dbotsToken != null) await SendDiscordBots(dbotsToken, count, botId, token);
} }
private async Task SendDiscordBots(int userCount, ulong botId, CancellationToken token) private async Task SendDiscordBots(string apiToken, int userCount, ulong botId, CancellationToken token) {
{ try {
var dbotsToken = ShardInstance.Config.DBotsToken;
if (dbotsToken != null)
{
try
{
const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats"; const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats";
const string Body = "{{ \"guildCount\": {0}, \"shardCount\": {1}, \"shardId\": {2} }}"; const string Body = "{{ \"guildCount\": {0}, \"shardCount\": {1}, \"shardId\": {2} }}";
var uri = new Uri(string.Format(dBotsApiUrl, botId)); var uri = new Uri(string.Format(dBotsApiUrl, botId));
var post = new HttpRequestMessage(HttpMethod.Post, uri); var post = new HttpRequestMessage(HttpMethod.Post, uri);
post.Headers.Add("Authorization", dbotsToken); post.Headers.Add("Authorization", apiToken);
post.Content = new StringContent(string.Format(Body, post.Content = new StringContent(string.Format(Body,
userCount, ShardInstance.Config.ShardTotal, ShardInstance.ShardId), userCount, ShardInstance.Config.ShardTotal, ShardInstance.ShardId),
Encoding.UTF8, "application/json"); Encoding.UTF8, "application/json");
await _httpClient.SendAsync(post, token); await _httpClient.SendAsync(post, token);
Log("Discord Bots: Update successful."); Log("Discord Bots: Update successful.");
} } catch (Exception ex) {
catch (Exception ex)
{
Log("Discord Bots: Exception encountered during update: " + ex.Message); Log("Discord Bots: Exception encountered during update: " + ex.Message);
} }
} }
} }
}
}

View file

@ -18,7 +18,7 @@ class SelectiveAutoUserDownload : BackgroundService {
public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { } public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { }
public override async Task OnTick(CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
IEnumerable<ulong> requests; IEnumerable<ulong> requests;
lock (_fetchRequests) { lock (_fetchRequests) {
requests = _fetchRequests.ToArray(); requests = _fetchRequests.ToArray();
@ -26,10 +26,8 @@ class SelectiveAutoUserDownload : BackgroundService {
} }
foreach (var guild in ShardInstance.DiscordClient.Guilds) { foreach (var guild in ShardInstance.DiscordClient.Guilds) {
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) { // Has the potential to disconnect while in the middle of processing.
Log("Client no longer connected. Stopping early."); if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
return;
}
// Determine if there is action to be taken... // Determine if there is action to be taken...
if (guild.HasAllMembers) continue; if (guild.HasAllMembers) continue;

View file

@ -18,22 +18,20 @@ namespace BirthdayBot.BackgroundServices
private readonly Task _workerTask; private readonly Task _workerTask;
private readonly CancellationTokenSource _workerCanceller; private readonly CancellationTokenSource _workerCanceller;
private readonly List<BackgroundService> _workers; private readonly List<BackgroundService> _workers;
private int _tickCount = -1;
private ShardInstance Instance { get; } private ShardInstance Instance { get; }
public ConnectionStatus ConnStatus { get; }
public BirthdayRoleUpdate BirthdayUpdater { get; } public BirthdayRoleUpdate BirthdayUpdater { get; }
public SelectiveAutoUserDownload UserDownloader { get; } public SelectiveAutoUserDownload UserDownloader { get; }
public DateTimeOffset LastBackgroundRun { get; private set; } public DateTimeOffset LastBackgroundRun { get; private set; }
public string CurrentExecutingService { get; private set; } public string? CurrentExecutingService { get; private set; }
public int ConnectionScore => ConnStatus.Score;
public ShardBackgroundWorker(ShardInstance instance) public ShardBackgroundWorker(ShardInstance instance)
{ {
Instance = instance; Instance = instance;
_workerCanceller = new CancellationTokenSource(); _workerCanceller = new CancellationTokenSource();
ConnStatus = new ConnectionStatus(instance);
BirthdayUpdater = new BirthdayRoleUpdate(instance); BirthdayUpdater = new BirthdayRoleUpdate(instance);
UserDownloader = new SelectiveAutoUserDownload(instance); UserDownloader = new SelectiveAutoUserDownload(instance);
_workers = new List<BackgroundService>() _workers = new List<BackgroundService>()
@ -70,9 +68,8 @@ namespace BirthdayBot.BackgroundServices
{ {
await Task.Delay(Interval * 1000, _workerCanceller.Token).ConfigureAwait(false); await Task.Delay(Interval * 1000, _workerCanceller.Token).ConfigureAwait(false);
// ConnectionStatus will always run. Its result determines if remaining tasks also this time. // Skip this round of task execution if the client is not connected
await ConnStatus.OnTick(_workerCanceller.Token).ConfigureAwait(false); if (Instance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) continue;
if (!ConnStatus.Stable) continue;
// Execute tasks sequentially // Execute tasks sequentially
foreach (var service in _workers) foreach (var service in _workers)
@ -81,30 +78,20 @@ namespace BirthdayBot.BackgroundServices
try try
{ {
if (_workerCanceller.IsCancellationRequested) break; if (_workerCanceller.IsCancellationRequested) break;
await service.OnTick(_workerCanceller.Token).ConfigureAwait(false); _tickCount++;
await service.OnTick(_tickCount, _workerCanceller.Token).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex) when (ex is not TaskCanceledException)
{
if (ex is TaskCanceledException)
{
Instance.Log(nameof(WorkerLoop), $"{CurrentExecutingService} was interrupted by a cancellation request.");
throw;
}
else
{ {
// TODO webhook log // TODO webhook log
Instance.Log(nameof(WorkerLoop), $"{CurrentExecutingService} encountered an exception:\n" + ex.ToString()); Instance.Log(nameof(WorkerLoop), $"{CurrentExecutingService} encountered an exception:\n" + ex.ToString());
} }
} }
}
CurrentExecutingService = null; CurrentExecutingService = null;
LastBackgroundRun = DateTimeOffset.UtcNow; LastBackgroundRun = DateTimeOffset.UtcNow;
} }
} }
catch (TaskCanceledException) { } catch (TaskCanceledException) { }
Instance.Log(nameof(WorkerLoop), "Background worker has concluded normally.");
} }
} }
} }

View file

@ -6,16 +6,15 @@ using System.IO;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace BirthdayBot namespace BirthdayBot;
{
/// <summary> /// <summary>
/// Loads and holds configuration values. /// Loads and holds configuration values.
/// </summary> /// </summary>
class Configuration class Configuration {
{
public string BotToken { get; } public string BotToken { get; }
public string LogWebhook { get; } public string LogWebhook { get; }
public string DBotsToken { get; } public string? DBotsToken { get; }
public const string ShardLenConfKey = "ShardRange"; public const string ShardLenConfKey = "ShardRange";
public int ShardStart { get; } public int ShardStart { get; }
@ -25,14 +24,12 @@ namespace BirthdayBot
public bool QuitOnFails { get; } public bool QuitOnFails { get; }
public Configuration() public Configuration() {
{
// Looks for settings.json in the executable directory. // Looks for settings.json in the executable directory.
var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
confPath += Path.DirectorySeparatorChar + "settings.json"; confPath += Path.DirectorySeparatorChar + "settings.json";
if (!File.Exists(confPath)) if (!File.Exists(confPath)) {
{
throw new Exception("Settings file not found." throw new Exception("Settings file not found."
+ " Create a file in the executable directory named 'settings.json'."); + " Create a file in the executable directory named 'settings.json'.");
} }
@ -48,12 +45,9 @@ namespace BirthdayBot
throw new Exception($"'{nameof(LogWebhook)}' must be specified."); throw new Exception($"'{nameof(LogWebhook)}' must be specified.");
var dbj = jc[nameof(DBotsToken)]; var dbj = jc[nameof(DBotsToken)];
if (dbj != null) if (dbj != null) {
{
DBotsToken = dbj.Value<string>(); DBotsToken = dbj.Value<string>();
} } else {
else
{
DBotsToken = null; DBotsToken = null;
} }
@ -62,8 +56,7 @@ namespace BirthdayBot
var sqlpass = jc["SqlPassword"]?.Value<string>(); var sqlpass = jc["SqlPassword"]?.Value<string>();
if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass)) if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass))
throw new Exception("'SqlUsername', 'SqlPassword' must be specified."); throw new Exception("'SqlUsername', 'SqlPassword' must be specified.");
var csb = new NpgsqlConnectionStringBuilder() var csb = new NpgsqlConnectionStringBuilder() {
{
Host = sqlhost, Host = sqlhost,
Username = sqluser, Username = sqluser,
Password = sqlpass Password = sqlpass
@ -74,33 +67,25 @@ namespace BirthdayBot
int? sc = jc[nameof(ShardTotal)]?.Value<int>(); int? sc = jc[nameof(ShardTotal)]?.Value<int>();
if (!sc.HasValue) ShardTotal = 1; if (!sc.HasValue) ShardTotal = 1;
else else {
{
ShardTotal = sc.Value; ShardTotal = sc.Value;
if (ShardTotal <= 0) if (ShardTotal <= 0) {
{
throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer."); throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
} }
} }
string srVal = jc[ShardLenConfKey]?.Value<string>(); string srVal = jc[ShardLenConfKey]?.Value<string>();
if (!string.IsNullOrWhiteSpace(srVal)) if (!string.IsNullOrWhiteSpace(srVal)) {
{
Regex srPicker = new(@"(?<low>\d{1,2})[-,]{1}(?<high>\d{1,2})"); Regex srPicker = new(@"(?<low>\d{1,2})[-,]{1}(?<high>\d{1,2})");
var m = srPicker.Match(srVal); var m = srPicker.Match(srVal);
if (m.Success) if (m.Success) {
{
ShardStart = int.Parse(m.Groups["low"].Value); ShardStart = int.Parse(m.Groups["low"].Value);
int high = int.Parse(m.Groups["high"].Value); int high = int.Parse(m.Groups["high"].Value);
ShardAmount = high - (ShardStart - 1); ShardAmount = high - (ShardStart - 1);
} } else {
else
{
throw new Exception($"Shard range not properly formatted in '{ShardLenConfKey}'."); throw new Exception($"Shard range not properly formatted in '{ShardLenConfKey}'.");
} }
} } else {
else
{
// Default: this instance handles all shards from ShardTotal // Default: this instance handles all shards from ShardTotal
ShardStart = 0; ShardStart = 0;
ShardAmount = ShardTotal; ShardAmount = ShardTotal;
@ -109,4 +94,3 @@ namespace BirthdayBot
QuitOnFails = jc[nameof(QuitOnFails)]?.Value<bool>() ?? false; QuitOnFails = jc[nameof(QuitOnFails)]?.Value<bool>() ?? false;
} }
} }
}

View file

@ -30,11 +30,6 @@ namespace BirthdayBot
/// </summary> /// </summary>
public string CurrentExecutingService => _background.CurrentExecutingService; public string CurrentExecutingService => _background.CurrentExecutingService;
public Configuration Config => _manager.Config; public Configuration Config => _manager.Config;
/// <summary>
/// Returns this shard's connection score.
/// See <see cref="BackgroundServices.ConnectionStatus"/> for details on what this means.
/// </summary>
public int ConnectionScore => _background.ConnectionScore;
/// <summary> /// <summary>
/// Prepares and configures the shard instances, but does not yet start its connection. /// Prepares and configures the shard instances, but does not yet start its connection.
@ -51,7 +46,6 @@ namespace BirthdayBot
// Background task constructor begins background processing immediately. // Background task constructor begins background processing immediately.
_background = new ShardBackgroundWorker(this); _background = new ShardBackgroundWorker(this);
DiscordClient.Disconnected += Client_Disconnected;
} }
/// <summary> /// <summary>
@ -69,13 +63,10 @@ namespace BirthdayBot
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
Log("Instance", "Cleaning up...");
// Unsubscribe from own events // Unsubscribe from own events
DiscordClient.Log -= Client_Log; DiscordClient.Log -= Client_Log;
DiscordClient.Ready -= Client_Ready; DiscordClient.Ready -= Client_Ready;
DiscordClient.MessageReceived -= Client_MessageReceived; DiscordClient.MessageReceived -= Client_MessageReceived;
DiscordClient.Disconnected -= Client_Disconnected;
_background.Dispose(); _background.Dispose();
try try
@ -98,9 +89,8 @@ namespace BirthdayBot
} }
var clientDispose = Task.Run(DiscordClient.Dispose); var clientDispose = Task.Run(DiscordClient.Dispose);
if (!clientDispose.Wait(10000)) if (!clientDispose.Wait(10000)) Log("Instance", "Warning: Client is hanging on dispose. Will continue.");
Log("Instance", "Warning: Client hanging on dispose."); else Log("Instance", "Shard instance disposed.");
Log("Instance", "Shard instance disposed.");
} }
public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message);
@ -130,6 +120,7 @@ namespace BirthdayBot
case "Disconnecting": case "Disconnecting":
case "Disconnected": case "Disconnected":
case "WebSocket connection was closed": case "WebSocket connection was closed":
case "Server requested a reconnect":
return Task.CompletedTask; return Task.CompletedTask;
} }
if (arg.Message == "Heartbeat Errored") if (arg.Message == "Heartbeat Errored")
@ -152,15 +143,6 @@ namespace BirthdayBot
/// </summary> /// </summary>
private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help"); private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help");
/// <summary>
/// Notify ConnectionStatus of a disconnect.
/// </summary>
private Task Client_Disconnected(Exception arg)
{
_background.ConnStatus.Disconnected();
return Task.CompletedTask;
}
/// <summary> /// <summary>
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary. /// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
/// </summary> /// </summary>

View file

@ -150,7 +150,7 @@ namespace BirthdayBot
Log($"Bot uptime: {Common.BotUptime}"); Log($"Bot uptime: {Common.BotUptime}");
// Iterate through shard list, extract data // Iterate through shard list, extract data
var guildInfo = new Dictionary<int, (int, int, TimeSpan, string)>(); var guildInfo = new Dictionary<int, (int, TimeSpan, string)>();
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var nullShards = new List<int>(); var nullShards = new List<int>();
foreach (var item in _shards) foreach (var item in _shards)
@ -163,11 +163,10 @@ namespace BirthdayBot
var shard = item.Value; var shard = item.Value;
var guildCount = shard.DiscordClient.Guilds.Count; var guildCount = shard.DiscordClient.Guilds.Count;
var connScore = shard.ConnectionScore;
var lastRun = now - shard.LastBackgroundRun; var lastRun = now - shard.LastBackgroundRun;
var lastExec = shard.CurrentExecutingService ?? "null"; var lastExec = shard.CurrentExecutingService ?? "null";
guildInfo[item.Key] = (guildCount, connScore, lastRun, lastExec); guildInfo[item.Key] = (guildCount, lastRun, lastExec);
} }
// Process info // Process info
@ -182,10 +181,9 @@ namespace BirthdayBot
var deadShards = new List<int>(); // shards to destroy and reinitialize var deadShards = new List<int>(); // shards to destroy and reinitialize
foreach (var item in guildInfo) foreach (var item in guildInfo)
{ {
var connScore = item.Value.Item2; var lastRun = item.Value.Item2;
var lastRun = item.Value.Item3;
if (lastRun > new TimeSpan(0, 10, 0) || connScore < ConnectionStatus.StableScore) if (lastRun > DeadShardThreshold / 3)
{ {
badShards.Add(item.Key); badShards.Add(item.Key);
@ -208,9 +206,8 @@ namespace BirthdayBot
if (detailedInfo) if (detailedInfo)
{ {
result.Remove(result.Length - 1, 1); result.Remove(result.Length - 1, 1);
result.Append($"[{guildInfo[item].Item2:+0;-0}"); result.Append($"[{Math.Floor(guildInfo[item].Item2.TotalSeconds):000}s");
result.Append($" {Math.Floor(guildInfo[item].Item3.TotalSeconds):000}s"); result.Append($" {guildInfo[item].Item3}] ");
result.Append($" {guildInfo[item].Item4}] ");
} }
} }
if (result.Length > 0) result.Remove(result.Length - 1, 1); if (result.Length > 0) result.Remove(result.Length - 1, 1);