2020-04-02 18:27:55 +00:00
|
|
|
|
using BirthdayBot.Data;
|
|
|
|
|
using Discord.WebSocket;
|
|
|
|
|
using NodaTime;
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Text;
|
2020-10-05 04:40:38 +00:00
|
|
|
|
using System.Threading;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
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
|
|
|
|
|
{
|
2020-10-05 04:40:38 +00:00
|
|
|
|
public BirthdayRoleUpdate(ShardInstance instance) : base(instance) { }
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
/// <summary>
|
2020-10-05 04:40:38 +00:00
|
|
|
|
/// Processes birthday updates for all available guilds synchronously
|
2020-10-09 04:46:36 +00:00
|
|
|
|
/// (to avoid database connection pool bottlenecks and rate limiting).
|
2020-04-02 18:27:55 +00:00
|
|
|
|
/// </summary>
|
2020-10-05 04:40:38 +00:00
|
|
|
|
public override async Task OnTick(CancellationToken token)
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
2020-10-05 04:40:38 +00:00
|
|
|
|
var exs = new List<Exception>();
|
|
|
|
|
foreach (var guild in ShardInstance.DiscordClient.Guilds)
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
2020-10-09 04:46:36 +00:00
|
|
|
|
// Single guilds are fully processed and are not interrupted by task cancellation.
|
2020-10-05 04:40:38 +00:00
|
|
|
|
if (token.IsCancellationRequested) throw new TaskCanceledException();
|
|
|
|
|
try
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
2020-10-05 04:40:38 +00:00
|
|
|
|
await ProcessGuildAsync(guild);
|
2020-04-04 03:58:02 +00:00
|
|
|
|
}
|
2020-10-05 04:40:38 +00:00
|
|
|
|
catch (Exception ex)
|
2020-04-04 03:58:02 +00:00
|
|
|
|
{
|
2020-10-05 04:40:38 +00:00
|
|
|
|
// Catch all exceptions per-guild but continue processing, throw at end
|
|
|
|
|
exs.Add(ex);
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
2020-10-05 04:40:38 +00:00
|
|
|
|
if (exs.Count != 0) throw new AggregateException(exs);
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
// TODO metrics for role sets, unsets, announcements - and how to do that for singles too?
|
|
|
|
|
}
|
|
|
|
|
|
2020-07-14 18:59:14 +00:00
|
|
|
|
/// <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();
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Main method where actual guild processing occurs.
|
|
|
|
|
/// </summary>
|
2020-07-14 18:59:14 +00:00
|
|
|
|
private async Task<PGDiagnostic> ProcessGuildAsync(SocketGuild guild)
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
var diag = new PGDiagnostic();
|
|
|
|
|
|
2020-07-23 02:43:45 +00:00
|
|
|
|
// 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;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
2020-04-04 04:19:29 +00:00
|
|
|
|
// Check if role settings are correct before continuing with further processing
|
|
|
|
|
SocketRole role = null;
|
2020-07-16 21:48:09 +00:00
|
|
|
|
if (gc.RoleId.HasValue) role = guild.GetRole(gc.RoleId.Value);
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.RoleCheck = CheckCorrectRoleSettings(guild, role);
|
|
|
|
|
if (diag.RoleCheck != null) return diag;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
2020-04-04 04:19:29 +00:00
|
|
|
|
// Determine who's currently having a birthday
|
2020-10-09 04:46:36 +00:00
|
|
|
|
// Note: This is where we'd call DownloadUsersAsync, but this method is capable of blocking indefinitely
|
|
|
|
|
// and making the task completely unresponsive. Must investigate further before calling it here and disabling
|
|
|
|
|
// AlwaysDownloadUsers in client settings.
|
2020-07-16 21:48:09 +00:00
|
|
|
|
var users = await GuildUserConfiguration.LoadAllAsync(guild.Id);
|
|
|
|
|
var tz = gc.TimeZone;
|
2020-04-04 04:19:29 +00:00
|
|
|
|
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.
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.CurrentBirthdays = birthdays.Count.ToString();
|
2020-04-04 04:19:29 +00:00
|
|
|
|
|
2020-04-02 18:27:55 +00:00
|
|
|
|
IEnumerable<SocketGuildUser> announcementList;
|
2020-04-04 04:19:29 +00:00
|
|
|
|
// Update roles as appropriate
|
2020-04-02 18:27:55 +00:00
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays);
|
|
|
|
|
announcementList = updateResult.Item1;
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.RoleApplyResult = updateResult.Item2; // statistics
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
catch (Discord.Net.HttpException ex)
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.RoleApply = ex.Message;
|
|
|
|
|
return diag;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.RoleApply = null;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
2020-04-04 04:19:29 +00:00
|
|
|
|
// Birthday announcement
|
2020-07-16 21:48:09 +00:00
|
|
|
|
var announce = gc.AnnounceMessages;
|
|
|
|
|
var announceping = gc.AnnouncePing;
|
2020-04-04 04:19:29 +00:00
|
|
|
|
SocketTextChannel channel = null;
|
2020-07-16 21:48:09 +00:00
|
|
|
|
if (gc.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gc.AnnounceChannelId.Value);
|
2020-04-02 18:27:55 +00:00
|
|
|
|
if (announcementList.Count() != 0)
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
var announceResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList);
|
|
|
|
|
diag.Announcement = announceResult;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
diag.Announcement = "No new role additions. Announcement not needed.";
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-14 18:59:14 +00:00
|
|
|
|
return diag;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Checks if the bot may be allowed to alter roles.
|
|
|
|
|
/// </summary>
|
2020-07-14 18:59:14 +00:00
|
|
|
|
private string CheckCorrectRoleSettings(SocketGuild guild, SocketRole role)
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
if (role == null) return "Designated role is not set, or target role cannot be found.";
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
if (!guild.CurrentUser.GuildPermissions.ManageRoles)
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
return "Bot does not have the 'Manage Roles' permission.";
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check potential role order conflict
|
|
|
|
|
if (role.Position >= guild.CurrentUser.Hierarchy)
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
return "Bot is unable to access the designated role due to permission hierarchy.";
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
2020-07-14 18:59:14 +00:00
|
|
|
|
return null;
|
2020-04-02 18:27:55 +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>
|
2020-07-16 21:48:09 +00:00
|
|
|
|
private HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string defaultTzStr)
|
2020-04-02 18:27:55 +00:00
|
|
|
|
{
|
|
|
|
|
var birthdayUsers = new HashSet<ulong>();
|
|
|
|
|
|
|
|
|
|
DateTimeZone defaultTz = null;
|
2020-04-04 04:19:29 +00:00
|
|
|
|
if (defaultTzStr != null) defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr);
|
|
|
|
|
defaultTz ??= DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC");
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2020-04-04 04:19:29 +00:00
|
|
|
|
tz ??= defaultTz;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
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>
|
2020-04-04 04:19:29 +00:00
|
|
|
|
/// <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>
|
2020-04-02 18:27:55 +00:00
|
|
|
|
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)
|
|
|
|
|
{
|
2020-04-04 04:19:29 +00:00
|
|
|
|
if (!names.Contains(member.Id)) roleRemoves.Add(member);
|
|
|
|
|
else roleKeeps.Add(member.Id);
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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>
|
2020-04-04 04:19:29 +00:00
|
|
|
|
/// <returns>The message to place into operation status log.</returns>
|
2020-04-02 18:27:55 +00:00
|
|
|
|
private async Task<string> AnnounceBirthdaysAsync(
|
|
|
|
|
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names)
|
|
|
|
|
{
|
2020-07-14 18:59:14 +00:00
|
|
|
|
if (c == null) return "Announcement channel is not set, or previous announcement channel has been deleted.";
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
string announceMsg;
|
2020-04-04 04:19:29 +00:00
|
|
|
|
if (names.Count() == 1) announceMsg = announce.Item1 ?? announce.Item2 ?? DefaultAnnounce;
|
|
|
|
|
else announceMsg = announce.Item2 ?? announce.Item1 ?? DefaultAnnouncePl;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
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)
|
|
|
|
|
{
|
2020-05-22 04:18:15 +00:00
|
|
|
|
namedisplay.Append(", ");
|
2020-04-02 18:27:55 +00:00
|
|
|
|
namedisplay.Append(item);
|
|
|
|
|
}
|
2020-05-22 04:18:15 +00:00
|
|
|
|
namedisplay.Remove(0, 2); // Remove initial comma and space
|
2020-04-02 18:27:55 +00:00
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString()));
|
2020-07-14 18:59:14 +00:00
|
|
|
|
return null;
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
catch (Discord.Net.HttpException ex)
|
|
|
|
|
{
|
2020-04-04 04:19:29 +00:00
|
|
|
|
// Directly use the resulting exception message in the operation status log
|
2020-04-02 18:27:55 +00:00
|
|
|
|
return ex.Message;
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-07-14 18:59:14 +00:00
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
2020-04-02 18:27:55 +00:00
|
|
|
|
}
|
|
|
|
|
}
|