Merge branch 'slashcommands'

This commit is contained in:
Noi 2022-03-11 10:33:26 -08:00
commit 85b23e255c
19 changed files with 1018 additions and 61 deletions

View 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
}

View 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);
}
}
}

View 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
}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View file

@ -5,7 +5,7 @@
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.2.6</Version>
<Version>3.3.0</Version>
<Authors>NoiTheCat</Authors>
</PropertyGroup>
@ -22,10 +22,11 @@
<ItemGroup>
<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="NodaTime" Version="3.0.9" />
<PackageReference Include="Npgsql" Version="6.0.2" />
<PackageReference Include="Npgsql" Version="6.0.3" />
</ItemGroup>
</Project>

View file

@ -43,8 +43,6 @@ static class Common {
// For guilds of size over 30, require 85% or more of the members to be known
// (26/30, 42/50, 255/300, etc)
int threshold = (int)(guild.MemberCount * 0.85);
Program.Log(nameof(HasMostMembersDownloaded),
$"Passing with {guild.DownloadedMemberCount}/{guild.MemberCount} in cache for guild {guild.Id}.");
return guild.DownloadedMemberCount >= threshold;
} else {
// For smaller guilds, fail if two or more members are missing

21
Data/Extensions.cs Normal file
View 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);
}

View file

@ -125,6 +125,7 @@ class GuildConfiguration {
/// 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.
/// </summary>
[Obsolete("Usage should be phased out when text commands are removed. Use PreconditionAttribute from now on.", error: false)]
public bool IsBotModerator(SocketGuildUser user)
=> 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
/// not exist in the database.
/// </param>
[Obsolete("Begin using extension method to retrieve necessary data instead.", false)]
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?
using (var db = await Database.OpenConnectionAsync().ConfigureAwait(false)) {

View file

@ -1,9 +1,6 @@
using Npgsql;
using NpgsqlTypes;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading.Tasks;
namespace BirthdayBot.Data;
@ -23,7 +20,7 @@ class GuildUserConfiguration {
/// </summary>
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; } }
/// <summary>
@ -47,7 +44,7 @@ class GuildUserConfiguration {
/// <summary>
/// Updates user with given information.
/// </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 c = db.CreateCommand();
c.CommandText = $"insert into {BackingTable} "
@ -109,6 +106,7 @@ class GuildUserConfiguration {
/// <summary>
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
/// </summary>
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
@ -125,6 +123,7 @@ class GuildUserConfiguration {
/// <summary>
/// Gets all known user configuration records associated with the specified guild.
/// </summary>
[Obsolete("Migrate to using extension methods to retrieve necessary data instead.", false)]
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();

View file

@ -1,50 +1,65 @@
using BirthdayBot.BackgroundServices;
using BirthdayBot.ApplicationCommands;
using BirthdayBot.BackgroundServices;
using BirthdayBot.Data;
using Discord.Interactions;
using Discord.Net;
using static BirthdayBot.UserInterface.CommandsCommon;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using static BirthdayBot.TextCommands.CommandsCommon;
namespace BirthdayBot;
/// <summary>
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
/// </summary>
class ShardInstance : IDisposable {
public sealed class ShardInstance : IDisposable {
private readonly ShardManager _manager;
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;
/// <summary>
/// Returns a value showing the time in which the last background run successfully completed.
/// </summary>
public DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
internal DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun;
/// <summary>
/// Returns the name of the background service currently in execution.
/// </summary>
public string? CurrentExecutingService => _background.CurrentExecutingService;
public Configuration Config => _manager.Config;
internal string? CurrentExecutingService => _background.CurrentExecutingService;
internal Configuration Config => _manager.Config;
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
/// <summary>
/// Prepares and configures the shard instances, but does not yet start its connection.
/// </summary>
public ShardInstance(ShardManager manager, DiscordSocketClient client, Dictionary<string, CommandHandler> commands) {
internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary<string, CommandHandler> textCmds) {
_manager = manager;
_dispatchCommands = commands;
_services = services;
_textDispatch = textCmds;
DiscordClient = client;
DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
DiscordClient.Log += Client_Log;
DiscordClient.Ready += Client_Ready;
DiscordClient.MessageReceived += Client_MessageReceived;
_interactionService = _services.GetRequiredService<InteractionService>();
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
_interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted;
// Background task constructor begins background processing immediately.
_background = new ShardBackgroundWorker(this);
Log(nameof(ShardInstance), "Instance created.");
}
/// <summary>
/// Starts up this shard's connection to Discord and background task handling associated with it.
/// </summary>
public async Task StartAsync() {
await _interactionService.AddModulesAsync(Assembly.GetExecutingAssembly(), _services).ConfigureAwait(false);
await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).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.
/// </summary>
public void Dispose() {
DiscordClient.Log -= Client_Log;
DiscordClient.Ready -= Client_Ready;
DiscordClient.MessageReceived -= Client_MessageReceived;
_background.Dispose();
DiscordClient.LogoutAsync().Wait(5000);
DiscordClient.StopAsync().Wait(5000);
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);
#region Event handling
private Task Client_Log(LogMessage arg) {
// Suppress certain messages
if (arg.Message != null) {
@ -94,9 +104,27 @@ class ShardInstance : IDisposable {
}
/// <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>
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>
/// 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);
if (csplit.Length > 0 && csplit[0].StartsWith(CommandPrefix, StringComparison.OrdinalIgnoreCase)) {
// 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
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
@ -132,10 +160,65 @@ class ShardInstance : IDisposable {
if (ex is HttpException) return;
Log("Command", ex.ToString());
try {
channel.SendMessageAsync(UserInterface.CommandsCommon.InternalError).Wait();
channel.SendMessageAsync(InternalError).Wait();
} 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);
}
}

View file

@ -1,9 +1,11 @@
global using Discord;
global using Discord.WebSocket;
using BirthdayBot.BackgroundServices;
using BirthdayBot.UserInterface;
using BirthdayBot.TextCommands;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
using static BirthdayBot.UserInterface.CommandsCommon;
using static BirthdayBot.TextCommands.CommandsCommon;
namespace BirthdayBot;
@ -43,11 +45,7 @@ class ShardManager : IDisposable {
/// </summary>
private readonly Dictionary<int, ShardInstance?> _shards;
private readonly Dictionary<string, CommandHandler> _dispatchCommands;
private readonly UserCommands _cmdsUser;
private readonly ListingCommands _cmdsListing;
private readonly HelpInfoCommands _cmdsHelp;
private readonly ManagerCommands _cmdsMods;
private readonly Dictionary<string, CommandHandler> _textCommands;
private readonly Task _statusTask;
private readonly CancellationTokenSource _mainCancel;
@ -62,15 +60,15 @@ class ShardManager : IDisposable {
Config = cfg;
// Command handler setup
_dispatchCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
_cmdsUser = new UserCommands(cfg);
foreach (var item in _cmdsUser.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
_cmdsListing = new ListingCommands(cfg);
foreach (var item in _cmdsListing.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
_cmdsHelp = new HelpInfoCommands(cfg);
foreach (var item in _cmdsHelp.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
_cmdsMods = new ManagerCommands(cfg, _cmdsUser.Commands);
foreach (var item in _cmdsMods.Commands) _dispatchCommands.Add(item.Item1, item.Item2);
_textCommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
var cmdsUser = new UserCommands(cfg);
foreach (var item in cmdsUser.Commands) _textCommands.Add(item.Item1, item.Item2);
var cmdsListing = new ListingCommands(cfg);
foreach (var item in cmdsListing.Commands) _textCommands.Add(item.Item1, item.Item2);
var cmdsHelp = new TextCommands.HelpInfoCommands(cfg);
foreach (var item in cmdsHelp.Commands) _textCommands.Add(item.Item1, item.Item2);
var cmdsMods = new ManagerCommands(cfg, cmdsUser.Commands);
foreach (var item in cmdsMods.Commands) _textCommands.Add(item.Item1, item.Item2);
// Allocate shards based on configuration
_shards = new Dictionary<int, ShardInstance?>();
@ -118,13 +116,25 @@ class ShardManager : IDisposable {
DefaultRetryMode = RetryMode.AlwaysRetry,
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages
};
var newClient = new DiscordSocketClient(clientConf);
newInstance = new ShardInstance(this, newClient, _dispatchCommands);
var services = new ServiceCollection()
.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);
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
private struct GuildStatusData {
public int GuildCount;

View file

@ -1,6 +1,6 @@
using System.Text;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
internal class CommandDocumentation {
public string[] Commands { get; }

View file

@ -3,7 +3,7 @@ using NodaTime;
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
/// <summary>
/// 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 ParameterError = ":x: Invalid usage. Refer to how to use the command and try again.";
public const string NoParameterError = ":x: This command does not accept any parameters.";
public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner.";
public const string MemberCacheEmptyError = ":warning: Please try the command again.";
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.
/// </summary>
protected static string ParseTimeZone(string tzinput) {
if (tzinput.Equals("Asia/Calcutta", StringComparison.OrdinalIgnoreCase)) tzinput = "Asia/Kolkata";
if (!TzNameMap.TryGetValue(tzinput, out string? tz)) throw new FormatException(":x: Unexpected time zone name."
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value.");
return tz;

View file

@ -1,7 +1,7 @@
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
internal class HelpInfoCommands : CommandsCommon {
private readonly Embed _helpEmbed;

View file

@ -1,7 +1,7 @@
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
/// <summary>
/// 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();
} catch (Exception ex) {
Program.Log("Listing", ex.ToString());
reqChannel.SendMessageAsync(InternalError).Wait();
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
} finally {
File.Delete(filepath);
}

View file

@ -2,7 +2,7 @@
using System.Text;
using System.Text.RegularExpressions;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
internal class ManagerCommands : CommandsCommon {
private static readonly string ConfErrorPostfix =

View file

@ -1,7 +1,7 @@
using BirthdayBot.Data;
using System.Text.RegularExpressions;
namespace BirthdayBot.UserInterface;
namespace BirthdayBot.TextCommands;
internal class UserCommands : CommandsCommon {
public UserCommands(Configuration db) : base(db) { }
@ -120,7 +120,7 @@ internal class UserCommands : CommandsCommon {
await user.UpdateAsync(bmonth, bday, user.TimeZone).ConfigureAwait(false);
} catch (Exception ex) {
Program.Log("Error", ex.ToString());
reqChannel.SendMessageAsync(InternalError).Wait();
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
return;
}
await reqChannel.SendMessageAsync($":white_check_mark: Your birthday has been { (known ? "updated" : "recorded") }.")