Rework member cache downloading

Fill it quicker when immediately needed, removing the need for a request list and all relating to it in the corresponding background service.

Additionally, updated usings, style, nullables in all affected files.
This commit is contained in:
Noi 2021-11-22 13:40:08 -08:00
parent 488ebfa163
commit 5e4d030467
8 changed files with 373 additions and 487 deletions

View file

@ -1,12 +1,6 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime; using NodaTime;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
@ -46,7 +40,7 @@ class BirthdayRoleUpdate : BackgroundService {
/// </summary> /// </summary>
private static async Task ProcessGuildAsync(SocketGuild guild) { private static async Task ProcessGuildAsync(SocketGuild guild) {
// Load guild information - stop if local cache is unavailable. // Load guild information - stop if local cache is unavailable.
if (!Common.HasMostMembersDownloaded(guild)) return; if (!guild.HasAllMembers) return;
var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); var gc = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
if (gc == null) return; if (gc == null) return;

View file

@ -1,40 +1,22 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace BirthdayBot.BackgroundServices; namespace BirthdayBot.BackgroundServices;
/// <summary> /// <summary>
/// Rather than use up unnecessary resources by auto-downloading the user list in -every- /// Proactively fills the user cache for guilds in which any birthday data already exists.
/// server we're in, this service checks if fetching the user list is warranted for each
/// guild before proceeding to request it.
/// </summary> /// </summary>
class SelectiveAutoUserDownload : BackgroundService { class SelectiveAutoUserDownload : BackgroundService {
private readonly HashSet<ulong> _fetchRequests = new();
public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { } public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { }
public override async Task OnTick(int tickCount, CancellationToken token) { public override async Task OnTick(int tickCount, CancellationToken token) {
IEnumerable<ulong> requests;
lock (_fetchRequests) {
requests = _fetchRequests.ToArray();
_fetchRequests.Clear();
}
foreach (var guild in ShardInstance.DiscordClient.Guilds) { foreach (var guild in ShardInstance.DiscordClient.Guilds) {
// Has the potential to disconnect while in the middle of processing. // Has the potential to disconnect while in the middle of processing.
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return; if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
// Determine if there is action to be taken... // Determine if there is action to be taken...
if (guild.HasAllMembers) continue; if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) {
if (requests.Contains(guild.Id) || await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) { await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run
await guild.DownloadUsersAsync().ConfigureAwait(false); await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
// Must delay after a download request. Seems to hang indefinitely otherwise.
await Task.Delay(300, CancellationToken.None).ConfigureAwait(false);
} }
} }
} }
@ -52,8 +34,4 @@ class SelectiveAutoUserDownload : BackgroundService {
using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false); using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false);
return r.Read(); return r.Read();
} }
public void RequestDownload(ulong guildId) {
lock (_fetchRequests) _fetchRequests.Add(guildId);
}
} }

View file

@ -5,7 +5,7 @@
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>3.2.2</Version> <Version>3.2.3</Version>
<Authors>NoiTheCat</Authors> <Authors>NoiTheCat</Authors>
</PropertyGroup> </PropertyGroup>

View file

@ -1,6 +1,4 @@
using Discord.WebSocket; using System.Text;
using System.Collections.Generic;
using System.Text;
namespace BirthdayBot; namespace BirthdayBot;
@ -33,21 +31,4 @@ static class Common {
{ 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" }, { 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" },
{ 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" } { 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" }
}; };
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/>.
/// Returns true if *most* members have been downloaded.
/// </summary>
public static bool HasMostMembersDownloaded(SocketGuild guild) {
if (guild.HasAllMembers) return true;
if (guild.MemberCount > 30) {
// For guilds of size over 30, require 85% or more of the members to be known
// (26/30, 42/50, 255/300, etc)
int threshold = (int)(guild.MemberCount * 0.85);
return guild.DownloadedMemberCount >= threshold;
} else {
// For smaller guilds, fail if two or more members are missing
return guild.MemberCount - guild.DownloadedMemberCount <= 2;
}
}
} }

View file

@ -80,8 +80,6 @@ class ShardInstance : IDisposable {
public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); public void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message);
public void RequestDownloadUsers(ulong guildId) => _background.UserDownloader.RequestDownload(guildId);
#region Event handling #region Event handling
private Task Client_Log(LogMessage arg) { private Task Client_Log(LogMessage arg) {
// TODO revise this some time, filters might need to be modified by now // TODO revise this some time, filters might need to be modified by now

View file

@ -1,99 +1,94 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using NodaTime; using NodaTime;
using System; using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface namespace BirthdayBot.UserInterface;
{
/// <summary> /// <summary>
/// Common base class for common constants and variables. /// Common base class for common constants and variables.
/// </summary> /// </summary>
internal abstract class CommandsCommon internal abstract class CommandsCommon {
{
#if DEBUG #if DEBUG
public const string CommandPrefix = "bt."; public const string CommandPrefix = "bt.";
#else #else
public const string CommandPrefix = "bb."; public const string CommandPrefix = "bb.";
#endif #endif
public const string BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID."; public const string BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID.";
public const string ParameterError = ":x: Invalid usage. Refer to how to use the command and try again."; public const string ParameterError = ":x: Invalid usage. Refer to how to use the command and try again.";
public const string NoParameterError = ":x: This command does not accept any parameters."; public const string NoParameterError = ":x: This command does not accept any parameters.";
public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue."; public const string InternalError = ":x: An internal bot error occurred. The bot maintainer has been notified of the issue.";
public const string UsersNotDownloadedError = ":eight_spoked_asterisk: Still catching up... Please try the command again in a few minutes."; public const string MemberCacheEmptyError = ":warning: Please try the command again.";
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf, public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser); string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
protected static Dictionary<string, string> TzNameMap { protected static ReadOnlyDictionary<string, string> TzNameMap { get; }
get { protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>");
// Because IDateTimeZoneProvider.GetZoneOrNull is not case sensitive: protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
// Getting every existing zone name and mapping it onto a dictionary. Now a case-insensitive
// search can be made with the accepted value retrieved as a result.
if (_tzNameMap == null)
{
_tzNameMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) _tzNameMap.Add(name, name);
}
return _tzNameMap;
}
}
protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>");
protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
private static Dictionary<string, string> _tzNameMap; // Value set by getter property on first read
protected Configuration BotConfig { get; } protected Configuration BotConfig { get; }
protected CommandsCommon(Configuration db) protected CommandsCommon(Configuration db) {
{ BotConfig = db;
BotConfig = db; }
static CommandsCommon() {
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name);
TzNameMap = new(dict);
}
/// <summary>
/// On command dispatcher initialization, it will retrieve all available commands through here.
/// </summary>
public abstract IEnumerable<(string, CommandHandler)> Commands { get; }
/// <summary>
/// Checks given time zone input. Returns a valid string for use with NodaTime.
/// </summary>
protected static string ParseTimeZone(string tzinput) {
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
if (!TzNameMap.TryGetValue(tzinput, out string? tz)) throw new FormatException(":x: Unexpected time zone name."
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value.");
return tz;
}
/// <summary>
/// Given user input where a user-like parameter is expected, attempts to resolve to an ID value.
/// Input must be a mention or explicit ID. No name resolution is done here.
/// </summary>
protected static bool TryGetUserId(string input, out ulong result) {
string doParse;
var m = UserMention.Match(input);
if (m.Success) doParse = m.Groups[1].Value;
else doParse = input;
if (ulong.TryParse(doParse, out ulong resultVal)) {
result = resultVal;
return true;
} }
/// <summary> result = default;
/// On command dispatcher initialization, it will retrieve all available commands through here. return false;
/// </summary> }
public abstract IEnumerable<(string, CommandHandler)> Commands { get; }
/// <summary> /// <summary>
/// Checks given time zone input. Returns a valid string for use with NodaTime. /// An alternative to <see cref="SocketGuild.HasAllMembers"/> to be called by command handlers needing a full member cache.
/// </summary> /// Creates a download request if necessary.
protected static string ParseTimeZone(string tzinput) /// </summary>
{ /// <returns>
string tz = null; /// True if the member cache is already filled, false otherwise.
if (tzinput != null) /// </returns>
{ /// <remarks>
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata"; /// Any updates to the member cache aren't accessible until the event handler finishes execution, meaning proactive downloading
/// is necessary, and is handled by <seealso cref="BackgroundServices.SelectiveAutoUserDownload"/>. In situations where
// Just check if the input exists in the map. Get the "true" value, or reject it altogether. /// this approach fails, this is to be called, and the user must be asked to attempt the command again if this returns false.
if (!TzNameMap.TryGetValue(tzinput, out tz)) /// </remarks>
{ protected static async Task<bool> HasMemberCacheAsync(SocketGuild guild) {
throw new FormatException(":x: Unexpected time zone name." if (guild.HasAllMembers) return true;
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value."); // Event handling thread hangs if awaited normally or used with Task.Run
} await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
} return false;
return tz;
}
/// <summary>
/// Given user input where a user-like parameter is expected, attempts to resolve to an ID value.
/// Input must be a mention or explicit ID. No name resolution is done here.
/// </summary>
protected static bool TryGetUserId(string input, out ulong result)
{
string doParse;
var m = UserMention.Match(input);
if (m.Success) doParse = m.Groups[1].Value;
else doParse = input;
if (ulong.TryParse(doParse, out ulong resultVal)) {
result = resultVal;
return true;
}
result = default;
return false;
}
} }
} }

View file

@ -1,367 +1,313 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface namespace BirthdayBot.UserInterface;
{
/// <summary>
/// Commands for listing upcoming and all birthdays.
/// </summary>
internal class ListingCommands : CommandsCommon
{
public ListingCommands(Configuration db) : base(db) { }
public override IEnumerable<(string, CommandHandler)> Commands /// <summary>
=> new List<(string, CommandHandler)>() /// Commands for listing upcoming and all birthdays.
{ /// </summary>
internal class ListingCommands : CommandsCommon {
public ListingCommands(Configuration db) : base(db) { }
public override IEnumerable<(string, CommandHandler)> Commands
=> new List<(string, CommandHandler)>()
{
("list", CmdList), ("list", CmdList),
("upcoming", CmdUpcoming), ("upcoming", CmdUpcoming),
("recent", CmdUpcoming), ("recent", CmdUpcoming),
("when", CmdWhen) ("when", CmdWhen)
}; };
#region Documentation #region Documentation
public static readonly CommandDocumentation DocList = public static readonly CommandDocumentation DocList =
new(new string[] { "list" }, "Exports all birthdays to a file." new(new string[] { "list" }, "Exports all birthdays to a file."
+ " Accepts `csv` as an optional parameter.", null); + " Accepts `csv` as an optional parameter.", null);
public static readonly CommandDocumentation DocUpcoming = public static readonly CommandDocumentation DocUpcoming =
new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null); new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null);
public static readonly CommandDocumentation DocWhen = public static readonly CommandDocumentation DocWhen =
new(new string[] { "when" }, "Displays the given user's birthday information.", null); new(new string[] { "when" }, "Displays the given user's birthday information.", null);
#endregion #endregion
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf, private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
{ if (!await HasMemberCacheAsync(reqChannel.Guild)) {
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) await reqChannel.SendMessageAsync(MemberCacheEmptyError);
{ return;
instance.RequestDownloadUsers(reqChannel.Guild.Id);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return;
}
// Requires a parameter
if (param.Length == 1)
{
await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return;
}
var search = param[1];
if (param.Length == 3)
{
// param maxes out at 3 values. param[2] might contain part of the search string (if name has a space)
search += " " + param[2];
}
SocketGuildUser searchTarget = null;
if (!TryGetUserId(search, out ulong searchId)) // ID lookup
{
// name lookup without discriminator
foreach (var searchuser in reqChannel.Guild.Users)
{
if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase))
{
searchTarget = searchuser;
break;
}
}
}
else
{
searchTarget = reqChannel.Guild.GetUser(searchId);
}
if (searchTarget == null)
{
await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return;
}
var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false);
if (!searchTargetData.IsKnown)
{
await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false);
return;
}
string result = Common.FormatName(searchTarget, false);
result += ": ";
result += $"`{searchTargetData.BirthDay:00}-{Common.MonthNames[searchTargetData.BirthMonth]}`";
result += searchTargetData.TimeZone == null ? "" : $" - `{searchTargetData.TimeZone}`";
await reqChannel.SendMessageAsync(result).ConfigureAwait(false);
} }
// Creates a file with all birthdays. // Requires a parameter
private async Task CmdList(ShardInstance instance, GuildConfiguration gconf, if (param.Length == 1) {
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
{ return;
// For now, we're restricting this command to moderators only. This may turn into an option later.
if (!gconf.IsBotModerator(reqUser))
{
// Do not add detailed usage information to this error message.
await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false);
return;
}
if (!Common.HasMostMembersDownloaded(reqChannel.Guild))
{
instance.RequestDownloadUsers(reqChannel.Guild.Id);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return;
}
bool useCsv = false;
// Check for CSV option
if (param.Length == 2)
{
if (param[1].ToLower() == "csv") useCsv = true;
else
{
await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed)
.ConfigureAwait(false);
return;
}
}
else if (param.Length > 2)
{
await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false);
return;
}
var bdlist = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id;
string fileoutput;
if (useCsv)
{
fileoutput = ListExportCsv(reqChannel, bdlist);
filepath += ".csv";
}
else
{
fileoutput = ListExportNormal(reqChannel, bdlist);
filepath += ".txt.";
}
await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false);
try
{
await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false);
}
catch (Discord.Net.HttpException)
{
reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
}
catch (Exception ex)
{
Program.Log("Listing", ex.ToString());
reqChannel.SendMessageAsync(InternalError).Wait();
// TODO webhook report
}
finally
{
File.Delete(filepath);
}
} }
// "Recent and upcoming birthdays" var search = param[1];
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here if (param.Length == 3) {
private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf, // param maxes out at 3 values. param[2] might contain part of the search string (if name has a space)
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) search += " " + param[2];
}
SocketGuildUser? searchTarget = null;
if (!TryGetUserId(search, out ulong searchId)) // ID lookup
{ {
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) // name lookup without discriminator
{ foreach (var searchuser in reqChannel.Guild.Users) {
instance.RequestDownloadUsers(reqChannel.Guild.Id); if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase)) {
await reqChannel.SendMessageAsync(UsersNotDownloadedError); searchTarget = searchuser;
return; break;
}
var now = DateTimeOffset.UtcNow;
var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC
if (search <= 0) search = 366 - Math.Abs(search);
var query = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
var output = new StringBuilder();
var resultCount = 0;
output.AppendLine("Recent and upcoming birthdays:");
for (int count = 0; count <= 21; count++) // cover 21 days total (7 prior, current day, 14 upcoming)
{
var results = from item in query
where item.DateIndex == search
select item;
// push up search by 1 now, in case we back out early
search += 1;
if (search > 366) search = 1; // wrap to beginning of year
if (!results.Any()) continue; // back out early
resultCount += results.Count();
// Build sorted name list
var names = new List<string>();
foreach (var item in results)
{
names.Add(item.DisplayName);
}
names.Sort(StringComparer.OrdinalIgnoreCase);
var first = true;
output.AppendLine();
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
foreach (var item in names)
{
// If the output is starting to fill up, send out this message and prepare a new one.
if (output.Length > 800)
{
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
output.Clear();
first = true;
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
}
if (first) first = false;
else output.Append(", ");
output.Append(item);
} }
} }
} else {
searchTarget = reqChannel.Guild.GetUser(searchId);
}
if (searchTarget == null) {
await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return;
}
if (resultCount == 0) var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false);
await reqChannel.SendMessageAsync( if (!searchTargetData.IsKnown) {
"There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).") await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false);
return;
}
string result = Common.FormatName(searchTarget, false);
result += ": ";
result += $"`{searchTargetData.BirthDay:00}-{Common.MonthNames[searchTargetData.BirthMonth]}`";
result += searchTargetData.TimeZone == null ? "" : $" - `{searchTargetData.TimeZone}`";
await reqChannel.SendMessageAsync(result).ConfigureAwait(false);
}
// Creates a file with all birthdays.
private async Task CmdList(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// For now, we're restricting this command to moderators only. This may turn into an option later.
if (!gconf.IsBotModerator(reqUser)) {
// Do not add detailed usage information to this error message.
await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false);
return;
}
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
bool useCsv = false;
// Check for CSV option
if (param.Length == 2) {
if (param[1].ToLower() == "csv") useCsv = true;
else {
await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed)
.ConfigureAwait(false); .ConfigureAwait(false);
else return;
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
}
/// <summary>
/// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result.
/// </summary>
private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild)
{
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable
+ " where guild_id = @Gid order by birth_month, birth_day";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
var result = new List<ListItem>();
while (await r.ReadAsync())
{
var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem()
{
BirthMonth = month,
BirthDay = day,
DateIndex = DateIndex(month, day),
UserId = guildUser.Id,
DisplayName = Common.FormatName(guildUser, false)
});
} }
return result; } else if (param.Length > 2) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false);
return;
} }
private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) var bdlist = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
{
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id;
var result = new StringBuilder(); string fileoutput;
result.AppendLine("Birthdays in " + channel.Guild.Name); if (useCsv) {
result.AppendLine(); fileoutput = ListExportCsv(reqChannel, bdlist);
foreach (var item in list) filepath += ".csv";
{ } else {
var user = channel.Guild.GetUser(item.UserId); fileoutput = ListExportNormal(reqChannel, bdlist);
if (user == null) continue; // User disappeared in the instant between getting list and processing filepath += ".txt.";
result.Append($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: ");
result.Append(item.UserId);
result.Append(" " + user.Username + "#" + user.Discriminator);
if (user.Nickname != null) result.Append(" - Nickname: " + user.Nickname);
result.AppendLine();
}
return result.ToString();
} }
await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false);
private string ListExportCsv(SocketGuildChannel channel, IEnumerable<ListItem> list) try {
{ await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false);
// Output: User ID, Username, Nickname, Month-Day, Month, Day } catch (Discord.Net.HttpException) {
var result = new StringBuilder(); reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
} catch (Exception ex) {
// Conforming to RFC 4180; with header Program.Log("Listing", ex.ToString());
result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day"); reqChannel.SendMessageAsync(InternalError).Wait();
result.Append("\r\n"); // crlf line break is specified by the standard // TODO webhook report
foreach (var item in list) } finally {
{ File.Delete(filepath);
var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append(item.UserId);
result.Append(',');
result.Append(CsvEscape(user.Username + "#" + user.Discriminator));
result.Append(',');
if (user.Nickname != null) result.Append(user.Nickname);
result.Append(',');
result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}");
result.Append(',');
result.Append(item.BirthMonth);
result.Append(',');
result.Append(item.BirthDay);
result.Append("\r\n");
}
return result.ToString();
}
private static string CsvEscape(string input)
{
var result = new StringBuilder();
result.Append('"');
foreach (var ch in input)
{
if (ch == '"') result.Append('"');
result.Append(ch);
}
result.Append('"');
return result.ToString();
}
private static int DateIndex(int month, int day)
{
var dateindex = 0;
// Add month offsets
if (month > 1) dateindex += 31; // Offset January
if (month > 2) dateindex += 29; // Offset February (incl. leap day)
if (month > 3) dateindex += 31; // etc
if (month > 4) dateindex += 30;
if (month > 5) dateindex += 31;
if (month > 6) dateindex += 30;
if (month > 7) dateindex += 31;
if (month > 8) dateindex += 31;
if (month > 9) dateindex += 30;
if (month > 10) dateindex += 31;
if (month > 11) dateindex += 30;
dateindex += day;
return dateindex;
}
private struct ListItem
{
public int DateIndex;
public int BirthMonth;
public int BirthDay;
public ulong UserId;
public string DisplayName;
} }
} }
// "Recent and upcoming birthdays"
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
var now = DateTimeOffset.UtcNow;
var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC
if (search <= 0) search = 366 - Math.Abs(search);
var query = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
var output = new StringBuilder();
var resultCount = 0;
output.AppendLine("Recent and upcoming birthdays:");
for (int count = 0; count <= 21; count++) // cover 21 days total (7 prior, current day, 14 upcoming)
{
var results = from item in query
where item.DateIndex == search
select item;
// push up search by 1 now, in case we back out early
search += 1;
if (search > 366) search = 1; // wrap to beginning of year
if (!results.Any()) continue; // back out early
resultCount += results.Count();
// Build sorted name list
var names = new List<string>();
foreach (var item in results) {
names.Add(item.DisplayName);
}
names.Sort(StringComparer.OrdinalIgnoreCase);
var first = true;
output.AppendLine();
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
foreach (var item in names) {
// If the output is starting to fill up, send out this message and prepare a new one.
if (output.Length > 800) {
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
output.Clear();
first = true;
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
}
if (first) first = false;
else output.Append(", ");
output.Append(item);
}
}
if (resultCount == 0)
await reqChannel.SendMessageAsync(
"There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).")
.ConfigureAwait(false);
else
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
}
/// <summary>
/// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result.
/// </summary>
private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild) {
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable
+ " where guild_id = @Gid order by birth_month, birth_day";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
var result = new List<ListItem>();
while (await r.ReadAsync()) {
var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem() {
BirthMonth = month,
BirthDay = day,
DateIndex = DateIndex(month, day),
UserId = guildUser.Id,
DisplayName = Common.FormatName(guildUser, false)
});
}
return result;
}
private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) {
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]"
var result = new StringBuilder();
result.AppendLine("Birthdays in " + channel.Guild.Name);
result.AppendLine();
foreach (var item in list) {
var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: ");
result.Append(item.UserId);
result.Append(" " + user.Username + "#" + user.Discriminator);
if (user.Nickname != null) result.Append(" - Nickname: " + user.Nickname);
result.AppendLine();
}
return result.ToString();
}
private string ListExportCsv(SocketGuildChannel channel, IEnumerable<ListItem> list) {
// Output: User ID, Username, Nickname, Month-Day, Month, Day
var result = new StringBuilder();
// Conforming to RFC 4180; with header
result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day");
result.Append("\r\n"); // crlf line break is specified by the standard
foreach (var item in list) {
var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append(item.UserId);
result.Append(',');
result.Append(CsvEscape(user.Username + "#" + user.Discriminator));
result.Append(',');
if (user.Nickname != null) result.Append(user.Nickname);
result.Append(',');
result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}");
result.Append(',');
result.Append(item.BirthMonth);
result.Append(',');
result.Append(item.BirthDay);
result.Append("\r\n");
}
return result.ToString();
}
private static string CsvEscape(string input) {
var result = new StringBuilder();
result.Append('"');
foreach (var ch in input) {
if (ch == '"') result.Append('"');
result.Append(ch);
}
result.Append('"');
return result.ToString();
}
private static int DateIndex(int month, int day) {
var dateindex = 0;
// Add month offsets
if (month > 1) dateindex += 31; // Offset January
if (month > 2) dateindex += 29; // Offset February (incl. leap day)
if (month > 3) dateindex += 31; // etc
if (month > 4) dateindex += 30;
if (month > 5) dateindex += 31;
if (month > 6) dateindex += 30;
if (month > 7) dateindex += 31;
if (month > 8) dateindex += 31;
if (month > 9) dateindex += 30;
if (month > 10) dateindex += 31;
if (month > 11) dateindex += 30;
dateindex += day;
return dateindex;
}
private struct ListItem {
public int DateIndex;
public int BirthMonth;
public int BirthDay;
public ulong UserId;
public string DisplayName;
}
} }

View file

@ -1,11 +1,6 @@
using BirthdayBot.Data; using BirthdayBot.Data;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace BirthdayBot.UserInterface; namespace BirthdayBot.UserInterface;
@ -327,9 +322,8 @@ internal class ManagerCommands : CommandsCommon {
// Moderators only. As with config, silently drop if this check fails. // Moderators only. As with config, silently drop if this check fails.
if (!gconf.IsBotModerator(reqUser)) return; if (!gconf.IsBotModerator(reqUser)) return;
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) { if (!await HasMemberCacheAsync(reqChannel.Guild)) {
instance.RequestDownloadUsers(reqChannel.Guild.Id); await reqChannel.SendMessageAsync(MemberCacheEmptyError);
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
return; return;
} }
@ -393,7 +387,7 @@ internal class ManagerCommands : CommandsCommon {
var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false); var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
result.AppendLine($"Server ID: {guild.Id} | Bot shard ID: {instance.ShardId:00}"); result.AppendLine($"Server ID: {guild.Id} | Bot shard ID: {instance.ShardId:00}");
bool hasMembers = Common.HasMostMembersDownloaded(guild); bool hasMembers = guild.HasAllMembers;
result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers)); result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers));
result.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members."); result.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members.");
int bdayCount = -1; int bdayCount = -1;