mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-22 05:54:36 +00:00
Merge branch 'slashcommands'
This commit is contained in:
commit
85b23e255c
19 changed files with 1018 additions and 61 deletions
319
ApplicationCommands/BirthdayModule.cs
Normal file
319
ApplicationCommands/BirthdayModule.cs
Normal file
|
@ -0,0 +1,319 @@
|
||||||
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[Group("birthday", HelpCmdBirthday)]
|
||||||
|
public class BirthdayModule : BotModuleBase {
|
||||||
|
public const string HelpCmdBirthday = "Commands relating to birthdays.";
|
||||||
|
public const string HelpCmdSetDate = "Sets or updates your birthday.";
|
||||||
|
public const string HelpCmdSetZone = "Sets or updates your time zone if your birthday is already set.";
|
||||||
|
public const string HelpCmdRemove = "Removes your birthday information from this bot.";
|
||||||
|
public const string HelpCmdGet = "Gets a user's birthday.";
|
||||||
|
public const string HelpCmdNearest = "Get a list of users who recently had or will have a birthday.";
|
||||||
|
public const string HelpCmdExport = "Generates a text file with all known and available birthdays.";
|
||||||
|
|
||||||
|
// Note that these methods have largely been copied to BirthdayOverrideModule. Changes here should be reflected there as needed.
|
||||||
|
|
||||||
|
[Group("set", "Subcommands for setting birthday information.")]
|
||||||
|
public class SubCmdsBirthdaySet : BotModuleBase {
|
||||||
|
[SlashCommand("date", HelpCmdSetDate)]
|
||||||
|
public async Task CmdSetBday([Summary(description: HelpOptDate)] string date,
|
||||||
|
[Summary(description: HelpOptZone)] string? zone = null) {
|
||||||
|
int inmonth, inday;
|
||||||
|
try {
|
||||||
|
(inmonth, inday) = ParseDate(date);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
// Our parse method's FormatException has its message to send out to Discord.
|
||||||
|
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? inzone = null;
|
||||||
|
if (zone != null) {
|
||||||
|
try {
|
||||||
|
inzone = ParseTimeZone(zone);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
await ReplyAsync(e.Message).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
|
||||||
|
await user.UpdateAsync(inmonth, inday, inzone ?? user.TimeZone).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await RespondAsync($":white_check_mark: Your birthday has been set to **{FormatDate(inmonth, inday)}**" +
|
||||||
|
(inzone == null ? "" : $", with time zone {inzone}") + ".").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("timezone", HelpCmdSetZone)]
|
||||||
|
public async Task CmdSetZone([Summary(description: HelpOptZone)] string zone) {
|
||||||
|
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
|
||||||
|
if (!user.IsKnown) {
|
||||||
|
await RespondAsync(":x: You do not have a birthday set.", ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string inzone;
|
||||||
|
try {
|
||||||
|
inzone = ParseTimeZone(zone);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await user.UpdateAsync(user.BirthMonth, user.BirthDay, inzone).ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: Your time zone has been set to **{inzone}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("remove", HelpCmdRemove)]
|
||||||
|
public async Task CmdRemove() {
|
||||||
|
var user = await ((SocketGuildUser)Context.User).GetConfigAsync().ConfigureAwait(false);
|
||||||
|
if (user.IsKnown) {
|
||||||
|
await user.DeleteAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync(":white_check_mark: Your birthday in this server has been removed.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
await RespondAsync(":white_check_mark: Your birthday is not registered.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 user!.GetConfigAsync().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", HelpCmdNearest)]
|
||||||
|
public async Task CmdShowNearest() {
|
||||||
|
if (!await HasMemberCacheAsync(Context.Guild).ConfigureAwait(false)) {
|
||||||
|
await RespondAsync(MemberCacheEmptyError, ephemeral: true).ConfigureAwait(false);
|
||||||
|
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 ReplyAsync(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequireBotModerator]
|
||||||
|
[SlashCommand("export", HelpPfxModOnly + HelpCmdExport)]
|
||||||
|
public async Task CmdExport([Summary(description: "Specify whether to export the list in CSV format.")] bool asCsv = false) {
|
||||||
|
if (!await HasMemberCacheAsync(Context.Guild)) {
|
||||||
|
await RespondAsync(MemberCacheEmptyError, ephemeral: true).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 static 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 static 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
|
||||||
|
}
|
69
ApplicationCommands/BirthdayOverrideModule.cs
Normal file
69
ApplicationCommands/BirthdayOverrideModule.cs
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[RequireBotModerator]
|
||||||
|
[Group("override", HelpCmdOverride)]
|
||||||
|
public class BirthdayOverrideModule : BotModuleBase {
|
||||||
|
public const string HelpCmdOverride = "Commands to set options for other users.";
|
||||||
|
const string HelpOptOvTarget = "The user whose data to modify.";
|
||||||
|
|
||||||
|
// Note that these methods have largely been copied from BirthdayModule. Changes there should be reflected here as needed.
|
||||||
|
// TODO possible to use a common base class for shared functionality instead?
|
||||||
|
|
||||||
|
[SlashCommand("set-birthday", HelpPfxModOnly + "Set a user's birthday on their behalf.")]
|
||||||
|
public async Task OvSetBirthday([Summary(description: HelpOptOvTarget)]SocketGuildUser target,
|
||||||
|
[Summary(description: HelpOptDate)]string date) {
|
||||||
|
int inmonth, inday;
|
||||||
|
try {
|
||||||
|
(inmonth, inday) = ParseDate(date);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
// Our parse method's FormatException has its message to send out to Discord.
|
||||||
|
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await target.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
await user.UpdateAsync(inmonth, inday, user.TimeZone).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s birthday has been set to " +
|
||||||
|
$"**{FormatDate(inmonth, inday)}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-timezone", HelpPfxModOnly + "Set a user's time zone on their behalf.")]
|
||||||
|
public async Task OvSetTimezone([Summary(description: HelpOptOvTarget)]SocketGuildUser target,
|
||||||
|
[Summary(description: HelpOptZone)]string zone) {
|
||||||
|
var user = await target.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
if (!user.IsKnown) {
|
||||||
|
await RespondAsync($":x: {Common.FormatName(target, false)} does not have a birthday set.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string inzone;
|
||||||
|
try {
|
||||||
|
inzone = ParseTimeZone(zone);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await user.UpdateAsync(user.BirthMonth, user.BirthDay, inzone).ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s time zone has been set to " +
|
||||||
|
$"**{inzone}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("remove-birthday", HelpPfxModOnly + "Remove a user's birthday information on their behalf.")]
|
||||||
|
public async Task OvRemove([Summary(description: HelpOptOvTarget)]SocketGuildUser target) {
|
||||||
|
var user = await target.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
if (user.IsKnown) {
|
||||||
|
await user.DeleteAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s birthday in this server has been removed.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
await RespondAsync($":white_check_mark: {Common.FormatName(target, false)}'s birthday is not registered.")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
136
ApplicationCommands/BotModuleBase.cs
Normal file
136
ApplicationCommands/BotModuleBase.cs
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
using NodaTime;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for our interaction module classes. Contains common data for use in implementing classes.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BotModuleBase : InteractionModuleBase<SocketInteractionContext> {
|
||||||
|
protected const string HelpPfxModOnly = "Bot moderators only: ";
|
||||||
|
protected const string ErrGuildOnly = ":x: This command can only be run within a server.";
|
||||||
|
protected const string ErrNotAllowed = ":x: Only server moderators may use this command.";
|
||||||
|
protected const string MemberCacheEmptyError = ":warning: Please try the command again.";
|
||||||
|
public const string AccessDeniedError = ":warning: You are not allowed to run this command.";
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The corresponding <see cref="ShardInstance"/> handling the client where the command originated from.
|
||||||
|
/// </summary>
|
||||||
|
[NotNull]
|
||||||
|
public ShardInstance? Shard { get; set; }
|
||||||
|
|
||||||
|
protected static IReadOnlyDictionary<string, string> TzNameMap { get; }
|
||||||
|
|
||||||
|
static BotModuleBase() {
|
||||||
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name);
|
||||||
|
TzNameMap = new ReadOnlyDictionary<string, string>(dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks given time zone input. Returns a valid string for use with NodaTime,
|
||||||
|
/// throwing a FormatException if the input is not recognized.
|
||||||
|
/// </summary>
|
||||||
|
protected static string ParseTimeZone(string tzinput) {
|
||||||
|
if (!TzNameMap.TryGetValue(tzinput, out string? tz))
|
||||||
|
throw new FormatException(":x: Unknown time zone name.\n" +
|
||||||
|
"To find your time zone, please refer to: https://kevinnovak.github.io/Time-Zone-Picker/");
|
||||||
|
return tz!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.AutoUserDownload"/>. 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 (Common.HasMostMembersDownloaded(guild)) return true;
|
||||||
|
// Event handling thread hangs if awaited normally or used with Task.Run
|
||||||
|
await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Date parsing
|
||||||
|
const string FormatError = ":x: Unrecognized date format. The following formats are accepted, as examples: "
|
||||||
|
+ "`15-jan`, `jan-15`, `15 jan`, `jan 15`, `15 January`, `January 15`.";
|
||||||
|
|
||||||
|
private static readonly Regex DateParse1 = new(@"^(?<day>\d{1,2})[ -](?<month>[A-Za-z]+)$", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex DateParse2 = new(@"^(?<month>[A-Za-z]+)[ -](?<day>\d{1,2})$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a date input.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Tuple: month, day</returns>
|
||||||
|
/// <exception cref="FormatException">
|
||||||
|
/// Thrown for any parsing issue. Reason is expected to be sent to Discord as-is.
|
||||||
|
/// </exception>
|
||||||
|
protected static (int, int) ParseDate(string dateInput) {
|
||||||
|
var m = DateParse1.Match(dateInput);
|
||||||
|
if (!m.Success) {
|
||||||
|
// Flip the fields around, try again
|
||||||
|
m = DateParse2.Match(dateInput);
|
||||||
|
if (!m.Success) throw new FormatException(FormatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
int day, month;
|
||||||
|
string monthVal;
|
||||||
|
try {
|
||||||
|
day = int.Parse(m.Groups["day"].Value);
|
||||||
|
} catch (FormatException) {
|
||||||
|
throw new Exception(FormatError);
|
||||||
|
}
|
||||||
|
monthVal = m.Groups["month"].Value;
|
||||||
|
|
||||||
|
int dayUpper; // upper day of month check
|
||||||
|
(month, dayUpper) = GetMonth(monthVal);
|
||||||
|
|
||||||
|
if (day == 0 || day > dayUpper) throw new FormatException(":x: The date you specified is not a valid calendar date.");
|
||||||
|
|
||||||
|
return (month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns information for a given month input.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input"></param>
|
||||||
|
/// <returns>Tuple: Month value, upper limit of days in the month</returns>
|
||||||
|
/// <exception cref="FormatException">
|
||||||
|
/// Thrown on error. Send out to Discord as-is.
|
||||||
|
/// </exception>
|
||||||
|
private static (int, int) GetMonth(string input) {
|
||||||
|
return input.ToLower() switch {
|
||||||
|
"jan" or "january" => (1, 31),
|
||||||
|
"feb" or "february" => (2, 29),
|
||||||
|
"mar" or "march" => (3, 31),
|
||||||
|
"apr" or "april" => (4, 30),
|
||||||
|
"may" => (5, 31),
|
||||||
|
"jun" or "june" => (6, 30),
|
||||||
|
"jul" or "july" => (7, 31),
|
||||||
|
"aug" or "august" => (8, 31),
|
||||||
|
"sep" or "september" => (9, 30),
|
||||||
|
"oct" or "october" => (10, 31),
|
||||||
|
"nov" or "november" => (11, 30),
|
||||||
|
"dec" or "december" => (12, 31),
|
||||||
|
_ => 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
|
||||||
|
}
|
237
ApplicationCommands/ConfigModule.cs
Normal file
237
ApplicationCommands/ConfigModule.cs
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
[RequireContext(ContextType.Guild)]
|
||||||
|
[RequireBotModerator]
|
||||||
|
[Group("config", HelpCmdConfig)]
|
||||||
|
public class ConfigModule : BotModuleBase {
|
||||||
|
public const string HelpCmdConfig = "Configure basic settings for the bot.";
|
||||||
|
public const string HelpCmdAnnounce = "Settings regarding birthday announcements.";
|
||||||
|
public const string HelpCmdBlocking = "Settings for limiting user access.";
|
||||||
|
public const string HelpCmdRole = "Settings for roles used by this bot.";
|
||||||
|
public const string HelpCmdCheck = "Test the bot's current configuration and show the results.";
|
||||||
|
|
||||||
|
const string HelpPofxBlankUnset = " Leave blank to unset.";
|
||||||
|
const string HelpOptChannel = "The corresponding channel to use.";
|
||||||
|
const string HelpOptRole = "The corresponding role to use.";
|
||||||
|
|
||||||
|
[Group("announce", HelpPfxModOnly + HelpCmdAnnounce)]
|
||||||
|
public class SubCmdsConfigAnnounce : BotModuleBase {
|
||||||
|
private const string HelpSubCmdChannel = "Set which channel will receive announcement messages.";
|
||||||
|
private const string HelpSubCmdMessage = "Modify the announcement message.";
|
||||||
|
private const string HelpSubCmdPing = "Set whether to ping users mentioned in the announcement.";
|
||||||
|
|
||||||
|
[SlashCommand("help", "Show information regarding announcement messages.")]
|
||||||
|
public async Task CmdAnnounceHelp() {
|
||||||
|
const string subcommands =
|
||||||
|
$"`/config announce` - {HelpCmdAnnounce}\n" +
|
||||||
|
$" ⤷`set-channel` - {HelpSubCmdChannel}\n" +
|
||||||
|
$" ⤷`set-message` - {HelpSubCmdMessage}\n" +
|
||||||
|
$" ⤷`set-ping` - {HelpSubCmdPing}";
|
||||||
|
const string whatIs =
|
||||||
|
"As the name implies, an announcement message is the messages displayed when somebody's birthday be" +
|
||||||
|
"arrives. If enabled, an announcment message is shown at midnight respective to the appropriate time zone, " +
|
||||||
|
"first using the user's local time (if it is known), or else using the server's default time zone, or else " +
|
||||||
|
"referring back to midnight in Universal Time (UTC).\n\n" +
|
||||||
|
"To enable announcement messages, use the `set-channel` subcommand.";
|
||||||
|
const string editMsg =
|
||||||
|
"The `set-message` subcommand allow moderators to edit the message sent into the announcement channel.\n" +
|
||||||
|
"Two messages may be provided: `single` sets the message that is displayed when one user has a birthday, and " +
|
||||||
|
"`multi` sets the message used when two or more users have birthdays. If only one of the two messages " +
|
||||||
|
"have been set, this bot will use the same message in both cases.\n\n" +
|
||||||
|
"You may use the token `%n` in your message to specify where the name(s) should appear, otherwise the names " +
|
||||||
|
"will appear at the very end of your custom message.";
|
||||||
|
await RespondAsync(embed: new EmbedBuilder()
|
||||||
|
.WithAuthor("Announcement configuration")
|
||||||
|
.WithDescription(subcommands)
|
||||||
|
.AddField("What is an announcement message?", whatIs)
|
||||||
|
.AddField("Customization", editMsg)
|
||||||
|
.Build()).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-channel", HelpPfxModOnly + HelpSubCmdChannel + HelpPofxBlankUnset)]
|
||||||
|
public async Task CmdSetChannel([Summary(description: HelpOptRole)] SocketTextChannel? channel = null) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
gconf.AnnounceChannelId = channel?.Id;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync(":white_check_mark: The announcement channel has been " +
|
||||||
|
(channel == null ? "unset." : $"set to **{channel.Name}**."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-message", HelpPfxModOnly + HelpSubCmdMessage)]
|
||||||
|
public async Task CmdSetMessage() {
|
||||||
|
// TODO implement this
|
||||||
|
var pfx = TextCommands.CommandsCommon.CommandPrefix;
|
||||||
|
await RespondAsync(":x: Sorry, changing the announcement message via slash commands is not yet available. " +
|
||||||
|
"Please use the corresponding text command: " +
|
||||||
|
$"`{pfx}config message` for single, `{pfx}config message-pl` for multi.", ephemeral: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-ping", HelpPfxModOnly + HelpSubCmdPing)]
|
||||||
|
public async Task CmdSetPing([Summary(description: "Set True to ping users, False to display them normally.")]bool option) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
gconf.AnnouncePing = option;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: Announcement pings are now **{(option ? "on" : "off")}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Group("role", HelpPfxModOnly + HelpCmdRole)]
|
||||||
|
public class SubCmdsConfigRole : BotModuleBase {
|
||||||
|
[SlashCommand("set-birthday-role", HelpPfxModOnly + "Set the role given to users having a birthday.")]
|
||||||
|
public async Task CmdSetBRole([Summary(description: HelpOptRole)] SocketRole role) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
gconf.RoleId = role.Id;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: The birthday role has been set to **{role.Name}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-moderator-role", HelpPfxModOnly + "Designate a role whose members can configure the bot." + HelpPofxBlankUnset)]
|
||||||
|
public async Task CmdSetModRole([Summary(description: HelpOptRole)]SocketRole? role = null) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
gconf.ModeratorRole = role?.Id;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync(":white_check_mark: The moderator role has been " +
|
||||||
|
(role == null ? "unset." : $"set to **{role.Name}**."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Group("block", HelpCmdBlocking)]
|
||||||
|
public class SubCmdsConfigBlocking : BotModuleBase {
|
||||||
|
[SlashCommand("add-block", HelpPfxModOnly + "Add a user to the block list.")]
|
||||||
|
public Task CmdAddBlock([Summary(description: "The user to block.")] SocketGuildUser user) => UpdateBlockAsync(user, true);
|
||||||
|
|
||||||
|
[SlashCommand("remove-block", HelpPfxModOnly + "Remove a user from the block list.")]
|
||||||
|
public Task CmdDelBlock([Summary(description: "The user to unblock.")] SocketGuildUser user) => UpdateBlockAsync(user, false);
|
||||||
|
|
||||||
|
private async Task UpdateBlockAsync(SocketGuildUser user, bool setting) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
bool already = setting == await gconf.IsUserBlockedAsync(user.Id).ConfigureAwait(false);
|
||||||
|
if (already) {
|
||||||
|
await RespondAsync($":white_check_mark: User is already {(setting ? "" : "not ")}blocked.").ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
if (setting) await gconf.BlockUserAsync(user.Id).ConfigureAwait(false);
|
||||||
|
else await gconf.UnblockUserAsync(user.Id).ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: {Common.FormatName(user, false)} has been {(setting ? "" : "un")}blocked.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-moderated", HelpPfxModOnly + "Set moderated mode on the server.")]
|
||||||
|
public async Task CmdAddBlock([Summary(name: "enable", description: "The moderated mode setting.")] bool setting) {
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
bool already = setting == gconf.IsModerated;
|
||||||
|
if (already) {
|
||||||
|
await RespondAsync($":white_check_mark: Moderated mode is already **{(setting ? "en" : "dis")}abled**.").ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
gconf.IsModerated = setting;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync($":white_check_mark: Moderated mode is now **{(setting ? "en" : "dis")}abled**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("check", HelpPfxModOnly + HelpCmdCheck)]
|
||||||
|
public async Task CmdCheck() {
|
||||||
|
static string DoTestFor(string label, Func<bool> test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }";
|
||||||
|
var result = new StringBuilder();
|
||||||
|
SocketTextChannel channel = (SocketTextChannel)Context.Channel;
|
||||||
|
var guild = Context.Guild;
|
||||||
|
var conf = await guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
var usercfgs = await guild.GetUserConfigurationsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{Shard.ShardId:00}`");
|
||||||
|
result.AppendLine($"Number of registered birthdays: `{ usercfgs.Count() }`");
|
||||||
|
result.AppendLine($"Server time zone: `{ (conf?.TimeZone ?? "Not set - using UTC") }`");
|
||||||
|
result.AppendLine();
|
||||||
|
|
||||||
|
bool hasMembers = Common.HasMostMembersDownloaded(guild);
|
||||||
|
result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers));
|
||||||
|
result.AppendLine($" - Has `{guild.DownloadedMemberCount}` of `{guild.MemberCount}` members.");
|
||||||
|
int bdayCount = -1;
|
||||||
|
result.Append(DoTestFor("Birthday processing", delegate {
|
||||||
|
if (!hasMembers) return false;
|
||||||
|
bdayCount = BackgroundServices.BirthdayRoleUpdate.GetGuildCurrentBirthdays(usercfgs, conf?.TimeZone).Count;
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
if (hasMembers) result.AppendLine($" - `{bdayCount}` user(s) currently having a birthday.");
|
||||||
|
else result.AppendLine(" - Previous step failed.");
|
||||||
|
result.AppendLine();
|
||||||
|
|
||||||
|
result.AppendLine(DoTestFor("Birthday role set with `bb.config role`", delegate {
|
||||||
|
if (conf == null) return false;
|
||||||
|
SocketRole? role = guild.GetRole(conf.RoleId ?? 0);
|
||||||
|
return role != null;
|
||||||
|
}));
|
||||||
|
result.AppendLine(DoTestFor("Birthday role can be managed by bot", delegate {
|
||||||
|
if (conf == null) return false;
|
||||||
|
SocketRole? role = guild.GetRole(conf.RoleId ?? 0);
|
||||||
|
if (role == null) return false;
|
||||||
|
return guild.CurrentUser.GuildPermissions.ManageRoles && role.Position < guild.CurrentUser.Hierarchy;
|
||||||
|
}));
|
||||||
|
result.AppendLine();
|
||||||
|
|
||||||
|
SocketTextChannel? announcech = null;
|
||||||
|
result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate {
|
||||||
|
if (conf == null) return false;
|
||||||
|
announcech = guild.GetTextChannel(conf.AnnounceChannelId ?? 0);
|
||||||
|
return announcech != null;
|
||||||
|
}));
|
||||||
|
string disp = announcech == null ? "announcement channel" : $"<#{announcech.Id}>";
|
||||||
|
result.AppendLine(DoTestFor($"(Optional) Bot can send messages into { disp }", delegate {
|
||||||
|
if (announcech == null) return false;
|
||||||
|
return guild.CurrentUser.GetPermissions(announcech).SendMessages;
|
||||||
|
}));
|
||||||
|
|
||||||
|
await RespondAsync(embed: new EmbedBuilder() {
|
||||||
|
Author = new EmbedAuthorBuilder() { Name = "Status and config check" },
|
||||||
|
Description = result.ToString()
|
||||||
|
}.Build()).ConfigureAwait(false);
|
||||||
|
|
||||||
|
const int announceMsgPreviewLimit = 350;
|
||||||
|
static string prepareAnnouncePreview(string announce) {
|
||||||
|
string trunc = announce.Length > announceMsgPreviewLimit ? announce[..announceMsgPreviewLimit] + "`(...)`" : announce;
|
||||||
|
var result = new StringBuilder();
|
||||||
|
foreach (var line in trunc.Split('\n'))
|
||||||
|
result.AppendLine($"> {line}");
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
if (conf != null && (conf.AnnounceMessages.Item1 != null || conf.AnnounceMessages.Item2 != null)) {
|
||||||
|
var em = new EmbedBuilder().WithAuthor(new EmbedAuthorBuilder() { Name = "Custom announce messages:" });
|
||||||
|
var dispAnnounces = new StringBuilder("Custom announcement message(s):\n");
|
||||||
|
if (conf.AnnounceMessages.Item1 != null) {
|
||||||
|
em = em.AddField("Single", prepareAnnouncePreview(conf.AnnounceMessages.Item1));
|
||||||
|
}
|
||||||
|
if (conf.AnnounceMessages.Item2 != null) {
|
||||||
|
em = em.AddField("Multi", prepareAnnouncePreview(conf.AnnounceMessages.Item2));
|
||||||
|
}
|
||||||
|
await ReplyAsync(embed: em.Build()).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SlashCommand("set-timezone", HelpPfxModOnly + "Configure the time zone to use by default in the server." + HelpPofxBlankUnset)]
|
||||||
|
public async Task CmdSetTimezone([Summary(description: HelpOptZone)] string? zone = null) {
|
||||||
|
const string Response = ":white_check_mark: The server's time zone has been ";
|
||||||
|
var gconf = await Context.Guild.GetConfigAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (zone == null) {
|
||||||
|
gconf.TimeZone = null;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync(Response + "unset.").ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
string parsedZone;
|
||||||
|
try {
|
||||||
|
parsedZone = ParseTimeZone(zone);
|
||||||
|
} catch (FormatException e) {
|
||||||
|
await RespondAsync(e.Message).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gconf.TimeZone = parsedZone;
|
||||||
|
await gconf.UpdateAsync().ConfigureAwait(false);
|
||||||
|
await RespondAsync(Response + $"set to **{zone}**.").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
53
ApplicationCommands/HelpModule.cs
Normal file
53
ApplicationCommands/HelpModule.cs
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
using Discord.Interactions;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
public class HelpModule : BotModuleBase {
|
||||||
|
private const string TopMessage =
|
||||||
|
"Thank you for using Birthday Bot!\n" +
|
||||||
|
"Support, data policy, more info: https://noithecat.dev/bots/BirthdayBot\n\n" +
|
||||||
|
"This bot is provided for free, without any paywalls or exclusive paid features. If this bot has been useful to you, " +
|
||||||
|
"please consider making a small contribution via the author's Ko-fi: https://ko-fi.com/noithecat.";
|
||||||
|
private const string RegularCommandsField =
|
||||||
|
$"`/birthday` - {BirthdayModule.HelpCmdBirthday}\n" +
|
||||||
|
$"` ⤷get` - {BirthdayModule.HelpCmdGet}\n" +
|
||||||
|
$"` ⤷show-nearest` - {BirthdayModule.HelpCmdNearest}\n" +
|
||||||
|
$"` ⤷set date` - {BirthdayModule.HelpCmdSetDate}\n" +
|
||||||
|
$"` ⤷set timezone` - {BirthdayModule.HelpCmdSetZone}\n" +
|
||||||
|
$"` ⤷remove` - {BirthdayModule.HelpCmdRemove}";
|
||||||
|
private const string ModCommandsField =
|
||||||
|
$"`/birthday export` - {BirthdayModule.HelpCmdExport}\n" +
|
||||||
|
$"`/config` - {ConfigModule.HelpCmdConfig}\n" +
|
||||||
|
$"` ⤷check` - {ConfigModule.HelpCmdCheck}\n" +
|
||||||
|
$"` ⤷announce` - {ConfigModule.HelpCmdAnnounce}\n" +
|
||||||
|
$"` ⤷` See also: `/config announce help`.\n" +
|
||||||
|
$"` ⤷block` - {ConfigModule.HelpCmdBlocking}\n" +
|
||||||
|
$"` ⤷add-block`, `⤷remove-block`, `⤷set-moderated`\n" +
|
||||||
|
$"` ⤷role` - {ConfigModule.HelpCmdRole}\n" +
|
||||||
|
$"` ⤷set-birthday-role`, `⤷set-moderator-role`\n" +
|
||||||
|
$"`/override` - {BirthdayOverrideModule.HelpCmdOverride}\n" +
|
||||||
|
$"` ⤷set-birthday`, `⤷set-timezone`, `⤷remove`\n" +
|
||||||
|
"**Caution:** Skipping optional parameters __removes__ their configuration.";
|
||||||
|
|
||||||
|
[SlashCommand("help", "Show an overview of available commands.")]
|
||||||
|
public async Task CmdHelp() {
|
||||||
|
const string DMWarn = "Please note that this bot works in servers only. " +
|
||||||
|
"The bot will not respond to any other commands within a DM.";
|
||||||
|
|
||||||
|
string ver =
|
||||||
|
#if DEBUG
|
||||||
|
"DEBUG flag set";
|
||||||
|
#else
|
||||||
|
"v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
||||||
|
#endif
|
||||||
|
var result = new EmbedBuilder()
|
||||||
|
.WithAuthor("Help & About")
|
||||||
|
.WithFooter($"Birthday Bot {ver} - Shard {Shard.ShardId:00} up {Program.BotUptime}",
|
||||||
|
Context.Client.CurrentUser.GetAvatarUrl())
|
||||||
|
.WithDescription(TopMessage)
|
||||||
|
.AddField("Commands", RegularCommandsField)
|
||||||
|
.AddField("Moderator commands", ModCommandsField)
|
||||||
|
.Build();
|
||||||
|
await RespondAsync(text: (Context.Channel is IDMChannel ? DMWarn : null), embed: result).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
31
ApplicationCommands/Preconditions.cs
Normal file
31
ApplicationCommands/Preconditions.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
|
|
||||||
|
namespace BirthdayBot.ApplicationCommands;
|
||||||
|
|
||||||
|
// Contains preconditions used by our interaction modules.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Precondition requiring the executing user be considered a bot moderator.
|
||||||
|
/// That is, they must either have the Manage Server permission or be a member of the designated bot moderator role.
|
||||||
|
/// </summary>
|
||||||
|
class RequireBotModeratorAttribute : PreconditionAttribute {
|
||||||
|
public const string FailMsg = "User did not pass the mod check.";
|
||||||
|
public const string Reply = ":x: You must be a moderator to use this command.";
|
||||||
|
|
||||||
|
public override string ErrorMessage => FailMsg;
|
||||||
|
|
||||||
|
public override async Task<PreconditionResult> CheckRequirementsAsync(
|
||||||
|
IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) {
|
||||||
|
if (context.User is not SocketGuildUser user) {
|
||||||
|
return PreconditionResult.FromError("Failed due to non-guild context.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.GuildPermissions.ManageGuild) return PreconditionResult.FromSuccess();
|
||||||
|
var gconf = await ((SocketGuild)context.Guild).GetConfigAsync().ConfigureAwait(false);
|
||||||
|
if (gconf.ModeratorRole.HasValue && user.Roles.Any(r => r.Id == gconf.ModeratorRole.Value))
|
||||||
|
return PreconditionResult.FromSuccess();
|
||||||
|
|
||||||
|
return PreconditionResult.FromError(FailMsg);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.6</Version>
|
<Version>3.3.0</Version>
|
||||||
<Authors>NoiTheCat</Authors>
|
<Authors>NoiTheCat</Authors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
@ -22,10 +22,11 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
||||||
<PackageReference Include="Discord.Net" Version="3.1.0" />
|
<PackageReference Include="Discord.Net" Version="3.4.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
<PackageReference Include="NodaTime" Version="3.0.9" />
|
<PackageReference Include="NodaTime" Version="3.0.9" />
|
||||||
<PackageReference Include="Npgsql" Version="6.0.2" />
|
<PackageReference Include="Npgsql" Version="6.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -43,8 +43,6 @@ static class Common {
|
||||||
// For guilds of size over 30, require 85% or more of the members to be known
|
// For guilds of size over 30, require 85% or more of the members to be known
|
||||||
// (26/30, 42/50, 255/300, etc)
|
// (26/30, 42/50, 255/300, etc)
|
||||||
int threshold = (int)(guild.MemberCount * 0.85);
|
int threshold = (int)(guild.MemberCount * 0.85);
|
||||||
Program.Log(nameof(HasMostMembersDownloaded),
|
|
||||||
$"Passing with {guild.DownloadedMemberCount}/{guild.MemberCount} in cache for guild {guild.Id}.");
|
|
||||||
return guild.DownloadedMemberCount >= threshold;
|
return guild.DownloadedMemberCount >= threshold;
|
||||||
} else {
|
} else {
|
||||||
// For smaller guilds, fail if two or more members are missing
|
// For smaller guilds, fail if two or more members are missing
|
||||||
|
|
21
Data/Extensions.cs
Normal file
21
Data/Extensions.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
namespace BirthdayBot.Data;
|
||||||
|
|
||||||
|
internal static class Extensions {
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the database-backed bot configuration for this guild.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<GuildConfiguration> GetConfigAsync(this SocketGuild guild)
|
||||||
|
=> await GuildConfiguration.LoadAsync(guild.Id, false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a collection of all existing user configurations for this guild.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<IEnumerable<GuildUserConfiguration>> GetUserConfigurationsAsync(this SocketGuild guild)
|
||||||
|
=> await GuildUserConfiguration.LoadAllAsync(guild.Id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the database-backed bot configuration (birthday info) for this guild user.
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<GuildUserConfiguration> GetConfigAsync(this SocketGuildUser user)
|
||||||
|
=> await GuildUserConfiguration.LoadAsync(user.Guild.Id, user.Id);
|
||||||
|
}
|
|
@ -125,6 +125,7 @@ class GuildConfiguration {
|
||||||
/// Checks if the given user can be considered a bot moderator.
|
/// Checks if the given user can be considered a bot moderator.
|
||||||
/// Checks for either the Manage Guild permission or if the user is within a predetermined role.
|
/// Checks for either the Manage Guild permission or if the user is within a predetermined role.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("Usage should be phased out when text commands are removed. Use PreconditionAttribute from now on.", error: false)]
|
||||||
public bool IsBotModerator(SocketGuildUser user)
|
public bool IsBotModerator(SocketGuildUser user)
|
||||||
=> user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value));
|
=> user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value));
|
||||||
|
|
||||||
|
@ -165,6 +166,7 @@ class GuildConfiguration {
|
||||||
/// If true, this method shall not create a new entry and will return null if the guild does
|
/// If true, this method shall not create a new entry and will return null if the guild does
|
||||||
/// not exist in the database.
|
/// not exist in the database.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
[Obsolete("Begin using extension method to retrieve necessary data instead.", false)]
|
||||||
public static async Task<GuildConfiguration?> LoadAsync(ulong guildId, bool nullIfUnknown) {
|
public static async Task<GuildConfiguration?> LoadAsync(ulong guildId, bool nullIfUnknown) {
|
||||||
// TODO nullable static analysis problem: how to indicate non-null return when nullIfUnknown parameter is true?
|
// TODO nullable static analysis problem: how to indicate non-null return when nullIfUnknown parameter is true?
|
||||||
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {
|
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace BirthdayBot.Data;
|
namespace BirthdayBot.Data;
|
||||||
|
|
||||||
|
@ -23,7 +20,7 @@ class GuildUserConfiguration {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int BirthDay { get; private set; }
|
public int BirthDay { get; private set; }
|
||||||
|
|
||||||
public string TimeZone { get; private set; }
|
public string? TimeZone { get; private set; }
|
||||||
public bool IsKnown { get { return BirthMonth != 0 && BirthDay != 0; } }
|
public bool IsKnown { get { return BirthMonth != 0 && BirthDay != 0; } }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -47,7 +44,7 @@ class GuildUserConfiguration {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates user with given information.
|
/// Updates user with given information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task UpdateAsync(int month, int day, string newtz) {
|
public async Task UpdateAsync(int month, int day, string? newtz) {
|
||||||
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {
|
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
c.CommandText = $"insert into {BackingTable} "
|
c.CommandText = $"insert into {BackingTable} "
|
||||||
|
@ -109,6 +106,7 @@ class GuildUserConfiguration {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
|
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
|
||||||
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId) {
|
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId) {
|
||||||
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
|
@ -125,6 +123,7 @@ class GuildUserConfiguration {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all known user configuration records associated with the specified guild.
|
/// Gets all known user configuration records associated with the specified guild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
|
||||||
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId) {
|
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId) {
|
||||||
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
|
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
|
||||||
using var c = db.CreateCommand();
|
using var c = db.CreateCommand();
|
||||||
|
|
129
ShardInstance.cs
129
ShardInstance.cs
|
@ -1,50 +1,65 @@
|
||||||
using BirthdayBot.BackgroundServices;
|
using BirthdayBot.ApplicationCommands;
|
||||||
|
using BirthdayBot.BackgroundServices;
|
||||||
using BirthdayBot.Data;
|
using BirthdayBot.Data;
|
||||||
|
using Discord.Interactions;
|
||||||
using Discord.Net;
|
using Discord.Net;
|
||||||
using static BirthdayBot.UserInterface.CommandsCommon;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Reflection;
|
||||||
|
using static BirthdayBot.TextCommands.CommandsCommon;
|
||||||
|
|
||||||
namespace BirthdayBot;
|
namespace BirthdayBot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
|
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class ShardInstance : IDisposable {
|
public sealed class ShardInstance : IDisposable {
|
||||||
private readonly ShardManager _manager;
|
private readonly ShardManager _manager;
|
||||||
private readonly ShardBackgroundWorker _background;
|
private readonly ShardBackgroundWorker _background;
|
||||||
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
|
private readonly Dictionary<string, CommandHandler> _textDispatch;
|
||||||
|
private readonly InteractionService _interactionService;
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
public DiscordSocketClient DiscordClient { get; }
|
internal DiscordSocketClient DiscordClient { get; }
|
||||||
public int ShardId => DiscordClient.ShardId;
|
public int ShardId => DiscordClient.ShardId;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a value showing the time in which the last background run successfully completed.
|
/// Returns a value showing the time in which the last background run successfully completed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
|
internal DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the name of the background service currently in execution.
|
/// Returns the name of the background service currently in execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CurrentExecutingService => _background.CurrentExecutingService;
|
internal string? CurrentExecutingService => _background.CurrentExecutingService;
|
||||||
public 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.";
|
||||||
|
|
||||||
/// <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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ShardInstance(ShardManager manager, DiscordSocketClient client, Dictionary<string, CommandHandler> commands) {
|
internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary<string, CommandHandler> textCmds) {
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_dispatchCommands = commands;
|
_services = services;
|
||||||
|
_textDispatch = textCmds;
|
||||||
|
|
||||||
DiscordClient = client;
|
DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
|
||||||
DiscordClient.Log += Client_Log;
|
DiscordClient.Log += Client_Log;
|
||||||
DiscordClient.Ready += Client_Ready;
|
DiscordClient.Ready += Client_Ready;
|
||||||
DiscordClient.MessageReceived += Client_MessageReceived;
|
DiscordClient.MessageReceived += Client_MessageReceived;
|
||||||
|
|
||||||
|
_interactionService = _services.GetRequiredService<InteractionService>();
|
||||||
|
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
||||||
|
_interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
|
||||||
|
|
||||||
// Background task constructor begins background processing immediately.
|
// Background task constructor begins background processing immediately.
|
||||||
_background = new ShardBackgroundWorker(this);
|
_background = new ShardBackgroundWorker(this);
|
||||||
|
Log(nameof(ShardInstance), "Instance created.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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);
|
||||||
}
|
}
|
||||||
|
@ -53,20 +68,15 @@ class ShardInstance : IDisposable {
|
||||||
/// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting.
|
/// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
DiscordClient.Log -= Client_Log;
|
|
||||||
DiscordClient.Ready -= Client_Ready;
|
|
||||||
DiscordClient.MessageReceived -= Client_MessageReceived;
|
|
||||||
|
|
||||||
_background.Dispose();
|
_background.Dispose();
|
||||||
DiscordClient.LogoutAsync().Wait(5000);
|
DiscordClient.LogoutAsync().Wait(5000);
|
||||||
DiscordClient.StopAsync().Wait(5000);
|
|
||||||
DiscordClient.Dispose();
|
DiscordClient.Dispose();
|
||||||
Log(nameof(ShardInstance), "Shard instance disposed.");
|
_interactionService.Dispose();
|
||||||
|
Log(nameof(ShardInstance), "Instance disposed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
#region Event handling
|
|
||||||
private Task Client_Log(LogMessage arg) {
|
private Task Client_Log(LogMessage arg) {
|
||||||
// Suppress certain messages
|
// Suppress certain messages
|
||||||
if (arg.Message != null) {
|
if (arg.Message != null) {
|
||||||
|
@ -94,9 +104,27 @@ class ShardInstance : IDisposable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the shard's status to display the help command.
|
/// Registers all available slash commands.
|
||||||
|
/// Additionally, sets the shard's status to display the help command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task Client_Ready() => await DiscordClient.SetGameAsync(CommandPrefix + "help");
|
private async Task Client_Ready() {
|
||||||
|
// TODO get rid of this eventually? or change it to something fun...
|
||||||
|
await DiscordClient.SetGameAsync("/help");
|
||||||
|
|
||||||
|
#if !DEBUG
|
||||||
|
// Update slash/interaction commands
|
||||||
|
if (ShardId == 0) {
|
||||||
|
await _interactionService.RegisterCommandsGloballyAsync(true).ConfigureAwait(false);
|
||||||
|
Log(nameof(ShardInstance), "Updated global command registration.");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Debug: Register our commands locally instead, in each guild we're in
|
||||||
|
foreach (var g in DiscordClient.Guilds) {
|
||||||
|
await _interactionService.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false);
|
||||||
|
Log(nameof(ShardInstance), $"Updated DEBUG command registration in guild {g.Id}.");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
|
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
|
||||||
|
@ -113,7 +141,7 @@ class ShardInstance : IDisposable {
|
||||||
var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
|
var csplit = msg.Content.Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) {
|
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) {
|
||||||
// Determine if it's something we're listening for.
|
// Determine if it's something we're listening for.
|
||||||
if (!_dispatchCommands.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler? command)) return;
|
if (!_textDispatch.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler? command)) return;
|
||||||
|
|
||||||
// Load guild information here
|
// Load guild information here
|
||||||
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
|
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
|
||||||
|
@ -132,10 +160,65 @@ class ShardInstance : IDisposable {
|
||||||
if (ex is HttpException) return;
|
if (ex is HttpException) return;
|
||||||
Log("Command", ex.ToString());
|
Log("Command", ex.ToString());
|
||||||
try {
|
try {
|
||||||
channel.SendMessageAsync(UserInterface.CommandsCommon.InternalError).Wait();
|
channel.SendMessageAsync(InternalError).Wait();
|
||||||
} catch (HttpException) { } // Fail silently
|
} catch (HttpException) { } // Fail silently
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
|
||||||
|
// Slash command preparation and invocation
|
||||||
|
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
||||||
|
var context = new SocketInteractionContext(DiscordClient, arg);
|
||||||
|
|
||||||
|
// Blocklist/moderated check
|
||||||
|
// TODO convert to precondition
|
||||||
|
var gconf = await GuildConfiguration.LoadAsync(context.Guild.Id, false);
|
||||||
|
if (context.Channel is SocketGuildChannel) { // Check only if in a guild context
|
||||||
|
if (!gconf!.IsBotModerator((SocketGuildUser)arg.User)) { // Moderators exempted from this check
|
||||||
|
if (await gconf.IsUserBlockedAsync(arg.User.Id)) {
|
||||||
|
Log("Interaction", $"Interaction blocked per guild policy for {context.Guild}!{context.User}");
|
||||||
|
await arg.RespondAsync(BotModuleBase.AccessDeniedError, ephemeral: true).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _interactionService.ExecuteCommandAsync(context, _services).ConfigureAwait(false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log(nameof(DiscordClient_InteractionCreated), $"Unhandled exception. {e}");
|
||||||
|
// TODO when implementing proper error logging, see here
|
||||||
|
if (arg.Type == InteractionType.ApplicationCommand)
|
||||||
|
if (arg.HasResponded) await arg.ModifyOriginalResponseAsync(prop => prop.Content = ":warning: An unknown error occured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash command logging and failed execution handling
|
||||||
|
private async Task InteractionService_SlashCommandExecuted(SlashCommandInfo info, IInteractionContext context, IResult result) {
|
||||||
|
string sender;
|
||||||
|
if (context.Guild != null) {
|
||||||
|
sender = $"{context.Guild}!{context.User}";
|
||||||
|
} else {
|
||||||
|
sender = $"{context.User} in non-guild context";
|
||||||
|
}
|
||||||
|
var logresult = $"{(result.IsSuccess ? "Success" : "Fail")}: `/{info}` by {sender}.";
|
||||||
|
|
||||||
|
if (result.Error != null) {
|
||||||
|
// Additional log information with error detail
|
||||||
|
logresult += Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason;
|
||||||
|
|
||||||
|
// Specific responses to errors, if necessary
|
||||||
|
if (result.Error == InteractionCommandError.UnmetPrecondition && result.ErrorReason == RequireBotModeratorAttribute.FailMsg) {
|
||||||
|
await context.Interaction.RespondAsync(RequireBotModeratorAttribute.Reply, ephemeral: true).ConfigureAwait(false);
|
||||||
|
} else {
|
||||||
|
// Generic error response
|
||||||
|
var ia = context.Interaction;
|
||||||
|
if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError).ConfigureAwait(false);
|
||||||
|
else await ia.RespondAsync(InternalError).ConfigureAwait(false);
|
||||||
|
// TODO when implementing proper error logging, see here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("Command", logresult);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
global using Discord;
|
global using Discord;
|
||||||
global using Discord.WebSocket;
|
global using Discord.WebSocket;
|
||||||
using BirthdayBot.BackgroundServices;
|
using BirthdayBot.BackgroundServices;
|
||||||
using BirthdayBot.UserInterface;
|
using BirthdayBot.TextCommands;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using static BirthdayBot.UserInterface.CommandsCommon;
|
using static BirthdayBot.TextCommands.CommandsCommon;
|
||||||
|
|
||||||
namespace BirthdayBot;
|
namespace BirthdayBot;
|
||||||
|
|
||||||
|
@ -43,11 +45,7 @@ class ShardManager : IDisposable {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<int, ShardInstance?> _shards;
|
private readonly Dictionary<int, ShardInstance?> _shards;
|
||||||
|
|
||||||
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
|
private readonly Dictionary<string, CommandHandler> _textCommands;
|
||||||
private readonly UserCommands _cmdsUser;
|
|
||||||
private readonly ListingCommands _cmdsListing;
|
|
||||||
private readonly HelpInfoCommands _cmdsHelp;
|
|
||||||
private readonly ManagerCommands _cmdsMods;
|
|
||||||
|
|
||||||
private readonly Task _statusTask;
|
private readonly Task _statusTask;
|
||||||
private readonly CancellationTokenSource _mainCancel;
|
private readonly CancellationTokenSource _mainCancel;
|
||||||
|
@ -62,15 +60,15 @@ class ShardManager : IDisposable {
|
||||||
Config = cfg;
|
Config = cfg;
|
||||||
|
|
||||||
// Command handler setup
|
// Command handler setup
|
||||||
_dispatchCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
|
_textCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
|
||||||
_cmdsUser = new UserCommands(cfg);
|
var cmdsUser = new UserCommands(cfg);
|
||||||
foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
foreach (var item in cmdsUser.Commands) _textCommands.Add(item.Item1, item.Item2);
|
||||||
_cmdsListing = new ListingCommands(cfg);
|
var cmdsListing = new ListingCommands(cfg);
|
||||||
foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
foreach (var item in cmdsListing.Commands) _textCommands.Add(item.Item1, item.Item2);
|
||||||
_cmdsHelp = new HelpInfoCommands(cfg);
|
var cmdsHelp = new TextCommands.HelpInfoCommands(cfg);
|
||||||
foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
foreach (var item in cmdsHelp.Commands) _textCommands.Add(item.Item1, item.Item2);
|
||||||
_cmdsMods = new ManagerCommands(cfg, _cmdsUser.Commands);
|
var cmdsMods = new ManagerCommands(cfg, cmdsUser.Commands);
|
||||||
foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
|
foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2);
|
||||||
|
|
||||||
// Allocate shards based on configuration
|
// Allocate shards based on configuration
|
||||||
_shards = new Dictionary<int, ShardInstance?>();
|
_shards = new Dictionary<int, ShardInstance?>();
|
||||||
|
@ -118,13 +116,25 @@ class ShardManager : IDisposable {
|
||||||
DefaultRetryMode = RetryMode.AlwaysRetry,
|
DefaultRetryMode = RetryMode.AlwaysRetry,
|
||||||
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
|
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
|
||||||
};
|
};
|
||||||
var newClient = new DiscordSocketClient(clientConf);
|
var services = new ServiceCollection()
|
||||||
newInstance = new ShardInstance(this, newClient, _dispatchCommands);
|
.AddSingleton(s => new ShardInstance(this, s, _textCommands))
|
||||||
|
.AddSingleton(s => new DiscordSocketClient(clientConf))
|
||||||
|
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordSocketClient>()))
|
||||||
|
.BuildServiceProvider();
|
||||||
|
newInstance = services.GetRequiredService<ShardInstance>();
|
||||||
await newInstance.StartAsync().ConfigureAwait(false);
|
await newInstance.StartAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
return newInstance;
|
return newInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int? GetShardIdFor(ulong guildId) {
|
||||||
|
foreach (var sh in _shards.Values) {
|
||||||
|
if (sh == null) continue;
|
||||||
|
if (sh.DiscordClient.GetGuild(guildId) != null) return sh.ShardId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
#region Status checking and display
|
#region Status checking and display
|
||||||
private struct GuildStatusData {
|
private struct GuildStatusData {
|
||||||
public int GuildCount;
|
public int GuildCount;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
internal class CommandDocumentation {
|
internal class CommandDocumentation {
|
||||||
public string[] Commands { get; }
|
public string[] Commands { get; }
|
|
@ -3,7 +3,7 @@ using NodaTime;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Common base class for common constants and variables.
|
/// Common base class for common constants and variables.
|
||||||
|
@ -17,7 +17,6 @@ internal abstract class CommandsCommon {
|
||||||
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 unknown error occurred. If it persists, please notify the bot owner.";
|
|
||||||
public const string MemberCacheEmptyError = ":warning: Please try the command again.";
|
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,
|
||||||
|
@ -48,7 +47,6 @@ internal abstract class CommandsCommon {
|
||||||
/// Checks given time zone input. Returns a valid string for use with NodaTime.
|
/// Checks given time zone input. Returns a valid string for use with NodaTime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected static string ParseTimeZone(string tzinput) {
|
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."
|
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.");
|
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value.");
|
||||||
return tz;
|
return tz;
|
|
@ -1,7 +1,7 @@
|
||||||
using BirthdayBot.Data;
|
using BirthdayBot.Data;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
internal class HelpInfoCommands : CommandsCommon {
|
internal class HelpInfoCommands : CommandsCommon {
|
||||||
private readonly Embed _helpEmbed;
|
private readonly Embed _helpEmbed;
|
|
@ -1,7 +1,7 @@
|
||||||
using BirthdayBot.Data;
|
using BirthdayBot.Data;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Commands for listing upcoming and all birthdays.
|
/// Commands for listing upcoming and all birthdays.
|
||||||
|
@ -128,7 +128,7 @@ internal class ListingCommands : CommandsCommon {
|
||||||
reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
|
reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Program.Log("Listing", ex.ToString());
|
Program.Log("Listing", ex.ToString());
|
||||||
reqChannel.SendMessageAsync(InternalError).Wait();
|
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
|
||||||
} finally {
|
} finally {
|
||||||
File.Delete(filepath);
|
File.Delete(filepath);
|
||||||
}
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
internal class ManagerCommands : CommandsCommon {
|
internal class ManagerCommands : CommandsCommon {
|
||||||
private static readonly string ConfErrorPostfix =
|
private static readonly string ConfErrorPostfix =
|
|
@ -1,7 +1,7 @@
|
||||||
using BirthdayBot.Data;
|
using BirthdayBot.Data;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace BirthdayBot.UserInterface;
|
namespace BirthdayBot.TextCommands;
|
||||||
|
|
||||||
internal class UserCommands : CommandsCommon {
|
internal class UserCommands : CommandsCommon {
|
||||||
public UserCommands(Configuration db) : base(db) { }
|
public UserCommands(Configuration db) : base(db) { }
|
||||||
|
@ -120,7 +120,7 @@ internal class UserCommands : CommandsCommon {
|
||||||
await user.UpdateAsync(bmonth, bday, user.TimeZone).ConfigureAwait(false);
|
await user.UpdateAsync(bmonth, bday, user.TimeZone).ConfigureAwait(false);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Program.Log("Error", ex.ToString());
|
Program.Log("Error", ex.ToString());
|
||||||
reqChannel.SendMessageAsync(InternalError).Wait();
|
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await reqChannel.SendMessageAsync($":white_check_mark: Your birthday has been { (known ? "updated" : "recorded") }.")
|
await reqChannel.SendMessageAsync($":white_check_mark: Your birthday has been { (known ? "updated" : "recorded") }.")
|
Loading…
Reference in a new issue