mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-21 13:54:36 +00:00
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:
parent
488ebfa163
commit
5e4d030467
8 changed files with 373 additions and 487 deletions
|
@ -1,12 +1,6 @@
|
|||
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;
|
||||
|
||||
|
@ -46,7 +40,7 @@ class BirthdayRoleUpdate : BackgroundService {
|
|||
/// </summary>
|
||||
private static async Task ProcessGuildAsync(SocketGuild guild) {
|
||||
// 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);
|
||||
if (gc == null) return;
|
||||
|
||||
|
|
|
@ -1,40 +1,22 @@
|
|||
using BirthdayBot.Data;
|
||||
using Discord;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Rather than use up unnecessary resources by auto-downloading the user list in -every-
|
||||
/// server we're in, this service checks if fetching the user list is warranted for each
|
||||
/// guild before proceeding to request it.
|
||||
/// Proactively fills the user cache for guilds in which any birthday data already exists.
|
||||
/// </summary>
|
||||
class SelectiveAutoUserDownload : BackgroundService {
|
||||
private readonly HashSet<ulong> _fetchRequests = new();
|
||||
|
||||
public SelectiveAutoUserDownload(ShardInstance instance) : base(instance) { }
|
||||
|
||||
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) {
|
||||
// Has the potential to disconnect while in the middle of processing.
|
||||
if (ShardInstance.DiscordClient.ConnectionState != ConnectionState.Connected) return;
|
||||
|
||||
// Determine if there is action to be taken...
|
||||
if (guild.HasAllMembers) continue;
|
||||
if (requests.Contains(guild.Id) || await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) {
|
||||
await guild.DownloadUsersAsync().ConfigureAwait(false);
|
||||
// Must delay after a download request. Seems to hang indefinitely otherwise.
|
||||
await Task.Delay(300, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!guild.HasAllMembers && await GuildUserAnyAsync(guild.Id).ConfigureAwait(false)) {
|
||||
await guild.DownloadUsersAsync().ConfigureAwait(false); // This is already on a separate thread; no need to Task.Run
|
||||
await Task.Delay(200, CancellationToken.None).ConfigureAwait(false); // Must delay, or else it seems to hang...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,8 +34,4 @@ class SelectiveAutoUserDownload : BackgroundService {
|
|||
using var r = await c.ExecuteReaderAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
return r.Read();
|
||||
}
|
||||
|
||||
public void RequestDownload(ulong guildId) {
|
||||
lock (_fetchRequests) _fetchRequests.Add(guildId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>3.2.2</Version>
|
||||
<Version>3.2.3</Version>
|
||||
<Authors>NoiTheCat</Authors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
21
Common.cs
21
Common.cs
|
@ -1,6 +1,4 @@
|
|||
using Discord.WebSocket;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
|
||||
namespace BirthdayBot;
|
||||
|
||||
|
@ -33,21 +31,4 @@ static class Common {
|
|||
{ 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" },
|
||||
{ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,8 +80,6 @@ class ShardInstance : IDisposable {
|
|||
|
||||
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
|
||||
private Task Client_Log(LogMessage arg) {
|
||||
// TODO revise this some time, filters might need to be modified by now
|
||||
|
|
|
@ -1,99 +1,94 @@
|
|||
using BirthdayBot.Data;
|
||||
using Discord.WebSocket;
|
||||
using NodaTime;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.UserInterface
|
||||
{
|
||||
/// <summary>
|
||||
/// Common base class for common constants and variables.
|
||||
/// </summary>
|
||||
internal abstract class CommandsCommon
|
||||
{
|
||||
namespace BirthdayBot.UserInterface;
|
||||
|
||||
/// <summary>
|
||||
/// Common base class for common constants and variables.
|
||||
/// </summary>
|
||||
internal abstract class CommandsCommon {
|
||||
#if DEBUG
|
||||
public const string CommandPrefix = "bt.";
|
||||
public const string CommandPrefix = "bt.";
|
||||
#else
|
||||
public const string CommandPrefix = "bb.";
|
||||
#endif
|
||||
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 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 UsersNotDownloadedError = ":eight_spoked_asterisk: Still catching up... Please try the command again in a few minutes.";
|
||||
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 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 MemberCacheEmptyError = ":warning: Please try the command again.";
|
||||
|
||||
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
|
||||
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
|
||||
|
||||
protected static Dictionary<string, string> TzNameMap {
|
||||
get {
|
||||
// Because IDateTimeZoneProvider.GetZoneOrNull is not case sensitive:
|
||||
// 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 static ReadOnlyDictionary<string, string> TzNameMap { get; }
|
||||
protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>");
|
||||
protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
|
||||
|
||||
protected Configuration BotConfig { get; }
|
||||
protected Configuration BotConfig { get; }
|
||||
|
||||
protected CommandsCommon(Configuration db)
|
||||
{
|
||||
BotConfig = db;
|
||||
protected CommandsCommon(Configuration 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>
|
||||
/// On command dispatcher initialization, it will retrieve all available commands through here.
|
||||
/// </summary>
|
||||
public abstract IEnumerable<(string, CommandHandler)> Commands { get; }
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks given time zone input. Returns a valid string for use with NodaTime.
|
||||
/// </summary>
|
||||
protected static string ParseTimeZone(string tzinput)
|
||||
{
|
||||
string tz = null;
|
||||
if (tzinput != null)
|
||||
{
|
||||
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
|
||||
|
||||
// Just check if the input exists in the map. Get the "true" value, or reject it altogether.
|
||||
if (!TzNameMap.TryGetValue(tzinput, out 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;
|
||||
}
|
||||
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
/// <summary>
|
||||
/// An alternative to <see cref="SocketGuild.HasAllMembers"/> to be called by command handlers needing a full member cache.
|
||||
/// Creates a download request if necessary.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// True if the member cache is already filled, false otherwise.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// this approach fails, this is to be called, and the user must be asked to attempt the command again if this returns false.
|
||||
/// </remarks>
|
||||
protected static async Task<bool> HasMemberCacheAsync(SocketGuild guild) {
|
||||
if (guild.HasAllMembers) return true;
|
||||
// Event handling thread hangs if awaited normally or used with Task.Run
|
||||
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,367 +1,313 @@
|
|||
using BirthdayBot.Data;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.UserInterface
|
||||
{
|
||||
/// <summary>
|
||||
/// Commands for listing upcoming and all birthdays.
|
||||
/// </summary>
|
||||
internal class ListingCommands : CommandsCommon
|
||||
{
|
||||
public ListingCommands(Configuration db) : base(db) { }
|
||||
namespace BirthdayBot.UserInterface;
|
||||
|
||||
public override IEnumerable<(string, CommandHandler)> Commands
|
||||
=> new List<(string, CommandHandler)>()
|
||||
{
|
||||
/// <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
|
||||
=> new List<(string, CommandHandler)>()
|
||||
{
|
||||
("list", CmdList),
|
||||
("upcoming", CmdUpcoming),
|
||||
("recent", CmdUpcoming),
|
||||
("when", CmdWhen)
|
||||
};
|
||||
};
|
||||
|
||||
#region Documentation
|
||||
public static readonly CommandDocumentation DocList =
|
||||
new(new string[] { "list" }, "Exports all birthdays to a file."
|
||||
+ " Accepts `csv` as an optional parameter.", null);
|
||||
public static readonly CommandDocumentation DocUpcoming =
|
||||
new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null);
|
||||
public static readonly CommandDocumentation DocWhen =
|
||||
new(new string[] { "when" }, "Displays the given user's birthday information.", null);
|
||||
#endregion
|
||||
#region Documentation
|
||||
public static readonly CommandDocumentation DocList =
|
||||
new(new string[] { "list" }, "Exports all birthdays to a file."
|
||||
+ " Accepts `csv` as an optional parameter.", null);
|
||||
public static readonly CommandDocumentation DocUpcoming =
|
||||
new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null);
|
||||
public static readonly CommandDocumentation DocWhen =
|
||||
new(new string[] { "when" }, "Displays the given user's birthday information.", null);
|
||||
#endregion
|
||||
|
||||
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
|
||||
{
|
||||
if (!Common.HasMostMembersDownloaded(reqChannel.Guild))
|
||||
{
|
||||
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);
|
||||
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
|
||||
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
|
||||
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
|
||||
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 (!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);
|
||||
}
|
||||
// Requires a parameter
|
||||
if (param.Length == 1) {
|
||||
await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// "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)
|
||||
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
|
||||
{
|
||||
if (!Common.HasMostMembersDownloaded(reqChannel.Guild))
|
||||
{
|
||||
instance.RequestDownloadUsers(reqChannel.Guild.Id);
|
||||
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (resultCount == 0)
|
||||
await reqChannel.SendMessageAsync(
|
||||
"There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).")
|
||||
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.
|
||||
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);
|
||||
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;
|
||||
}
|
||||
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)
|
||||
{
|
||||
// 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();
|
||||
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);
|
||||
|
||||
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;
|
||||
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"
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
using BirthdayBot.Data;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BirthdayBot.UserInterface;
|
||||
|
||||
|
@ -327,9 +322,8 @@ internal class ManagerCommands : CommandsCommon {
|
|||
// Moderators only. As with config, silently drop if this check fails.
|
||||
if (!gconf.IsBotModerator(reqUser)) return;
|
||||
|
||||
if (!Common.HasMostMembersDownloaded(reqChannel.Guild)) {
|
||||
instance.RequestDownloadUsers(reqChannel.Guild.Id);
|
||||
await reqChannel.SendMessageAsync(UsersNotDownloadedError);
|
||||
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
|
||||
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -393,7 +387,7 @@ internal class ManagerCommands : CommandsCommon {
|
|||
var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
|
||||
|
||||
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.AppendLine($" - Has {guild.DownloadedMemberCount} of {guild.MemberCount} members.");
|
||||
int bdayCount = -1;
|
||||
|
|
Loading…
Reference in a new issue