mirror of
https://github.com/NoiTheCat/BirthdayBot.git
synced 2024-11-21 21:54:36 +00:00
Remove text-based commands
This commit is contained in:
parent
f59dbc0e19
commit
b9604dadfe
14 changed files with 9 additions and 1865 deletions
|
@ -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]);
|
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>
|
/// <summary>
|
||||||
/// Gets all known users from the given guild and returns a list including only those who are
|
/// 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.
|
/// currently experiencing a birthday in the respective time zone.
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
16
Program.cs
16
Program.cs
|
@ -1,7 +1,4 @@
|
||||||
using BirthdayBot.Data;
|
namespace BirthdayBot;
|
||||||
|
|
||||||
namespace BirthdayBot;
|
|
||||||
|
|
||||||
class Program {
|
class Program {
|
||||||
private static ShardManager? _bot;
|
private static ShardManager? _bot;
|
||||||
private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow;
|
private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow;
|
||||||
|
@ -11,7 +8,7 @@ class Program {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss");
|
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;
|
Configuration? cfg = null;
|
||||||
try {
|
try {
|
||||||
cfg = new Configuration();
|
cfg = new Configuration();
|
||||||
|
@ -20,15 +17,6 @@ class Program {
|
||||||
Environment.Exit((int)ExitCodes.ConfigError);
|
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;
|
Console.CancelKeyPress += OnCancelKeyPressed;
|
||||||
_bot = new ShardManager(cfg);
|
_bot = new ShardManager(cfg);
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,16 @@
|
||||||
using BirthdayBot.ApplicationCommands;
|
using BirthdayBot.ApplicationCommands;
|
||||||
using BirthdayBot.BackgroundServices;
|
using BirthdayBot.BackgroundServices;
|
||||||
using BirthdayBot.Data;
|
|
||||||
using Discord.Interactions;
|
using Discord.Interactions;
|
||||||
using Discord.Net;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using static BirthdayBot.TextCommands.CommandsCommon;
|
|
||||||
|
|
||||||
namespace BirthdayBot;
|
namespace BirthdayBot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
|
/// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ShardInstance : IDisposable {
|
public sealed class ShardInstance : IDisposable {
|
||||||
private readonly ShardManager _manager;
|
private readonly ShardManager _manager;
|
||||||
private readonly ShardBackgroundWorker _background;
|
private readonly ShardBackgroundWorker _background;
|
||||||
private readonly Dictionary<string, CommandHandler> _textDispatch;
|
|
||||||
private readonly InteractionService _interactionService;
|
private readonly InteractionService _interactionService;
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
|
@ -36,15 +31,13 @@ public sealed class ShardInstance : IDisposable {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Prepares and configures the shard instances, but does not yet start its connection.
|
/// Prepares and configures the shard instances, but does not yet start its connection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal ShardInstance(ShardManager manager, IServiceProvider services, Dictionary<string, CommandHandler> textCmds) {
|
internal ShardInstance(ShardManager manager, IServiceProvider services) {
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
_services = services;
|
_services = services;
|
||||||
_textDispatch = textCmds;
|
|
||||||
|
|
||||||
DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
|
DiscordClient = _services.GetRequiredService<DiscordSocketClient>();
|
||||||
DiscordClient.Log += Client_Log;
|
DiscordClient.Log += Client_Log;
|
||||||
DiscordClient.Ready += Client_Ready;
|
DiscordClient.Ready += Client_Ready;
|
||||||
DiscordClient.MessageReceived += Client_MessageReceived;
|
|
||||||
|
|
||||||
_interactionService = _services.GetRequiredService<InteractionService>();
|
_interactionService = _services.GetRequiredService<InteractionService>();
|
||||||
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
DiscordClient.InteractionCreated += DiscordClient_InteractionCreated;
|
||||||
|
@ -105,14 +98,7 @@ public sealed class ShardInstance : IDisposable {
|
||||||
return Task.CompletedTask;
|
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() {
|
private async Task Client_Ready() {
|
||||||
// TODO get rid of this eventually? or change it to something fun...
|
|
||||||
await DiscordClient.SetGameAsync("/help");
|
|
||||||
|
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
// Update slash/interaction commands
|
// Update slash/interaction commands
|
||||||
if (ShardId == 0) {
|
if (ShardId == 0) {
|
||||||
|
@ -133,49 +119,6 @@ public sealed class ShardInstance : IDisposable {
|
||||||
#endif
|
#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
|
// Slash command preparation and invocation
|
||||||
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
private async Task DiscordClient_InteractionCreated(SocketInteraction arg) {
|
||||||
var context = new SocketInteractionContext(DiscordClient, arg);
|
var context = new SocketInteractionContext(DiscordClient, arg);
|
||||||
|
@ -208,7 +151,7 @@ public sealed class ShardInstance : IDisposable {
|
||||||
|
|
||||||
// Specific responses to errors, if necessary
|
// Specific responses to errors, if necessary
|
||||||
if (result.Error == InteractionCommandError.UnmetPrecondition) {
|
if (result.Error == InteractionCommandError.UnmetPrecondition) {
|
||||||
string errReply = result.ErrorReason switch {
|
var errReply = result.ErrorReason switch {
|
||||||
RequireBotModeratorAttribute.Error => RequireBotModeratorAttribute.Reply,
|
RequireBotModeratorAttribute.Error => RequireBotModeratorAttribute.Reply,
|
||||||
EnforceBlockingAttribute.FailBlocked => EnforceBlockingAttribute.ReplyBlocked,
|
EnforceBlockingAttribute.FailBlocked => EnforceBlockingAttribute.ReplyBlocked,
|
||||||
EnforceBlockingAttribute.FailModerated => EnforceBlockingAttribute.ReplyModerated,
|
EnforceBlockingAttribute.FailModerated => EnforceBlockingAttribute.ReplyModerated,
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
global using Discord;
|
global using Discord;
|
||||||
global using Discord.WebSocket;
|
global using Discord.WebSocket;
|
||||||
using BirthdayBot.BackgroundServices;
|
using BirthdayBot.BackgroundServices;
|
||||||
using BirthdayBot.TextCommands;
|
|
||||||
using Discord.Interactions;
|
using Discord.Interactions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using static BirthdayBot.TextCommands.CommandsCommon;
|
|
||||||
|
|
||||||
namespace BirthdayBot;
|
namespace BirthdayBot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// More or less the main class for the program. Handles individual shards and provides frequent
|
/// More or less the main class for the program. Handles individual shards and provides frequent
|
||||||
/// status reports regarding the overall health of the application.
|
/// status reports regarding the overall health of the application.
|
||||||
|
@ -45,8 +42,6 @@ class ShardManager : IDisposable {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<int, ShardInstance?> _shards;
|
private readonly Dictionary<int, ShardInstance?> _shards;
|
||||||
|
|
||||||
private readonly Dictionary<string, CommandHandler> _textCommands;
|
|
||||||
|
|
||||||
private readonly Task _statusTask;
|
private readonly Task _statusTask;
|
||||||
private readonly CancellationTokenSource _mainCancel;
|
private readonly CancellationTokenSource _mainCancel;
|
||||||
private int _destroyedShards = 0;
|
private int _destroyedShards = 0;
|
||||||
|
@ -59,20 +54,9 @@ class ShardManager : IDisposable {
|
||||||
|
|
||||||
Config = cfg;
|
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
|
// Allocate shards based on configuration
|
||||||
_shards = new Dictionary<int, ShardInstance?>();
|
_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);
|
_shards.Add(i, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,12 +98,12 @@ class ShardManager : IDisposable {
|
||||||
TotalShards = Config.ShardTotal,
|
TotalShards = Config.ShardTotal,
|
||||||
LogLevel = LogSeverity.Info,
|
LogLevel = LogSeverity.Info,
|
||||||
DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts,
|
DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts,
|
||||||
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages,
|
GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers,
|
||||||
SuppressUnknownDispatchWarnings = true,
|
SuppressUnknownDispatchWarnings = true,
|
||||||
LogGatewayIntentWarnings = false
|
LogGatewayIntentWarnings = false
|
||||||
};
|
};
|
||||||
var services = new ServiceCollection()
|
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 DiscordSocketClient(clientConf))
|
||||||
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordSocketClient>()))
|
.AddSingleton(s => new InteractionService(s.GetRequiredService<DiscordSocketClient>()))
|
||||||
.BuildServiceProvider();
|
.BuildServiceProvider();
|
||||||
|
@ -144,7 +128,7 @@ class ShardManager : IDisposable {
|
||||||
public string? ExecutingTask;
|
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 "--";
|
if (!guildList.Any()) return "--";
|
||||||
var result = new StringBuilder();
|
var result = new StringBuilder();
|
||||||
foreach (var item in guildList) {
|
foreach (var item in guildList) {
|
||||||
|
@ -223,7 +207,7 @@ class ShardManager : IDisposable {
|
||||||
Program.ProgramStop();
|
Program.ProgramStop();
|
||||||
} else {
|
} else {
|
||||||
// Start up any missing shards
|
// Start up any missing shards
|
||||||
int startAllowance = MaxConcurrentOperations;
|
var startAllowance = MaxConcurrentOperations;
|
||||||
foreach (var id in nullShards) {
|
foreach (var id in nullShards) {
|
||||||
// To avoid possible issues with resources strained over so many shards starting at once,
|
// 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.
|
// initialization is spread out by only starting a few at a time.
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue