BirthdayBot/BackgroundServices/BirthdayRoleUpdate.cs

171 lines
8.2 KiB
C#
Raw Normal View History

using BirthdayBot.Data;
using NodaTime;
using System.Text;
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(ShardInstance instance) : BackgroundService(instance) {
/// <summary>
/// Processes birthday updates for all available guilds synchronously.
/// </summary>
public override async Task OnTick(int tickCount, CancellationToken token) {
try {
2023-09-04 22:37:52 +00:00
await ConcurrentSemaphore.WaitAsync(token).ConfigureAwait(false);
await ProcessBirthdaysAsync(token).ConfigureAwait(false);
} finally {
try {
ConcurrentSemaphore.Release();
} catch (ObjectDisposedException) { }
}
}
private async Task ProcessBirthdaysAsync(CancellationToken token) {
2023-09-04 22:37:52 +00:00
// For database efficiency, fetch all pertinent 'global' database information at once before proceeding
2022-03-21 19:11:30 +00:00
using var db = new BotDatabaseContext();
var shardGuilds = Shard.DiscordClient.Guilds.Select(g => g.Id).ToHashSet();
var presentGuildSettings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId));
2022-11-23 07:19:37 +00:00
var guildChecks = presentGuildSettings.ToList().Select(s => Tuple.Create(s.GuildId, s));
2022-03-21 19:11:30 +00:00
var exceptions = new List<Exception>();
foreach (var (guildId, settings) in guildChecks) {
var guild = Shard.DiscordClient.GetGuild(guildId);
2022-03-21 19:11:30 +00:00
if (guild == null) continue; // A guild disappeared...?
// Check task cancellation here. Processing during a single guild is never interrupted.
if (token.IsCancellationRequested) throw new TaskCanceledException();
2023-09-04 22:37:52 +00:00
// Stop if we've disconnected.
if (Shard.DiscordClient.ConnectionState != ConnectionState.Connected) break;
try {
2022-03-21 19:11:30 +00:00
// Verify that role settings and permissions are usable
2023-09-04 22:37:52 +00:00
SocketRole? role = guild.GetRole(settings.BirthdayRole ?? 0);
if (role == null) continue; // Role not set.
if (!guild.CurrentUser.GuildPermissions.ManageRoles || role.Position >= guild.CurrentUser.Hierarchy) {
// Quit this guild if insufficient role permissions.
continue;
}
if (role.IsEveryone || role.IsManaged) {
// Invalid role was configured. Clear the setting and quit.
settings.BirthdayRole = null;
db.Update(settings);
2023-09-04 22:37:52 +00:00
await db.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
continue;
}
2022-03-21 19:11:30 +00:00
// Load up user configs and begin processing birthdays
2023-09-04 22:37:52 +00:00
await db.Entry(settings).Collection(t => t.UserEntries).LoadAsync(CancellationToken.None).ConfigureAwait(false);
var birthdays = GetGuildCurrentBirthdays(settings.UserEntries, settings.GuildTimeZone);
2022-03-21 19:11:30 +00:00
// Add or remove roles as appropriate
2023-09-04 22:37:52 +00:00
var announcementList = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false);
2022-03-21 19:11:30 +00:00
// Process birthday announcement
2022-03-21 19:11:30 +00:00
if (announcementList.Any()) {
2023-09-04 22:37:52 +00:00
await AnnounceBirthdaysAsync(settings, guild, announcementList).ConfigureAwait(false);
2022-03-21 19:11:30 +00:00
}
} catch (Exception ex) {
// Catch all exceptions per-guild but continue processing, throw at end.
2022-03-21 19:11:30 +00:00
exceptions.Add(ex);
}
}
if (exceptions.Count > 1) throw new AggregateException("Unhandled exceptions occurred when processing birthdays.", exceptions);
else if (exceptions.Count == 1) throw new Exception("An unhandled exception occurred when processing a birthday.", exceptions[0]);
}
2022-03-20 08:07:17 +00:00
/// <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>
public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<UserEntry> guildUsers, string? serverDefaultTzId) {
2022-03-20 08:07:17 +00:00
var birthdayUsers = new HashSet<ulong>();
foreach (var record in guildUsers) {
// Determine final time zone to use for calculation
DateTimeZone tz = DateTimeZoneProviders.Tzdb
.GetZoneOrNull(record.TimeZone ?? serverDefaultTzId ?? "UTC")!;
2022-03-20 08:07:17 +00:00
var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz);
// Special case: If user's birthday is 29-Feb and it's currently not a leap year, check against 1-Mar
if (!DateTime.IsLeapYear(checkNow.Year) && record.BirthMonth == 2 && record.BirthDay == 29) {
2023-09-04 22:37:52 +00:00
if (checkNow.Month == 3 && checkNow.Day == 1) birthdayUsers.Add(record.UserId);
2024-04-28 08:51:58 +00:00
} else if (record.BirthMonth == checkNow.Month && record.BirthDay == checkNow.Day) {
2023-09-04 22:37:52 +00:00
birthdayUsers.Add(record.UserId);
2022-03-20 08:07:17 +00:00
}
}
return birthdayUsers;
}
/// <summary>
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
/// </summary>
/// <returns>
2022-03-21 19:11:30 +00:00
/// List of users who had the birthday role applied, used to announce.
/// </returns>
private static async Task<IEnumerable<SocketGuildUser>> UpdateGuildBirthdayRoles(SocketGuild g, SocketRole r, HashSet<ulong> toApply) {
var additions = new List<SocketGuildUser>();
try {
var removals = new List<SocketGuildUser>();
var no_ops = new HashSet<ulong>();
// Scan role for members no longer needing it
foreach (var user in r.Members) {
if (!toApply.Contains(user.Id)) removals.Add(user);
else no_ops.Add(user.Id);
}
foreach (var user in removals) {
2023-09-04 22:37:52 +00:00
await user.RemoveRoleAsync(r).ConfigureAwait(false);
}
foreach (var target in toApply) {
if (no_ops.Contains(target)) continue;
var user = g.GetUser(target);
if (user == null) continue; // User existing in database but not in guild
2023-09-04 22:37:52 +00:00
await user.AddRoleAsync(r).ConfigureAwait(false);
additions.Add(user);
}
} catch (Discord.Net.HttpException ex)
when (ex.DiscordCode is DiscordErrorCode.MissingPermissions or DiscordErrorCode.InsufficientPermissions) {
2022-03-23 18:13:55 +00:00
// Encountered access and/or permission issues despite earlier checks. Quit the loop here, don't report error.
}
return additions;
}
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>
/// Attempts to send an announcement message.
/// </summary>
internal static async Task AnnounceBirthdaysAsync(GuildConfig settings, SocketGuild g, IEnumerable<SocketGuildUser> names) {
2023-09-04 22:37:52 +00:00
var c = g.GetTextChannel(settings.AnnouncementChannel ?? 0);
if (c == null) return;
if (!c.Guild.CurrentUser.GetPermissions(c).SendMessages) return;
string announceMsg;
2022-03-21 19:11:30 +00:00
if (names.Count() == 1) announceMsg = settings.AnnounceMessage ?? settings.AnnounceMessagePl ?? DefaultAnnounce;
else announceMsg = settings.AnnounceMessagePl ?? settings.AnnounceMessage ?? DefaultAnnouncePl;
announceMsg = announceMsg.TrimEnd();
if (!announceMsg.Contains("%n")) announceMsg += " %n";
// Build sorted name list
var namestrings = new List<string>();
foreach (var item in names)
2022-03-21 19:11:30 +00:00
namestrings.Add(Common.FormatName(item, settings.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
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false);
}
}