mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-10-16 15:59:58 +00:00
Noi
ddcde10e09
All existing VB code was 'translated' to C# as closely as possible, with minor changes and additional notes. Currently untested and likely broken. Further commits will go toward making overall improvements until this version replaces the currently existing code.
328 lines
12 KiB
C#
328 lines
12 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>();
|
|
foreach (var guild in BotInstance.DiscordClient.Guilds)
|
|
{
|
|
var t = ProcessGuildAsync(guild);
|
|
tasks.Add(t);
|
|
}
|
|
|
|
try
|
|
{
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// TODO does this not actually work as might be expected?
|
|
var exs = from task in tasks
|
|
where task.Exception != null
|
|
select task.Exception;
|
|
Log($"Encountered {exs.Count()} errors during bulk guild processing.");
|
|
foreach (var iex in exs)
|
|
{
|
|
// TODO probably not a good idea
|
|
Log(iex.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();
|
|
}
|
|
|
|
public async Task SingleUpdateFor(SocketGuild guild)
|
|
{
|
|
try
|
|
{
|
|
await ProcessGuildAsync(guild);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log("Encountered an error during guild processing:");
|
|
Log(ex.ToString());
|
|
}
|
|
|
|
// TODO metrics for role sets, unsets, announcements - and I mentioned this above too
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main method where actual guild processing occurs.
|
|
/// </summary>
|
|
private async Task ProcessGuildAsync(SocketGuild guild)
|
|
{
|
|
// Gather required information
|
|
string tz;
|
|
IEnumerable<GuildUserSettings> users;
|
|
SocketRole role = null;
|
|
SocketTextChannel channel = null;
|
|
(string, string) announce;
|
|
bool announceping;
|
|
|
|
// Skip processing of guild if local info has not yet been loaded
|
|
if (!BotInstance.GuildCache.ContainsKey(guild.Id)) return;
|
|
|
|
// Lock once to grab all info
|
|
var gs = BotInstance.GuildCache[guild.Id];
|
|
tz = gs.TimeZone;
|
|
users = gs.Users;
|
|
announce = gs.AnnounceMessages;
|
|
announceping = gs.AnnouncePing;
|
|
|
|
if (gs.AnnounceChannelId.HasValue) channel = guild.GetTextChannel(gs.AnnounceChannelId.Value);
|
|
if (gs.RoleId.HasValue) role = guild.GetRole(gs.RoleId.Value);
|
|
|
|
// Determine who's currently having a birthday
|
|
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.
|
|
|
|
// Set birthday roles, get list of users that had the role added
|
|
// But first check if we are able to do so. Letting all requests fail instead will lead to rate limiting.
|
|
var roleCheck = CheckCorrectRoleSettings(guild, role);
|
|
if (!roleCheck.Item1)
|
|
{
|
|
lock (gs)
|
|
{
|
|
gs.OperationLog = new OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, roleCheck.Item2));
|
|
}
|
|
return;
|
|
}
|
|
|
|
IEnumerable<SocketGuildUser> announcementList;
|
|
(int, int) roleResult; // role additions, removals
|
|
// Do actual role updating
|
|
try
|
|
{
|
|
var updateResult = await UpdateGuildBirthdayRoles(guild, role, birthdays);
|
|
announcementList = updateResult.Item1;
|
|
roleResult = updateResult.Item2;
|
|
}
|
|
catch (Discord.Net.HttpException ex)
|
|
{
|
|
lock (gs)
|
|
{
|
|
gs.OperationLog = new OperationStatus((OperationStatus.OperationType.UpdateBirthdayRoleMembership, ex.Message));
|
|
}
|
|
if (ex.HttpCode != System.Net.HttpStatusCode.Forbidden)
|
|
{
|
|
// Send unusual exceptions to calling method
|
|
throw;
|
|
}
|
|
return;
|
|
}
|
|
|
|
(OperationStatus.OperationType, string) opResult1, opResult2;
|
|
opResult1 = (OperationStatus.OperationType.UpdateBirthdayRoleMembership,
|
|
$"Success: Added {roleResult.Item1} member(s), Removed {roleResult.Item2} member(s) from target role.");
|
|
|
|
if (announcementList.Count() != 0)
|
|
{
|
|
var announceOpResult = await AnnounceBirthdaysAsync(announce, announceping, channel, announcementList);
|
|
opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, announceOpResult);
|
|
}
|
|
else
|
|
{
|
|
opResult2 = (OperationStatus.OperationType.SendBirthdayAnnouncementMessage, "Announcement not considered.");
|
|
}
|
|
|
|
lock (gs)
|
|
{
|
|
gs.OperationLog = new OperationStatus(opResult1, opResult2);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the bot may be allowed to alter roles.
|
|
/// </summary>
|
|
private (bool, string) CheckCorrectRoleSettings(SocketGuild guild, SocketRole role)
|
|
{
|
|
if (role == null)
|
|
{
|
|
return (false, "Failed: Designated role not found or defined.");
|
|
}
|
|
|
|
if (!guild.CurrentUser.GuildPermissions.ManageRoles)
|
|
{
|
|
return (false, "Failed: Bot does not contain Manage Roles permission.");
|
|
}
|
|
|
|
// Check potential role order conflict
|
|
if (role.Position >= guild.CurrentUser.Hierarchy)
|
|
{
|
|
return (false, "Failed: Bot is beneath the designated role in the role hierarchy.");
|
|
}
|
|
|
|
return (true, 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<GuildUserSettings> guildUsers, string defaultTzStr)
|
|
{
|
|
var birthdayUsers = new HashSet<ulong>();
|
|
|
|
DateTimeZone defaultTz = null;
|
|
if (defaultTzStr != null)
|
|
{
|
|
defaultTz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr);
|
|
}
|
|
defaultTz = defaultTz ?? DateTimeZoneProviders.Tzdb.GetZoneOrNull("UTC");
|
|
// TODO determine defaultTz from guild's voice region
|
|
|
|
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 = 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>A list of users who had the birthday role applied. Use for the announcement message.</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>();
|
|
var q = 0;
|
|
foreach (var member in r.Members)
|
|
{
|
|
if (!names.Contains(member.Id))
|
|
{
|
|
roleRemoves.Add(member);
|
|
}
|
|
else
|
|
{
|
|
roleKeeps.Add(member.Id);
|
|
}
|
|
q += 1;
|
|
}
|
|
|
|
// 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>
|
|
private async Task<string> AnnounceBirthdaysAsync(
|
|
(string, string) announce, bool announcePing, SocketTextChannel c, IEnumerable<SocketGuildUser> names)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "Announcement channel is undefined.";
|
|
}
|
|
|
|
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();
|
|
var first = true;
|
|
foreach (var item in namestrings)
|
|
{
|
|
if (!first)
|
|
{
|
|
namedisplay.Append(", ");
|
|
first = false;
|
|
}
|
|
namedisplay.Append(item);
|
|
}
|
|
|
|
try
|
|
{
|
|
await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString()));
|
|
return $"Successfully announced {names.Count()} name(s)";
|
|
}
|
|
catch (Discord.Net.HttpException ex)
|
|
{
|
|
return ex.Message;
|
|
}
|
|
}
|
|
}
|
|
}
|