BirthdayBot/ApplicationCommands/ConfigModule.cs
Noi e265cafd25 Fix incorrect value checked for determining moderator
The public instance will be updated with this fix immediately.
This fixes cases in which those with moderator roles are unable
to use mod-only commands as intended.
It also fixes a dangerous bug in which users with the birthday role
assigned to them have unrestricted access to moderator commands.
2022-10-10 14:27:54 -07:00

307 lines
16 KiB
C#

using BirthdayBot.Data;
using Discord.Interactions;
using System.Text;
namespace BirthdayBot.ApplicationCommands;
[RequireBotModerator]
[Group("config", HelpCmdConfig)]
public class ConfigModule : BotModuleBase {
public const string HelpCmdConfig = "Configure basic settings for the bot.";
public const string HelpCmdAnnounce = "Settings regarding birthday announcements.";
public const string HelpCmdBlocking = "Settings for limiting user access.";
public const string HelpCmdRole = "Settings for roles used by this bot.";
public const string HelpCmdCheck = "Test the bot's current configuration and show the results.";
const string HelpPofxBlankUnset = " Leave blank to unset.";
const string HelpOptChannel = "The corresponding channel to use.";
const string HelpOptRole = "The corresponding role to use.";
[Group("announce", HelpPfxModOnly + HelpCmdAnnounce)]
public class SubCmdsConfigAnnounce : BotModuleBase {
private const string HelpSubCmdChannel = "Set which channel will receive announcement messages.";
private const string HelpSubCmdMessage = "Modify the announcement message.";
private const string HelpSubCmdPing = "Set whether to ping users mentioned in the announcement.";
internal const string ModalCidAnnounce = "edit-announce";
private const string ModalComCidSingle = "msg-single";
private const string ModalComCidMulti = "msg-multi";
[SlashCommand("help", "Show information regarding announcement messages.")]
public async Task CmdAnnounceHelp() {
const string subcommands =
$"`/config announce` - {HelpCmdAnnounce}\n" +
$" ⤷`set-channel` - {HelpSubCmdChannel}\n" +
$" ⤷`set-message` - {HelpSubCmdMessage}\n" +
$" ⤷`set-ping` - {HelpSubCmdPing}";
const string whatIs =
"As the name implies, an announcement message is the messages displayed when somebody's birthday be" +
"arrives. If enabled, an announcment message is shown at midnight respective to the appropriate time zone, " +
"first using the user's local time (if it is known), or else using the server's default time zone, or else " +
"referring back to midnight in Universal Time (UTC).\n\n" +
"To enable announcement messages, use the `set-channel` subcommand.";
const string editMsg =
"The `set-message` subcommand allow moderators to edit the message sent into the announcement channel.\n" +
"Two messages may be provided: `single` sets the message that is displayed when one user has a birthday, and " +
"`multi` sets the message used when two or more users have birthdays. If only one of the two messages " +
"have been set, this bot will use the same message in both cases.\n\n" +
"You may use the token `%n` in your message to specify where the name(s) should appear, otherwise the names " +
"will appear at the very end of your custom message.";
await RespondAsync(embed: new EmbedBuilder()
.WithAuthor("Announcement configuration")
.WithDescription(subcommands)
.AddField("What is an announcement message?", whatIs)
.AddField("Customization", editMsg)
.Build()).ConfigureAwait(false);
}
[SlashCommand("set-channel", HelpPfxModOnly + HelpSubCmdChannel + HelpPofxBlankUnset)]
public async Task CmdSetChannel([Summary(description: HelpOptChannel)] SocketTextChannel? channel = null) {
await DoDatabaseUpdate(Context, s => s.AnnouncementChannel = (long?)channel?.Id);
await RespondAsync(":white_check_mark: The announcement channel has been " +
(channel == null ? "unset." : $"set to **{channel.Name}**."));
}
[SlashCommand("set-message", HelpPfxModOnly + HelpSubCmdMessage)]
public async Task CmdSetMessage() {
using var db = new BotDatabaseContext();
var settings = Context.Guild.GetConfigOrNew(db);
var txtSingle = new TextInputBuilder() {
Label = "Single - Message for one birthday",
CustomId = ModalComCidSingle,
Style = TextInputStyle.Paragraph,
MaxLength = 1500,
Required = false,
Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnounce,
Value = settings.AnnounceMessage ?? ""
};
var txtMulti = new TextInputBuilder() {
Label = "Multi - Message for multiple birthdays",
CustomId = ModalComCidMulti,
Style = TextInputStyle.Paragraph,
MaxLength = 1500,
Required = false,
Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnouncePl,
Value = settings.AnnounceMessagePl ?? ""
};
var form = new ModalBuilder()
.WithTitle("Edit announcement message")
.WithCustomId(ModalCidAnnounce)
.AddTextInput(txtSingle)
.AddTextInput(txtMulti)
.Build();
await RespondWithModalAsync(form).ConfigureAwait(false);
}
internal static async Task CmdSetMessageResponse(SocketModal modal, SocketGuildChannel channel,
Dictionary<string, SocketMessageComponentData> data) {
string? newSingle = data[ModalComCidSingle].Value;
string? newMulti = data[ModalComCidMulti].Value;
if (string.IsNullOrWhiteSpace(newSingle)) newSingle = null;
if (string.IsNullOrWhiteSpace(newMulti)) newMulti = null;
using var db = new BotDatabaseContext();
var settings = channel.Guild.GetConfigOrNew(db);
if (settings.IsNew) db.GuildConfigurations.Add(settings);
settings.AnnounceMessage = newSingle;
settings.AnnounceMessagePl = newMulti;
await db.SaveChangesAsync();
await modal.RespondAsync(":white_check_mark: Announcement messages have been updated.");
}
[SlashCommand("set-ping", HelpPfxModOnly + HelpSubCmdPing)]
public async Task CmdSetPing([Summary(description: "Set True to ping users, False to display them normally.")]bool option) {
await DoDatabaseUpdate(Context, s => s.AnnouncePing = option);
await RespondAsync($":white_check_mark: Announcement pings are now **{(option ? "on" : "off")}**.").ConfigureAwait(false);
}
}
[Group("role", HelpPfxModOnly + HelpCmdRole)]
public class SubCmdsConfigRole : BotModuleBase {
[SlashCommand("set-birthday-role", HelpPfxModOnly + "Set the role given to users having a birthday.")]
public async Task CmdSetBRole([Summary(description: HelpOptRole)]SocketRole role) {
if (role.IsEveryone || role.IsManaged) {
await RespondAsync(":x: This role cannot be used for this setting.", ephemeral: true);
return;
}
await DoDatabaseUpdate(Context, s => s.BirthdayRole = (long)role.Id);
await RespondAsync($":white_check_mark: The birthday role has been set to **{role.Name}**.").ConfigureAwait(false);
}
[SlashCommand("set-moderator-role", HelpPfxModOnly + "Designate a role whose members can configure the bot." + HelpPofxBlankUnset)]
public async Task CmdSetModRole([Summary(description: HelpOptRole)]SocketRole? role = null) {
if (role != null && (role.IsEveryone || role.IsManaged)) {
await RespondAsync(":x: This role cannot be used for this setting.", ephemeral: true);
return;
}
await DoDatabaseUpdate(Context, s => s.ModeratorRole = (long?)role?.Id);
await RespondAsync(":white_check_mark: The moderator role has been " +
(role == null ? "unset." : $"set to **{role.Name}**."));
}
}
[Group("block", HelpCmdBlocking)]
public class SubCmdsConfigBlocking : BotModuleBase {
[SlashCommand("add-block", HelpPfxModOnly + "Add a user to the block list.")]
public Task CmdAddBlock([Summary(description: "The user to block.")] SocketGuildUser user) => UpdateBlockAsync(user, true);
[SlashCommand("remove-block", HelpPfxModOnly + "Remove a user from the block list.")]
public Task CmdDelBlock([Summary(description: "The user to unblock.")] SocketGuildUser user) => UpdateBlockAsync(user, false);
private async Task UpdateBlockAsync(SocketGuildUser user, bool setting) {
// setting: true to add (set), false to remove (unset)
using var db = new BotDatabaseContext();
var existing = db.BlocklistEntries
.Where(bl => bl.GuildId == (long)user.Guild.Id && bl.UserId == (long)user.Id).FirstOrDefault();
bool already = (existing != null) == setting;
if (already) {
await RespondAsync($":white_check_mark: User is already {(setting ? "" : "not ")}blocked.").ConfigureAwait(false);
return;
}
if (setting) db.BlocklistEntries.Add(new BlocklistEntry() { GuildId = (long)user.Guild.Id, UserId = (long)user.Id });
else db.Remove(existing!);
await db.SaveChangesAsync();
await RespondAsync($":white_check_mark: {Common.FormatName(user, false)} has been {(setting ? "" : "un")}blocked.");
}
[SlashCommand("set-moderated", HelpPfxModOnly + "Set moderated mode on the server.")]
public async Task CmdSetModerated([Summary(name: "enable", description: "The moderated mode setting.")] bool setting) {
bool current = false;
await DoDatabaseUpdate(Context, s => {
current = s.Moderated;
s.Moderated = setting;
});
bool already = setting == current;
if (already) {
await RespondAsync($":white_check_mark: Moderated mode is already **{(setting ? "en" : "dis")}abled**.");
} else {
await RespondAsync($":white_check_mark: Moderated mode is now **{(setting ? "en" : "dis")}abled**.").ConfigureAwait(false);
}
}
}
[SlashCommand("check", HelpPfxModOnly + HelpCmdCheck)]
public async Task CmdCheck() {
static string DoTestFor(string label, Func<bool> test) => $"{label}: { (test() ? ":white_check_mark: Yes" : ":x: No") }";
SocketTextChannel channel = (SocketTextChannel)Context.Channel;
var guild = Context.Guild;
using var db = new BotDatabaseContext();
var guildconf = guild.GetConfigOrNew(db);
if (!guildconf.IsNew) await db.Entry(guildconf).Collection(t => t.UserEntries).LoadAsync();
var result = new StringBuilder();
result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{Shard.ShardId:00}`");
result.AppendLine($"Number of registered birthdays: `{ guildconf.UserEntries.Count }`");
result.AppendLine($"Server time zone: `{ (guildconf.GuildTimeZone ?? "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 = default;
result.Append(DoTestFor("Birthday processing", delegate {
if (!hasMembers) return false;
if (guildconf.IsNew) return false;
bdayCount = BackgroundServices.BirthdayRoleUpdate.GetGuildCurrentBirthdays(guildconf.UserEntries, guildconf.GuildTimeZone).Count;
return true;
}));
if (!hasMembers) result.AppendLine(" - Previous step failed.");
else if (guildconf.IsNew) result.AppendLine(" - No data.");
else result.AppendLine($" - `{bdayCount}` user(s) currently having a birthday.");
result.AppendLine();
result.AppendLine(DoTestFor("Birthday role set with `/config role set-birthday-role`", delegate {
if (guildconf.IsNew) return false;
SocketRole? role = guild.GetRole((ulong)(guildconf.BirthdayRole ?? 0));
return role != null;
}));
result.AppendLine(DoTestFor("Birthday role can be managed by bot", delegate {
if (guildconf.IsNew) return false;
SocketRole? role = guild.GetRole((ulong)(guildconf.BirthdayRole ?? 0));
if (role == null) return false;
return guild.CurrentUser.GuildPermissions.ManageRoles && role.Position < guild.CurrentUser.Hierarchy;
}));
result.AppendLine();
SocketTextChannel? announcech = null;
result.AppendLine(DoTestFor("(Optional) Announcement channel set with `bb.config channel`", delegate {
if (guildconf.IsNew) return false;
announcech = guild.GetTextChannel((ulong)(guildconf.AnnouncementChannel ?? 0));
return announcech != null;
}));
string disp = announcech == null ? "announcement channel" : $"<#{announcech.Id}>";
result.AppendLine(DoTestFor($"(Optional) Bot can send messages into { disp }", delegate {
if (announcech == null) return false;
return guild.CurrentUser.GetPermissions(announcech).SendMessages;
}));
await RespondAsync(embed: new EmbedBuilder() {
Author = new EmbedAuthorBuilder() { Name = "Status and config check" },
Description = result.ToString()
}.Build()).ConfigureAwait(false);
const int announceMsgPreviewLimit = 350;
static string prepareAnnouncePreview(string announce) {
string trunc = announce.Length > announceMsgPreviewLimit ? announce[..announceMsgPreviewLimit] + "`(...)`" : announce;
var result = new StringBuilder();
foreach (var line in trunc.Split('\n'))
result.AppendLine($"> {line}");
return result.ToString();
}
if (!guildconf.IsNew && (guildconf.AnnounceMessage != null || guildconf.AnnounceMessagePl != null)) {
var em = new EmbedBuilder().WithAuthor(new EmbedAuthorBuilder() { Name = "Custom announce messages:" });
var dispAnnounces = new StringBuilder("Custom announcement message(s):\n");
if (guildconf.AnnounceMessage != null) {
em = em.AddField("Single", prepareAnnouncePreview(guildconf.AnnounceMessage));
}
if (guildconf.AnnounceMessagePl != null) {
em = em.AddField("Multi", prepareAnnouncePreview(guildconf.AnnounceMessagePl));
}
await ReplyAsync(embed: em.Build()).ConfigureAwait(false);
}
}
[SlashCommand("set-timezone", HelpPfxModOnly + "Configure the time zone to use by default in the server." + HelpPofxBlankUnset)]
public async Task CmdSetTimezone([Summary(description: HelpOptZone)] string? zone = null) {
const string Response = ":white_check_mark: The server's time zone has been ";
if (zone == null) {
await DoDatabaseUpdate(Context, s => s.GuildTimeZone = null);
await RespondAsync(Response + "unset.").ConfigureAwait(false);
} else {
string parsedZone;
try {
parsedZone = ParseTimeZone(zone);
} catch (FormatException e) {
await RespondAsync(e.Message).ConfigureAwait(false);
return;
}
await DoDatabaseUpdate(Context, s => s.GuildTimeZone = parsedZone);
await RespondAsync(Response + $"set to **{parsedZone}**.").ConfigureAwait(false);
}
}
/// <summary>
/// Helper method for updating arbitrary <see cref="GuildConfig"/> values without all the boilerplate.
/// </summary>
/// <param name="valueUpdater">A delegate which modifies <see cref="GuildConfig"/> properties as needed.</param>
private static async Task DoDatabaseUpdate(SocketInteractionContext context, Action<GuildConfig> valueUpdater) {
using var db = new BotDatabaseContext();
var settings = context.Guild.GetConfigOrNew(db);
valueUpdater(settings);
if (settings.IsNew) db.GuildConfigurations.Add(settings);
await db.SaveChangesAsync();
}
}