mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-21 21: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>
|
||||
<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>
|
||||
|
|
|
@ -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
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 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)) {
|
||||
|
|
|
@ -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();
|
||||
|
|
129
ShardInstance.cs
129
ShardInstance.cs
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
using System.Text;
|
||||
|
||||
namespace BirthdayBot.UserInterface;
|
||||
namespace BirthdayBot.TextCommands;
|
||||
|
||||
internal class CommandDocumentation {
|
||||
public string[] Commands { get; }
|
|
@ -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;
|
|
@ -1,7 +1,7 @@
|
|||
using BirthdayBot.Data;
|
||||
using System.Text;
|
||||
|
||||
namespace BirthdayBot.UserInterface;
|
||||
namespace BirthdayBot.TextCommands;
|
||||
|
||||
internal class HelpInfoCommands : CommandsCommon {
|
||||
private readonly Embed _helpEmbed;
|
|
@ -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);
|
||||
}
|
|
@ -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 =
|
|
@ -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") }.")
|
Loading…
Reference in a new issue