using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices;
///
/// Core automatic functionality of the bot. Manages role memberships based on birthday information,
/// and optionally sends the announcement message to appropriate guilds.
///
class BirthdayRoleUpdate : BackgroundService {
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
///
/// Processes birthday updates for all available guilds synchronously.
///
public override async Task OnTick(int tickCount, CancellationToken token) {
var exs = new List();
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);
}
///
/// Access to for the testing command.
///
/// Diagnostic data in string form.
public static async Task SingleProcessGuildAsync(SocketGuild guild)
=> (await ProcessGuildAsync(guild).ConfigureAwait(false)).Export();
///
/// Main method where actual guild processing occurs.
///
private static async Task ProcessGuildAsync(SocketGuild guild) {
var diag = new PGDiagnostic();
// 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;
// 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 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.";
}
return diag;
}
///
/// Checks if the bot may be allowed to alter roles.
///
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;
}
///
/// Gets all known users from the given guild and returns a list including only those who are
/// currently experiencing a birthday in the respective time zone.
///
private static HashSet GetGuildCurrentBirthdays(IEnumerable guildUsers, string defaultTzStr) {
var birthdayUsers = new HashSet();
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;
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;
}
///
/// Sets the birthday role to all applicable users. Unsets it from all others who may have it.
///
///
/// 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.
///
private static async Task<(IEnumerable, (int, int))> UpdateGuildBirthdayRoles(
SocketGuild g, SocketRole r, HashSet names) {
// Check members currently with the role. Figure out which users to remove it from.
var roleRemoves = new List();
var roleKeeps = new HashSet();
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();
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";
///
/// Makes (or attempts to make) an announcement in the specified channel that includes all users
/// who have just had their birthday role added.
///
/// The message to place into operation status log.
private static async Task AnnounceBirthdaysAsync(
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable 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();
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();
}
}
}