Remove text-based commands

This commit is contained in:
Noi 2022-08-29 12:22:42 -07:00
parent f59dbc0e19
commit b9604dadfe
14 changed files with 9 additions and 1865 deletions

View file

@ -78,36 +78,6 @@ class BirthdayRoleUpdate : BackgroundService {
else if (exceptions.Count == 1) throw new Exception("An unhandled exception occurred when processing a birthday.", exceptions[0]);
}
/// <summary>
/// Gets all known users from the given guild and returns a list including only those who are
/// currently experiencing a birthday in the respective time zone.
/// </summary>
[Obsolete(Database.ObsoleteReason)]
public static HashSet<ulong> GetGuildCurrentBirthdays(IEnumerable<GuildUserConfiguration> guildUsers, string? defaultTzStr) {
var tzdb = DateTimeZoneProviders.Tzdb;
DateTimeZone defaultTz = (defaultTzStr != null ? DateTimeZoneProviders.Tzdb.GetZoneOrNull(defaultTzStr) : null)
?? tzdb.GetZoneOrNull("UTC")!;
var birthdayUsers = new HashSet<ulong>();
foreach (var item in guildUsers) {
// Determine final time zone to use for calculation
DateTimeZone tz = (item.TimeZone != null ? tzdb.GetZoneOrNull(item.TimeZone) : null) ?? defaultTz;
var targetMonth = item.BirthMonth;
var targetDay = item.BirthDay;
var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz);
// Special case: If birthday is February 29 and it's not a leap year, recognize it on March 1st
if (targetMonth == 2 && targetDay == 29 && !DateTime.IsLeapYear(checkNow.Year)) {
targetMonth = 3;
targetDay = 1;
}
if (targetMonth == checkNow.Month && targetDay == checkNow.Day) {
birthdayUsers.Add(item.UserId);
}
}
return birthdayUsers;
}
/// <summary>
/// Gets all known users from the given guild and returns a list including only those who are
/// currently experiencing a birthday in the respective time zone.

View file

@ -1,28 +0,0 @@
using Npgsql;
namespace BirthdayBot.Data;
[Obsolete(ObsoleteReason, error: false)]
internal static class Database {
public const string ObsoleteReason = "Will be removed in favor of EF6 stuff when text commands are removed";
public static string DBConnectionString { get; set; } = null!;
/// <summary>
/// Sets up and opens a database connection.
/// </summary>
public static async Task<NpgsqlConnection> OpenConnectionAsync() {
var db = new NpgsqlConnection(DBConnectionString);
await db.OpenAsync().ConfigureAwait(false);
return db;
}
public static async Task DoInitialDatabaseSetupAsync() {
using var db = await OpenConnectionAsync().ConfigureAwait(false);
// Refer to the methods being called for information on how the database is set up.
// Note: The order these are called is important. (Foreign reference constraints.)
await GuildConfiguration.DatabaseSetupAsync(db).ConfigureAwait(false);
await GuildUserConfiguration.DatabaseSetupAsync(db).ConfigureAwait(false);
}
}

View file

@ -1,253 +0,0 @@
using Npgsql;
using NpgsqlTypes;
using System.Data.Common;
namespace BirthdayBot.Data;
/// <summary>
/// Represents guild-specific configuration as exists in the database.
/// Updating any property requires a call to <see cref="UpdateAsync"/> for changes to take effect.
/// </summary>
[Obsolete(Database.ObsoleteReason, error: false)]
class GuildConfiguration {
/// <summary>
/// Gets this configuration's corresponding guild ID.
/// </summary>
public ulong GuildId { get; }
/// <summary>
/// Gets or sets the guild's designated usable role ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? RoleId { get; set; }
/// <summary>
/// Gets or sets the announcement channel ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? AnnounceChannelId { get; set; }
/// <summary>
/// Gets or sets the guild's default time zone ztring.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public string? TimeZone { get; set; }
/// <summary>
/// Gets or sets the guild's moderated mode setting.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public bool IsModerated { get; set; }
/// <summary>
/// Gets or sets the guild's corresponding bot moderator role ID.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public ulong? ModeratorRole { get; set; }
/// <summary>
/// Gets or sets the guild-specific birthday announcement message.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public (string?, string?) AnnounceMessages { get; set; }
/// <summary>
/// Gets or sets the announcement ping setting.
/// Updating this value requires a call to <see cref="UpdateAsync"/>.
/// </summary>
public bool AnnouncePing { get; set; }
// Called by Load. Double-check ordinals when changes are made.
private GuildConfiguration(DbDataReader reader) {
GuildId = (ulong)reader.GetInt64(0);
if (!reader.IsDBNull(1)) RoleId = (ulong)reader.GetInt64(1);
if (!reader.IsDBNull(2)) AnnounceChannelId = (ulong)reader.GetInt64(2);
TimeZone = reader.IsDBNull(3) ? null : reader.GetString(3);
IsModerated = reader.GetBoolean(4);
if (!reader.IsDBNull(5)) ModeratorRole = (ulong)reader.GetInt64(5);
string? announceMsg = reader.IsDBNull(6) ? null : reader.GetString(6);
string? announceMsgPl = reader.IsDBNull(7) ? null : reader.GetString(7);
AnnounceMessages = (announceMsg, announceMsgPl);
AnnouncePing = reader.GetBoolean(8);
}
/// <summary>
/// Checks if the specified user is blocked by current guild policy (block list or moderated mode).
/// </summary>
public async Task<bool> IsUserBlockedAsync(ulong userId) {
if (IsModerated) return true;
return await IsUserInBlocklistAsync(userId).ConfigureAwait(false);
}
/// <summary>
/// Checks if the given user exists in the block list.
/// </summary>
public async Task<bool> IsUserInBlocklistAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"select * from {BackingTableBans} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
using var r = await c.ExecuteReaderAsync().ConfigureAwait(false);
if (!await r.ReadAsync().ConfigureAwait(false)) return false;
return true;
}
/// <summary>
/// Adds the specified user to the block list corresponding to this guild.
/// </summary>
public async Task BlockUserAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"insert into {BackingTableBans} (guild_id, user_id) "
+ "values (@Gid, @Uid) "
+ "on conflict (guild_id, user_id) do nothing";
// There is no validation on whether the requested user is even in the guild. will this be a problem?
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
/// <summary>
/// Removes the specified user from the block list corresponding to this guild.
/// </summary>
/// <returns>True if a user has been removed, false if the requested user was not in this list.</returns>
public async Task<bool> UnblockUserAsync(ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"delete from {BackingTableBans} where "
+ "guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
var result = await c.ExecuteNonQueryAsync().ConfigureAwait(false);
return result != 0;
}
/// <summary>
/// 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>
public bool IsBotModerator(SocketGuildUser user)
=> user.GuildPermissions.ManageGuild || (ModeratorRole.HasValue && user.Roles.Any(r => r.Id == ModeratorRole.Value));
#region Database
public const string BackingTable = "settings";
public const string BackingTableBans = "banned_users";
[Obsolete("DELETE THIS", error: true)]
internal static async Task DatabaseSetupAsync(NpgsqlConnection db) {
using (var c = db.CreateCommand()) {
c.CommandText = $"create table if not exists {BackingTable} ("
+ "guild_id bigint primary key, "
+ "role_id bigint null, "
+ "channel_announce_id bigint null, "
+ "time_zone text null, "
+ "moderated boolean not null default FALSE, "
+ "moderator_role bigint null, "
+ "announce_message text null, "
+ "announce_message_pl text null, "
+ "announce_ping boolean not null default FALSE, "
+ "last_seen timestamptz not null default NOW()"
+ ")";
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
using (var c = db.CreateCommand()) {
c.CommandText = $"create table if not exists {BackingTableBans} ("
+ $"guild_id bigint not null references {BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "PRIMARY KEY (guild_id, user_id)"
+ ")";
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Fetches guild settings from the database. If no corresponding entry exists, it will be created.
/// </summary>
/// <param name="nullIfUnknown">
/// If true, this method shall not create a new entry and will return null if the guild does
/// not exist in the database.
/// </param>
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)) {
using (var c = db.CreateCommand()) {
// Take note of ordinals for the constructor
c.CommandText = "select guild_id, role_id, channel_announce_id, time_zone, "
+ " moderated, moderator_role, announce_message, announce_message_pl, announce_ping "
+ $"from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
using var r = await c.ExecuteReaderAsync().ConfigureAwait(false);
if (await r.ReadAsync().ConfigureAwait(false)) return new GuildConfiguration(r);
}
if (nullIfUnknown) return null;
// If we got here, no row exists. Create it with default values.
using (var c = db.CreateCommand()) {
c.CommandText = $"insert into {BackingTable} (guild_id) values (@Gid)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
}
// With a new row created, try this again
return await LoadAsync(guildId, nullIfUnknown).ConfigureAwait(false);
}
/// <summary>
/// Updates values on the backing database with values from this object instance.
/// </summary>
public async Task UpdateAsync() {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"update {BackingTable} set "
+ "role_id = @RoleId, "
+ "channel_announce_id = @ChannelId, "
+ "time_zone = @TimeZone, "
+ "moderated = @Moderated, "
+ "moderator_role = @ModRole, "
+ "announce_message = @AnnounceMsg, "
+ "announce_message_pl = @AnnounceMsgPl, "
+ "announce_ping = @AnnouncePing "
+ "where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
NpgsqlParameter p;
p = c.Parameters.Add("@RoleId", NpgsqlDbType.Bigint);
if (RoleId.HasValue) p.Value = (long)RoleId.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint);
if (AnnounceChannelId.HasValue) p.Value = (long)AnnounceChannelId.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@TimeZone", NpgsqlDbType.Text);
if (TimeZone != null) p.Value = TimeZone;
else p.Value = DBNull.Value;
c.Parameters.Add("@Moderated", NpgsqlDbType.Boolean).Value = IsModerated;
p = c.Parameters.Add("@ModRole", NpgsqlDbType.Bigint);
if (ModeratorRole.HasValue) p.Value = (long)ModeratorRole.Value;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsg", NpgsqlDbType.Text);
if (AnnounceMessages.Item1 != null) p.Value = AnnounceMessages.Item1;
else p.Value = DBNull.Value;
p = c.Parameters.Add("@AnnounceMsgPl", NpgsqlDbType.Text);
if (AnnounceMessages.Item2 != null) p.Value = AnnounceMessages.Item2;
else p.Value = DBNull.Value;
c.Parameters.Add("@AnnouncePing", NpgsqlDbType.Boolean).Value = AnnouncePing;
await c.PrepareAsync().ConfigureAwait(false);
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#endregion
}

View file

@ -1,139 +0,0 @@
using Npgsql;
using NpgsqlTypes;
using System.Data.Common;
namespace BirthdayBot.Data;
/// <summary>
/// Represents configuration for a guild user as may exist in the database.
/// </summary>
[Obsolete(Database.ObsoleteReason, error: false)]
class GuildUserConfiguration {
public ulong GuildId { get; }
public ulong UserId { get; }
/// <summary>
/// Month of birth as a numeric value. Range 1-12.
/// </summary>
public int BirthMonth { get; private set; }
/// <summary>
/// Day of birth as a numeric value. Ranges between 1-31 or lower based on month value.
/// </summary>
public int BirthDay { get; private set; }
public string? TimeZone { get; private set; }
public bool IsKnown { get { return BirthMonth != 0 && BirthDay != 0; } }
/// <summary>
/// Creates a new, data-less instance without a corresponding database entry.
/// Calling <see cref="UpdateAsync(int, int, int)"/> will create a real database enty
/// </summary>
private GuildUserConfiguration(ulong guildId, ulong userId) {
GuildId = guildId;
UserId = userId;
}
// Called by GetGuildUsersAsync. Double-check ordinals when changes are made.
private GuildUserConfiguration(DbDataReader reader) {
GuildId = (ulong)reader.GetInt64(0);
UserId = (ulong)reader.GetInt64(1);
BirthMonth = reader.GetInt32(2);
BirthDay = reader.GetInt32(3);
if (!reader.IsDBNull(4)) TimeZone = reader.GetString(4);
}
/// <summary>
/// Updates user with given information.
/// </summary>
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} "
+ "(guild_id, user_id, birth_month, birth_day, time_zone) values "
+ "(@Gid, @Uid, @Month, @Day, @Tz) "
+ "on conflict (guild_id, user_id) do update "
+ "set birth_month = EXCLUDED.birth_month, birth_day = EXCLUDED.birth_day, time_zone = EXCLUDED.time_zone";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Parameters.Add("@Month", NpgsqlDbType.Numeric).Value = month;
c.Parameters.Add("@Day", NpgsqlDbType.Numeric).Value = day;
var tzp = c.Parameters.Add("@Tz", NpgsqlDbType.Text);
if (newtz != null) tzp.Value = newtz;
else tzp.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
// Database update succeeded; update instance values
BirthMonth = month;
BirthDay = day;
TimeZone = newtz;
}
/// <summary>
/// Deletes information of this user from the backing database.
/// The corresponding object reference should ideally be discarded after calling this.
/// </summary>
public async Task DeleteAsync() {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"delete from {BackingTable} "
+ "where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)GuildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)UserId;
c.Prepare();
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region Database
public const string BackingTable = "user_birthdays";
// Take note of ordinals for use in the constructor
private const string SelectFields = "guild_id, user_id, birth_month, birth_day, time_zone";
internal static async Task DatabaseSetupAsync(NpgsqlConnection db) {
using var c = db.CreateCommand();
c.CommandText = $"create table if not exists {BackingTable} ("
+ $"guild_id bigint not null references {GuildConfiguration.BackingTable} ON DELETE CASCADE, "
+ "user_id bigint not null, "
+ "birth_month integer not null, "
+ "birth_day integer not null, "
+ "time_zone text null, "
+ "last_seen timestamptz not null default NOW(), "
+ "PRIMARY KEY (guild_id, user_id)" // index automatically created with this
+ ")";
await c.ExecuteNonQueryAsync().ConfigureAwait(false);
}
/// <summary>
/// Attempts to retrieve a user's configuration. Returns a new, updateable instance if none is found.
/// </summary>
public static async Task<GuildUserConfiguration> LoadAsync(ulong guildId, ulong userId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid and user_id = @Uid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)userId;
c.Prepare();
using var r = c.ExecuteReader();
if (await r.ReadAsync().ConfigureAwait(false)) return new GuildUserConfiguration(r);
else return new GuildUserConfiguration(guildId, userId);
}
/// <summary>
/// Gets all known user configuration records associated with the specified guild.
/// </summary>
public static async Task<IEnumerable<GuildUserConfiguration>> LoadAllAsync(ulong guildId) {
using var db = await Database.OpenConnectionAsync().ConfigureAwait(false);
using var c = db.CreateCommand();
c.CommandText = $"select {SelectFields} from {BackingTable} where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Prepare();
using var r = await c.ExecuteReaderAsync().ConfigureAwait(false);
var result = new List<GuildUserConfiguration>();
while (await r.ReadAsync().ConfigureAwait(false)) result.Add(new GuildUserConfiguration(r));
return result;
}
#endregion
}

View file

@ -1,7 +1,4 @@
using BirthdayBot.Data;
namespace BirthdayBot;
namespace BirthdayBot;
class Program {
private static ShardManager? _bot;
private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow;
@ -11,7 +8,7 @@ class Program {
/// </summary>
public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss");
static async Task Main(string[] args) {
static async Task Main() {
Configuration? cfg = null;
try {
cfg = new Configuration();
@ -20,15 +17,6 @@ class Program {
Environment.Exit((int)ExitCodes.ConfigError);
}
Database.DBConnectionString = new Npgsql.NpgsqlConnectionStringBuilder() {
Host = cfg.SqlHost ?? "localhost", // default to localhost
Database = cfg.SqlDatabase,
Username = cfg.SqlUsername,
Password = cfg.SqlPassword,
ApplicationName = cfg.SqlApplicationName,
MaxPoolSize = Math.Max((int)Math.Ceiling(cfg.ShardAmount * 2 * 0.6), 8)
}.ToString();
Console.CancelKeyPress += OnCancelKeyPressed;
_bot = new ShardManager(cfg);

View file

@ -1,21 +1,16 @@
using BirthdayBot.ApplicationCommands;
using BirthdayBot.BackgroundServices;
using BirthdayBot.Data;
using Discord.Interactions;
using Discord.Net;
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>
public sealed class ShardInstance : IDisposable {
private readonly ShardManager _manager;
private readonly ShardBackgroundWorker _background;
private readonly Dictionary<string, CommandHandler> _textDispatch;
private readonly InteractionService _interactionService;
private readonly IServiceProvider _services;
@ -36,15 +31,13 @@ public sealed class ShardInstance : IDisposable {
/// <summary>
/// Prepares and configures the shard instances, but does not yet start its connection.
/// </summary>
internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary<string, CommandHandler> textCmds) {
internal ShardInstance(ShardManager manager, IServiceProvider services) {
_manager = manager;
_services = services;
_textDispatch = textCmds;
DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
DiscordClient.Log += Client_Log;
DiscordClient.Ready += Client_Ready;
DiscordClient.MessageReceived += Client_MessageReceived;
_interactionService = _services.GetRequiredService<InteractionService>();
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
@ -105,14 +98,7 @@ public sealed class ShardInstance : IDisposable {
return Task.CompletedTask;
}
/// <summary>
/// Registers all available slash commands.
/// Additionally, sets the shard's status to display the help command.
/// </summary>
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) {
@ -133,49 +119,6 @@ public sealed class ShardInstance : IDisposable {
#endif
}
#pragma warning disable CS0618
/// <summary>
/// Determines if the incoming message is an incoming command, and dispatches to the appropriate handler if necessary.
/// </summary>
private async Task Client_MessageReceived(SocketMessage msg) {
if (msg.Channel is not SocketTextChannel channel) return;
if (msg.Author.IsBot || msg.Author.IsWebhook) return;
if (((IMessage)msg).Type != MessageType.Default) return;
var author = (SocketGuildUser)msg.Author;
// Limit 3:
// For all cases: base command, 2 parameters.
// Except this case: "bb.config", subcommand name, subcommand parameters in a single string
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 (!_textDispatch.TryGetValue(csplit[0][CommandPrefix.Length..], out CommandHandler? command)) return;
// Load guild information here
var gconf = await GuildConfiguration.LoadAsync(channel.Guild.Id, false);
// Ban check
if (!gconf!.IsBotModerator(author)) // skip check if user is a moderator
{
if (await gconf.IsUserBlockedAsync(author.Id)) return; // silently ignore
}
// Execute the command
try {
NoiTheCat.TextCommandRemovalWarning.Intercept(msg, channel.Guild.Id);
Log("Command", $"{channel.Guild.Name}/{author.Username}#{author.Discriminator}: {msg.Content}");
await command(this, gconf, csplit, channel, author);
} catch (Exception ex) {
if (ex is HttpException) return;
Log("Command", ex.ToString());
try {
channel.SendMessageAsync(InternalError).Wait();
} catch (HttpException) { } // Fail silently
}
}
}
#pragma warning restore CS0618
// Slash command preparation and invocation
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
var context = new SocketInteractionContext(DiscordClient, arg);
@ -208,7 +151,7 @@ public sealed class ShardInstance : IDisposable {
// Specific responses to errors, if necessary
if (result.Error == InteractionCommandError.UnmetPrecondition) {
string errReply = result.ErrorReason switch {
var errReply = result.ErrorReason switch {
RequireBotModeratorAttribute.Error => RequireBotModeratorAttribute.Reply,
EnforceBlockingAttribute.FailBlocked => EnforceBlockingAttribute.ReplyBlocked,
EnforceBlockingAttribute.FailModerated => EnforceBlockingAttribute.ReplyModerated,

View file

@ -1,14 +1,11 @@
global using Discord;
global using Discord.WebSocket;
using BirthdayBot.BackgroundServices;
using BirthdayBot.TextCommands;
using Discord.Interactions;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
using static BirthdayBot.TextCommands.CommandsCommon;
namespace BirthdayBot;
/// <summary>
/// More or less the main class for the program. Handles individual shards and provides frequent
/// status reports regarding the overall health of the application.
@ -45,8 +42,6 @@ class ShardManager : IDisposable {
/// </summary>
private readonly Dictionary<int, ShardInstance?> _shards;
private readonly Dictionary<string, CommandHandler> _textCommands;
private readonly Task _statusTask;
private readonly CancellationTokenSource _mainCancel;
private int _destroyedShards = 0;
@ -59,20 +54,9 @@ class ShardManager : IDisposable {
Config = cfg;
// Command handler setup
_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?>();
for (int i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) {
for (var i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) {
_shards.Add(i, null);
}
@ -114,12 +98,12 @@ class ShardManager : IDisposable {
TotalShards = Config.ShardTotal,
LogLevel = LogSeverity.Info,
DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts,
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages,
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers,
SuppressUnknownDispatchWarnings = true,
LogGatewayIntentWarnings = false
};
var services = new ServiceCollection()
.AddSingleton(s => new ShardInstance(this, s, _textCommands))
.AddSingleton(s => new ShardInstance(this, s))
.AddSingleton(s => new DiscordSocketClient(clientConf))
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordSocketClient>()))
.BuildServiceProvider();
@ -144,7 +128,7 @@ class ShardManager : IDisposable {
public string? ExecutingTask;
}
private string StatusDisplay(IEnumerable<int> guildList, Dictionary<int, GuildStatusData> guildInfo, bool showDetail) {
private static string StatusDisplay(IEnumerable<int> guildList, Dictionary<int, GuildStatusData> guildInfo, bool showDetail) {
if (!guildList.Any()) return "--";
var result = new StringBuilder();
foreach (var item in guildList) {
@ -223,7 +207,7 @@ class ShardManager : IDisposable {
Program.ProgramStop();
} else {
// Start up any missing shards
int startAllowance = MaxConcurrentOperations;
var startAllowance = MaxConcurrentOperations;
foreach (var id in nullShards) {
// To avoid possible issues with resources strained over so many shards starting at once,
// initialization is spread out by only starting a few at a time.

View file

@ -1,41 +0,0 @@
namespace NoiTheCat;
static class TextCommandRemovalWarning {
public const string StopUsingTextCommands = ":warning: **Reminder**: Text-based commands will be phased out by the end of August. " +
"Please switch to using slash commands. For details on their usage, use this bot's `/help` command.";
private static readonly RateLimit<ulong> _warnedList = new(8 * 60 * 60); // 8 hours
public static void Intercept(SocketMessage msg, ulong gid) {
lock (_warnedList) {
if (!_warnedList.IsPermitted(gid)) return;
try {
msg.Channel.SendMessageAsync(StopUsingTextCommands).GetAwaiter().GetResult();
} catch (Exception e) {
_warnedList.Reset(gid);
Console.WriteLine(e.ToString());
}
}
}
private class RateLimit<T> where T : notnull {
private const int DefaultTimeout = 20;
public int Timeout { get; }
private Dictionary<T, DateTime> Entries { get; } = new Dictionary<T, DateTime>();
public RateLimit() : this(DefaultTimeout) { }
public RateLimit(int timeout) {
if (timeout < 0) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout valie cannot be negative.");
Timeout = timeout;
}
public bool IsPermitted(T value) {
if (Timeout == 0) return true;
var now = DateTime.Now;
var expired = Entries.Where(x => x.Value.AddSeconds(Timeout) <= now).Select(x => x.Key).ToList();
foreach (var item in expired) Entries.Remove(item);
if (Entries.ContainsKey(value)) return false;
else {
Entries.Add(value, DateTime.Now);
return true;
}
}
public bool Reset(T value) => Entries.Remove(value);
}
}

View file

@ -1,43 +0,0 @@
using System.Text;
namespace BirthdayBot.TextCommands;
internal class CommandDocumentation {
public string[] Commands { get; }
public string Usage { get; }
public string? Examples { get; }
public CommandDocumentation(IEnumerable<string> commands, string usage, string? examples) {
var cmds = new List<string>();
foreach (var item in commands) cmds.Add(CommandsCommon.CommandPrefix + item);
if (cmds.Count == 0) throw new ArgumentException(null, nameof(commands));
Commands = cmds.ToArray();
Usage = usage ?? throw new ArgumentException(null, nameof(usage));
Examples = examples;
}
/// <summary>
/// Returns a string that can be inserted into a help or usage message.
/// </summary>
public string Export() {
var result = new StringBuilder();
foreach (var item in Commands) result.Append(", `" + item + "`");
result.Remove(0, 2);
result.Insert(0, '●');
result.AppendLine();
result.Append("» " + Usage);
if (Examples != null) {
result.AppendLine();
result.Append("» Examples: " + Examples);
}
return result.ToString();
}
/// <summary>
/// Creates an embeddable message containing the command documentation.
/// </summary>
public Embed UsageEmbed => new EmbedBuilder() {
Author = new EmbedAuthorBuilder() { Name = "Usage" },
Description = Export()
}.Build();
}

View file

@ -1,93 +0,0 @@
#pragma warning disable CS0618
using BirthdayBot.Data;
using NodaTime;
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
namespace BirthdayBot.TextCommands;
/// <summary>
/// Common base class for common constants and variables.
/// </summary>
internal abstract class CommandsCommon {
#if DEBUG
public const string CommandPrefix = "bt.";
#else
public const string CommandPrefix = "bb.";
#endif
public const string BadUserError = ":x: Unable to find user. Specify their `@` mention or their ID.";
public const string ParameterError = ":x: Invalid usage. Refer to how to use the command and try again.";
public const string NoParameterError = ":x: This command does not accept any parameters.";
public const string MemberCacheEmptyError = ":warning: Please try the command again.";
public delegate Task CommandHandler(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser);
protected static ReadOnlyDictionary<string, string> TzNameMap { get; }
protected static Regex ChannelMention { get; } = new Regex(@"<#(\d+)>");
protected static Regex UserMention { get; } = new Regex(@"\!?(\d+)>");
protected Configuration BotConfig { get; }
protected CommandsCommon(Configuration db) {
BotConfig = db;
}
static CommandsCommon() {
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name);
TzNameMap = new(dict);
}
/// <summary>
/// On command dispatcher initialization, it will retrieve all available commands through here.
/// </summary>
public abstract IEnumerable<(string, CommandHandler)> Commands { get; }
/// <summary>
/// Checks given time zone input. Returns a valid string for use with NodaTime.
/// </summary>
protected static string ParseTimeZone(string tzinput) {
if (!TzNameMap.TryGetValue(tzinput, out string? tz)) throw new FormatException(":x: Unexpected time zone name."
+ $" Refer to `{CommandPrefix}help-tzdata` to help determine the correct value.");
return tz;
}
/// <summary>
/// Given user input where a user-like parameter is expected, attempts to resolve to an ID value.
/// Input must be a mention or explicit ID. No name resolution is done here.
/// </summary>
protected static bool TryGetUserId(string input, out ulong result) {
string doParse;
var m = UserMention.Match(input);
if (m.Success) doParse = m.Groups[1].Value;
else doParse = input;
if (ulong.TryParse(doParse, out ulong resultVal)) {
result = resultVal;
return true;
}
result = default;
return false;
}
/// <summary>
/// An alternative to <see cref="SocketGuild.HasAllMembers"/> to be called by command handlers needing a full member cache.
/// Creates a download request if necessary.
/// </summary>
/// <returns>
/// True if the member cache is already filled, false otherwise.
/// </returns>
/// <remarks>
/// Any updates to the member cache aren't accessible until the event handler finishes execution, meaning proactive downloading
/// is necessary, and is handled by <seealso cref="BackgroundServices.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;
}
}

View file

@ -1,164 +0,0 @@
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.TextCommands;
internal class HelpInfoCommands : CommandsCommon {
private readonly Embed _helpEmbed;
private readonly Embed _helpConfigEmbed;
public HelpInfoCommands(Configuration cfg) : base(cfg) {
var embeds = BuildHelpEmbeds();
_helpEmbed = embeds.Item1;
_helpConfigEmbed = embeds.Item2;
}
public override IEnumerable<(string, CommandHandler)> Commands =>
new List<(string, CommandHandler)>() {
("help", CmdHelp),
("help-config", CmdHelpConfig),
("helpconfig", CmdHelpConfig),
("help-tzdata", CmdHelpTzdata),
("helptzdata", CmdHelpTzdata),
("help-message", CmdHelpMessage),
("helpmessage", CmdHelpMessage),
("info", CmdInfo),
("about", CmdInfo),
("invite", CmdInfo)
};
private static (Embed, Embed) BuildHelpEmbeds() {
var cpfx = $"●`{CommandPrefix}";
// Normal section
var cmdField = new EmbedFieldBuilder() {
Name = "Commands",
Value = "----\n**Notice**: Text commands will soon be __removed__. "
+ "For a list of new commands, see this bot's `/help` command.\n----\n"
+ $"{cpfx}help`, `{CommandPrefix}info`, `{CommandPrefix}help-tzdata`\n"
+ $" » Help and informational messages.\n"
+ ListingCommands.DocUpcoming.Export() + "\n"
+ UserCommands.DocSet.Export() + "\n"
+ UserCommands.DocZone.Export() + "\n"
+ UserCommands.DocRemove.Export() + "\n"
+ ListingCommands.DocWhen.Export()
};
var cmdModField = new EmbedFieldBuilder() {
Name = "Moderator actions",
Value = $"{cpfx}config`\n"
+ $" » Edit bot configuration. See `{CommandPrefix}help-config`.\n"
+ ListingCommands.DocList.Export() + "\n"
+ ManagerCommands.DocOverride.Export()
};
var helpRegular = new EmbedBuilder().AddField(cmdField).AddField(cmdModField);
// Manager section
var mpfx = cpfx + "config ";
var configField1 = new EmbedFieldBuilder() {
Name = "Basic settings",
Value = $"{mpfx}role (role name or ID)`\n"
+ " » Sets the role to apply to users having birthdays.\n"
+ $"{mpfx}channel (channel name or ID)`\n"
+ " » Sets the announcement channel. Leave blank to disable.\n"
+ $"{mpfx}message (message)`, `{CommandPrefix}config messagepl (message)`\n"
+ $" » Sets a custom announcement message. See `{CommandPrefix}help-message`.\n"
+ $"{mpfx}ping (off|on)`\n"
+ $" » Sets whether to ping the respective users in the announcement message.\n"
+ $"{mpfx}zone (time zone name)`\n"
+ $" » Sets the default server time zone. See `{CommandPrefix}help-tzdata`."
};
var configField2 = new EmbedFieldBuilder() {
Name = "Access management",
Value = $"{mpfx}modrole (role name, role ping, or ID)`\n"
+ " » Establishes a role for bot moderators. Grants access to `bb.config` and `bb.override`.\n"
+ $"{mpfx}block/unblock (user ping or ID)`\n"
+ " » Prevents or allows usage of bot commands to the given user.\n"
+ $"{mpfx}moderated on/off`\n"
+ " » Prevents or allows using commands for all members excluding moderators."
};
var helpConfig = new EmbedBuilder() {
Author = new EmbedAuthorBuilder() { Name = $"{CommandPrefix} config subcommands" },
Description = "All the following subcommands are only usable by moderators and server managers."
}.AddField(configField1).AddField(configField2);
return (helpRegular.Build(), helpConfig.Build());
}
private async Task CmdHelp(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
=> await reqChannel.SendMessageAsync(embed: _helpEmbed).ConfigureAwait(false);
private async Task CmdHelpConfig(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser)
=> await reqChannel.SendMessageAsync(embed: _helpConfigEmbed).ConfigureAwait(false);
private async Task CmdHelpTzdata(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
const string tzhelp = "You may specify a time zone in order to have your birthday recognized with respect to your local time. "
+ "This bot only accepts zone names from the IANA Time Zone Database (a.k.a. Olson Database).\n\n"
+ "To find your zone: https://xske.github.io/tz/" + "\n"
+ "Interactive map: https://kevinnovak.github.io/Time-Zone-Picker/" + "\n"
+ "Complete list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones";
var embed = new EmbedBuilder();
embed.AddField(new EmbedFieldBuilder() {
Name = "Time Zone Support",
Value = tzhelp
});
await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
private async Task CmdHelpMessage(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
const string msghelp = "The `message` and `messagepl` subcommands allow for editing the message sent into the announcement "
+ "channel (defined with `{0}config channel`). This feature is separated across two commands:\n"
+ "●`{0}config message`\n"
+ "●`{0}config messagepl`\n"
+ "The first command sets the message to be displayed when *one* user is having a birthday. The second command sets the "
+ "message for when *two or more* users are having birthdays ('pl' means plural). If only one of the two custom messages "
+ "are defined, it will be used for both cases.\n\n"
+ "To further allow customization, you may place the token `%n` in your message to specify where the name(s) should appear.\n"
+ "Leave the parameter blank to clear or reset the message to its default value.";
const string msghelp2 = "As examples, these are the default announcement messages used by this bot:\n"
+ "`message`: {0}\n" + "`messagepl`: {1}";
var embed = new EmbedBuilder().AddField(new EmbedFieldBuilder() {
Name = "Custom announcement message",
Value = string.Format(msghelp, CommandPrefix)
}).AddField(new EmbedFieldBuilder() {
Name = "Examples",
Value = string.Format(msghelp2,
BackgroundServices.BirthdayRoleUpdate.DefaultAnnounce, BackgroundServices.BirthdayRoleUpdate.DefaultAnnouncePl)
});
await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
private async Task CmdInfo(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
var strStats = new StringBuilder();
var asmnm = System.Reflection.Assembly.GetExecutingAssembly().GetName();
strStats.AppendLine("BirthdayBot v" + asmnm.Version!.ToString(3));
//strStats.AppendLine("Server count: " + Discord.Guilds.Count.ToString()); // TODO restore this statistic
strStats.AppendLine("Shard #" + instance.ShardId.ToString("00"));
strStats.AppendLine("Uptime: " + Program.BotUptime);
// TODO fun stats
// current birthdays, total names registered, unique time zones
var embed = new EmbedBuilder() {
Author = new EmbedAuthorBuilder() {
Name = "Thank you for using Birthday Bot!",
IconUrl = instance.DiscordClient.CurrentUser.GetAvatarUrl()
},
Description = "For more information regarding support, data retention, privacy, and other details, please refer to: "
+ "https://github.com/NoiTheCat/BirthdayBot/blob/master/Readme.md" + "\n\n"
+ "This bot is provided for free, without any paywalled 'premium' features. "
+ "If you've found this bot useful, please consider contributing via the "
+ "bot author's page on Ko-fi: https://ko-fi.com/noithecat."
}.AddField(new EmbedFieldBuilder() {
Name = "Statistics",
Value = strStats.ToString()
});
await reqChannel.SendMessageAsync(embed: embed.Build()).ConfigureAwait(false);
}
}

View file

@ -1,313 +0,0 @@
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
namespace BirthdayBot.TextCommands;
/// <summary>
/// Commands for listing upcoming and all birthdays.
/// </summary>
internal class ListingCommands : CommandsCommon {
public ListingCommands(Configuration db) : base(db) { }
public override IEnumerable<(string, CommandHandler)> Commands
=> new List<(string, CommandHandler)>()
{
("list", CmdList),
("upcoming", CmdUpcoming),
("recent", CmdUpcoming),
("when", CmdWhen)
};
#region Documentation
public static readonly CommandDocumentation DocList =
new(new string[] { "list" }, "Exports all birthdays to a file."
+ " Accepts `csv` as an optional parameter.", null);
public static readonly CommandDocumentation DocUpcoming =
new(new string[] { "recent", "upcoming" }, "Lists recent and upcoming birthdays.", null);
public static readonly CommandDocumentation DocWhen =
new(new string[] { "when" }, "Displays the given user's birthday information.", null);
#endregion
private async Task CmdWhen(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
// Requires a parameter
if (param.Length == 1) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return;
}
var search = param[1];
if (param.Length == 3) {
// param maxes out at 3 values. param[2] might contain part of the search string (if name has a space)
search += " " + param[2];
}
SocketGuildUser? searchTarget = null;
if (!TryGetUserId(search, out ulong searchId)) // ID lookup
{
// name lookup without discriminator
foreach (var searchuser in reqChannel.Guild.Users) {
if (string.Equals(search, searchuser.Username, StringComparison.OrdinalIgnoreCase)) {
searchTarget = searchuser;
break;
}
}
} else {
searchTarget = reqChannel.Guild.GetUser(searchId);
}
if (searchTarget == null) {
await reqChannel.SendMessageAsync(BadUserError, embed: DocWhen.UsageEmbed).ConfigureAwait(false);
return;
}
var searchTargetData = await GuildUserConfiguration.LoadAsync(reqChannel.Guild.Id, searchId).ConfigureAwait(false);
if (!searchTargetData.IsKnown) {
await reqChannel.SendMessageAsync("I do not have birthday information for that user.").ConfigureAwait(false);
return;
}
string result = Common.FormatName(searchTarget, false);
result += ": ";
result += $"`{searchTargetData.BirthDay:00}-{Common.MonthNames[searchTargetData.BirthMonth]}`";
result += searchTargetData.TimeZone == null ? "" : $" - `{searchTargetData.TimeZone}`";
await reqChannel.SendMessageAsync(result).ConfigureAwait(false);
}
// Creates a file with all birthdays.
private async Task CmdList(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// For now, we're restricting this command to moderators only. This may turn into an option later.
if (!gconf.IsBotModerator(reqUser)) {
// Do not add detailed usage information to this error message.
await reqChannel.SendMessageAsync(":x: Only bot moderators may use this command.").ConfigureAwait(false);
return;
}
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
bool useCsv = false;
// Check for CSV option
if (param.Length == 2) {
if (param[1].ToLower() == "csv") useCsv = true;
else {
await reqChannel.SendMessageAsync(":x: That is not available as an export format.", embed: DocList.UsageEmbed)
.ConfigureAwait(false);
return;
}
} else if (param.Length > 2) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocList.UsageEmbed).ConfigureAwait(false);
return;
}
var bdlist = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
var filepath = Path.GetTempPath() + "birthdaybot-" + reqChannel.Guild.Id;
string fileoutput;
if (useCsv) {
fileoutput = ListExportCsv(reqChannel, bdlist);
filepath += ".csv";
} else {
fileoutput = ListExportNormal(reqChannel, bdlist);
filepath += ".txt.";
}
await File.WriteAllTextAsync(filepath, fileoutput, Encoding.UTF8).ConfigureAwait(false);
try {
await reqChannel.SendFileAsync(filepath, $"Exported {bdlist.Count} birthdays to file.").ConfigureAwait(false);
} catch (Discord.Net.HttpException) {
reqChannel.SendMessageAsync(":x: Unable to send list due to a permissions issue. Check the 'Attach Files' permission.").Wait();
} catch (Exception ex) {
Program.Log("Listing", ex.ToString());
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
} finally {
File.Delete(filepath);
}
}
// "Recent and upcoming birthdays"
// The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here
private async Task CmdUpcoming(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
var now = DateTimeOffset.UtcNow;
var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC
if (search <= 0) search = 366 - Math.Abs(search);
var query = await GetSortedUsersAsync(reqChannel.Guild).ConfigureAwait(false);
var output = new StringBuilder();
var resultCount = 0;
output.AppendLine("Recent and upcoming birthdays:");
for (int count = 0; count <= 21; count++) // cover 21 days total (7 prior, current day, 14 upcoming)
{
var results = from item in query
where item.DateIndex == search
select item;
// push up search by 1 now, in case we back out early
search += 1;
if (search > 366) search = 1; // wrap to beginning of year
if (!results.Any()) continue; // back out early
resultCount += results.Count();
// Build sorted name list
var names = new List<string>();
foreach (var item in results) {
names.Add(item.DisplayName);
}
names.Sort(StringComparer.OrdinalIgnoreCase);
var first = true;
output.AppendLine();
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
foreach (var item in names) {
// If the output is starting to fill up, send out this message and prepare a new one.
if (output.Length > 800) {
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
output.Clear();
first = true;
output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: ");
}
if (first) first = false;
else output.Append(", ");
output.Append(item);
}
}
if (resultCount == 0)
await reqChannel.SendMessageAsync(
"There are no recent or upcoming birthdays (within the last 7 days and/or next 14 days).")
.ConfigureAwait(false);
else
await reqChannel.SendMessageAsync(output.ToString()).ConfigureAwait(false);
}
/// <summary>
/// Fetches all guild birthdays and places them into an easily usable structure.
/// Users currently not in the guild are not included in the result.
/// </summary>
private static async Task<List<ListItem>> GetSortedUsersAsync(SocketGuild guild) {
using var db = await Database.OpenConnectionAsync();
using var c = db.CreateCommand();
c.CommandText = "select user_id, birth_month, birth_day from " + GuildUserConfiguration.BackingTable
+ " where guild_id = @Gid order by birth_month, birth_day";
c.Parameters.Add("@Gid", NpgsqlTypes.NpgsqlDbType.Bigint).Value = (long)guild.Id;
c.Prepare();
using var r = await c.ExecuteReaderAsync();
var result = new List<ListItem>();
while (await r.ReadAsync()) {
var id = (ulong)r.GetInt64(0);
var month = r.GetInt32(1);
var day = r.GetInt32(2);
var guildUser = guild.GetUser(id);
if (guildUser == null) continue; // Skip user not in guild
result.Add(new ListItem() {
BirthMonth = month,
BirthDay = day,
DateIndex = DateIndex(month, day),
UserId = guildUser.Id,
DisplayName = Common.FormatName(guildUser, false)
});
}
return result;
}
private string ListExportNormal(SocketGuildChannel channel, IEnumerable<ListItem> list) {
// Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]"
var result = new StringBuilder();
result.AppendLine("Birthdays in " + channel.Guild.Name);
result.AppendLine();
foreach (var item in list) {
var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: ");
result.Append(item.UserId);
result.Append(" " + user.Username + "#" + user.Discriminator);
if (user.Nickname != null) result.Append(" - Nickname: " + user.Nickname);
result.AppendLine();
}
return result.ToString();
}
private string ListExportCsv(SocketGuildChannel channel, IEnumerable<ListItem> list) {
// Output: User ID, Username, Nickname, Month-Day, Month, Day
var result = new StringBuilder();
// Conforming to RFC 4180; with header
result.Append("UserId,Username,Nickname,MonthDayDisp,Month,Day");
result.Append("\r\n"); // crlf line break is specified by the standard
foreach (var item in list) {
var user = channel.Guild.GetUser(item.UserId);
if (user == null) continue; // User disappeared in the instant between getting list and processing
result.Append(item.UserId);
result.Append(',');
result.Append(CsvEscape(user.Username + "#" + user.Discriminator));
result.Append(',');
if (user.Nickname != null) result.Append(user.Nickname);
result.Append(',');
result.Append($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}");
result.Append(',');
result.Append(item.BirthMonth);
result.Append(',');
result.Append(item.BirthDay);
result.Append("\r\n");
}
return result.ToString();
}
private static string CsvEscape(string input) {
var result = new StringBuilder();
result.Append('"');
foreach (var ch in input) {
if (ch == '"') result.Append('"');
result.Append(ch);
}
result.Append('"');
return result.ToString();
}
private static int DateIndex(int month, int day) {
var dateindex = 0;
// Add month offsets
if (month > 1) dateindex += 31; // Offset January
if (month > 2) dateindex += 29; // Offset February (incl. leap day)
if (month > 3) dateindex += 31; // etc
if (month > 4) dateindex += 30;
if (month > 5) dateindex += 31;
if (month > 6) dateindex += 30;
if (month > 7) dateindex += 31;
if (month > 8) dateindex += 31;
if (month > 9) dateindex += 30;
if (month > 10) dateindex += 31;
if (month > 11) dateindex += 30;
dateindex += day;
return dateindex;
}
private struct ListItem {
public int DateIndex;
public int BirthMonth;
public int BirthDay;
public ulong UserId;
public string DisplayName;
}
}

View file

@ -1,487 +0,0 @@
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text;
using System.Text.RegularExpressions;
namespace BirthdayBot.TextCommands;
internal class ManagerCommands : CommandsCommon {
private static readonly string ConfErrorPostfix =
$" Refer to the `{CommandPrefix}help-config` command for information on this command's usage.";
private delegate Task ConfigSubcommand(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel);
private readonly Dictionary<string, ConfigSubcommand> _subcommands;
private readonly Dictionary<string, CommandHandler> _usercommands;
public ManagerCommands(Configuration db, IEnumerable<(string, CommandHandler)> userCommands) : base(db) {
_subcommands = new Dictionary<string, ConfigSubcommand>(StringComparer.OrdinalIgnoreCase)
{
{ "role", ScmdRole },
{ "channel", ScmdChannel },
{ "modrole", ScmdModRole },
{ "message", ScmdAnnounceMsg },
{ "messagepl", ScmdAnnounceMsg },
{ "ping", ScmdPing },
{ "zone", ScmdZone },
{ "block", ScmdBlock },
{ "unblock", ScmdBlock },
{ "moderated", ScmdModerated }
};
// Set up local copy of all user commands accessible by the override command
_usercommands = new Dictionary<string, CommandHandler>(StringComparer.OrdinalIgnoreCase);
foreach (var item in userCommands) _usercommands.Add(item.Item1, item.Item2);
}
public override IEnumerable<(string, CommandHandler)> Commands
=> new List<(string, CommandHandler)>()
{
("config", CmdConfigDispatch),
("override", CmdOverride),
("check", CmdCheck),
("test", CmdCheck)
};
#region Documentation
public static readonly CommandDocumentation DocOverride =
new(new string[] { "override (user ping or ID) (command w/ parameters)" },
"Perform certain commands on behalf of another user.", null);
#endregion
private async Task CmdConfigDispatch(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// Ignore those without the proper permissions.
if (!gconf.IsBotModerator(reqUser)) {
await reqChannel.SendMessageAsync(":x: This command may only be used by bot moderators.").ConfigureAwait(false);
return;
}
if (param.Length < 2) {
await reqChannel.SendMessageAsync($":x: See `{CommandPrefix}help-config` for information on how to use this command.")
.ConfigureAwait(false);
return;
}
// Special case: Restrict 'modrole' to only guild managers, not mods
if (string.Equals(param[1], "modrole", StringComparison.OrdinalIgnoreCase) && !reqUser.GuildPermissions.ManageGuild) {
await reqChannel.SendMessageAsync(":x: This command may only be used by those with the `Manage Server` permission.")
.ConfigureAwait(false);
return;
}
// Subcommands get a subset of the parameters, to make things a little easier.
var confparam = new string[param.Length - 1];
Array.Copy(param, 1, confparam, 0, param.Length - 1);
if (_subcommands.TryGetValue(confparam[0], out var h)) {
await h(confparam, gconf, reqChannel).ConfigureAwait(false);
}
}
#region Configuration sub-commands
// Birthday role set
private async Task ScmdRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length != 2) {
await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified.")
.ConfigureAwait(false);
return;
}
var guild = reqChannel.Guild;
var role = FindUserInputRole(param[1], guild);
if (role == null) {
await reqChannel.SendMessageAsync(RoleInputError).ConfigureAwait(false);
} else if (role.Id == reqChannel.Guild.EveryoneRole.Id) {
await reqChannel.SendMessageAsync(":x: You cannot set that as the birthday role.").ConfigureAwait(false);
} else {
gconf.RoleId = role.Id;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync($":white_check_mark: The birthday role has been set as **{role.Name}**.")
.ConfigureAwait(false);
}
}
// Ping setting
private async Task ScmdPing(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
const string InputErr = ":x: You must specify either `off` or `on` in this setting.";
if (param.Length != 2) {
await reqChannel.SendMessageAsync(InputErr).ConfigureAwait(false);
return;
}
var input = param[1].ToLower();
bool setting;
string result;
if (input == "off") {
setting = false;
result = ":white_check_mark: Announcement pings are now **off**.";
} else if (input == "on") {
setting = true;
result = ":white_check_mark: Announcement pings are now **on**.";
} else {
await reqChannel.SendMessageAsync(InputErr).ConfigureAwait(false);
return;
}
gconf.AnnouncePing = setting;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync(result).ConfigureAwait(false);
}
// Announcement channel set
private async Task ScmdChannel(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length == 1) // No extra parameter. Unset announcement channel.
{
// Extra detail: Show a unique message if a channel hadn't been set prior.
if (!gconf.AnnounceChannelId.HasValue) {
await reqChannel.SendMessageAsync(":x: There is no announcement channel set. Nothing to unset.")
.ConfigureAwait(false);
return;
}
gconf.AnnounceChannelId = null;
await gconf.UpdateAsync();
await reqChannel.SendMessageAsync(":white_check_mark: The announcement channel has been unset.")
.ConfigureAwait(false);
} else {
// Determine channel from input
ulong chId = 0;
// Try channel mention
var m = ChannelMention.Match(param[1]);
if (m.Success) {
chId = ulong.Parse(m.Groups[1].Value);
} else if (ulong.TryParse(param[1], out chId)) {
// Continue...
} else {
// Try text-based search
var res = reqChannel.Guild.TextChannels
.FirstOrDefault(ch => string.Equals(ch.Name, param[1], StringComparison.OrdinalIgnoreCase));
if (res != null) {
chId = res.Id; // Yep, we're throwing the full result away only to go look for it again later...
}
}
// Attempt to find channel in guild
SocketTextChannel? chTt = null;
if (chId != 0) chTt = reqChannel.Guild.GetTextChannel(chId);
if (chTt == null) {
await reqChannel.SendMessageAsync(":x: Unable to find the specified channel.").ConfigureAwait(false);
return;
}
// Update the value
gconf.AnnounceChannelId = chId;
await gconf.UpdateAsync().ConfigureAwait(false);
// Report the success
await reqChannel.SendMessageAsync($":white_check_mark: The announcement channel is now set to <#{chId}>.")
.ConfigureAwait(false);
}
}
// Moderator role set
private async Task ScmdModRole(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length != 2) {
await reqChannel.SendMessageAsync(":x: A role name, role mention, or ID value must be specified.")
.ConfigureAwait(false);
return;
}
var guild = reqChannel.Guild;
var role = FindUserInputRole(param[1], guild);
if (role == null) {
await reqChannel.SendMessageAsync(RoleInputError).ConfigureAwait(false);
} else {
gconf.ModeratorRole = role.Id;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync($":white_check_mark: The moderator role is now **{role.Name}**.")
.ConfigureAwait(false);
}
}
// Guild default time zone set/unset
private async Task ScmdZone(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length == 1) // No extra parameter. Unset guild default time zone.
{
// Extra detail: Show a unique message if there is no set zone.
if (!gconf.AnnounceChannelId.HasValue) {
await reqChannel.SendMessageAsync(":x: A default zone is not set. Nothing to unset.").ConfigureAwait(false);
return;
}
gconf.TimeZone = null;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync(":white_check_mark: The default time zone preference has been removed.")
.ConfigureAwait(false);
} else {
// Parameter check.
string zone;
try {
zone = ParseTimeZone(param[1]);
} catch (FormatException ex) {
reqChannel.SendMessageAsync(ex.Message).Wait();
return;
}
// Update value
gconf.TimeZone = zone;
await gconf.UpdateAsync().ConfigureAwait(false);
// Report the success
await reqChannel.SendMessageAsync($":white_check_mark: The server's time zone has been set to **{zone}**.")
.ConfigureAwait(false);
}
}
// Block/unblock individual non-manager users from using commands.
private async Task ScmdBlock(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length != 2) {
await reqChannel.SendMessageAsync(ParameterError + ConfErrorPostfix).ConfigureAwait(false);
return;
}
bool doBan = param[0].ToLower() == "block"; // true = block, false = unblock
if (!TryGetUserId(param[1], out ulong inputId)) {
await reqChannel.SendMessageAsync(BadUserError).ConfigureAwait(false);
return;
}
var isBanned = await gconf.IsUserBlockedAsync(inputId).ConfigureAwait(false);
if (doBan) {
if (!isBanned) {
await gconf.BlockUserAsync(inputId).ConfigureAwait(false);
await reqChannel.SendMessageAsync(":white_check_mark: User has been blocked.").ConfigureAwait(false);
} else {
// TODO bug: this is incorrectly always displayed when in moderated mode
await reqChannel.SendMessageAsync(":white_check_mark: User is already blocked.").ConfigureAwait(false);
}
} else {
if (await gconf.UnblockUserAsync(inputId).ConfigureAwait(false)) {
await reqChannel.SendMessageAsync(":white_check_mark: User is now unblocked.").ConfigureAwait(false);
} else {
await reqChannel.SendMessageAsync(":white_check_mark: The specified user is not blocked.").ConfigureAwait(false);
}
}
}
// "moderated on/off" - Sets/unsets moderated mode.
private async Task ScmdModerated(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
if (param.Length != 2) {
await reqChannel.SendMessageAsync(ParameterError + ConfErrorPostfix).ConfigureAwait(false);
return;
}
var parameter = param[1].ToLower();
bool modSet;
if (parameter == "on") modSet = true;
else if (parameter == "off") modSet = false;
else {
await reqChannel.SendMessageAsync(":x: Expecting `on` or `off` as a parameter." + ConfErrorPostfix)
.ConfigureAwait(false);
return;
}
if (gconf.IsModerated == modSet) {
await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode is already {parameter}.")
.ConfigureAwait(false);
} else {
gconf.IsModerated = modSet;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync($":white_check_mark: Moderated mode has been turned {parameter}.")
.ConfigureAwait(false);
}
}
// Sets/unsets custom announcement message.
private async Task ScmdAnnounceMsg(string[] param, GuildConfiguration gconf, SocketTextChannel reqChannel) {
var plural = param[0].ToLower().EndsWith("pl");
string? newmsg;
bool clear;
if (param.Length == 2) {
newmsg = param[1];
clear = false;
} else {
newmsg = null;
clear = true;
}
(string?, string?) update;
if (!plural) update = (newmsg, gconf.AnnounceMessages.Item2);
else update = (gconf.AnnounceMessages.Item1, newmsg);
gconf.AnnounceMessages = update;
await gconf.UpdateAsync().ConfigureAwait(false);
await reqChannel.SendMessageAsync(string.Format(":white_check_mark: The {0} birthday announcement message has been {1}.",
plural ? "plural" : "singular", clear ? "reset" : "updated")).ConfigureAwait(false);
}
#endregion
// Execute command as another user
private async Task CmdOverride(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// Moderators only. As with config, silently drop if this check fails.
if (!gconf.IsBotModerator(reqUser)) return;
if (!await HasMemberCacheAsync(reqChannel.Guild)) {
await reqChannel.SendMessageAsync(MemberCacheEmptyError);
return;
}
if (param.Length != 3) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocOverride.UsageEmbed).ConfigureAwait(false);
return;
}
// Second parameter: determine the user to act as
if (!TryGetUserId(param[1], out ulong user)) {
await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed).ConfigureAwait(false);
return;
}
var overuser = reqChannel.Guild.GetUser(user);
if (overuser == null) {
await reqChannel.SendMessageAsync(BadUserError, embed: DocOverride.UsageEmbed).ConfigureAwait(false);
return;
}
// Third parameter: determine command to invoke.
// Reminder that we're only receiving a param array of size 3 at maximum. String must be split again.
var overparam = param[2].Split(" ", 3, StringSplitOptions.RemoveEmptyEntries);
var cmdsearch = overparam[0];
if (cmdsearch.StartsWith(CommandPrefix)) {
// Strip command prefix to search for the given command.
cmdsearch = cmdsearch[CommandPrefix.Length..];
} else {
// Add command prefix to input, just in case.
overparam[0] = CommandPrefix + overparam[0].ToLower();
}
if (!_usercommands.TryGetValue(cmdsearch, out var action)) {
await reqChannel.SendMessageAsync(
$":x: `{cmdsearch}` is not an overridable command.", embed: DocOverride.UsageEmbed)
.ConfigureAwait(false);
return;
}
// Preparations complete. Run the command.
await reqChannel.SendMessageAsync(
$"Executing `{cmdsearch.ToLower()}` on behalf of {overuser.Nickname ?? overuser.Username}:")
.ConfigureAwait(false);
await action.Invoke(instance, gconf, overparam, reqChannel, overuser).ConfigureAwait(false);
}
// Troubleshooting tool: Check for common problems regarding typical background operation.
private async Task CmdCheck(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// Moderators only. As with config, silently drop if this check fails.
if (!gconf.IsBotModerator(reqUser)) return;
if (param.Length != 1) {
// Too many parameters
// Note: Non-standard error display
await reqChannel.SendMessageAsync(NoParameterError).ConfigureAwait(false);
return;
}
static string DoTestFor(string label, Func<bool> test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }";
var result = new StringBuilder();
var guild = reqChannel.Guild;
var conf = await GuildConfiguration.LoadAsync(guild.Id, true).ConfigureAwait(false);
var userbdays = await GuildUserConfiguration.LoadAllAsync(guild.Id).ConfigureAwait(false);
result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{instance.ShardId:00}`");
result.AppendLine($"Number of registered birthdays: `{ userbdays.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(userbdays, 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? channel = null;
result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate {
if (conf == null) return false;
channel = guild.GetTextChannel(conf.AnnounceChannelId ?? 0);
return channel != null;
}));
string disp = channel == null ? "announcement channel" : $"<#{channel.Id}>";
result.AppendLine(DoTestFor($"(Optional) Bot can send messages into { disp }", delegate {
if (channel == null) return false;
return guild.CurrentUser.GetPermissions(channel).SendMessages;
}));
await reqChannel.SendMessageAsync(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("Plural", prepareAnnouncePreview(conf.AnnounceMessages.Item2));
}
await reqChannel.SendMessageAsync(embed: em.Build()).ConfigureAwait(false);
}
}
#region Common/helper methods
private const string RoleInputError = ":x: Unable to determine the given role.";
private static readonly Regex RoleMention = new(@"<@?&(?<snowflake>\d+)>", RegexOptions.Compiled);
private static SocketRole? FindUserInputRole(string inputStr, SocketGuild guild) {
// Resembles a role mention? Strip it to the pure number
var input = inputStr;
var rmatch = RoleMention.Match(input);
if (rmatch.Success) input = rmatch.Groups["snowflake"].Value;
// Attempt to get role by ID, or null
if (ulong.TryParse(input, out ulong rid)) {
return guild.GetRole(rid);
} else {
// Reset the search value on the off chance there's a role name that actually resembles a role ping.
input = inputStr;
}
// If not already found, attempt to search role by string name
foreach (var search in guild.Roles) {
if (string.Equals(search.Name, input, StringComparison.OrdinalIgnoreCase)) return search;
}
return null;
}
#endregion
}

View file

@ -1,180 +0,0 @@
#pragma warning disable CS0618
using BirthdayBot.Data;
using System.Text.RegularExpressions;
namespace BirthdayBot.TextCommands;
internal class UserCommands : CommandsCommon {
public UserCommands(Configuration db) : base(db) { }
public override IEnumerable<(string, CommandHandler)> Commands
=> new List<(string, CommandHandler)>()
{
("set", CmdSet),
("zone", CmdZone),
("remove", CmdRemove)
};
#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>
private 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."),
};
}
#endregion
#region Documentation
public static readonly CommandDocumentation DocSet =
new(new string[] { "set (date)" }, "Registers your birth month and day.",
$"`{CommandPrefix}set jan-31`, `{CommandPrefix}set 15 may`.");
public static readonly CommandDocumentation DocZone =
new(new string[] { "zone (zone)" }, "Sets your local time zone. "
+ $"See also `{CommandPrefix}help-tzdata`.", null);
public static readonly CommandDocumentation DocRemove =
new(new string[] { "remove" }, "Removes your birthday information from this bot.", null);
#endregion
private async Task CmdSet(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
if (param.Length < 2) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocSet.UsageEmbed).ConfigureAwait(false);
return;
}
// Date format accepts spaces. Must coalesce parameters to a single string.
var fullinput = "";
foreach (var p in param[1..]) fullinput += " " + p;
fullinput = fullinput[1..]; // trim leading space
int bmonth, bday;
try {
(bmonth, bday) = ParseDate(fullinput);
} catch (FormatException ex) {
// Our parse method's FormatException has its message to send out to Discord.
reqChannel.SendMessageAsync(ex.Message, embed: DocSet.UsageEmbed).Wait();
return;
}
// Parsing successful. Update user information.
bool known; // Extra detail: Bot's response changes if the user was previously unknown.
try {
var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false);
known = user.IsKnown;
await user.UpdateAsync(bmonth, bday, user.TimeZone).ConfigureAwait(false);
} catch (Exception ex) {
Program.Log("Error", ex.ToString());
reqChannel.SendMessageAsync(ShardInstance.InternalError).Wait();
return;
}
await reqChannel.SendMessageAsync($":white_check_mark: Your birthday has been { (known ? "updated" : "recorded") }.")
.ConfigureAwait(false);
}
private async Task CmdZone(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
if (param.Length != 2) {
await reqChannel.SendMessageAsync(ParameterError, embed: DocZone.UsageEmbed).ConfigureAwait(false);
return;
}
var user = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false);
if (!user.IsKnown) {
await reqChannel.SendMessageAsync(":x: You may only update your time zone when you have a birthday registered."
+ $" Refer to the `{CommandPrefix}set` command.", embed: DocZone.UsageEmbed)
.ConfigureAwait(false);
return;
}
string btz;
try {
btz = ParseTimeZone(param[1]);
} catch (Exception ex) {
reqChannel.SendMessageAsync(ex.Message, embed: DocZone.UsageEmbed).Wait();
return;
}
await user.UpdateAsync(user.BirthMonth, user.BirthDay, btz).ConfigureAwait(false);
await reqChannel.SendMessageAsync($":white_check_mark: Your time zone has been updated to **{btz}**.")
.ConfigureAwait(false);
}
private async Task CmdRemove(ShardInstance instance, GuildConfiguration gconf,
string[] param, SocketTextChannel reqChannel, SocketGuildUser reqUser) {
// Parameter count check
if (param.Length != 1) {
await reqChannel.SendMessageAsync(NoParameterError, embed: DocRemove.UsageEmbed).ConfigureAwait(false);
return;
}
// Extra detail: Send a notification if the user isn't actually known by the bot.
bool known;
var u = await GuildUserConfiguration.LoadAsync(gconf.GuildId, reqUser.Id).ConfigureAwait(false);
known = u.IsKnown;
await u.DeleteAsync().ConfigureAwait(false);
if (!known) {
await reqChannel.SendMessageAsync(":white_check_mark: This bot already does not contain your information.")
.ConfigureAwait(false);
} else {
await reqChannel.SendMessageAsync(":white_check_mark: Your information has been removed.")
.ConfigureAwait(false);
}
}
}