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.Tasks;
namespace BirthdayBot.BackgroundServices
{
abstract class BackgroundService
{
protected ShardInstance ShardInstance { get; }
namespace BirthdayBot.BackgroundServices;
public BackgroundService(ShardInstance instance) => ShardInstance = instance;
abstract class BackgroundService {
protected ShardInstance ShardInstance { get; }
protected void Log(string message) => ShardInstance.Log(GetType().Name, message);
public BackgroundService(ShardInstance instance) => ShardInstance = instance;
public abstract Task OnTick(CancellationToken token);
}
protected void Log(string message) => ShardInstance.Log(GetType().Name, message);
public abstract Task OnTick(int tickCount, CancellationToken token);
}

View file

@ -8,281 +8,249 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices
{
namespace BirthdayBot.BackgroundServices;
/// <summary>
/// Core automatic functionality of the bot. Manages role memberships based on birthday information,
/// and optionally sends the announcement message to appropriate guilds.
/// </summary>
class BirthdayRoleUpdate : BackgroundService {
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
/// <summary>
/// Core automatic functionality of the bot. Manages role memberships based on birthday information,
/// and optionally sends the announcement message to appropriate guilds.
/// Processes birthday updates for all available guilds synchronously.
/// </summary>
class BirthdayRoleUpdate : BackgroundService
{
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
/// <summary>
/// Processes birthday updates for all available guilds synchronously.
/// </summary>
public override async Task OnTick(CancellationToken token)
{
var exs = new List<Exception>();
foreach (var guild in ShardInstance.DiscordClient.Guilds)
{
if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected)
{
Log("Client is not connected. Stopping early.");
return;
}
// Single guilds are fully processed and are not interrupted by task cancellation.
if (token.IsCancellationRequested) throw new TaskCanceledException();
try
{
await ProcessGuildAsync(guild).ConfigureAwait(false);
}
catch (Exception ex)
{
// Catch all exceptions per-guild but continue processing, throw at end.
exs.Add(ex);
}
public override async Task OnTick(int tickCount, CancellationToken token) {
var exs = new List<Exception>();
foreach (var guild in ShardInstance.DiscordClient.Guilds) {
if (ShardInstance.DiscordClient.ConnectionState != Discord.ConnectionState.Connected) {
Log("Client is not connected. Stopping early.");
return;
}
// Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException();
try {
await ProcessGuildAsync(guild).ConfigureAwait(false);
} catch (Exception ex) {
// Catch all exceptions per-guild but continue processing, throw at end.
exs.Add(ex);
}
if (exs.Count != 0) throw new AggregateException(exs);
}
if (exs.Count != 0) throw new AggregateException(exs);
}
/// <summary>
/// Access to <see cref="ProcessGuildAsync(SocketGuild)"/> for the testing command.
/// </summary>
/// <returns>Diagnostic data in string form.</returns>
public static async Task<string> SingleProcessGuildAsync(SocketGuild guild)
=> (await ProcessGuildAsync(guild).ConfigureAwait(false)).Export();
/// <summary>
/// Access to <see cref="ProcessGuildAsync(SocketGuild)"/> for the testing command.
/// </summary>
/// <returns>Diagnostic data in string form.</returns>
public static async Task<string> SingleProcessGuildAsync(SocketGuild guild)
=> (await ProcessGuildAsync(guild).ConfigureAwait(false)).Export();
/// <summary>
/// Main method where actual guild processing occurs.
/// </summary>
private static async Task<PGDiagnostic> ProcessGuildAsync(SocketGuild guild)
{
var diag = new PGDiagnostic();
/// <summary>
/// Main method where actual guild processing occurs.
/// </summary>
private static async Task<PGDiagnostic> ProcessGuildAsync(SocketGuild guild) {
var diag = new PGDiagnostic();
// Load guild information - stop if there is none (bot never previously used in guild)
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return diag;
// 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);
if (gc == null) return diag;
// Check if role settings are correct before continuing with further processing
SocketRole role = null;
if (gc.RoleId.HasValue) role = guild.GetRole(gc.RoleId.Value);
diag.RoleCheck = CheckCorrectRoleSettings(guild, role);
if (diag.RoleCheck != null) return diag;
// Check if role settings are correct before continuing with further processing
SocketRole role = null;
if (gc.RoleId.HasValue) role = guild.GetRole(gc.RoleId.Value);
diag.RoleCheck = CheckCorrectRoleSettings(guild, role);
if (diag.RoleCheck != null) return diag;
// Determine who's currently having a birthday
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false);
var tz = gc.TimeZone;
var birthdays = GetGuildCurrentBirthdays(users, tz);
// Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
diag.CurrentBirthdays = birthdays.Count.ToString();
IEnumerable<SocketGuildUser> announcementList;
// Update roles as appropriate
try
{
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
announcementList = updateResult.Item1;
diag.RoleApplyResult = updateResult.Item2; // statistics
}
catch (Discord.Net.HttpException ex)
{
diag.RoleApply = ex.Message;
return diag;
}
diag.RoleApply = null;
// Birthday announcement
var announce = gc.AnnounceMessages;
var announceping = gc.AnnouncePing;
SocketTextChannel channel = null;
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Any())
{
var announceResult =
await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false);
diag.Announcement = announceResult;
}
else
{
diag.Announcement = "No new role additions. Announcement not needed.";
}
// Determine who's currently having a birthday
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false);
var tz = gc.TimeZone;
var birthdays = GetGuildCurrentBirthdays(users, tz);
// Note: Don't quit here if zero people are having birthdays. Roles may still need to be removed by BirthdayApply.
diag.CurrentBirthdays = birthdays.Count.ToString();
IEnumerable<SocketGuildUser> announcementList;
// Update roles as appropriate
try {
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
announcementList = updateResult.Item1;
diag.RoleApplyResult = updateResult.Item2; // statistics
} catch (Discord.Net.HttpException ex) {
diag.RoleApply = ex.Message;
return diag;
}
diag.RoleApply = null;
/// <summary>
/// Checks if the bot may be allowed to alter roles.
/// </summary>
private static string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role)
{
if (role == null) return "Designated role is not set, or target role cannot be found.";
// Birthday announcement
var announce = gc.AnnounceMessages;
var announceping = gc.AnnouncePing;
SocketTextChannel channel = null;
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
if (announcementList.Any()) {
var announceResult =
await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList).ConfigureAwait(false);
diag.Announcement = announceResult;
} else {
diag.Announcement = "No new role additions. Announcement not needed.";
}
if (!guild.CurrentUser.GuildPermissions.ManageRoles)
{
return "Bot does not have the 'Manage Roles' permission.";
return diag;
}
/// <summary>
/// Checks if the bot may be allowed to alter roles.
/// </summary>
private static string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role) {
if (role == null) return "Birthday role is not configured, or has gone missing.";
if (!guild.CurrentUser.GuildPermissions.ManageRoles) {
return "Bot does not have the 'Manage Roles' permission.";
}
// Check potential role order conflict
if (role.Position >= guild.CurrentUser.Hierarchy) {
return "Can't access the birthday role. Is it above the bot's permissions?";
}
return null;
}
/// <summary>
/// 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.
/// </summary>
private static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr) {
var birthdayUsers = new HashSet<ulong>();
DateTimeZone defaultTz = null;
if (defaultTzStr != null) defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr);
defaultTz ??= DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC");
foreach (var item in guildUsers) {
// Determine final time zone to use for calculation
DateTimeZone tz = null;
if (item.TimeZone != null) {
// Try user-provided time zone
tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone);
}
tz ??= defaultTz;
// Check potential role order conflict
if (role.Position >= guild.CurrentUser.Hierarchy)
{
return "Bot is unable to access the designated role due to permission hierarchy.";
var targetMonth = item.BirthMonth;
var targetDay = item.BirthDay;
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
if (targetMonth == 2 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year)) {
targetMonth = 3;
targetDay = 1;
}
if (targetMonth == checkNow.Month && targetDay == checkNow.Day) {
birthdayUsers.Add(item.UserId);
}
}
return birthdayUsers;
}
/// <summary>
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
/// </summary>
/// <returns>
/// First item: List of users who had the birthday role applied, used to announce.
/// Second item: Counts of users who have had roles added/removed, used for operation reporting.
/// </returns>
private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles(
SocketGuild g, SocketRole r, HashSet<ulong> names) {
// Check members currently with the role. Figure out which users to remove it from.
var roleRemoves = new List<SocketGuildUser>();
var roleKeeps = new HashSet<ulong>();
foreach (var member in r.Members) {
if (!names.Contains(member.Id)) roleRemoves.Add(member);
else roleKeeps.Add(member.Id);
}
// TODO Can we remove during the iteration instead of after? investigate later...
foreach (var user in roleRemoves) {
await user.RemoveRoleAsync(r).ConfigureAwait(false);
}
// Apply role to members not already having it. Prepare announcement list.
var newBirthdays = new List<SocketGuildUser>();
foreach (var target in names) {
var member = g.GetUser(target);
if (member == null) continue;
if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing
await member.AddRoleAsync(r).ConfigureAwait(false);
newBirthdays.Add(member);
}
return (newBirthdays, (newBirthdays.Count, roleRemoves.Count));
}
public const string DefaultAnnounce = "Please wish a happy birthday to %n!";
public const string DefaultAnnouncePl = "Please wish a happy birthday to our esteemed members: %n";
/// <summary>
/// Makes (or attempts to make) an announcement in the specified channel that includes all users
/// who have just had their birthday role added.
/// </summary>
/// <returns>The message to place into operation status log.</returns>
private static async Task<string> AnnounceBirthdaysAsync(
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names) {
if (c == null) return "Announcement channel is not configured, or has gone missing.";
string announceMsg;
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce;
else announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl;
announceMsg = announceMsg.TrimEnd();
if (!announceMsg.Contains("%n")) announceMsg += " %n";
// Build sorted name list
var namestrings = new List<string>();
foreach (var item in names)
namestrings.Add(Common.FormatName(item, announcePing));
namestrings.Sort(StringComparer.OrdinalIgnoreCase);
var namedisplay = new StringBuilder();
foreach (var item in namestrings) {
namedisplay.Append(", ");
namedisplay.Append(item);
}
namedisplay.Remove(0, 2); // Remove initial comma and space
try {
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false);
return null;
} catch (Discord.Net.HttpException ex) {
// Directly use the resulting exception message in the operation status log
return ex.Message;
}
}
/// <summary>
/// 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.
/// </summary>
private static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr)
{
var birthdayUsers = new HashSet<ulong>();
private class PGDiagnostic {
const string DefaultValue = "--";
DateTimeZone defaultTz = null;
if (defaultTzStr != null) defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr);
defaultTz ??= DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC");
public string RoleCheck = DefaultValue;
public string CurrentBirthdays = DefaultValue;
public string RoleApply = DefaultValue;
public (int, int)? RoleApplyResult;
public string Announcement = DefaultValue;
foreach (var item in guildUsers)
{
// Determine final time zone to use for calculation
DateTimeZone tz = null;
if (item.TimeZone != null)
{
// Try user-provided time zone
tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(item.TimeZone);
}
tz ??= defaultTz;
public string Export() {
var result = new StringBuilder();
result.AppendLine("Test result:");
result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:"));
result.AppendLine("Number of known users currently with a birthday: " + CurrentBirthdays);
result.AppendLine("Role application process: " + (RoleApply ?? ":white_check_mark:"));
result.Append("Role application metrics: ");
if (RoleApplyResult.HasValue) result.AppendLine($"{RoleApplyResult.Value.Item1} additions, {RoleApplyResult.Value.Item2} removals.");
else result.AppendLine(DefaultValue);
result.AppendLine("Announcement: " + (Announcement ?? ":white_check_mark:"));
var targetMonth = item.BirthMonth;
var targetDay = item.BirthDay;
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
if (targetMonth == 2 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year))
{
targetMonth = 3;
targetDay = 1;
}
if (targetMonth == checkNow.Month && targetDay == checkNow.Day)
{
birthdayUsers.Add(item.UserId);
}
}
return birthdayUsers;
}
/// <summary>
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
/// </summary>
/// <returns>
/// First item: List of users who had the birthday role applied, used to announce.
/// Second item: Counts of users who have had roles added/removed, used for operation reporting.
/// </returns>
private static async Task<(IEnumerable<SocketGuildUser>, (int, int))> UpdateGuildBirthdayRoles(
SocketGuild g, SocketRole r, HashSet<ulong> names)
{
// Check members currently with the role. Figure out which users to remove it from.
var roleRemoves = new List<SocketGuildUser>();
var roleKeeps = new HashSet<ulong>();
foreach (var member in r.Members)
{
if (!names.Contains(member.Id)) roleRemoves.Add(member);
else roleKeeps.Add(member.Id);
}
// TODO Can we remove during the iteration instead of after? investigate later...
foreach (var user in roleRemoves)
{
await user.RemoveRoleAsync(r).ConfigureAwait(false);
}
// Apply role to members not already having it. Prepare announcement list.
var newBirthdays = new List<SocketGuildUser>();
foreach (var target in names)
{
var member = g.GetUser(target);
if (member == null) continue;
if (roleKeeps.Contains(member.Id)) continue; // already has role - do nothing
await member.AddRoleAsync(r).ConfigureAwait(false);
newBirthdays.Add(member);
}
return (newBirthdays, (newBirthdays.Count, roleRemoves.Count));
}
public const string DefaultAnnounce = "Please wish a happy birthday to %n!";
public const string DefaultAnnouncePl = "Please wish a happy birthday to our esteemed members: %n";
/// <summary>
/// Makes (or attempts to make) an announcement in the specified channel that includes all users
/// who have just had their birthday role added.
/// </summary>
/// <returns>The message to place into operation status log.</returns>
private static async Task<string> AnnounceBirthdaysAsync(
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names)
{
if (c == null) return "Announcement channel is not set, or previous announcement channel has been deleted.";
string announceMsg;
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce;
else announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl;
announceMsg = announceMsg.TrimEnd();
if (!announceMsg.Contains("%n")) announceMsg += " %n";
// Build sorted name list
var namestrings = new List<string>();
foreach (var item in names)
namestrings.Add(Common.FormatName(item, announcePing));
namestrings.Sort(StringComparer.OrdinalIgnoreCase);
var namedisplay = new StringBuilder();
foreach (var item in namestrings)
{
namedisplay.Append(", ");
namedisplay.Append(item);
}
namedisplay.Remove(0, 2); // Remove initial comma and space
try
{
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false);
return null;
}
catch (Discord.Net.HttpException ex)
{
// Directly use the resulting exception message in the operation status log
return ex.Message;
}
}
private class PGDiagnostic
{
const string DefaultValue = "--";
public string RoleCheck = DefaultValue;
public string CurrentBirthdays = DefaultValue;
public string RoleApply = DefaultValue;
public (int, int)? RoleApplyResult;
public string Announcement = DefaultValue;
public string Export()
{
var result = new StringBuilder();
result.AppendLine("Test result:");
result.AppendLine("Check role permissions: " + (RoleCheck ?? ":white_check_mark:"));
result.AppendLine("Number of known users currently with a birthday: " + CurrentBirthdays);
result.AppendLine("Role application process: " + (RoleApply ?? ":white_check_mark:"));
result.Append("Role application metrics: ");
if (RoleApplyResult.HasValue) result.AppendLine($"{RoleApplyResult.Value.Item1} additions, {RoleApplyResult.Value.Item2} removals.");
else result.AppendLine(DefaultValue);
result.AppendLine("Announcement: " + (Announcement ?? ":white_check_mark:"));
return result.ToString();
}
return result.ToString();
}
}
}

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,129 +7,107 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices
{
/// <summary>
/// Data retention adherence service:
/// Automatically removes database information for guilds that have not been accessed in a long time.
/// </summary>
class DataRetention : BackgroundService
{
private static readonly SemaphoreSlim _updateLock = new(ShardManager.MaxConcurrentOperations);
const int ProcessInterval = 3600 / ShardBackgroundWorker.Interval; // Process about once per hour
private int _tickCount = -1;
namespace BirthdayBot.BackgroundServices;
public DataRetention(ShardInstance instance) : base(instance) { }
/// <summary>
/// Automatically removes database information for guilds that have not been accessed in a long time.
/// </summary>
class DataRetention : BackgroundService {
private static readonly SemaphoreSlim _updateLock = new(ShardManager.MaxConcurrentOperations);
const int ProcessInterval = 3600 / ShardBackgroundWorker.Interval; // Process about once per hour
const int Stagger = 3; // How many ticks in between each group of guilds to stagger processing.
public override async Task OnTick(CancellationToken token)
{
if ((++_tickCount + ShardInstance.ShardId * 3) % ProcessInterval != 0)
{
// Do not process on every tick.
// Stagger processing based on shard ID, to not choke the background processing task.
return;
public DataRetention(ShardInstance instance) : base(instance) { }
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 * Stagger) % ProcessInterval != 0) return;
try {
// 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
// ultimately is a low priority among other tasks.
await _updateLock.WaitAsync(token).ConfigureAwait(false);
} catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) {
// Caller does not expect the exception that SemaphoreSlim throws...
throw new TaskCanceledException();
}
try {
// Build a list of all values to update
var updateList = new Dictionary<ulong, List<ulong>>();
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
var userList = GuildUserConfiguration.LoadAllAsync(g.Id);
var guildUserIds = from gu in g.Users select gu.Id;
var savedUserIds = from cu in await userList.ConfigureAwait(false) select cu.UserId;
var existingCachedIds = savedUserIds.Intersect(guildUserIds);
updateList[g.Id] = existingCachedIds.ToList();
}
try
{
// 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
// ultimately is a low priority among other tasks.
await _updateLock.WaitAsync(token).ConfigureAwait(false);
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
// Statement for updating last_seen in guilds
var cUpdateGuild = db.CreateCommand();
cUpdateGuild.CommandText = $"update {GuildConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid";
var pUpdateG = cUpdateGuild.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
cUpdateGuild.Prepare();
// Statement for updating last_seen in guild users
var cUpdateGuildUser = db.CreateCommand();
cUpdateGuildUser.CommandText = $"update {GuildUserConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid and user_id = @Uid";
var pUpdateGU_g = cUpdateGuildUser.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var pUpdateGU_u = cUpdateGuildUser.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
cUpdateGuildUser.Prepare();
// Do actual updates
int updatedGuilds = 0;
int updatedUsers = 0;
foreach (var item in updateList) {
var guild = item.Key;
var userlist = item.Value;
pUpdateG.Value = (long)guild;
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist) {
pUpdateGU_u.Value = (long)userid;
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
{
// Calling thread does not expect the exception that SemaphoreSlim throws...
throw new TaskCanceledException();
var resultText = new StringBuilder();
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
// Delete all old values - expects referencing tables to have 'on delete cascade'
using var t = db.BeginTransaction();
int staleGuilds, staleUsers;
using (var c = db.CreateCommand()) {
// Delete data for guilds not seen in 4 weeks
c.CommandText = $"delete from {GuildConfiguration.BackingTable} where (now() - interval '28 days') > last_seen";
staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
try
{
// Build a list of all values to update
var updateList = new Dictionary<ulong, List<ulong>>();
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
var userList = GuildUserConfiguration.LoadAllAsync(g.Id);
var guildUserIds = from gu in g.Users select gu.Id;
var savedUserIds = from cu in await userList.ConfigureAwait(false) select cu.UserId;
var existingCachedIds = savedUserIds.Intersect(guildUserIds);
updateList[g.Id] = existingCachedIds.ToList();
}
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
// Statement for updating last_seen in guilds
var cUpdateGuild = db.CreateCommand();
cUpdateGuild.CommandText = $"update {GuildConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid";
var pUpdateG = cUpdateGuild.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
cUpdateGuild.Prepare();
// Statement for updating last_seen in guild users
var cUpdateGuildUser = db.CreateCommand();
cUpdateGuildUser.CommandText = $"update {GuildUserConfiguration.BackingTable} set last_seen = now() "
+ "where guild_id = @Gid and user_id = @Uid";
var pUpdateGU_g = cUpdateGuildUser.Parameters.Add("@Gid", NpgsqlDbType.Bigint);
var pUpdateGU_u = cUpdateGuildUser.Parameters.Add("@Uid", NpgsqlDbType.Bigint);
cUpdateGuildUser.Prepare();
// Do actual updates
int updatedGuilds = 0;
int updatedUsers = 0;
foreach (var item in updateList)
{
var guild = item.Key;
var userlist = item.Value;
pUpdateG.Value = (long)guild;
updatedGuilds += await cUpdateGuild.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
pUpdateGU_g.Value = (long)guild;
foreach (var userid in userlist)
{
pUpdateGU_u.Value = (long)userid;
updatedUsers += await cUpdateGuildUser.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
}
var resultText = new StringBuilder();
resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users.");
// Delete all old values - expects referencing tables to have 'on delete cascade'
using var t = db.BeginTransaction();
int staleGuilds, staleUsers;
using (var c = db.CreateCommand())
{
// Delete data for guilds not seen in 4 weeks
c.CommandText = $"delete from {GuildConfiguration.BackingTable} where (now() - interval '28 days') > last_seen";
staleGuilds = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
using (var c = db.CreateCommand())
{
// Delete data for users not seen in 8 weeks
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable} where (now() - interval '56 days') > last_seen";
staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
if (staleGuilds != 0 || staleUsers != 0)
{
resultText.Append(" Discarded ");
if (staleGuilds != 0)
{
resultText.Append($"{staleGuilds} guilds");
if (staleUsers != 0) resultText.Append(", ");
}
if (staleUsers != 0)
{
resultText.Append($"{staleUsers} standalone users");
}
resultText.Append('.');
}
t.Commit();
Log(resultText.ToString());
using (var c = db.CreateCommand()) {
// Delete data for users not seen in 8 weeks
c.CommandText = $"delete from {GuildUserConfiguration.BackingTable} where (now() - interval '56 days') > last_seen";
staleUsers = await c.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
}
finally
{
_updateLock.Release();
if (staleGuilds != 0 || staleUsers != 0) {
resultText.Append(" Discarded ");
if (staleGuilds != 0) {
resultText.Append($"{staleGuilds} guilds");
if (staleUsers != 0) resultText.Append(", ");
}
if (staleUsers != 0) {
resultText.Append($"{staleUsers} standalone users");
}
resultText.Append('.');
}
t.Commit();
Log(resultText.ToString());
} finally {
_updateLock.Release();
}
}
}

View file

@ -4,55 +4,47 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices
{
/// <summary>
/// Reports user count statistics to external services on a shard by shard basis.
/// </summary>
class ExternalStatisticsReporting : BackgroundService
{
const int ProcessInterval = 600 / ShardBackgroundWorker.Interval; // Process every ~5 minutes
private int _tickCount = 0;
namespace BirthdayBot.BackgroundServices;
private static readonly HttpClient _httpClient = new();
/// <summary>
/// Reports user count statistics to external services on a shard by shard basis.
/// </summary>
class ExternalStatisticsReporting : BackgroundService {
const int ProcessInterval = 1200 / ShardBackgroundWorker.Interval; // Process every ~20 minutes
const int ProcessOffset = 300 / ShardBackgroundWorker.Interval; // Begin processing 5 minutes after shard start
public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { }
private static readonly HttpClient _httpClient = new();
public override async Task OnTick(CancellationToken token)
{
if (++_tickCount % ProcessInterval != 0) return;
public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { }
var botId = ShardInstance.DiscordClient.CurrentUser.Id;
if (botId == 0) return;
public override async Task OnTick(int tickCount, CancellationToken token) {
if (tickCount < ProcessOffset) return;
if (tickCount % ProcessInterval != 0) return;
await SendDiscordBots(ShardInstance.DiscordClient.Guilds.Count, botId, token);
}
var botId = ShardInstance.DiscordClient.CurrentUser.Id;
if (botId == 0) return;
var count = ShardInstance.DiscordClient.Guilds.Count;
private async Task SendDiscordBots(int userCount, ulong botId, CancellationToken token)
{
var dbotsToken = ShardInstance.Config.DBotsToken;
if (dbotsToken != null)
{
try
{
const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats";
const string Body = "{{ \"guildCount\": {0}, \"shardCount\": {1}, \"shardId\": {2} }}";
var uri = new Uri(string.Format(dBotsApiUrl, botId));
var dbotsToken = ShardInstance.Config.DBotsToken;
if (dbotsToken != null) await SendDiscordBots(dbotsToken, count, botId, token);
}
var post = new HttpRequestMessage(HttpMethod.Post, uri);
post.Headers.Add("Authorization", dbotsToken);
post.Content = new StringContent(string.Format(Body,
userCount, ShardInstance.Config.ShardTotal, ShardInstance.ShardId),
Encoding.UTF8, "application/json");
private async Task SendDiscordBots(string apiToken, int userCount, ulong botId, CancellationToken token) {
try {
const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats";
const string Body = "{{ \"guildCount\": {0}, \"shardCount\": {1}, \"shardId\": {2} }}";
var uri = new Uri(string.Format(dBotsApiUrl, botId));
await _httpClient.SendAsync(post, token);
Log("Discord Bots: Update successful.");
}
catch (Exception ex)
{
Log("Discord Bots: Exception encountered during update: " + ex.Message);
}
}
var post = new HttpRequestMessage(HttpMethod.Post, uri);
post.Headers.Add("Authorization", apiToken);
post.Content = new StringContent(string.Format(Body,
userCount, ShardInstance.Config.ShardTotal, ShardInstance.ShardId),
Encoding.UTF8, "application/json");
await _httpClient.SendAsync(post, token);
Log("Discord Bots: Update successful.");
} catch (Exception ex) {
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 override async Task OnTick(CancellationToken token) {
public override async Task OnTick(int tickCount, CancellationToken token) {
IEnumerable<ulong> requests;
lock (_fetchRequests) {
requests = _fetchRequests.ToArray();
@ -26,10 +26,8 @@ class SelectiveAutoUserDownload : BackgroundService {
}
foreach (var guild in ShardInstance.DiscordClient.Guilds) {
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) {
Log("Client no longer connected. Stopping early.");
return;
}
// Has the potential to disconnect while in the middle of processing.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
// Determine if there is action to be taken...
if (guild.HasAllMembers) continue;

View file

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

View file

@ -6,107 +6,91 @@ using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
namespace BirthdayBot
{
/// <summary>
/// Loads and holds configuration values.
/// </summary>
class Configuration
{
public string BotToken { get; }
public string LogWebhook { get; }
public string DBotsToken { get; }
namespace BirthdayBot;
public const string ShardLenConfKey = "ShardRange";
public int ShardStart { get; }
public int ShardAmount { get; }
/// <summary>
/// Loads and holds configuration values.
/// </summary>
class Configuration {
public string BotToken { get; }
public string LogWebhook { get; }
public string? DBotsToken { get; }
public int ShardTotal { get; }
public const string ShardLenConfKey = "ShardRange";
public int ShardStart { get; }
public int ShardAmount { get; }
public bool QuitOnFails { get; }
public int ShardTotal { get; }
public Configuration()
{
// Looks for settings.json in the executable directory.
var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
confPath += Path.DirectorySeparatorChar + "settings.json";
public bool QuitOnFails { get; }
if (!File.Exists(confPath))
{
throw new Exception("Settings file not found."
+ " Create a file in the executable directory named 'settings.json'.");
}
public Configuration() {
// Looks for settings.json in the executable directory.
var confPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
confPath += Path.DirectorySeparatorChar + "settings.json";
var jc = JObject.Parse(File.ReadAllText(confPath));
BotToken = jc[nameof(BotToken)]?.Value<string>();
if (string.IsNullOrWhiteSpace(BotToken))
throw new Exception($"'{nameof(BotToken)}' must be specified.");
LogWebhook = jc[nameof(LogWebhook)]?.Value<string>();
if (string.IsNullOrWhiteSpace(LogWebhook))
throw new Exception($"'{nameof(LogWebhook)}' must be specified.");
var dbj = jc[nameof(DBotsToken)];
if (dbj != null)
{
DBotsToken = dbj.Value<string>();
}
else
{
DBotsToken = null;
}
var sqlhost = jc["SqlHost"]?.Value<string>() ?? "localhost"; // Default to localhost
var sqluser = jc["SqlUsername"]?.Value<string>();
var sqlpass = jc["SqlPassword"]?.Value<string>();
if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass))
throw new Exception("'SqlUsername', 'SqlPassword' must be specified.");
var csb = new NpgsqlConnectionStringBuilder()
{
Host = sqlhost,
Username = sqluser,
Password = sqlpass
};
var sqldb = jc["SqlDatabase"]?.Value<string>();
if (sqldb != null) csb.Database = sqldb; // Optional database setting
Database.DBConnectionString = csb.ToString();
int? sc = jc[nameof(ShardTotal)]?.Value<int>();
if (!sc.HasValue) ShardTotal = 1;
else
{
ShardTotal = sc.Value;
if (ShardTotal <= 0)
{
throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
}
}
string srVal = jc[ShardLenConfKey]?.Value<string>();
if (!string.IsNullOrWhiteSpace(srVal))
{
Regex srPicker = new(@"(?<low>\d{1,2})[-,]{1}(?<high>\d{1,2})");
var m = srPicker.Match(srVal);
if (m.Success)
{
ShardStart = int.Parse(m.Groups["low"].Value);
int high = int.Parse(m.Groups["high"].Value);
ShardAmount = high - (ShardStart - 1);
}
else
{
throw new Exception($"Shard range not properly formatted in '{ShardLenConfKey}'.");
}
}
else
{
// Default: this instance handles all shards from ShardTotal
ShardStart = 0;
ShardAmount = ShardTotal;
}
QuitOnFails = jc[nameof(QuitOnFails)]?.Value<bool>() ?? false;
if (!File.Exists(confPath)) {
throw new Exception("Settings file not found."
+ " Create a file in the executable directory named 'settings.json'.");
}
var jc = JObject.Parse(File.ReadAllText(confPath));
BotToken = jc[nameof(BotToken)]?.Value<string>();
if (string.IsNullOrWhiteSpace(BotToken))
throw new Exception($"'{nameof(BotToken)}' must be specified.");
LogWebhook = jc[nameof(LogWebhook)]?.Value<string>();
if (string.IsNullOrWhiteSpace(LogWebhook))
throw new Exception($"'{nameof(LogWebhook)}' must be specified.");
var dbj = jc[nameof(DBotsToken)];
if (dbj != null) {
DBotsToken = dbj.Value<string>();
} else {
DBotsToken = null;
}
var sqlhost = jc["SqlHost"]?.Value<string>() ?? "localhost"; // Default to localhost
var sqluser = jc["SqlUsername"]?.Value<string>();
var sqlpass = jc["SqlPassword"]?.Value<string>();
if (string.IsNullOrWhiteSpace(sqluser) || string.IsNullOrWhiteSpace(sqlpass))
throw new Exception("'SqlUsername', 'SqlPassword' must be specified.");
var csb = new NpgsqlConnectionStringBuilder() {
Host = sqlhost,
Username = sqluser,
Password = sqlpass
};
var sqldb = jc["SqlDatabase"]?.Value<string>();
if (sqldb != null) csb.Database = sqldb; // Optional database setting
Database.DBConnectionString = csb.ToString();
int? sc = jc[nameof(ShardTotal)]?.Value<int>();
if (!sc.HasValue) ShardTotal = 1;
else {
ShardTotal = sc.Value;
if (ShardTotal <= 0) {
throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer.");
}
}
string srVal = jc[ShardLenConfKey]?.Value<string>();
if (!string.IsNullOrWhiteSpace(srVal)) {
Regex srPicker = new(@"(?<low>\d{1,2})[-,]{1}(?<high>\d{1,2})");
var m = srPicker.Match(srVal);
if (m.Success) {
ShardStart = int.Parse(m.Groups["low"].Value);
int high = int.Parse(m.Groups["high"].Value);
ShardAmount = high - (ShardStart - 1);
} else {
throw new Exception($"Shard range not properly formatted in '{ShardLenConfKey}'.");
}
} else {
// Default: this instance handles all shards from ShardTotal
ShardStart = 0;
ShardAmount = ShardTotal;
}
QuitOnFails = jc[nameof(QuitOnFails)]?.Value<bool>() ?? false;
}
}

View file

@ -30,11 +30,6 @@ namespace BirthdayBot
/// </summary>
public string CurrentExecutingService => _background.CurrentExecutingService;
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>
/// 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 = new ShardBackgroundWorker(this);
DiscordClient.Disconnected += Client_Disconnected;
}
/// <summary>
@ -69,13 +63,10 @@ namespace BirthdayBot
/// </summary>
public void Dispose()
{
Log("Instance", "Cleaning up...");
// Unsubscribe from own events
DiscordClient.Log -= Client_Log;
DiscordClient.Ready -= Client_Ready;
DiscordClient.MessageReceived -= Client_MessageReceived;
DiscordClient.Disconnected -= Client_Disconnected;
_background.Dispose();
try
@ -98,9 +89,8 @@ namespace BirthdayBot
}
var clientDispose = Task.Run(DiscordClient.Dispose);
if (!clientDispose.Wait(10000))
Log("Instance", "Warning: Client hanging on dispose.");
Log("Instance", "Shard instance disposed.");
if (!clientDispose.Wait(10000)) Log("Instance", "Warning: Client is hanging on dispose. Will continue.");
else Log("Instance", "Shard instance disposed.");
}
public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message);
@ -130,6 +120,7 @@ namespace BirthdayBot
case "Disconnecting":
case "Disconnected":
case "WebSocket connection was closed":
case "Server requested a reconnect":
return Task.CompletedTask;
}
if (arg.Message == "Heartbeat Errored")
@ -152,15 +143,6 @@ namespace BirthdayBot
/// </summary>
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>
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
/// </summary>

View file

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