mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-24 01:14:12 +00:00
Add birthday querying commands
This commit is contained in:
parent
edec3134be
commit
a01b1113f9
4 changed files with 310 additions and 296 deletions
|
@ -1,9 +1,14 @@
|
||||||
using Discord.Interactions;
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace BirthdayBot.ApplicationCommands;
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
[Group("birthday", "Commands relating to birthdays.")]
|
[Group("birthday", "Commands relating to birthdays.")]
|
||||||
public class BirthdayModule : BotModuleBase {
|
public class BirthdayModule : BotModuleBase {
|
||||||
|
public const string HelpExport = "Generates a text file with all known and available birthdays.";
|
||||||
|
public const string HelpGet = "Gets a user's birthday.";
|
||||||
|
public const string HelpRecentUpcoming = "Get a list of users who recently had or will have a birthday.";
|
||||||
public const string HelpRemove = "Removes your birthday information from this bot.";
|
public const string HelpRemove = "Removes your birthday information from this bot.";
|
||||||
|
|
||||||
[Group("set", "Subcommands for setting birthday information.")]
|
[Group("set", "Subcommands for setting birthday information.")]
|
||||||
|
@ -70,4 +75,246 @@ public class BirthdayModule : BotModuleBase {
|
||||||
await RespondAsync(":white_check_mark: This bot already does not have your birthday for this server.");
|
await RespondAsync(":white_check_mark: This bot already does not have your birthday for this server.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SlashCommand("get", "Gets a user's birthday.")]
|
||||||
|
public async Task CmdGetBday([Summary(description: "Optional: The user's birthday to look up.")] SocketGuildUser? user = null) {
|
||||||
|
var self = user is null;
|
||||||
|
if (self) user = (SocketGuildUser)Context.User;
|
||||||
|
var targetdata = await GuildUserConfiguration.LoadAsync(Context.Guild.Id, user!.Id).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!targetdata.IsKnown) {
|
||||||
|
if (self) await RespondAsync(":x: You do not have your birthday registered.", ephemeral: true).ConfigureAwait(false);
|
||||||
|
else await RespondAsync(":x: The given user does not have their birthday registered.", ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RespondAsync($"{Common.FormatName(user, false)}: `{FormatDate(targetdata.BirthMonth, targetdata.BirthDay)}`" +
|
||||||
|
(targetdata.TimeZone == null ? "" : $" - {targetdata.TimeZone}")).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Recent and upcoming birthdays"
|
||||||
|
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
|
||||||
|
// TODO stop being lazy
|
||||||
|
[SlashCommand("show-nearest", HelpRecentUpcoming)]
|
||||||
|
public async Task CmdShowNearest() {
|
||||||
|
if (!await HasMemberCacheAsync(Context.Guild).ConfigureAwait(false)) {
|
||||||
|
await RespondAsync(MemberCacheEmptyError, ephemeral: true);
|
||||||
|
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(Context.Guild).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// TODO pagination instead of this workaround
|
||||||
|
bool hasOutputOneLine = false;
|
||||||
|
// First output is shown as an interaction response, followed then as regular channel messages
|
||||||
|
async Task doOutput(string msg) {
|
||||||
|
if (!hasOutputOneLine) {
|
||||||
|
await RespondAsync(msg).ConfigureAwait(false);
|
||||||
|
hasOutputOneLine = true;
|
||||||
|
} else {
|
||||||
|
await Context.Channel.SendMessageAsync(msg).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 doOutput(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 RespondAsync(
|
||||||
|
"There are no recent or upcoming birthdays (within the last 7 days and/or next 14 days).")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
else
|
||||||
|
await doOutput(output.ToString()).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("export", HelpPfxModOnly + HelpExport)]
|
||||||
|
public async Task CmdExport([Summary(description: "Specify whether to export the list in CSV format.")] bool asCsv = false) {
|
||||||
|
// For now, we're restricting this command to moderators only. This may turn into an option later.
|
||||||
|
if (!(await Context.GetGuildConfAsync()).IsBotModerator((SocketGuildUser)Context.User)) {
|
||||||
|
// Do not add detailed usage information to this error message.
|
||||||
|
await RespondAsync(":x: Only bot moderators may use this command.", ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await HasMemberCacheAsync(Context.Guild)) {
|
||||||
|
await RespondAsync(MemberCacheEmptyError).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bdlist = await GetSortedUsersAsync(Context.Guild).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var filename = "birthdaybot-" + Context.Guild.Id;
|
||||||
|
Stream fileoutput;
|
||||||
|
if (asCsv) {
|
||||||
|
fileoutput = ListExportCsv(Context.Guild, bdlist);
|
||||||
|
filename += ".csv";
|
||||||
|
} else {
|
||||||
|
fileoutput = ListExportNormal(Context.Guild, bdlist);
|
||||||
|
filename += ".txt.";
|
||||||
|
}
|
||||||
|
await RespondWithFileAsync(fileoutput, filename, text: $"Exported {bdlist.Count} birthdays to file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Listing helper methods
|
||||||
|
/// <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 Stream ListExportNormal(SocketGuild guild, IEnumerable<ListItem> list) {
|
||||||
|
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]"
|
||||||
|
var result = new MemoryStream();
|
||||||
|
var writer = new StreamWriter(result, Encoding.UTF8);
|
||||||
|
|
||||||
|
writer.WriteLine("Birthdays in " + guild.Name);
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var item in list) {
|
||||||
|
var user = guild.GetUser(item.UserId);
|
||||||
|
if (user == null) continue; // User disappeared in the instant between getting list and processing
|
||||||
|
writer.Write($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: ");
|
||||||
|
writer.Write(item.UserId);
|
||||||
|
writer.Write(" " + user.Username + "#" + user.Discriminator);
|
||||||
|
if (user.Nickname != null) writer.Write(" - Nickname: " + user.Nickname);
|
||||||
|
writer.WriteLine();
|
||||||
|
}
|
||||||
|
writer.Flush();
|
||||||
|
result.Position = 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stream ListExportCsv(SocketGuild guild, IEnumerable<ListItem> list) {
|
||||||
|
// Output: User ID, Username, Nickname, Month-Day, Month, Day
|
||||||
|
var result = new MemoryStream();
|
||||||
|
var writer = new StreamWriter(result, Encoding.UTF8);
|
||||||
|
|
||||||
|
// Conforming to RFC 4180; with header
|
||||||
|
writer.Write("UserId,Username,Nickname,MonthDayDisp,Month,Day");
|
||||||
|
writer.Write("\r\n"); // crlf line break is specified by the standard
|
||||||
|
foreach (var item in list) {
|
||||||
|
var user = guild.GetUser(item.UserId);
|
||||||
|
if (user == null) continue; // User disappeared in the instant between getting list and processing
|
||||||
|
writer.Write(item.UserId);
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write(CsvEscape(user.Username + "#" + user.Discriminator));
|
||||||
|
writer.Write(',');
|
||||||
|
if (user.Nickname != null) writer.Write(user.Nickname);
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}");
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write(item.BirthMonth);
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write(item.BirthDay);
|
||||||
|
writer.Write("\r\n");
|
||||||
|
}
|
||||||
|
writer.Flush();
|
||||||
|
result.Position = 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
using Discord.Interactions;
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace BirthdayBot.ApplicationCommands;
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
@ -15,13 +17,15 @@ public abstract class BotModuleBase : InteractionModuleBase<SocketInteractionCon
|
||||||
protected const string MemberCacheEmptyError = ":warning: Please try the command again.";
|
protected const string MemberCacheEmptyError = ":warning: Please try the command again.";
|
||||||
public const string AccessDeniedError = ":warning: You are not allowed to run this command.";
|
public const string AccessDeniedError = ":warning: You are not allowed to run this command.";
|
||||||
|
|
||||||
|
protected const string HelpOptPfxOptional = "Optional: ";
|
||||||
protected const string HelpOptDate = "A date, including the month and day. For example, \"15 January\".";
|
protected const string HelpOptDate = "A date, including the month and day. For example, \"15 January\".";
|
||||||
protected const string HelpOptZone = "A 'tzdata'-compliant time zone name. See help for more details.";
|
protected const string HelpOptZone = "A 'tzdata'-compliant time zone name. See help for more details.";
|
||||||
|
|
||||||
#pragma warning disable CS8618
|
/// <summary>
|
||||||
public DiscordSocketClient BotClient { get; set; }
|
/// The corresponding <see cref="ShardInstance"/> handling the client where the command originated from.
|
||||||
public ShardInstance Instance { get; set; }
|
/// </summary>
|
||||||
#pragma warning restore CS8618
|
[NotNull]
|
||||||
|
public ShardInstance? Shard { get; set; }
|
||||||
|
|
||||||
protected static IReadOnlyDictionary<string, string> TzNameMap { get; }
|
protected static IReadOnlyDictionary<string, string> TzNameMap { get; }
|
||||||
|
|
||||||
|
@ -125,5 +129,27 @@ public abstract class BotModuleBase : InteractionModuleBase<SocketInteractionCon
|
||||||
_ => throw new FormatException($":x: Can't determine month name `{input}`. Check your spelling and try again."),
|
_ => throw new FormatException($":x: Can't determine month name `{input}`. Check your spelling and try again."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a string representing a birthday in a consistent format.
|
||||||
|
/// </summary>
|
||||||
|
protected static string FormatDate(int month, int day) => $"{day:00}-{Common.MonthNames[month]}";
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class Extensions {
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the database-backed guild configuration for the executing guild.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<GuildConfiguration> GetGuildConfAsync(this SocketInteractionContext context)
|
||||||
|
#pragma warning disable CS8603 // Possible null reference return.
|
||||||
|
=> await GuildConfiguration.LoadAsync(context.Guild.Id, false);
|
||||||
|
#pragma warning restore CS8603 // Possible null reference return.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the database-backed guild user configuration for the executing user.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<GuildUserConfiguration> GetGuildUserConfAsync(this SocketInteractionContext context)
|
||||||
|
=> await GuildUserConfiguration.LoadAsync(context.Guild.Id, context.User.Id);
|
||||||
|
|
||||||
|
}
|
|
@ -1,281 +0,0 @@
|
||||||
using BirthdayBot.Data;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace BirthdayBot.ApplicationCommands;
|
|
||||||
|
|
||||||
internal class QueryCommands : BotApplicationCommand {
|
|
||||||
public const string HelpBirthdayFor = "Gets a user's birthday.";
|
|
||||||
public const string HelpListAll = "Show a full list of all known birthdays.";
|
|
||||||
public const string HelpRecentUpcoming = "Get a list of users who recently had or will have a birthday.";
|
|
||||||
|
|
||||||
public override IEnumerable<ApplicationCommandProperties> GetCommands() => new ApplicationCommandProperties[] {
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("birthday")
|
|
||||||
.WithDescription(HelpBirthdayFor)
|
|
||||||
.AddOption("user", ApplicationCommandOptionType.User, "The user whose birthday to check.", isRequired: false)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("recent")
|
|
||||||
.WithDescription(HelpRecentUpcoming)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("upcoming")
|
|
||||||
.WithDescription(HelpRecentUpcoming)
|
|
||||||
.Build(),
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.WithName("list-all")
|
|
||||||
.WithDescription(HelpPfxModOnly + HelpRecentUpcoming)
|
|
||||||
.AddOption("as-csv", ApplicationCommandOptionType.Boolean, "Whether to output the list in CSV format.")
|
|
||||||
.Build(),
|
|
||||||
};
|
|
||||||
public override CommandResponder? GetHandlerFor(string commandName) => commandName switch {
|
|
||||||
"birthday-for" => CmdBirthdayFor,
|
|
||||||
"recent" => CmdRecentUpcoming,
|
|
||||||
"upcoming" => CmdRecentUpcoming,
|
|
||||||
"list-all" => CmdListAll,
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task CmdBirthdayFor(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) {
|
|
||||||
var searchtarget = arg.Data.Options.FirstOrDefault()?.Value as SocketGuildUser ?? (SocketGuildUser)arg.User;
|
|
||||||
var targetdata = await GuildUserConfiguration.LoadAsync(gconf.GuildId, searchtarget.Id);
|
|
||||||
|
|
||||||
if (!targetdata.IsKnown) {
|
|
||||||
await arg.RespondAsync($"{Common.FormatName(searchtarget, false)} does not have their birthday registered.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await arg.RespondAsync($"{Common.FormatName(searchtarget, false)}: " +
|
|
||||||
$"`{targetdata.BirthDay:00}-{Common.MonthNames[targetdata.BirthMonth]}`" +
|
|
||||||
(targetdata.TimeZone == null ? "" : $" - {targetdata.TimeZone}")).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Recent and upcoming birthdays"
|
|
||||||
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
|
|
||||||
// TODO stop being lazy
|
|
||||||
private async Task CmdRecentUpcoming(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) {
|
|
||||||
var guild = ((SocketGuildChannel)arg.Channel).Guild;
|
|
||||||
if (!await HasMemberCacheAsync(guild).ConfigureAwait(false)) {
|
|
||||||
await arg.RespondAsync(MemberCacheEmptyError, ephemeral: true);
|
|
||||||
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(guild).ConfigureAwait(false);
|
|
||||||
|
|
||||||
// TODO pagination instead of this workaround
|
|
||||||
bool hasOutputOneLine = false;
|
|
||||||
// First output is shown as an interaction response, followed then as regular channel messages
|
|
||||||
async Task doOutput(string msg) {
|
|
||||||
if (!hasOutputOneLine) {
|
|
||||||
await arg.RespondAsync(msg).ConfigureAwait(false);
|
|
||||||
hasOutputOneLine = true;
|
|
||||||
} else {
|
|
||||||
await arg.Channel.SendMessageAsync(msg).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 doOutput(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 arg.RespondAsync(
|
|
||||||
"There are no recent or upcoming birthdays (within the last 7 days and/or next 21 days).")
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
else
|
|
||||||
await doOutput(output.ToString()).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CmdListAll(ShardInstance instance, GuildConfiguration gconf, SocketSlashCommand arg) {
|
|
||||||
var guild = ((SocketGuildChannel)arg.Channel).Guild;
|
|
||||||
// For now, we're restricting this command to moderators only. This may turn into an option later.
|
|
||||||
if (!gconf.IsBotModerator((SocketGuildUser)arg.User)) {
|
|
||||||
// Do not add detailed usage information to this error message.
|
|
||||||
await arg.RespondAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await HasMemberCacheAsync(guild)) {
|
|
||||||
await arg.RespondAsync(MemberCacheEmptyError).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for CSV option
|
|
||||||
var useCsv = arg.Data.Options.FirstOrDefault()?.Value as bool? ?? false;
|
|
||||||
|
|
||||||
var bdlist = await GetSortedUsersAsync(guild).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var filepath = Path.GetTempPath() + "birthdaybot-" + guild.Id;
|
|
||||||
string fileoutput;
|
|
||||||
if (useCsv) {
|
|
||||||
fileoutput = ListExportCsv(guild, bdlist);
|
|
||||||
filepath += ".csv";
|
|
||||||
} else {
|
|
||||||
fileoutput = ListExportNormal(guild, bdlist);
|
|
||||||
filepath += ".txt.";
|
|
||||||
}
|
|
||||||
await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await arg.RespondWithFileAsync(filepath, "birthdaybot-" + guild.Id + (useCsv ? ".csv" : ".txt"),
|
|
||||||
$"Exported {bdlist.Count} birthdays to file.",
|
|
||||||
null, false, false, null, null, null, null);
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Program.Log("Listing", ex.ToString());
|
|
||||||
} finally {
|
|
||||||
File.Delete(filepath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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(SocketGuild guild, IEnumerable<ListItem> list) {
|
|
||||||
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]"
|
|
||||||
var result = new StringBuilder();
|
|
||||||
result.AppendLine("Birthdays in " + guild.Name);
|
|
||||||
result.AppendLine();
|
|
||||||
foreach (var item in list) {
|
|
||||||
var user = 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(SocketGuild guild, 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 = 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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -31,7 +31,6 @@ public class ShardInstance : IDisposable {
|
||||||
internal Configuration Config => _manager.Config;
|
internal Configuration Config => _manager.Config;
|
||||||
|
|
||||||
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
|
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
|
||||||
public const string UnknownCommandError = "Oops, that command isn't supposed to be there... Please try something else.";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prepares and configures the shard instances, but does not yet start its connection.
|
/// Prepares and configures the shard instances, but does not yet start its connection.
|
||||||
|
@ -47,7 +46,6 @@ public class ShardInstance : IDisposable {
|
||||||
DiscordClient.MessageReceived += Client_MessageReceived;
|
DiscordClient.MessageReceived += Client_MessageReceived;
|
||||||
|
|
||||||
_interactionService = _services.GetRequiredService<InteractionService>();
|
_interactionService = _services.GetRequiredService<InteractionService>();
|
||||||
_interactionService.AddModulesAsync(Assembly.GetExecutingAssembly(), null);
|
|
||||||
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
||||||
_interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
|
_interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
|
||||||
|
|
||||||
|
@ -59,6 +57,7 @@ public class ShardInstance : IDisposable {
|
||||||
/// Starts up this shard's connection to Discord and background task handling associated with it.
|
/// Starts up this shard's connection to Discord and background task handling associated with it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task StartAsync() {
|
public async Task StartAsync() {
|
||||||
|
await _interactionService.AddModulesAsync(Assembly.GetExecutingAssembly(), _services).ConfigureAwait(false);
|
||||||
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false);
|
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false);
|
||||||
await DiscordClient.StartAsync().ConfigureAwait(false);
|
await DiscordClient.StartAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
@ -119,7 +118,7 @@ public class ShardInstance : IDisposable {
|
||||||
|
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
// Update slash/interaction commands
|
// Update slash/interaction commands
|
||||||
await _interactionService.RegisterCommandsGloballyAsync(true).ConfigureAwait(false);
|
if (ShardId == 0) await _interactionService.RegisterCommandsGloballyAsync(true).ConfigureAwait(false);
|
||||||
#else
|
#else
|
||||||
// Debug: Register our commands locally instead, in each guild we're in
|
// Debug: Register our commands locally instead, in each guild we're in
|
||||||
foreach (var g in DiscordClient.Guilds) {
|
foreach (var g in DiscordClient.Guilds) {
|
||||||
|
@ -169,17 +168,40 @@ public class ShardInstance : IDisposable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InteractionService_SlashCommandExecuted(SlashCommandInfo arg1, IInteractionContext arg2, IResult arg3) {
|
private Task InteractionService_SlashCommandExecuted(SlashCommandInfo arg1, IInteractionContext arg2, IResult arg3) {
|
||||||
if (arg3.IsSuccess) return;
|
// TODO extract command and subcommands to log here
|
||||||
Log("Interaction error", Enum.GetName(typeof(InteractionCommandError), arg3.Error) + " " + arg3.ErrorReason);
|
Log("Interaction", $"/{arg1.Name} executed by {arg2.Guild.Name}!{arg2.User}.");
|
||||||
|
if (!arg3.IsSuccess) {
|
||||||
|
Log("Interaction", Enum.GetName(typeof(InteractionCommandError), arg3.Error) + ": " + arg3.ErrorReason);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO finish this up
|
// TODO finish this up
|
||||||
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
||||||
// TODO this is straight from the example - look it over
|
// TODO verify this whole thing - it's a hastily done mash-up of example code and my old code
|
||||||
try {
|
|
||||||
// Create an execution context that matches the generic type parameter of your InteractionModuleBase<T> modules
|
|
||||||
var context = new SocketInteractionContext(DiscordClient, arg);
|
var context = new SocketInteractionContext(DiscordClient, arg);
|
||||||
|
|
||||||
|
// Specific reply for DM messages
|
||||||
|
if (context.Channel is not SocketGuildChannel) {
|
||||||
|
Log("Interaction", $"DM interaction. User ID {context.User.Id}, {context.User}");
|
||||||
|
await arg.RespondAsync("DMs are not supported by this bot.").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocklist/moderated check
|
||||||
|
var gconf = await GuildConfiguration.LoadAsync(context.Guild.Id, false);
|
||||||
|
if (!gconf!.IsBotModerator((SocketGuildUser)arg.User)) // Except if moderator
|
||||||
|
{
|
||||||
|
if (await gconf.IsUserBlockedAsync(arg.User.Id)) {
|
||||||
|
Log("Interaction", $"Blocking interaction per guild policy. User ID {context.User.Id}, {context.User}");
|
||||||
|
await arg.RespondAsync(ApplicationCommands.BotModuleBase.AccessDeniedError, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await _interactionService.ExecuteCommandAsync(context, _services);
|
await _interactionService.ExecuteCommandAsync(context, _services);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Console.WriteLine(ex);
|
Console.WriteLine(ex);
|
||||||
|
|
Loading…
Reference in a new issue