BirthdayBot/BackgroundServices/BirthdayRoleUpdate.cs
Noi 657181ec3e Less async birthday updates; download users on connect
From testing, it appears DownloadUsersAsync hangs forever and has other
consequences if its corresponding server is not fully connected, or is
struggling to connect. This is despite all attempts to mitigate it and
detect any disconnections shortly before attempting it.
Additionally, birthday updates are now run asynchronously per shard, but
synchronously per guild in an attempt to keep rare instances of rate
limiting under control.
2020-07-27 22:30:15 -07:00

329 lines
13 KiB
C#

using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
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(BirthdayBot instance) : base(instance) { }
/// <summary>
/// Does processing on all available guilds at once.
/// </summary>
public override async Task OnTick()
{
var tasks = new List<Task>();
// Work on each shard concurrently; guilds within each shard synchronously
foreach (var shard in BotInstance.DiscordClient.Shards)
{
tasks.Add(ProcessShardAsync(shard));
}
var alltasks = Task.WhenAll(tasks);
try
{
await alltasks;
}
catch (Exception ex)
{
var exs = alltasks.Exception;
if (exs != null)
{
Log($"{exs.InnerExceptions.Count} exception(s) during bulk processing!");
// TODO needs major improvements. output to file?
foreach (var iex in exs.InnerExceptions) Log(iex.Message);
}
else
{
Log(ex.ToString());
}
}
// TODO metrics for role sets, unsets, announcements - and how to do that for singles too?
// Running GC now. Many long-lasting items have likely been discarded by now.
GC.Collect();
}
/// <summary>
/// Access to <see cref="ProcessGuildAsync(SocketGuild)"/> for the testing command.
/// </summary>
/// <returns>Diagnostic data in string form.</returns>
public async Task<string> SingleProcessGuildAsync(SocketGuild guild) => (await ProcessGuildAsync(guild)).Export();
/// <summary>
/// Called by <see cref="OnTick"/>, processes all guilds within a shard synchronously.
/// </summary>
private async Task ProcessShardAsync(DiscordSocketClient shard)
{
if (shard.ConnectionState != Discord.ConnectionState.Connected)
{
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing stopped - shard disconnected.");
return;
}
var exs = new List<Exception>();
foreach (var guild in shard.Guilds)
{
try
{
// Check if shard remains available
if (shard.ConnectionState != Discord.ConnectionState.Connected)
{
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing interrupted.");
return;
}
await ProcessGuildAsync(guild);
}
catch (Exception ex)
{
// Catch all exceptions per-guild but continue processing, throw at end
exs.Add(ex);
}
}
Log($"Shard {shard.ShardId} (with {shard.Guilds.Count} guilds) processing completed.");
if (exs.Count != 0) throw new AggregateException(exs);
}
/// <summary>
/// Main method where actual guild processing occurs.
/// </summary>
private 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);
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
//await guild.DownloadUsersAsync();
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id);
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);
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.Count() != 0)
{
var announceResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList);
diag.Announcement = announceResult;
}
else
{
diag.Announcement = "No new role additions. Announcement not needed.";
}
return diag;
}
/// <summary>
/// Checks if the bot may be allowed to alter roles.
/// </summary>
private string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role)
{
if (role == null) return "Designated role is not set, or target role cannot be found.";
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 "Bot is unable to access the designated role due to permission hierarchy.";
}
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 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;
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 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);
}
// 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);
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 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()));
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();
}
}
}
}