diff --git a/Cliptok.csproj b/Cliptok.csproj index 05f2f2ed..1722d613 100644 --- a/Cliptok.csproj +++ b/Cliptok.csproj @@ -14,8 +14,7 @@ - - + diff --git a/CommandChecks/HomeServerPerms.cs b/CommandChecks/HomeServerPerms.cs index 22f3a710..25d8a509 100644 --- a/CommandChecks/HomeServerPerms.cs +++ b/CommandChecks/HomeServerPerms.cs @@ -66,7 +66,7 @@ public static async Task GetPermLevelAsync(DiscordMember target } [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] - public class RequireHomeserverPermAttribute : CheckBaseAttribute + public class RequireHomeserverPermAttribute : ContextCheckAttribute { public ServerPermLevel TargetLvl { get; set; } public bool WorkOutside { get; set; } @@ -79,16 +79,19 @@ public RequireHomeserverPermAttribute(ServerPermLevel targetlvl, bool workOutsid OwnerOverride = ownerOverride; TargetLvl = targetlvl; } + } - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + public class RequireHomeserverPermCheck : IContextCheck + { + public async ValueTask ExecuteCheckAsync(RequireHomeserverPermAttribute attribute, CommandContext ctx) { // If the command is supposed to stay within the server and its being used outside, fail silently - if (!WorkOutside && (ctx.Channel.IsPrivate || ctx.Guild.Id != Program.cfgjson.ServerID)) - return false; + if (!attribute.WorkOutside && (ctx.Channel.IsPrivate || ctx.Guild.Id != Program.cfgjson.ServerID)) + return "This command must be used in the home server, but was executed outside of it."; // bot owners can bypass perm checks ONLY if the command allows it. - if (OwnerOverride && Program.cfgjson.BotOwners.Contains(ctx.User.Id)) - return true; + if (attribute.OwnerOverride && Program.cfgjson.BotOwners.Contains(ctx.User.Id)) + return null; DiscordMember member; if (ctx.Channel.IsPrivate || ctx.Guild.Id != Program.cfgjson.ServerID) @@ -100,7 +103,7 @@ public override async Task ExecuteCheckAsync(CommandContext ctx, bool help } catch (DSharpPlus.Exceptions.NotFoundException) { - return false; + return "The invoking user must be a member of the home server; they are not."; } } else @@ -109,50 +112,21 @@ public override async Task ExecuteCheckAsync(CommandContext ctx, bool help } var level = await GetPermLevelAsync(member); - if (level >= TargetLvl) - return true; - - else if (!help && ctx.Command.QualifiedName != "edit") - { - var levelText = level.ToString(); - if (level == ServerPermLevel.Nothing && Program.rand.Next(1, 100) == 69) - levelText = $"naught but a thing, my dear human. Congratulations, you win {Program.rand.Next(1, 10)} bonus points."; + if (level >= attribute.TargetLvl) + return null; - await ctx.RespondAsync( - $"{Program.cfgjson.Emoji.NoPermissions} Invalid permissions to use command **{ctx.Command.Name}**!\n" + - $"Required: `{TargetLvl}`\nYou have: `{levelText}`"); - } - return false; + return "The invoking user does not have permission to use this command."; } } - public class HomeServerAttribute : CheckBaseAttribute - { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) - { - return !ctx.Channel.IsPrivate && ctx.Guild.Id == Program.cfgjson.ServerID; - } - } + public class HomeServerAttribute : ContextCheckAttribute; - public class SlashRequireHomeserverPermAttribute : SlashCheckBaseAttribute + public class HomeServerCheck : IContextCheck { - public ServerPermLevel TargetLvl; - - public SlashRequireHomeserverPermAttribute(ServerPermLevel targetlvl) - => TargetLvl = targetlvl; - - public override async Task ExecuteChecksAsync(InteractionContext ctx) + public async ValueTask ExecuteCheckAsync(HomeServerAttribute attribute, CommandContext ctx) { - if (ctx.Guild.Id != Program.cfgjson.ServerID) - return false; - - var level = await GetPermLevelAsync(ctx.Member); - if (level >= TargetLvl) - return true; - else - return false; + return !ctx.Channel.IsPrivate && ctx.Guild.Id == Program.cfgjson.ServerID ? null : "This command must be used in the home server, but was executed outside of it."; } } - } } diff --git a/CommandChecks/OwnerChecks.cs b/CommandChecks/OwnerChecks.cs index 66e7a301..acf91d97 100644 --- a/CommandChecks/OwnerChecks.cs +++ b/CommandChecks/OwnerChecks.cs @@ -1,20 +1,18 @@ namespace Cliptok.CommandChecks { - public class IsBotOwnerAttribute : CheckBaseAttribute + public class IsBotOwnerAttribute : ContextCheckAttribute; + + public class IsBotOwnerCheck : IContextCheck { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + public async ValueTask ExecuteCheckAsync(IsBotOwnerAttribute attribute, CommandContext ctx) { if (Program.cfgjson.BotOwners.Contains(ctx.User.Id)) { - return true; + return null; } else { - if (!help) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.NoPermissions} This command is only accessible to bot owners."); - } - return false; + return "Bot owner-only command was executed by a non-owner."; } } } diff --git a/CommandChecks/UserRoleChecks.cs b/CommandChecks/UserRoleChecks.cs index a03a6221..7a5a7388 100644 --- a/CommandChecks/UserRoleChecks.cs +++ b/CommandChecks/UserRoleChecks.cs @@ -1,10 +1,12 @@ namespace Cliptok.CommandChecks { - public class UserRolesPresentAttribute : CheckBaseAttribute + public class UserRolesPresentAttribute : ContextCheckAttribute; + + public class UserRolesPresentCheck : IContextCheck { - public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) + public async ValueTask ExecuteCheckAsync(UserRolesPresentAttribute attribute, CommandContext ctx) { - return Program.cfgjson.UserRoles is not null; + return Program.cfgjson.UserRoles is null ? "A user role command was executed, but user roles are not configured in config.json." : null; } } } diff --git a/Commands/AnnouncementCmds.cs b/Commands/AnnouncementCmds.cs new file mode 100644 index 00000000..6f70ed70 --- /dev/null +++ b/Commands/AnnouncementCmds.cs @@ -0,0 +1,440 @@ +namespace Cliptok.Commands +{ + public class AnnouncementCmds + { + [Command("announcebuild")] + [Description("Announce a Windows Insider build in the current channel.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task AnnounceBuildSlashCommand(SlashCommandContext ctx, + [SlashChoiceProvider(typeof(WindowsVersionChoiceProvider))] + [Parameter("windows_version"), Description("The Windows version to announce a build of. Must be either 10 or 11.")] long windowsVersion, + + [Parameter("build_number"), Description("Windows build number, including any decimals (Decimals are optional). Do not include the word Build.")] string buildNumber, + + [Parameter("blog_link"), Description("The link to the Windows blog entry relating to this build.")] string blogLink, + + [SlashChoiceProvider(typeof(WindowsInsiderChannelChoiceProvider))] + [Parameter("insider_role1"), Description("The first insider role to ping.")] string insiderChannel1, + + [SlashChoiceProvider(typeof(WindowsInsiderChannelChoiceProvider))] + [Parameter("insider_role2"), Description("The second insider role to ping.")] string insiderChannel2 = "", + + [Parameter("canary_create_new_thread"), Description("Enable this option if you want to create a new Canary thread for some reason")] bool canaryCreateNewThread = false, + [Parameter("thread"), Description("The thread to mention in the announcement.")] DiscordChannel threadChannel = default, + [Parameter("flavour_text"), Description("Extra text appended on the end of the main line, replacing :WindowsInsider: or :Windows10:")] string flavourText = "", + [Parameter("autothread_name"), Description("If no thread is given, create a thread with this name.")] string autothreadName = "Build {0} ({1})", + + [Parameter("lockdown"), Description("Set 0 to not lock. Lock the channel for a certain period of time after announcing the build.")] string lockdownTime = "auto" + ) + { + if (Program.cfgjson.InsiderCommandLockedToChannel != 0 && ctx.Channel.Id != Program.cfgjson.InsiderCommandLockedToChannel) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command only works in <#{Program.cfgjson.InsiderCommandLockedToChannel}>!", ephemeral: true); + return; + } + + if (insiderChannel1 == insiderChannel2) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Both insider channels cannot be the same! Simply set one instead.", ephemeral: true); + } + + if (windowsVersion == 10 && insiderChannel1 != "RP") + { + await ctx.RespondAsync(text: $"{Program.cfgjson.Emoji.Error} Windows 10 only has a Release Preview Channel.", ephemeral: true); + return; + } + + if (flavourText == "" && windowsVersion == 10) + { + flavourText = Program.cfgjson.Emoji.Windows10; + } + else if (flavourText == "" && windowsVersion == 11) + { + flavourText = Program.cfgjson.Emoji.Insider; + } + + string roleKey1; + if (windowsVersion == 10 && insiderChannel1 == "RP") + { + roleKey1 = "rp10"; + } + else if (windowsVersion == 10 && insiderChannel1 == "Beta") + { + roleKey1 = "beta10"; + } + else + { + roleKey1 = insiderChannel1.ToLower(); + } + + DiscordRole insiderRole1 = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleKey1]); + DiscordRole insiderRole2 = default; + + StringBuilder channelString = new(); + + string insiderChannel1Pretty = insiderChannel1 == "RP" ? "Release Preview" : insiderChannel1; + + if (insiderChannel1 == "RP" || insiderChannel2 == "RP") + { + channelString.Append($"the Windows {windowsVersion} "); + } + else + { + channelString.Append("the "); + } + + channelString.Append($"**{insiderChannel1Pretty}"); + + if (insiderChannel2 != "") + { + string insiderChannel2Pretty = insiderChannel2 == "RP" ? "Release Preview" : insiderChannel2; + channelString.Append($" **and **{insiderChannel2Pretty}** Channels"); + } + else + { + channelString.Append("** Channel"); + } + + if (insiderChannel2 != "") + { + string roleKey2; + if (windowsVersion == 10 && insiderChannel2 == "RP") + { + roleKey2 = "rp10"; + } + else if (windowsVersion == 10 && insiderChannel2 == "Beta") + { + roleKey2 = "beta10"; + } + else + { + roleKey2 = insiderChannel2.ToLower(); + } + + insiderRole2 = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleKey2]); + } + + string pingMsgBareString = $"{insiderRole1.Mention}{(insiderChannel2 != "" ? $" {insiderRole2.Mention}\n" : " - ")}Hi Insiders!\n\n" + + $"Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + + $"Check it out here: {blogLink}"; + + string innerThreadMsgString = $"Hi Insiders!\n\n" + + $"Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + + $"Check it out here: {blogLink}"; + + string noPingMsgString = $"{(windowsVersion == 11 ? Program.cfgjson.Emoji.Windows11 : Program.cfgjson.Emoji.Windows10)} Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + + $"Check it out here: <{blogLink}>"; + + string pingMsgString = pingMsgBareString; + + DiscordMessage messageSent; + if (Program.cfgjson.InsiderAnnouncementChannel == 0) + { + if (threadChannel != default) + { + pingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; + } + else if (insiderChannel1 == "Canary" && insiderChannel2 == "" && Program.cfgjson.InsiderCanaryThread != 0 && autothreadName == "Build {0} ({1})" && !canaryCreateNewThread) + { + threadChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderCanaryThread); + pingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; + var msg = await threadChannel.SendMessageAsync(innerThreadMsgString); + try + { + await msg.PinAsync(); + } + catch + { + // most likely we hit max pins, we can handle this later + // either way, lets ignore for now + } + } + else + { + pingMsgString += "\n\nDiscuss it in the thread below:"; + } + + await insiderRole1.ModifyAsync(mentionable: true); + if (insiderChannel2 != "") + await insiderRole2.ModifyAsync(mentionable: true); + + await ctx.RespondAsync(pingMsgString); + messageSent = await ctx.GetResponseAsync(); + + await insiderRole1.ModifyAsync(mentionable: false); + if (insiderChannel2 != "") + await insiderRole2.ModifyAsync(mentionable: false); + } + else + { + if (threadChannel != default) + { + noPingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; + } + else if (insiderChannel1 == "Canary" && insiderChannel2 == "" && Program.cfgjson.InsiderCanaryThread != 0 && autothreadName == "Build {0} ({1})" && !canaryCreateNewThread) + { + threadChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderCanaryThread); + noPingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; + var msg = await threadChannel.SendMessageAsync(innerThreadMsgString); + try + { + await msg.PinAsync(); + } + catch + { + // most likely we hit max pins, we can handle this later + // either way, lets ignore for now + } + + } + else + { + noPingMsgString += "\n\nDiscuss it in the thread below:"; + } + + await ctx.RespondAsync(noPingMsgString); + messageSent = await ctx.GetResponseAsync(); + } + + if (threadChannel == default) + { + string threadBrackets = insiderChannel1; + if (insiderChannel2 != "") + threadBrackets = $"{insiderChannel1} & {insiderChannel2}"; + + if (insiderChannel1 == "RP" && insiderChannel2 == "" && windowsVersion == 10) + threadBrackets = "10 RP"; + + string threadName = string.Format(autothreadName, buildNumber, threadBrackets); + threadChannel = await messageSent.CreateThreadAsync(threadName, DiscordAutoArchiveDuration.Week, "Creating thread for Insider build."); + + var initialMsg = await threadChannel.SendMessageAsync($"{blogLink}"); + await initialMsg.PinAsync(); + } + + if (Program.cfgjson.InsiderAnnouncementChannel != 0) + { + pingMsgString = pingMsgBareString + $"\n\nDiscuss it here: {threadChannel.Mention}"; + + var announcementChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderAnnouncementChannel); + await insiderRole1.ModifyAsync(mentionable: true); + if (insiderChannel2 != "") + await insiderRole2.ModifyAsync(mentionable: true); + + var msg = await announcementChannel.SendMessageAsync(pingMsgString); + + await insiderRole1.ModifyAsync(mentionable: false); + if (insiderChannel2 != "") + await insiderRole2.ModifyAsync(mentionable: false); + + if (announcementChannel.Type is DiscordChannelType.News) + await announcementChannel.CrosspostMessageAsync(msg); + } + + if (lockdownTime == "auto") + { + if (Program.cfgjson.InsiderAnnouncementChannel == 0) + lockdownTime = "1h"; + else + lockdownTime = "0"; + } + + if (lockdownTime != "0") + { + TimeSpan lockDuration; + try + { + lockDuration = HumanDateParser.HumanDateParser.Parse(lockdownTime).Subtract(DateTime.Now); + } + catch + { + lockDuration = TimeSpan.FromHours(2); + } + + await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: ctx.Channel, duration: lockDuration); + } + } + + [Command("editannouncetextcmd")] + [TextAlias("editannounce")] + [Description("Edit an announcement, preserving the ping highlight.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task EditAnnounce( + TextCommandContext ctx, + [Description("The ID of the message to edit.")] ulong messageId, + [Description("The short name for the role to ping.")] string roleName, + [RemainingText, Description("The new message content, excluding the ping.")] string content + ) + { + DiscordRole discordRole; + + if (Program.cfgjson.AnnouncementRoles.ContainsKey(roleName)) + { + discordRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleName]); + await discordRole.ModifyAsync(mentionable: true); + try + { + await ctx.Message.DeleteAsync(); + var msg = await ctx.Channel.GetMessageAsync(messageId); + await msg.ModifyAsync($"{discordRole.Mention} {content}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + await discordRole.ModifyAsync(mentionable: false); + } + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That role name isn't recognised!"); + return; + } + } + + [Command("announcetextcmd")] + [TextAlias("announce")] + [Description("Announces something in the current channel, pinging an Insider role in the process.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task AnnounceCmd(TextCommandContext ctx, [Description("'canary', 'dev', 'beta', 'beta10', 'rp', 'rp10', 'patch', 'rpbeta', 'rpbeta10', 'betadev', 'candev'")] string roleName, [RemainingText, Description("The announcement message to send.")] string announcementMessage) + { + DiscordRole discordRole; + + if (Program.cfgjson.AnnouncementRoles.ContainsKey(roleName)) + { + discordRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleName]); + await discordRole.ModifyAsync(mentionable: true); + try + { + await ctx.Message.DeleteAsync(); + await ctx.Channel.SendMessageAsync($"{discordRole.Mention} {announcementMessage}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + await discordRole.ModifyAsync(mentionable: false); + } + else if (roleName == "rpbeta") + { + var rpRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["rp"]); + var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta"]); + + await rpRole.ModifyAsync(mentionable: true); + await betaRole.ModifyAsync(mentionable: true); + + try + { + await ctx.Message.DeleteAsync(); + await ctx.Channel.SendMessageAsync($"{rpRole.Mention} {betaRole.Mention}\n{announcementMessage}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + + await rpRole.ModifyAsync(mentionable: false); + await betaRole.ModifyAsync(mentionable: false); + } + // this is rushed pending an actual solution + else if (roleName == "rpbeta10") + { + var rpRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["rp10"]); + var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta10"]); + + await rpRole.ModifyAsync(mentionable: true); + await betaRole.ModifyAsync(mentionable: true); + + try + { + await ctx.Message.DeleteAsync(); + await ctx.Channel.SendMessageAsync($"{rpRole.Mention} {betaRole.Mention}\n{announcementMessage}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + + await rpRole.ModifyAsync(mentionable: false); + await betaRole.ModifyAsync(mentionable: false); + } + else if (roleName == "betadev") + { + var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta"]); + var devRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["dev"]); + + await betaRole.ModifyAsync(mentionable: true); + await devRole.ModifyAsync(mentionable: true); + + try + { + await ctx.Message.DeleteAsync(); + await ctx.Channel.SendMessageAsync($"{betaRole.Mention} {devRole.Mention}\n{announcementMessage}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + + await betaRole.ModifyAsync(mentionable: false); + await devRole.ModifyAsync(mentionable: false); + } + else if (roleName == "candev") + { + var canaryRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["canary"]); + var devRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["dev"]); + + await canaryRole.ModifyAsync(mentionable: true); + await devRole.ModifyAsync(mentionable: true); + + try + { + await ctx.Message.DeleteAsync(); + await ctx.Channel.SendMessageAsync($"{canaryRole.Mention} {devRole.Mention}\n{announcementMessage}"); + } + catch + { + // We still need to remember to make it unmentionable even if the msg fails. + } + + await canaryRole.ModifyAsync(mentionable: false); + await devRole.ModifyAsync(mentionable: false); + } + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That role name isn't recognised!"); + return; + } + + } + + internal class WindowsVersionChoiceProvider : IChoiceProvider + { + public async ValueTask> ProvideAsync(CommandParameter _) + { + return new List + { + new("Windows 10", "10"), + new("Windows 11", "11") + }; + } + } + + internal class WindowsInsiderChannelChoiceProvider : IChoiceProvider + { + public async ValueTask> ProvideAsync(CommandParameter _) + { + return new List + { + new("Canary Channel", "Canary"), + new("Dev Channel", "Dev"), + new("Beta Channel", "Beta"), + new("Release Preview Channel", "RP") + }; + } + } + } +} \ No newline at end of file diff --git a/Commands/Announcements.cs b/Commands/Announcements.cs deleted file mode 100644 index 042e200e..00000000 --- a/Commands/Announcements.cs +++ /dev/null @@ -1,156 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Announcements : BaseCommandModule - { - - [Command("editannounce")] - [Description("Edit an announcement, preserving the ping highlight.")] - [RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task EditAnnounce( - CommandContext ctx, - [Description("The ID of the message to edit.")] ulong messageId, - [Description("The short name for the role to ping.")] string roleName, - [RemainingText, Description("The new message content, excluding the ping.")] string content - ) - { - DiscordRole discordRole; - - if (Program.cfgjson.AnnouncementRoles.ContainsKey(roleName)) - { - discordRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleName]); - await discordRole.ModifyAsync(mentionable: true); - try - { - await ctx.Message.DeleteAsync(); - var msg = await ctx.Channel.GetMessageAsync(messageId); - await msg.ModifyAsync($"{discordRole.Mention} {content}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - await discordRole.ModifyAsync(mentionable: false); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That role name isn't recognised!"); - return; - } - } - - [Command("announce")] - [Description("Announces something in the current channel, pinging an Insider role in the process.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task AnnounceCmd(CommandContext ctx, [Description("'canary', 'dev', 'beta', 'beta10', 'rp', 'rp10', 'patch', 'rpbeta', 'rpbeta10', 'betadev', 'candev'")] string roleName, [RemainingText, Description("The announcement message to send.")] string announcementMessage) - { - DiscordRole discordRole; - - if (Program.cfgjson.AnnouncementRoles.ContainsKey(roleName)) - { - discordRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleName]); - await discordRole.ModifyAsync(mentionable: true); - try - { - await ctx.Message.DeleteAsync(); - await ctx.Channel.SendMessageAsync($"{discordRole.Mention} {announcementMessage}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - await discordRole.ModifyAsync(mentionable: false); - } - else if (roleName == "rpbeta") - { - var rpRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["rp"]); - var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta"]); - - await rpRole.ModifyAsync(mentionable: true); - await betaRole.ModifyAsync(mentionable: true); - - try - { - await ctx.Message.DeleteAsync(); - await ctx.Channel.SendMessageAsync($"{rpRole.Mention} {betaRole.Mention}\n{announcementMessage}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - - await rpRole.ModifyAsync(mentionable: false); - await betaRole.ModifyAsync(mentionable: false); - } - // this is rushed pending an actual solution - else if (roleName == "rpbeta10") - { - var rpRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["rp10"]); - var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta10"]); - - await rpRole.ModifyAsync(mentionable: true); - await betaRole.ModifyAsync(mentionable: true); - - try - { - await ctx.Message.DeleteAsync(); - await ctx.Channel.SendMessageAsync($"{rpRole.Mention} {betaRole.Mention}\n{announcementMessage}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - - await rpRole.ModifyAsync(mentionable: false); - await betaRole.ModifyAsync(mentionable: false); - } - else if (roleName == "betadev") - { - var betaRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["beta"]); - var devRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["dev"]); - - await betaRole.ModifyAsync(mentionable: true); - await devRole.ModifyAsync(mentionable: true); - - try - { - await ctx.Message.DeleteAsync(); - await ctx.Channel.SendMessageAsync($"{betaRole.Mention} {devRole.Mention}\n{announcementMessage}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - - await betaRole.ModifyAsync(mentionable: false); - await devRole.ModifyAsync(mentionable: false); - } - else if (roleName == "candev") - { - var canaryRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["canary"]); - var devRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles["dev"]); - - await canaryRole.ModifyAsync(mentionable: true); - await devRole.ModifyAsync(mentionable: true); - - try - { - await ctx.Message.DeleteAsync(); - await ctx.Channel.SendMessageAsync($"{canaryRole.Mention} {devRole.Mention}\n{announcementMessage}"); - } - catch - { - // We still need to remember to make it unmentionable even if the msg fails. - } - - await canaryRole.ModifyAsync(mentionable: false); - await devRole.ModifyAsync(mentionable: false); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That role name isn't recognised!"); - return; - } - - } - } -} diff --git a/Commands/Bans.cs b/Commands/BanCmds.cs similarity index 57% rename from Commands/Bans.cs rename to Commands/BanCmds.cs index 3466f2be..77491e30 100644 --- a/Commands/Bans.cs +++ b/Commands/BanCmds.cs @@ -1,15 +1,162 @@ -using static Cliptok.Helpers.BanHelpers; +using static Cliptok.Helpers.BanHelpers; namespace Cliptok.Commands { - class Bans : BaseCommandModule + public class BanCmds { - [Command("massban")] - [Aliases("bigbonk")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task MassBanCmd(CommandContext ctx, [RemainingText] string input) + [Command("ban")] + [Description("Bans a user from the server, either permanently or temporarily.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(DiscordPermission.BanMembers)] + public async Task BanSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to ban")] DiscordUser user, + [Parameter("reason"), Description("The reason the user is being banned")] string reason, + [Parameter("keep_messages"), Description("Whether to keep the users messages when banning")] bool keepMessages = false, + [Parameter("time"), Description("The length of time the user is banned for")] string time = null, + [Parameter("appeal_link"), Description("Whether to show the user an appeal URL in the DM")] bool appealable = false, + [Parameter("compromised_account"), Description("Whether to include special instructions for compromised accounts")] bool compromisedAccount = false + ) + { + // Initial response to avoid the 3 second timeout, will edit later. + var eout = new DiscordInteractionResponseBuilder().AsEphemeral(true); + await ctx.DeferResponseAsync(true); + + // Edits need a webhook rather than interaction..? + DiscordWebhookBuilder webhookOut = new(); + int messageDeleteDays = 7; + if (keepMessages) + messageDeleteDays = 0; + + if (user.IsBot) + { + webhookOut.Content = $"{Program.cfgjson.Emoji.Error} To prevent accidents, I won't ban bots. If you really need to do this, do it manually in Discord."; + await ctx.EditResponseAsync(webhookOut); + return; + } + + DiscordMember targetMember; + + try + { + targetMember = await ctx.Guild.GetMemberAsync(user.Id); + if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator)) + { + webhookOut.Content = $"{Program.cfgjson.Emoji.Error} As a Trial Moderator you cannot perform moderation actions on other staff members."; + await ctx.EditResponseAsync(webhookOut); + return; + } + } + catch + { + // do nothing :/ + } + + TimeSpan banDuration; + if (time is null) + banDuration = default; + else + { + try + { + banDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.DateTime); + } + catch + { + webhookOut.Content = $"{Program.cfgjson.Emoji.Error} There was an error parsing your supplied ban length!"; + await ctx.EditResponseAsync(webhookOut); + return; + } + + } + + DiscordMember member; + try + { + member = await ctx.Guild.GetMemberAsync(user.Id); + } + catch + { + member = null; + } + + if (member is null) + { + await BanHelpers.BanFromServerAsync(user.Id, reason, ctx.User.Id, ctx.Guild, messageDeleteDays, ctx.Channel, banDuration, appealable); + } + else + { + if (DiscordHelpers.AllowedToMod(ctx.Member, member)) + { + if (DiscordHelpers.AllowedToMod(await ctx.Guild.GetMemberAsync(ctx.Client.CurrentUser.Id), member)) + { + await BanHelpers.BanFromServerAsync(user.Id, reason, ctx.User.Id, ctx.Guild, messageDeleteDays, ctx.Channel, banDuration, appealable); + } + else + { + webhookOut.Content = $"{Program.cfgjson.Emoji.Error} I don't have permission to ban **{DiscordHelpers.UniqueUsername(user)}**!"; + await ctx.EditResponseAsync(webhookOut); + return; + } + } + else + { + webhookOut.Content = $"{Program.cfgjson.Emoji.Error} You don't have permission to ban **{DiscordHelpers.UniqueUsername(user)}**!"; + await ctx.EditResponseAsync(webhookOut); + return; + } + } + reason = reason.Replace("`", "\\`").Replace("*", "\\*"); + if (banDuration == default) + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Banned} {user.Mention} has been banned: **{reason}**"); + else + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Banned} {user.Mention} has been banned for **{TimeHelpers.TimeToPrettyFormat(banDuration, false)}**: **{reason}**"); + + webhookOut.Content = $"{Program.cfgjson.Emoji.Success} User was successfully bonked."; + await ctx.EditResponseAsync(webhookOut); + } + + [Command("unban")] + [Description("Unbans a user who has been previously banned.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(permissions: DiscordPermission.BanMembers)] + public async Task UnbanCmd(CommandContext ctx, [Description("The user to unban, usually a mention or ID")] DiscordUser targetUser, [Description("Used in audit log only currently")] string reason = "No reason specified.") + { + if ((await Program.db.HashExistsAsync("bans", targetUser.Id))) + { + await UnbanUserAsync(ctx.Guild, targetUser, $"[Unban by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); + } + else + { + bool banSuccess = await UnbanUserAsync(ctx.Guild, targetUser); + if (banSuccess) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.Member.Mention}, that user doesn't appear to be banned, *and* an error occurred while attempting to unban them anyway.\nPlease contact the bot owner if this wasn't expected, the error has been logged."); + } + } + } + + [Command("baninfo")] + [Description("Show information about the ban for a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task BanInfoSlashCommand( + SlashCommandContext ctx, + [Parameter("user"), Description("The user whose ban information to show.")] DiscordUser targetUser, + [Parameter("public"), Description("Whether to show the output publicly. Default: false")] bool isPublic = false) { + await ctx.RespondAsync(embed: await BanHelpers.BanStatusEmbed(targetUser, ctx.Guild), ephemeral: !isPublic); + } + [Command("massbantextcmd")] + [TextAlias("massban", "bigbonk")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task MassBanCmd(TextCommandContext ctx, [RemainingText] string input) + { List inputString = input.Replace("\n", " ").Replace("\r", "").Split(' ').ToList(); List users = new(); string reason = ""; @@ -31,7 +178,8 @@ public async Task MassBanCmd(CommandContext ctx, [RemainingText] string input) List> taskList = new(); int successes = 0; - var loading = await ctx.RespondAsync("Processing, please wait."); + await ctx.RespondAsync("Processing, please wait."); + var loading = await ctx.GetResponseAsync(); foreach (ulong user in users) { @@ -53,11 +201,12 @@ public async Task MassBanCmd(CommandContext ctx, [RemainingText] string input) await loading.DeleteAsync(); } - [Command("ban")] - [Aliases("tempban", "bonk", "isekaitruck")] + [Command("bantextcmd")] + [TextAlias("ban", "tempban", "bonk", "isekaitruck")] [Description("Bans a user that you have permission to ban, deleting all their messages in the process. See also: bankeep.")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(permissions: DiscordPermission.BanMembers)] - public async Task BanCmd(CommandContext ctx, + public async Task BanCmd(TextCommandContext ctx, [Description("The user you wish to ban. Accepts many formats")] DiscordUser targetMember, [RemainingText, Description("The time and reason for the ban. e.g. '14d trolling' NOTE: Add 'appeal' to the start of the reason to include an appeal link")] string timeAndReason = "No reason specified.") { @@ -140,10 +289,11 @@ public async Task BanCmd(CommandContext ctx, /// I CANNOT find a way to do this as alias so I made it a separate copy of the command. /// Sue me, I beg you. - [Command("bankeep")] - [Aliases("bansave")] + [Command("bankeeptextcmd")] + [TextAlias("bankeep", "bansave")] [Description("Bans a user but keeps their messages around."), HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(permissions: DiscordPermission.BanMembers)] - public async Task BankeepCmd(CommandContext ctx, + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task BankeepCmd(TextCommandContext ctx, [Description("The user you wish to ban. Accepts many formats")] DiscordUser targetMember, [RemainingText, Description("The time and reason for the ban. e.g. '14d trolling' NOTE: Add 'appeal' to the start of the reason to include an appeal link")] string timeAndReason = "No reason specified.") { @@ -216,28 +366,5 @@ public async Task BankeepCmd(CommandContext ctx, } } } - - [Command("unban")] - [Description("Unbans a user who has been previously banned.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(permissions: DiscordPermission.BanMembers)] - public async Task UnbanCmd(CommandContext ctx, [Description("The user to unban, usually a mention or ID")] DiscordUser targetUser, [Description("Used in audit log only currently")] string reason = "No reason specified.") - { - if ((await Program.db.HashExistsAsync("bans", targetUser.Id))) - { - await UnbanUserAsync(ctx.Guild, targetUser, $"[Unban by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); - } - else - { - bool banSuccess = await UnbanUserAsync(ctx.Guild, targetUser); - if (banSuccess) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.Member.Mention}, that user doesn't appear to be banned, *and* an error occurred while attempting to unban them anyway.\nPlease contact the bot owner if this wasn't expected, the error has been logged."); - } - } - } - } -} +} \ No newline at end of file diff --git a/Commands/InteractionCommands/ClearInteractions.cs b/Commands/ClearCmds.cs similarity index 79% rename from Commands/InteractionCommands/ClearInteractions.cs rename to Commands/ClearCmds.cs index 7a45a546..bc363a25 100644 --- a/Commands/InteractionCommands/ClearInteractions.cs +++ b/Commands/ClearCmds.cs @@ -1,37 +1,39 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - public class ClearInteractions : ApplicationCommandModule + public class ClearCmds { public static Dictionary> MessagesToClear = new(); - [SlashCommand("clear", "Delete many messages from the current channel.", defaultPermission: false)] - [HomeServer, SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), RequireBotPermissions(permissions: DiscordPermission.ManageMessages), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task ClearSlashCommand(InteractionContext ctx, - [Option("count", "The number of messages to consider for deletion. Required if you don't use the 'up_to' argument.")] long count = 0, - [Option("up_to", "Optionally delete messages up to (not including) this one. Accepts IDs and links.")] string upTo = "", - [Option("user", "Optionally filter the deletion to a specific user.")] DiscordUser user = default, - [Option("ignore_mods", "Optionally filter the deletion to only messages sent by users who are not Moderators.")] bool ignoreMods = false, - [Option("match", "Optionally filter the deletion to only messages containing certain text.")] string match = "", - [Option("bots_only", "Optionally filter the deletion to only bots.")] bool botsOnly = false, - [Option("humans_only", "Optionally filter the deletion to only humans.")] bool humansOnly = false, - [Option("attachments_only", "Optionally filter the deletion to only messages with attachments.")] bool attachmentsOnly = false, - [Option("stickers_only", "Optionally filter the deletion to only messages with stickers.")] bool stickersOnly = false, - [Option("links_only", "Optionally filter the deletion to only messages containing links.")] bool linksOnly = false, - [Option("dry_run", "Don't actually delete the messages, just output what would be deleted.")] bool dryRun = false + [Command("clear")] + [Description("Delete many messages from the current channel.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ManageMessages, DiscordPermission.ModerateMembers)] + public async Task ClearSlashCommand(SlashCommandContext ctx, + [Parameter("count"), Description("The number of messages to consider for deletion. Required if you don't use the 'up_to' argument.")] long count = 0, + [Parameter("up_to"), Description("Optionally delete messages up to (not including) this one. Accepts IDs and links.")] string upTo = "", + [Parameter("user"), Description("Optionally filter the deletion to a specific user.")] DiscordUser user = default, + [Parameter("ignore_mods"), Description("Optionally filter the deletion to only messages sent by users who are not Moderators.")] bool ignoreMods = false, + [Parameter("match"), Description("Optionally filter the deletion to only messages containing certain text.")] string match = "", + [Parameter("bots_only"), Description("Optionally filter the deletion to only bots.")] bool botsOnly = false, + [Parameter("humans_only"), Description("Optionally filter the deletion to only humans.")] bool humansOnly = false, + [Parameter("attachments_only"), Description("Optionally filter the deletion to only messages with attachments.")] bool attachmentsOnly = false, + [Parameter("stickers_only"), Description("Optionally filter the deletion to only messages with stickers.")] bool stickersOnly = false, + [Parameter("links_only"), Description("Optionally filter the deletion to only messages containing links.")] bool linksOnly = false, + [Parameter("dry_run"), Description("Don't actually delete the messages, just output what would be deleted.")] bool dryRun = false ) { - await ctx.DeferAsync(ephemeral: !dryRun); + await ctx.DeferResponseAsync(ephemeral: !dryRun); // If all args are unset if (count == 0 && upTo == "" && user == default && ignoreMods == false && match == "" && botsOnly == false && humansOnly == false && attachmentsOnly == false && stickersOnly == false && linksOnly == false) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You must provide at least one argument! I need to know which messages to delete.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You must provide at least one argument! I need to know which messages to delete.").AsEphemeral(true)); return; } if (count == 0 && upTo == "") { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I need to know how many messages to delete! Please provide a value for `count` or `up_to`.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I need to know how many messages to delete! Please provide a value for `count` or `up_to`.").AsEphemeral(true)); return; } @@ -39,13 +41,13 @@ public async Task ClearSlashCommand(InteractionContext ctx, if (count < 0) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I can't delete a negative number of messages! Try setting `count` to a positive number.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I can't delete a negative number of messages! Try setting `count` to a positive number.").AsEphemeral(true)); return; } if (count >= 1000) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Deleting that many messages poses a risk of something disastrous happening, so I'm refusing your request, sorry.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Deleting that many messages poses a risk of something disastrous happening, so I'm refusing your request, sorry.").AsEphemeral(true)); return; } @@ -53,7 +55,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, if (upTo != "" && count != 0) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You can't provide both a count of messages and a message to delete up to! Please only provide one of the two arguments.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You can't provide both a count of messages and a message to delete up to! Please only provide one of the two arguments.").AsEphemeral(true)); return; } @@ -71,7 +73,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, { if (!ulong.TryParse(upTo, out messageId)) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That doesn't look like a valid message ID or link! Please try again.")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That doesn't look like a valid message ID or link! Please try again.")); return; } } @@ -82,7 +84,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, || !ulong.TryParse(Constants.RegexConstants.discord_link_rx.Match(upTo).Groups[3].Value, out messageId) ) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Please provide a valid link to a message in this channel!").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Please provide a valid link to a message in this channel!").AsEphemeral(true)); return; } } @@ -159,7 +161,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, { if (humansOnly) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You can't use `bots_only` and `humans_only` together! Pick one or the other please.").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You can't use `bots_only` and `humans_only` together! Pick one or the other please.").AsEphemeral(true)); return; } @@ -234,7 +236,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, if (messagesToClear.Count == 0 && skipped) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} All of the messages to delete are older than 2 weeks, so I can't delete them!").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} All of the messages to delete are older than 2 weeks, so I can't delete them!").AsEphemeral(true)); return; } @@ -245,7 +247,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, var msg = await LogChannelHelper.CreateDumpMessageAsync($"{Program.cfgjson.Emoji.Information} **{messagesToClear.Count}** messages would have been deleted, but are instead logged below.", messagesToClear, ctx.Channel); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent(msg.Content).AddFiles(msg.Files).AddEmbeds(msg.Embeds).AsEphemeral(false)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent(msg.Content).AddFiles(msg.Files).AddEmbeds(msg.Embeds).AsEphemeral(false)); return; } @@ -253,7 +255,7 @@ public async Task ClearSlashCommand(InteractionContext ctx, if (messagesToClear.Count >= 50) { DiscordButtonComponent confirmButton = new(DiscordButtonStyle.Danger, "clear-confirm-callback", "Delete Messages"); - DiscordMessage confirmationMessage = await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Muted} You're about to delete {messagesToClear.Count} messages. Are you sure?").AddComponents(confirmButton).AsEphemeral(true)); + DiscordMessage confirmationMessage = await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Muted} You're about to delete {messagesToClear.Count} messages. Are you sure?").AddComponents(confirmButton).AsEphemeral(true)); MessagesToClear.Add(confirmationMessage.Id, messagesToClear); } @@ -275,11 +277,11 @@ await LogChannelHelper.LogMessageAsync("mod", .WithContent($"{Program.cfgjson.Emoji.Deleted} **{messagesToClear.Count}** messages were cleared in {ctx.Channel.Mention} by {ctx.User.Mention}.") .WithAllowedMentions(Mentions.None) ); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!").AsEphemeral(true)); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!").AsEphemeral(true)); } else { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} There were no messages that matched all of the arguments you provided! Nothing to do.")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} There were no messages that matched all of the arguments you provided! Nothing to do.")); } await LogChannelHelper.LogDeletedMessagesAsync( diff --git a/Commands/Debug.cs b/Commands/DebugCmds.cs similarity index 90% rename from Commands/Debug.cs rename to Commands/DebugCmds.cs index 16aa92c6..8947eb5d 100644 --- a/Commands/Debug.cs +++ b/Commands/DebugCmds.cs @@ -1,17 +1,18 @@ -namespace Cliptok.Commands +namespace Cliptok.Commands { - internal class Debug : BaseCommandModule + public class DebugCmds { public static Dictionary OverridesPendingAddition = new(); - [Group("debug")] - [Aliases("troubleshoot", "unbug", "bugn't", "helpsomethinghasgoneverywrong")] + [Command("debugtextcmd")] + [TextAlias("debug", "troubleshoot", "unbug", "bugn't", "helpsomethinghasgoneverywrong")] [Description("Commands and things for fixing the bot in the unlikely event that it breaks a bit.")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - class DebugCmds : BaseCommandModule + class DebugCmd { [Command("mutestatus")] - public async Task MuteStatus(CommandContext ctx, DiscordUser targetUser = default) + public async Task MuteStatus(TextCommandContext ctx, DiscordUser targetUser = default) { if (targetUser == default) targetUser = ctx.User; @@ -20,9 +21,9 @@ public async Task MuteStatus(CommandContext ctx, DiscordUser targetUser = defaul } [Command("mutes")] - [Aliases("mute")] + [TextAlias("mute")] [Description("Debug the list of mutes.")] - public async Task MuteDebug(CommandContext ctx, DiscordUser targetUser = default) + public async Task MuteDebug(TextCommandContext ctx, DiscordUser targetUser = default) { await DiscordHelpers.SafeTyping(ctx.Channel); @@ -61,9 +62,9 @@ public async Task MuteDebug(CommandContext ctx, DiscordUser targetUser = default } [Command("bans")] - [Aliases("ban")] + [TextAlias("ban")] [Description("Debug the list of bans.")] - public async Task BanDebug(CommandContext ctx, DiscordUser targetUser = default) + public async Task BanDebug(TextCommandContext ctx, DiscordUser targetUser = default) { await DiscordHelpers.SafeTyping(ctx.Channel); @@ -101,7 +102,7 @@ public async Task BanDebug(CommandContext ctx, DiscordUser targetUser = default) [Command("restart")] [RequireHomeserverPerm(ServerPermLevel.Admin, ownerOverride: true), Description("Restart the bot. If not under Docker (Cliptok is, dw) this WILL exit instead.")] - public async Task Restart(CommandContext ctx) + public async Task Restart(TextCommandContext ctx) { await ctx.RespondAsync("Bot is restarting. Please hold."); Environment.Exit(1); @@ -109,7 +110,7 @@ public async Task Restart(CommandContext ctx) [Command("shutdown")] [RequireHomeserverPerm(ServerPermLevel.Admin, ownerOverride: true), Description("Panics and shuts the bot down. Check the arguments for usage.")] - public async Task Shutdown(CommandContext ctx, [Description("This MUST be set to \"I understand what I am doing\" for the command to work."), RemainingText] string verificationArgument) + public async Task Shutdown(TextCommandContext ctx, [Description("This MUST be set to \"I understand what I am doing\" for the command to work."), RemainingText] string verificationArgument) { if (verificationArgument == "I understand what I am doing") { @@ -126,9 +127,10 @@ public async Task Restart(CommandContext ctx) [Command("refresh")] [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] [Description("Manually run all the automatic actions.")] - public async Task Refresh(CommandContext ctx) + public async Task Refresh(TextCommandContext ctx) { - var msg = await ctx.RespondAsync("Checking for pending scheduled tasks..."); + await ctx.RespondAsync("Checking for pending scheduled tasks..."); + var msg = await ctx.GetResponseAsync(); bool bans = await Tasks.PunishmentTasks.CheckBansAsync(); bool mutes = await Tasks.PunishmentTasks.CheckMutesAsync(); bool punishmentMessages = await Tasks.PunishmentTasks.CleanUpPunishmentMessagesAsync(); @@ -142,10 +144,10 @@ public async Task Refresh(CommandContext ctx) } [Command("sh")] - [Aliases("cmd")] + [TextAlias("cmd")] [IsBotOwner] [Description("Run shell commands! Bash for Linux/macOS, batch for Windows!")] - public async Task Shell(CommandContext ctx, [RemainingText] string command) + public async Task Shell(TextCommandContext ctx, [RemainingText] string command) { if (string.IsNullOrWhiteSpace(command)) { @@ -153,7 +155,8 @@ public async Task Shell(CommandContext ctx, [RemainingText] string command) return; } - DiscordMessage msg = await ctx.RespondAsync("executing.."); + await ctx.RespondAsync("executing.."); + DiscordMessage msg = await ctx.GetResponseAsync(); ShellResult finishedShell = RunShellCommand(command); string result = Regex.Replace(finishedShell.result, "ghp_[0-9a-zA-Z]{36}", "ghp_REDACTED").Replace(Environment.GetEnvironmentVariable("CLIPTOK_TOKEN"), "REDACTED").Replace(Environment.GetEnvironmentVariable("CLIPTOK_ANTIPHISHING_ENDPOINT") ?? "DUMMYVALUE", "REDACTED"); @@ -166,7 +169,7 @@ public async Task Shell(CommandContext ctx, [RemainingText] string command) } [Command("logs")] - public async Task Logs(CommandContext ctx) + public async Task Logs(TextCommandContext ctx) { await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command has been removed! Please find logs through other means."); } @@ -174,7 +177,7 @@ public async Task Logs(CommandContext ctx) [Command("dumpwarnings"), Description("Dump all warning data. EXTREMELY computationally expensive, use with caution.")] [IsBotOwner] [RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task MostWarningsCmd(CommandContext ctx) + public async Task MostWarningsCmd(TextCommandContext ctx) { await DiscordHelpers.SafeTyping(ctx.Channel); @@ -206,10 +209,10 @@ public async Task MostWarningsCmd(CommandContext ctx) } [Command("checkpendingchannelevents")] - [Aliases("checkpendingevents", "pendingevents")] + [TextAlias("checkpendingevents", "pendingevents")] [Description("Check pending events to handle in the Channel Update and Channel Delete handlers.")] [IsBotOwner] - public async Task CheckPendingChannelEvents(CommandContext ctx) + public async Task CheckPendingChannelEvents(TextCommandContext ctx) { var pendingUpdateEvents = Tasks.EventTasks.PendingChannelUpdateEvents; var pendingDeleteEvents = Tasks.EventTasks.PendingChannelDeleteEvents; @@ -244,12 +247,14 @@ public async Task CheckPendingChannelEvents(CommandContext ctx) await ctx.RespondAsync(await StringHelpers.CodeOrHasteBinAsync(list)); } - [Group("overrides")] + [Command("overrides")] [Description("Commands for managing stored permission overrides.")] - public class Overrides : BaseCommandModule + [AllowedProcessors(typeof(TextCommandProcessor))] + public class Overrides { - [GroupCommand] - public async Task ShowOverrides(CommandContext ctx, + [DefaultGroupCommand] + [Command("show")] + public async Task ShowOverrides(TextCommandContext ctx, [Description("The user whose overrides to show.")] DiscordUser user) { var userOverrides = await Program.db.HashGetAsync("overrides", user.Id.ToString()); @@ -302,7 +307,7 @@ await ctx.RespondAsync(new DiscordMessageBuilder().WithContent(response) [Command("import")] [Description("Import overrides from a channel to the database.")] - public async Task Import(CommandContext ctx, + public async Task Import(TextCommandContext ctx, [Description("The channel to import overrides from.")] DiscordChannel channel) { // Import overrides @@ -318,9 +323,10 @@ await ctx.RespondAsync( [Command("importall")] [Description("Import all overrides from all channels to the database.")] - public async Task ImportAll(CommandContext ctx) + public async Task ImportAll(TextCommandContext ctx) { - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working..."); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working..."); + var msg = await ctx.GetResponseAsync(); // Get all channels var channels = await ctx.Guild.GetChannelsAsync(); @@ -344,7 +350,7 @@ public async Task ImportAll(CommandContext ctx) [Command("add")] [Description("Insert an override into the db. Useful if you want to add an override for a user who has left.")] [IsBotOwner] - public async Task Add(CommandContext ctx, + public async Task Add(TextCommandContext ctx, [Description("The user to add an override for.")] DiscordUser user, [Description("The channel to add the override to.")] DiscordChannel channel, [Description("Allowed permissions. Use a permission integer. See https://discordlookup.com/permissions-calculator.")] int allowedPermissions, @@ -357,11 +363,12 @@ public async Task Add(CommandContext ctx, var confirmButton = new DiscordButtonComponent(DiscordButtonStyle.Success, "debug-overrides-add-confirm-callback", "Yes"); var cancelButton = new DiscordButtonComponent(DiscordButtonStyle.Danger, "debug-overrides-add-cancel-callback", "No"); - var confirmationMessage = await ctx.RespondAsync(new DiscordMessageBuilder().WithContent( + await ctx.RespondAsync(new DiscordMessageBuilder().WithContent( $"{Program.cfgjson.Emoji.ShieldHelp} Just to confirm, you want to add the following override for {user.Mention} to {channel.Mention}?\n" + $"**Allowed:** {parsedAllowedPerms}\n" + $"**Denied:** {parsedDeniedPerms}\n") .AddComponents([confirmButton, cancelButton])); + var confirmationMessage = await ctx.GetResponseAsync(); OverridesPendingAddition.Add(confirmationMessage.Id, new PendingUserOverride { @@ -377,7 +384,7 @@ public async Task Add(CommandContext ctx, [Command("remove")] [Description("Remove a user's overrides for a channel from the database.")] - public async Task Remove(CommandContext ctx, + public async Task Remove(TextCommandContext ctx, [Description("The user whose overrides to remove.")] DiscordUser user, [Description("The channel to remove overrides from.")] DiscordChannel channel) { @@ -409,10 +416,11 @@ await Program.db.HashSetAsync("overrides", user.Id, [Command("apply")] [Description("Apply a user's overrides from the db.")] [IsBotOwner] - public async Task Apply(CommandContext ctx, + public async Task Apply(TextCommandContext ctx, [Description("The user whose overrides to apply.")] DiscordUser user) { - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); + var msg = await ctx.GetResponseAsync(); // Try fetching member to determine whether they are in the server. If they are not, we can't apply overrides for them. DiscordMember member; @@ -469,12 +477,12 @@ public async Task Apply(CommandContext ctx, await msg.ModifyAsync(x => x.Content = $"{Program.cfgjson.Emoji.Success} Successfully applied {numAppliedOverrides}/{dictionary.Count} overrides for {user.Mention}!"); } - [Group("dump")] + [Command("dump")] [Description("Dump all of a channel's overrides from Discord or the database.")] [IsBotOwner] - public class DumpChannelOverrides : BaseCommandModule + public class DumpChannelOverrides { - [GroupCommand] + [DefaultGroupCommand] [Command("discord")] [Description("Dump all of a channel's overrides as they exist on the Discord channel. Does not read from db.")] public async Task DumpFromDiscord(CommandContext ctx, @@ -492,7 +500,7 @@ public async Task DumpFromDiscord(CommandContext ctx, } [Command("db")] - [Aliases("database")] + [TextAlias("database")] [Description("Dump all of a channel's overrides as they are stored in the db.")] public async Task DumpFromDb(CommandContext ctx, [Description("The channel to dump overrides for.")] DiscordChannel channel) @@ -535,12 +543,13 @@ public async Task DumpFromDb(CommandContext ctx, } [Command("cleanup")] - [Aliases("clean", "prune")] + [TextAlias("clean", "prune")] [Description("Removes overrides from the db for channels that no longer exist.")] [IsBotOwner] public async Task CleanUpOverrides(CommandContext ctx) { - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); + var msg = await ctx.GetResponseAsync(); var removedOverridesCount = 0; var dbOverwrites = await Program.db.HashGetAllAsync("overrides"); @@ -579,7 +588,7 @@ public async Task CleanUpOverrides(CommandContext ctx) [Command("dmchannel")] [Description("Create or find a DM channel ID for a user.")] [IsBotOwner] - public async Task GetDMChannel(CommandContext ctx, DiscordUser user) + public async Task GetDMChannel(TextCommandContext ctx, DiscordUser user) { var dmChannel = await user.CreateDmChannelAsync(); await ctx.RespondAsync(dmChannel.Id.ToString()); @@ -588,7 +597,7 @@ public async Task GetDMChannel(CommandContext ctx, DiscordUser user) [Command("dumpdmchannels")] [Description("Dump all DM channels")] [IsBotOwner] - public async Task DumpDMChannels(CommandContext ctx) + public async Task DumpDMChannels(TextCommandContext ctx) { var dmChannels = ctx.Client.PrivateChannels; @@ -600,11 +609,12 @@ public async Task DumpDMChannels(CommandContext ctx) [Command("searchmembers")] [Description("Search member list with a regex. Restricted to bot owners bc regexes are scary.")] [IsBotOwner] - public async Task SearchMembersCmd(CommandContext ctx, string regex) + public async Task SearchMembersCmd(TextCommandContext ctx, string regex) { var rx = new Regex(regex); - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); + var msg = await ctx.GetResponseAsync(); var discordMembers = await ctx.Guild.GetAllMembersAsync().ToListAsync(); var matchedMembers = discordMembers.Where(discordMember => discordMember.Username is not null && rx.IsMatch(discordMember.Username)).ToList(); @@ -662,6 +672,5 @@ await Program.db.HashSetAsync("overrides", overwrite.Id.ToString(), } } - } -} +} \ No newline at end of file diff --git a/Commands/Dehoist.cs b/Commands/Dehoist.cs deleted file mode 100644 index 3e1a61b5..00000000 --- a/Commands/Dehoist.cs +++ /dev/null @@ -1,329 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Dehoist : BaseCommandModule - { - [Command("dehoist")] - [Description("Adds an invisible character to someone's nickname that drops them to the bottom of the member list. Accepts multiple members.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task DehoistCmd(CommandContext ctx, [Description("List of server members to dehoist")] params DiscordMember[] discordMembers) - { - if (discordMembers.Length == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You need to tell me who to dehoist!"); - return; - } - else if (discordMembers.Length == 1) - { - if (discordMembers[0].DisplayName[0] == DehoistHelpers.dehoistCharacter) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {discordMembers[0].Mention} is already dehoisted!"); - return; - } - try - { - await discordMembers[0].ModifyAsync(a => - { - a.Nickname = DehoistHelpers.DehoistName(discordMembers[0].DisplayName); - a.AuditLogReason = $"[Dehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; - }); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Successfully dehoisted {discordMembers[0].Mention}!"); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to dehoist {discordMembers[0].Mention}!"); - } - return; - } - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); - int failedCount = 0; - - foreach (DiscordMember discordMember in discordMembers) - { - var origName = discordMember.DisplayName; - if (origName[0] == '\u17b5') - { - failedCount++; - } - else - { - try - { - await discordMember.ModifyAsync(a => - { - a.Nickname = DehoistHelpers.DehoistName(origName); - a.AuditLogReason = $"[Dehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; - }); - } - catch - { - failedCount++; - } - } - - } - _ = await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully dehoisted {discordMembers.Length - failedCount} of {discordMembers.Length} member(s)! (Check Audit Log for details)"); - } - - [Command("massdehoist")] - [Description("Dehoist everyone on the server who has a bad name. This may take a while and can exhaust rate limits.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task MassDehoist(CommandContext ctx) - { - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); - var discordMembers = await ctx.Guild.GetAllMembersAsync().ToListAsync(); - int failedCount = 0; - - foreach (DiscordMember discordMember in discordMembers) - { - bool success = await DehoistHelpers.CheckAndDehoistMemberAsync(discordMember, ctx.User, true); - if (!success) - failedCount++; - } - - _ = msg.DeleteAsync(); - await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully dehoisted {discordMembers.Count() - failedCount} of {discordMembers.Count()} member(s)! (Check Audit Log for details)").WithReply(ctx.Message.Id, true, false)); - } - - [Command("massundehoist")] - [Description("Remove the dehoist for users attached via a txt file.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task MassUndhoist(CommandContext ctx) - { - int failedCount = 0; - - if (ctx.Message.Attachments.Count == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Please upload an attachment as well."); - } - else - { - string strList; - using (HttpClient client = new()) - { - strList = await client.GetStringAsync(ctx.Message.Attachments[0].Url); - } - - var list = strList.Split(' '); - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); - - foreach (string strID in list) - { - ulong id = Convert.ToUInt64(strID); - DiscordMember member = default; - try - { - member = await ctx.Guild.GetMemberAsync(id); - } - catch (DSharpPlus.Exceptions.NotFoundException) - { - failedCount++; - continue; - } - - if (member.DisplayName[0] == DehoistHelpers.dehoistCharacter) - { - var newNickname = member.Nickname[1..]; - await member.ModifyAsync(a => - { - a.Nickname = newNickname; - a.AuditLogReason = $"[Mass undehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; - } - ); - } - else - { - failedCount++; - } - } - - await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully undehoisted {list.Length - failedCount} of {list.Length} member(s)! (Check Audit Log for details)"); - - } - } - - [Group("permadehoist")] - [Description("Permanently/persistently dehoist members.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public class Permadehoist : BaseCommandModule - { - // Toggle - [GroupCommand] - public async Task PermadehoistToggleCmd(CommandContext ctx, [Description("The member(s) to permadehoist.")] params DiscordUser[] discordUsers) - { - if (discordUsers.Length == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You need to tell me who to permadehoist!"); - return; - } - - if (discordUsers.Length == 1) - { - // Toggle permadehoist for single member - - var (success, isPermissionError, isDehoist) = await DehoistHelpers.TogglePermadehoist(discordUsers[0], ctx.User, ctx.Guild); - - if (success) - { - if (isDehoist) - { - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.On} Successfully permadehoisted {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - } - else - { - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Off} Successfully removed the permadehoist for {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - } - } - else - { - if (isDehoist) - { - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent(isPermissionError ? $"{Program.cfgjson.Emoji.Error} Failed to permadehoist {discordUsers[0].Mention}! Do I have permission?" : $"{Program.cfgjson.Emoji.Error} Failed to permadehoist {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - } - else - { - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent(isPermissionError ? $"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {discordUsers[0].Mention}! Do I have permission?" : $"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - } - } - - return; - } - - // Toggle permadehoist for multiple members - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); - int failedCount = 0; - - foreach (var discordUser in discordUsers) - { - var (success, _, _) = await DehoistHelpers.TogglePermadehoist(discordUser, ctx.User, ctx.Guild); - - if (!success) - failedCount++; - } - _ = await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully toggled permadehoist for {discordUsers.Length - failedCount} of {discordUsers.Length} member(s)! (Check Audit Log for details)"); - } - - [Command("enable")] - [Description("Permanently dehoist a member (or members). They will be automatically dehoisted until disabled.")] - public async Task PermadehoistEnableCmd(CommandContext ctx, [Description("The member(s) to permadehoist.")] params DiscordUser[] discordUsers) - { - if (discordUsers.Length == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You need to tell me who to permadehoist!"); - return; - } - - if (discordUsers.Length == 1) - { - // Permadehoist single member - - var (success, isPermissionError) = await DehoistHelpers.PermadehoistMember(discordUsers[0], ctx.User, ctx.Guild); - - if (success) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.On} Successfully permadehoisted {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - - if (!success & !isPermissionError) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Error} {discordUsers[0].Mention} is already permadehoisted!") - .WithAllowedMentions(Mentions.None)); - - if (!success && isPermissionError) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Error} Failed to permadehoist {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - - return; - } - - // Permadehoist multiple members - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); - int failedCount = 0; - - foreach (var discordUser in discordUsers) - { - var (success, _) = await DehoistHelpers.PermadehoistMember(discordUser, ctx.User, ctx.Guild); - - if (!success) - failedCount++; - } - _ = await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully permadehoisted {discordUsers.Length - failedCount} of {discordUsers.Length} member(s)! (Check Audit Log for details)"); - } - - [Command("disable")] - [Description("Disable permadehoist for a member (or members).")] - public async Task PermadehoistDisableCmd(CommandContext ctx, [Description("The member(s) to remove the permadehoist for.")] params DiscordUser[] discordUsers) - { - if (discordUsers.Length == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You need to tell me who to un-permadehoist!"); - return; - } - - if (discordUsers.Length == 1) - { - // Un-permadehoist single member - - var (success, isPermissionError) = await DehoistHelpers.UnpermadehoistMember(discordUsers[0], ctx.User, ctx.Guild); - - if (success) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Off} Successfully removed the permadehoist for {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - - if (!success & !isPermissionError) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Error} {discordUsers[0].Mention} isn't permadehoisted!") - .WithAllowedMentions(Mentions.None)); - - if (!success && isPermissionError) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {discordUsers[0].Mention}!") - .WithAllowedMentions(Mentions.None)); - - return; - } - - // Un-permadehoist multiple members - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it..."); - int failedCount = 0; - - foreach (var discordUser in discordUsers) - { - var (success, _) = await DehoistHelpers.UnpermadehoistMember(discordUser, ctx.User, ctx.Guild); - - if (!success) - failedCount++; - } - _ = await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully removed the permadehoist for {discordUsers.Length - failedCount} of {discordUsers.Length} member(s)! (Check Audit Log for details)"); - } - - [Command("status")] - [Description("Check the status of permadehoist for a member.")] - public async Task PermadehoistStatus(CommandContext ctx, [Description("The member whose permadehoist status to check.")] DiscordUser discordUser) - { - if (await Program.db.SetContainsAsync("permadehoists", discordUser.Id)) - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.On} {discordUser.Mention} is permadehoisted.") - .WithAllowedMentions(Mentions.None)); - else - await ctx.RespondAsync(new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Off} {discordUser.Mention} is not permadehoisted.") - .WithAllowedMentions(Mentions.None)); - } - } - } -} diff --git a/Commands/DehoistCmds.cs b/Commands/DehoistCmds.cs new file mode 100644 index 00000000..380be075 --- /dev/null +++ b/Commands/DehoistCmds.cs @@ -0,0 +1,217 @@ +namespace Cliptok.Commands +{ + public class DehoistCmds + { + [Command("dehoist")] + [Description("Dehoist a member, dropping them to the bottom of the list. Lasts until they change nickname.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public async Task DehoistCmd(CommandContext ctx, [Parameter("member"), Description("The member to dehoist.")] DiscordUser user) + { + DiscordMember member; + try + { + member = await ctx.Guild.GetMemberAsync(user.Id); + } + catch + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to find {user.Mention} as a member! Are they in the server?", ephemeral: true); + return; + } + + if (member.DisplayName[0] == DehoistHelpers.dehoistCharacter) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {member.Mention} is already dehoisted!", ephemeral: true); + return; + } + + try + { + await member.ModifyAsync(a => + { + a.Nickname = DehoistHelpers.DehoistName(member.DisplayName); + a.AuditLogReason = $"[Dehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; + }); + } + catch + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to dehoist {member.Mention}! Do I have permission?", ephemeral: true); + return; + } + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Successfuly dehoisted {member.Mention}!", mentions: false); + } + + [Command("permadehoist")] + [Description("Permanently/persistently dehoist members.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ManageNicknames)] + public class PermadehoistCmds + { + [DefaultGroupCommand] + [Command("toggle")] + [Description("Toggle permadehoist status for a member.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task PermadehoistToggleCmd(CommandContext ctx, [Description("The member to permadehoist.")] DiscordUser user) + { + var (success, isPermissionError, isDehoist) = await DehoistHelpers.TogglePermadehoist(user, ctx.User, ctx.Guild); + + if (success) + { + if (isDehoist) + { + await ctx.RespondAsync(new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.On} Successfully permadehoisted {user.Mention}!") + .WithAllowedMentions(Mentions.None)); + } + else + { + await ctx.RespondAsync(new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Off} Successfully removed the permadehoist for {user.Mention}!") + .WithAllowedMentions(Mentions.None)); + } + } + else + { + if (isDehoist) + { + await ctx.RespondAsync(new DiscordMessageBuilder() + .WithContent(isPermissionError ? $"{Program.cfgjson.Emoji.Error} Failed to permadehoist {user.Mention}! Do I have permission?" : $"{Program.cfgjson.Emoji.Error} Failed to permadehoist {user.Mention}!") + .WithAllowedMentions(Mentions.None)); + } + else + { + await ctx.RespondAsync(new DiscordMessageBuilder() + .WithContent(isPermissionError ? $"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {user.Mention}! Do I have permission?" : $"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {user.Mention}!") + .WithAllowedMentions(Mentions.None)); + } + } + } + + [Command("enable")] + [Description("Permanently dehoist a member. They will be automatically dehoisted until disabled.")] + public async Task PermadehoistEnableSlashCmd(CommandContext ctx, [Parameter("member"), Description("The member to permadehoist.")] DiscordUser discordUser) + { + var (success, isPermissionError) = await DehoistHelpers.PermadehoistMember(discordUser, ctx.User, ctx.Guild); + + if (success) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} Successfully permadehoisted {discordUser.Mention}!", mentions: false); + + if (!success & !isPermissionError) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {discordUser.Mention} is already permadehoisted!", mentions: false); + + if (!success && isPermissionError) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to permadehoist {discordUser.Mention}!", mentions: false); + } + + [Command("disable")] + [Description("Disable permadehoist for a member.")] + public async Task PermadehoistDisableSlashCmd(CommandContext ctx, [Parameter("member"), Description("The member to remove the permadehoist for.")] DiscordUser discordUser) + { + var (success, isPermissionError) = await DehoistHelpers.UnpermadehoistMember(discordUser, ctx.User, ctx.Guild); + + if (success) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} Successfully removed the permadehoist for {discordUser.Mention}!", mentions: false); + + if (!success & !isPermissionError) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {discordUser.Mention} isn't permadehoisted!", mentions: false); + + if (!success && isPermissionError) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {discordUser.Mention}!", mentions: false); + } + + [Command("status")] + [Description("Check the status of permadehoist for a member.")] + public async Task PermadehoistStatusSlashCmd(CommandContext ctx, [Parameter("member"), Description("The member whose permadehoist status to check.")] DiscordUser discordUser) + { + if (await Program.db.SetContainsAsync("permadehoists", discordUser.Id)) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} {discordUser.Mention} is permadehoisted.", mentions: false); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} {discordUser.Mention} is not permadehoisted.", mentions: false); + } + } + + [Command("massdehoisttextcmd")] + [TextAlias("massdehoist")] + [Description("Dehoist everyone on the server who has a bad name. This may take a while and can exhaust rate limits.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task MassDehoist(TextCommandContext ctx) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); + var msg = await ctx.GetResponseAsync(); + var discordMembers = await ctx.Guild.GetAllMembersAsync().ToListAsync(); + int failedCount = 0; + + foreach (DiscordMember discordMember in discordMembers) + { + bool success = await DehoistHelpers.CheckAndDehoistMemberAsync(discordMember, ctx.User, true); + if (!success) + failedCount++; + } + + _ = msg.DeleteAsync(); + await ctx.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully dehoisted {discordMembers.Count() - failedCount} of {discordMembers.Count()} member(s)! (Check Audit Log for details)").WithReply(ctx.Message.Id, true, false)); + } + + [Command("massundehoisttextcmd")] + [TextAlias("massundehoist")] + [Description("Remove the dehoist for users attached via a txt file.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task MassUndhoist(TextCommandContext ctx) + { + int failedCount = 0; + + if (ctx.Message.Attachments.Count == 0) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Please upload an attachment as well."); + } + else + { + string strList; + using (HttpClient client = new()) + { + strList = await client.GetStringAsync(ctx.Message.Attachments[0].Url); + } + + var list = strList.Split(' '); + + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it. This will take a while."); + var msg = await ctx.GetResponseAsync(); + + foreach (string strID in list) + { + ulong id = Convert.ToUInt64(strID); + DiscordMember member = default; + try + { + member = await ctx.Guild.GetMemberAsync(id); + } + catch (DSharpPlus.Exceptions.NotFoundException) + { + failedCount++; + continue; + } + + if (member.DisplayName[0] == DehoistHelpers.dehoistCharacter) + { + var newNickname = member.Nickname[1..]; + await member.ModifyAsync(a => + { + a.Nickname = newNickname; + a.AuditLogReason = $"[Mass undehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; + } + ); + } + else + { + failedCount++; + } + } + + await msg.ModifyAsync($"{Program.cfgjson.Emoji.Success} Successfully undehoisted {list.Length - failedCount} of {list.Length} member(s)! (Check Audit Log for details)"); + + } + } + } +} \ No newline at end of file diff --git a/Commands/DmRelayBlock.cs b/Commands/DmRelayCmds.cs similarity index 76% rename from Commands/DmRelayBlock.cs rename to Commands/DmRelayCmds.cs index 69c11e38..c292ffbe 100644 --- a/Commands/DmRelayBlock.cs +++ b/Commands/DmRelayCmds.cs @@ -1,12 +1,13 @@ -namespace Cliptok.Commands +namespace Cliptok.Commands { - internal class DmRelayBlock : BaseCommandModule + public class DmRelayCmds { - [Command("dmrelayblock")] + [Command("dmrelayblocktextcmd")] + [TextAlias("dmrelayblock", "dmblock")] [Description("Stop a member's DMs from being relayed to the configured DM relay channel.")] - [Aliases("dmblock")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task DmRelayBlockCommand(CommandContext ctx, [Description("The member to stop relaying DMs from.")] DiscordUser user) + public async Task DmRelayBlockCommand(TextCommandContext ctx, [Description("The member to stop relaying DMs from.")] DiscordUser user) { // Only function in configured DM relay channel/thread; do nothing if in wrong channel if (ctx.Channel.Id != Program.cfgjson.DmLogChannelId && Program.cfgjson.LogChannels.All(a => a.Value.ChannelId != ctx.Channel.Id)) return; @@ -25,4 +26,4 @@ public async Task DmRelayBlockCommand(CommandContext ctx, [Description("The memb await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} {user.Mention} has been blocked. Their DMs will not appear here."); } } -} +} \ No newline at end of file diff --git a/Commands/FunCmds.cs b/Commands/FunCmds.cs index 4769ed5d..e007f3ef 100644 --- a/Commands/FunCmds.cs +++ b/Commands/FunCmds.cs @@ -1,30 +1,96 @@ -namespace Cliptok.Commands +using Cliptok.Constants; + +namespace Cliptok.Commands { - internal class FunCmds : BaseCommandModule + public class FunCmds { + [Command("Hug")] + [SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [AllowedProcessors(typeof(UserCommandProcessor))] + public async Task Hug(UserCommandContext ctx, DiscordUser targetUser) + { + var user = targetUser; + + if (user is not null) + { + switch (new Random().Next(4)) + { + case 0: + await ctx.RespondAsync($"*{ctx.User.Mention} snuggles {user.Mention}*"); + break; + + case 1: + await ctx.RespondAsync($"*{ctx.User.Mention} huggles {user.Mention}*"); + break; + + case 2: + await ctx.RespondAsync($"*{ctx.User.Mention} cuddles {user.Mention}*"); + break; + + case 3: + await ctx.RespondAsync($"*{ctx.User.Mention} hugs {user.Mention}*"); + break; + } + } + } + [Command("tellraw")] - [Description("Nothing of interest.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task TellRaw(CommandContext ctx, [Description("???")] DiscordChannel discordChannel, [RemainingText, Description("???")] string output) + [Description("You know what you're here for.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task TellRaw(CommandContext ctx, [Parameter("channel"), Description("Either mention or ID. Not a name.")] string discordChannel, [Parameter("input"), Description("???")] string input, [Parameter("reply_msg_id"), Description("ID of message to use in a reply context.")] string replyID = "0", [Parameter("pingreply"), Description("Ping pong.")] bool pingreply = true) { + DiscordChannel channelObj = default; + ulong channelId; + if (!ulong.TryParse(discordChannel, out channelId)) + { + var captures = RegexConstants.channel_rx.Match(discordChannel).Groups[1].Captures; + if (captures.Count > 0) + channelId = Convert.ToUInt64(captures[0].Value); + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} The channel you gave can't be parsed. Please give either an ID or a mention of a channel.", ephemeral: true); + return; + } + } try { - await discordChannel.SendMessageAsync(output); + channelObj = await ctx.Client.GetChannelAsync(channelId); } catch { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Your dumb message didn't want to send. Congrats, I'm proud of you."); + // caught immediately after + } + if (channelObj == default) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I can't find a channel with the provided ID!", ephemeral: true); return; } - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} I sent your stupid message to {discordChannel.Mention}."); + try + { + await channelObj.SendMessageAsync(new DiscordMessageBuilder().WithContent(input).WithReply(Convert.ToUInt64(replyID), pingreply, false)); + } + catch + { + await ctx.RespondAsync($"Your dumb message didn't want to send. Congrats, I'm proud of you.", ephemeral: true); + return; + } + await ctx.RespondAsync($"I sent your stupid message to {channelObj.Mention}.", ephemeral: true); + await LogChannelHelper.LogMessageAsync("secret", + new DiscordMessageBuilder() + .WithContent($"{ctx.User.Mention} used tellraw in {channelObj.Mention}:") + .WithAllowedMentions(Mentions.None) + .AddEmbed(new DiscordEmbedBuilder().WithDescription(input)) + ); } - [Command("no")] + [Command("notextcmd")] + [TextAlias("no", "yes")] [Description("Makes Cliptok choose something for you. Outputs either Yes or No.")] - [Aliases("yes")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.Tier5)] - public async Task No(CommandContext ctx) + public async Task No(TextCommandContext ctx) { List noResponses = new() { @@ -61,4 +127,4 @@ public async Task No(CommandContext ctx) } } -} +} \ No newline at end of file diff --git a/Commands/GlobalCmds.cs b/Commands/GlobalCmds.cs new file mode 100644 index 00000000..4864bee6 --- /dev/null +++ b/Commands/GlobalCmds.cs @@ -0,0 +1,380 @@ +using System.Reflection; + +namespace Cliptok.Commands +{ + public class GlobalCmds + { + // These commands will be registered outside of the home server and can be used anywhere, even in DMs. + + // Most of this is taken from DSharpPlus.CommandsNext and adapted to fit here. + // https://github.com/DSharpPlus/DSharpPlus/blob/1c1aa15/DSharpPlus.CommandsNext/CommandsNextExtension.cs#L829 + [Command("helptextcmd"), Description("Displays command help.")] + [TextAlias("help")] + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task Help(CommandContext ctx, [Description("Command to provide help for."), RemainingText] string command = "") + { + var commandSplit = command.Split(' '); + + DiscordEmbedBuilder helpEmbed = new() + { + Title = "Help", + Color = new DiscordColor("#0080ff") + }; + + IEnumerable cmds = ctx.Extension.Commands.Values.Where(cmd => + cmd.Attributes.Any(attr => attr is AllowedProcessorsAttribute apAttr + && apAttr.Processors.Contains(typeof(TextCommandProcessor)))); + + if (commandSplit.Length != 0 && commandSplit[0] != "") + { + commandSplit[0] += "textcmd"; + + Command? cmd = null; + IEnumerable? searchIn = cmds; + for (int i = 0; i < commandSplit.Length; i++) + { + if (searchIn is null) + { + cmd = null; + break; + } + + StringComparison comparison = StringComparison.InvariantCultureIgnoreCase; + StringComparer comparer = StringComparer.InvariantCultureIgnoreCase; + cmd = searchIn.FirstOrDefault(xc => xc.Name.Equals(commandSplit[i], comparison) || xc.Name.Equals(commandSplit[i].Replace("textcmd", ""), comparison) || ((xc.Attributes.FirstOrDefault(x => x is TextAliasAttribute) as TextAliasAttribute)?.Aliases.Contains(commandSplit[i].Replace("textcmd", ""), comparer) ?? false)); + + if (cmd is null) + { + break; + } + + // Only run checks on the last command in the chain. + // So if we are looking at a command group here, only run checks against the actual command, + // not the group(s) it's under. + if (i == commandSplit.Length - 1) + { + IEnumerable failedChecks = (await CheckPermissionsAsync(ctx, cmd)).ToList(); + if (failedChecks.Any()) + { + if (failedChecks.All(x => x is RequireHomeserverPermAttribute)) + { + var att = failedChecks.FirstOrDefault(x => x is RequireHomeserverPermAttribute) as RequireHomeserverPermAttribute; + if (att is not null) + { + var level = (await GetPermLevelAsync(ctx.Member)); + var levelText = level.ToString(); + if (level == ServerPermLevel.Nothing && Program.rand.Next(1, 100) == 69) + levelText = $"naught but a thing, my dear human. Congratulations, you win {Program.rand.Next(1, 10)} bonus points."; + + await ctx.RespondAsync( + $"{Program.cfgjson.Emoji.NoPermissions} Invalid permissions to use command **{command.Replace("textcmd", "")}**!\n" + + $"Required: `{att.TargetLvl}`\nYou have: `{levelText}`"); + + return; + } + } + + return; + } + } + + searchIn = cmd.Subcommands.Any() ? cmd.Subcommands : null; + } + + if (cmd is null) + { + throw new CommandNotFoundException(string.Join(" ", commandSplit)); + } + + helpEmbed.Description = $"`{cmd.Name.Replace("textcmd", "")}`: {cmd.Description ?? "No description provided."}"; + + + if (cmd.Subcommands.Count > 0 && cmd.Subcommands.Any(subCommand => subCommand.Attributes.Any(attr => attr is DefaultGroupCommandAttribute))) + { + helpEmbed.Description += "\n\nThis group can be executed as a standalone command."; + } + + var aliases = cmd.Method?.GetCustomAttributes().FirstOrDefault()?.Aliases ?? (cmd.Attributes.FirstOrDefault(x => x is TextAliasAttribute) as TextAliasAttribute)?.Aliases ?? null; + if (aliases is not null && (aliases.Length > 1 || (aliases.Length == 1 && aliases[0] != cmd.Name.Replace("textcmd", "")))) + { + var aliasStr = ""; + foreach (var alias in aliases) + { + if (alias == cmd.Name.Replace("textcmd", "")) + continue; + + aliasStr += $"`{alias}`, "; + } + aliasStr = aliasStr.TrimEnd(',', ' '); + helpEmbed.AddField("Aliases", aliasStr); + } + + var arguments = cmd.Method?.GetParameters(); + if (arguments is not null && arguments.Length > 0) + { + var argumentsStr = $"`{cmd.Name.Replace("textcmd", "")}"; + foreach (var arg in arguments) + { + if (arg.ParameterType == typeof(CommandContext) || arg.ParameterType.IsSubclassOf(typeof(CommandContext))) + continue; + + bool isCatchAll = arg.GetCustomAttribute() != null; + argumentsStr += $"{(arg.IsOptional || isCatchAll ? " [" : " <")}{arg.Name}{(isCatchAll ? "..." : "")}{(arg.IsOptional || isCatchAll ? "]" : ">")}"; + } + + argumentsStr += "`\n"; + + foreach (var arg in arguments) + { + if (arg.ParameterType == typeof(CommandContext) || arg.ParameterType.IsSubclassOf(typeof(CommandContext))) + continue; + + argumentsStr += $"`{arg.Name} ({arg.ParameterType.Name})`: {arg.GetCustomAttribute()?.Description ?? "No description provided."}\n"; + } + + helpEmbed.AddField("Arguments", argumentsStr.Trim()); + } + //helpBuilder.WithCommand(cmd); + + if (cmd.Subcommands.Any()) + { + IEnumerable commandsToSearch = cmd.Subcommands; + List eligibleCommands = []; + foreach (Command? candidateCommand in commandsToSearch) + { + var executionChecks = candidateCommand.Attributes.Where(x => x is ContextCheckAttribute) as List; + + if (executionChecks == null || !executionChecks.Any()) + { + eligibleCommands.Add(candidateCommand); + continue; + } + + IEnumerable candidateFailedChecks = await CheckPermissionsAsync(ctx, candidateCommand); + if (!candidateFailedChecks.Any()) + { + eligibleCommands.Add(candidateCommand); + } + } + + if (eligibleCommands.Count != 0) + { + eligibleCommands = eligibleCommands.OrderBy(x => x.Name).ToList(); + string cmdList = ""; + foreach (var subCommand in eligibleCommands) + { + cmdList += $"`{subCommand.Name}`, "; + } + helpEmbed.AddField("Subcommands", cmdList.TrimEnd(',', ' ')); + //helpBuilder.WithSubcommands(eligibleCommands.OrderBy(xc => xc.Name)); + } + } + } + else + { + IEnumerable commandsToSearch = cmds; + List eligibleCommands = []; + foreach (Command? sc in commandsToSearch) + { + var executionChecks = sc.Attributes.Where(x => x is ContextCheckAttribute); + + if (!executionChecks.Any()) + { + eligibleCommands.Add(sc); + continue; + } + + IEnumerable candidateFailedChecks = await CheckPermissionsAsync(ctx, sc); + if (!candidateFailedChecks.Any()) + { + eligibleCommands.Add(sc); + } + } + + if (eligibleCommands.Count != 0) + { + eligibleCommands = eligibleCommands.OrderBy(x => x.Name).ToList(); + string cmdList = ""; + foreach (var eligibleCommand in eligibleCommands) + { + cmdList += $"`{eligibleCommand.Name.Replace("textcmd", "")}`, "; + } + helpEmbed.AddField("Commands", cmdList.TrimEnd(',', ' ')); + helpEmbed.Description = "Listing all top-level commands and groups. Specify a command to see more information."; + //helpBuilder.WithSubcommands(eligibleCommands.OrderBy(xc => xc.Name)); + } + } + + DiscordMessageBuilder builder = new DiscordMessageBuilder().AddEmbed(helpEmbed); + + await ctx.RespondAsync(builder); + } + + [Command("pingtextcmd")] + [TextAlias("ping")] + [Description("Pong? This command lets you know whether I'm working well.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task Ping(TextCommandContext ctx) + { + ctx.Client.Logger.LogDebug(ctx.Client.GetConnectionLatency(Program.cfgjson.ServerID).ToString()); + DiscordMessage return_message = await ctx.Message.RespondAsync("Pinging..."); + ulong ping = (return_message.Id - ctx.Message.Id) >> 22; + char[] choices = new char[] { 'a', 'e', 'o', 'u', 'i', 'y' }; + char letter = choices[Program.rand.Next(0, choices.Length)]; + await return_message.ModifyAsync($"P{letter}ng! 🏓\n" + + $"• It took me `{ping}ms` to reply to your message!\n" + + $"• Last Websocket Heartbeat took `{Math.Round(ctx.Client.GetConnectionLatency(0).TotalMilliseconds, 0)}ms`!"); + } + + [Command("userinfo")] + [TextAlias("user-info", "whois")] + [Description("Show info about a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + public async Task UserInfoSlashCommand(CommandContext ctx, [Parameter("user"), Description("The user to retrieve information about.")] DiscordUser user = null, [Parameter("public"), Description("Whether to show the output publicly.")] bool publicMessage = false) + { + if (user is null) + user = ctx.User; + + await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(user, ctx.Guild), ephemeral: !publicMessage); + } + + [Command("remindmetextcmd")] + [Description("Set a reminder for yourself. Example: !reminder 1h do the thing")] + [TextAlias("remindme", "reminder", "rember", "wemember", "remember", "remind")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Tier4, WorkOutside = true)] + public async Task RemindMe( + TextCommandContext ctx, + [Description("The amount of time to wait before reminding you. For example: 2s, 5m, 1h, 1d")] string timetoParse, + [RemainingText, Description("The text to send when the reminder triggers.")] string reminder + ) + { + DateTime t = HumanDateParser.HumanDateParser.Parse(timetoParse); + if (t <= DateTime.Now) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time can't be in the past!"); + return; + } +#if !DEBUG + else if (t < (DateTime.Now + TimeSpan.FromSeconds(59))) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time must be at least a minute in the future!"); + return; + } +#endif + string guildId; + + if (ctx.Channel.IsPrivate) + guildId = "@me"; + else + guildId = ctx.Guild.Id.ToString(); + + var reminderObject = new Reminder() + { + UserID = ctx.User.Id, + ChannelID = ctx.Channel.Id, + MessageID = ctx.Message.Id, + MessageLink = $"https://discord.com/channels/{guildId}/{ctx.Channel.Id}/{ctx.Message.Id}", + ReminderText = reminder, + ReminderTime = t, + OriginalTime = DateTime.Now + }; + + await Program.db.ListRightPushAsync("reminders", JsonConvert.SerializeObject(reminderObject)); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()"); // (In roughly **{TimeHelpers.TimeToPrettyFormat(t.Subtract(ctx.Message.Timestamp.DateTime), false)}**)"); + } + + public class Reminder + { + [JsonProperty("userID")] + public ulong UserID { get; set; } + + [JsonProperty("channelID")] + public ulong ChannelID { get; set; } + + [JsonProperty("messageID")] + public ulong MessageID { get; set; } + + [JsonProperty("messageLink")] + public string MessageLink { get; set; } + + [JsonProperty("reminderText")] + public string ReminderText { get; set; } + + [JsonProperty("reminderTime")] + public DateTime ReminderTime { get; set; } + + [JsonProperty("originalTime")] + public DateTime OriginalTime { get; set; } + } + + // Runs command context checks manually. Returns a list of failed checks. + // Unfortunately DSharpPlus.Commands does not provide a way to execute a command's context checks manually, + // so this will have to do. This may not include all checks, but it includes everything I could think of. -Milkshake + private async Task> CheckPermissionsAsync(CommandContext ctx, Command cmd) + { + var contextChecks = cmd.Attributes.Where(x => x is ContextCheckAttribute); + var failedChecks = new List(); + + foreach (var check in contextChecks) + { + if (check is HomeServerAttribute homeServerAttribute) + { + if (ctx.Channel.IsPrivate || ctx.Guild is null || ctx.Guild.Id != Program.cfgjson.ServerID) + { + failedChecks.Add(homeServerAttribute); + } + } + + if (check is RequireHomeserverPermAttribute requireHomeserverPermAttribute) + { + // Fail if guild member is null but this cmd does not work outside of the home server + if (ctx.Member is null && !requireHomeserverPermAttribute.WorkOutside) + { + failedChecks.Add(requireHomeserverPermAttribute); + } + else + { + var level = await GetPermLevelAsync(ctx.Member); + if (level < requireHomeserverPermAttribute.TargetLvl) + { + if (requireHomeserverPermAttribute.OwnerOverride && !Program.cfgjson.BotOwners.Contains(ctx.User.Id) + || !requireHomeserverPermAttribute.OwnerOverride) + { + failedChecks.Add(requireHomeserverPermAttribute); + } + } + } + } + + if (check is RequirePermissionsAttribute requirePermissionsAttribute) + { + if (ctx.Member is null || ctx.Guild is null + || !ctx.Channel.PermissionsFor(ctx.Member).HasAllPermissions(requirePermissionsAttribute.UserPermissions) + || !ctx.Channel.PermissionsFor(ctx.Guild.CurrentMember).HasAllPermissions(requirePermissionsAttribute.BotPermissions)) + { + failedChecks.Add(requirePermissionsAttribute); + } + } + + if (check is IsBotOwnerAttribute isBotOwnerAttribute) + { + if (!Program.cfgjson.BotOwners.Contains(ctx.User.Id)) + { + failedChecks.Add(isBotOwnerAttribute); + } + } + + if (check is UserRolesPresentAttribute userRolesPresentAttribute) + { + if (Program.cfgjson.UserRoles is null) + { + failedChecks.Add(userRolesPresentAttribute); + } + } + } + + return failedChecks; + } + } +} \ No newline at end of file diff --git a/Commands/Grant.cs b/Commands/Grant.cs deleted file mode 100644 index 78207849..00000000 --- a/Commands/Grant.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Grant : BaseCommandModule - { - [Command("grant")] - [Description("Grant a user access to the server, by giving them the Tier 1 role.")] - [Aliases("clipgrant", "verify")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task GrantCommand(CommandContext ctx, [Description("The member to grant Tier 1 role to.")] DiscordUser _) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works. Please right click (or tap and hold on mobile) the user and click \"Verify Member\" if available."); - } - } -} diff --git a/Commands/InteractionCommands/AnnouncementInteractions.cs b/Commands/InteractionCommands/AnnouncementInteractions.cs deleted file mode 100644 index 7c97a73c..00000000 --- a/Commands/InteractionCommands/AnnouncementInteractions.cs +++ /dev/null @@ -1,266 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class AnnouncementInteractions : ApplicationCommandModule - { - [SlashCommand("announcebuild", "Announce a Windows Insider build in the current channel.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task AnnounceBuildSlashCommand(InteractionContext ctx, - [Choice("Windows 10", 10)] - [Choice("Windows 11", 11)] - [Option("windows_version", "The Windows version to announce a build of. Must be either 10 or 11.")] long windowsVersion, - - [Option("build_number", "Windows build number, including any decimals (Decimals are optional). Do not include the word Build.")] string buildNumber, - - [Option("blog_link", "The link to the Windows blog entry relating to this build.")] string blogLink, - - [Choice("Canary Channel", "Canary")] - [Choice("Dev Channel", "Dev")] - [Choice("Beta Channel", "Beta")] - [Choice("Release Preview Channel", "RP")] - [Option("insider_role1", "The first insider role to ping.")] string insiderChannel1, - - [Choice("Canary Channel", "Canary")] - [Choice("Dev Channel", "Dev")] - [Choice("Beta Channel", "Beta")] - [Choice("Release Preview Channel", "RP")] - [Option("insider_role2", "The second insider role to ping.")] string insiderChannel2 = "", - - [Option("canary_create_new_thread", "Enable this option if you want to create a new Canary thread for some reason")] bool canaryCreateNewThread = false, - [Option("thread", "The thread to mention in the announcement.")] DiscordChannel threadChannel = default, - [Option("flavour_text", "Extra text appended on the end of the main line, replacing :WindowsInsider: or :Windows10:")] string flavourText = "", - [Option("autothread_name", "If no thread is given, create a thread with this name.")] string autothreadName = "Build {0} ({1})", - - [Option("lockdown", "Set 0 to not lock. Lock the channel for a certain period of time after announcing the build.")] string lockdownTime = "auto" - ) - { - if (Program.cfgjson.InsiderCommandLockedToChannel != 0 && ctx.Channel.Id != Program.cfgjson.InsiderCommandLockedToChannel) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command only works in <#{Program.cfgjson.InsiderCommandLockedToChannel}>!", ephemeral: true); - return; - } - - if (insiderChannel1 == insiderChannel2) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Both insider channels cannot be the same! Simply set one instead.", ephemeral: true); - } - - if (windowsVersion == 10 && insiderChannel1 != "RP") - { - await ctx.RespondAsync(text: $"{Program.cfgjson.Emoji.Error} Windows 10 only has a Release Preview Channel.", ephemeral: true); - return; - } - - if (flavourText == "" && windowsVersion == 10) - { - flavourText = Program.cfgjson.Emoji.Windows10; - } - else if (flavourText == "" && windowsVersion == 11) - { - flavourText = Program.cfgjson.Emoji.Insider; - } - - string roleKey1; - if (windowsVersion == 10 && insiderChannel1 == "RP") - { - roleKey1 = "rp10"; - } - else if (windowsVersion == 10 && insiderChannel1 == "Beta") - { - roleKey1 = "beta10"; - } - else - { - roleKey1 = insiderChannel1.ToLower(); - } - - DiscordRole insiderRole1 = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleKey1]); - DiscordRole insiderRole2 = default; - - StringBuilder channelString = new(); - - string insiderChannel1Pretty = insiderChannel1 == "RP" ? "Release Preview" : insiderChannel1; - - if (insiderChannel1 == "RP" || insiderChannel2 == "RP") - { - channelString.Append($"the Windows {windowsVersion} "); - } - else - { - channelString.Append("the "); - } - - channelString.Append($"**{insiderChannel1Pretty}"); - - if (insiderChannel2 != "") - { - string insiderChannel2Pretty = insiderChannel2 == "RP" ? "Release Preview" : insiderChannel2; - channelString.Append($" **and **{insiderChannel2Pretty}** Channels"); - } - else - { - channelString.Append("** Channel"); - } - - if (insiderChannel2 != "") - { - string roleKey2; - if (windowsVersion == 10 && insiderChannel2 == "RP") - { - roleKey2 = "rp10"; - } - else if (windowsVersion == 10 && insiderChannel2 == "Beta") - { - roleKey2 = "beta10"; - } - else - { - roleKey2 = insiderChannel2.ToLower(); - } - - insiderRole2 = await ctx.Guild.GetRoleAsync(Program.cfgjson.AnnouncementRoles[roleKey2]); - } - - string pingMsgBareString = $"{insiderRole1.Mention}{(insiderChannel2 != "" ? $" {insiderRole2.Mention}\n" : " - ")}Hi Insiders!\n\n" + - $"Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + - $"Check it out here: {blogLink}"; - - string innerThreadMsgString = $"Hi Insiders!\n\n" + - $"Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + - $"Check it out here: {blogLink}"; - - string noPingMsgString = $"{(windowsVersion == 11 ? Program.cfgjson.Emoji.Windows11 : Program.cfgjson.Emoji.Windows10)} Windows {windowsVersion} Build **{buildNumber}** has just been released to {channelString}! {flavourText}\n\n" + - $"Check it out here: <{blogLink}>"; - - string pingMsgString = pingMsgBareString; - - DiscordMessage messageSent; - if (Program.cfgjson.InsiderAnnouncementChannel == 0) - { - if (threadChannel != default) - { - pingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; - } - else if (insiderChannel1 == "Canary" && insiderChannel2 == "" && Program.cfgjson.InsiderCanaryThread != 0 && autothreadName == "Build {0} ({1})" && !canaryCreateNewThread) - { - threadChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderCanaryThread); - pingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; - var msg = await threadChannel.SendMessageAsync(innerThreadMsgString); - try - { - await msg.PinAsync(); - } - catch - { - // most likely we hit max pins, we can handle this later - // either way, lets ignore for now - } - } - else - { - pingMsgString += "\n\nDiscuss it in the thread below:"; - } - - await insiderRole1.ModifyAsync(mentionable: true); - if (insiderChannel2 != "") - await insiderRole2.ModifyAsync(mentionable: true); - - await ctx.RespondAsync(pingMsgString); - messageSent = await ctx.GetOriginalResponseAsync(); - - await insiderRole1.ModifyAsync(mentionable: false); - if (insiderChannel2 != "") - await insiderRole2.ModifyAsync(mentionable: false); - } - else - { - if (threadChannel != default) - { - noPingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; - } - else if (insiderChannel1 == "Canary" && insiderChannel2 == "" && Program.cfgjson.InsiderCanaryThread != 0 && autothreadName == "Build {0} ({1})" && !canaryCreateNewThread) - { - threadChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderCanaryThread); - noPingMsgString += $"\n\nDiscuss it here: {threadChannel.Mention}"; - var msg = await threadChannel.SendMessageAsync(innerThreadMsgString); - try - { - await msg.PinAsync(); - } - catch - { - // most likely we hit max pins, we can handle this later - // either way, lets ignore for now - } - - } - else - { - noPingMsgString += "\n\nDiscuss it in the thread below:"; - } - - await ctx.RespondAsync(noPingMsgString); - messageSent = await ctx.GetOriginalResponseAsync(); - } - - if (threadChannel == default) - { - string threadBrackets = insiderChannel1; - if (insiderChannel2 != "") - threadBrackets = $"{insiderChannel1} & {insiderChannel2}"; - - if (insiderChannel1 == "RP" && insiderChannel2 == "" && windowsVersion == 10) - threadBrackets = "10 RP"; - - string threadName = string.Format(autothreadName, buildNumber, threadBrackets); - threadChannel = await messageSent.CreateThreadAsync(threadName, DiscordAutoArchiveDuration.Week, "Creating thread for Insider build."); - - var initialMsg = await threadChannel.SendMessageAsync($"{blogLink}"); - await initialMsg.PinAsync(); - } - - if (Program.cfgjson.InsiderAnnouncementChannel != 0) - { - pingMsgString = pingMsgBareString + $"\n\nDiscuss it here: {threadChannel.Mention}"; - - var announcementChannel = await ctx.Client.GetChannelAsync(Program.cfgjson.InsiderAnnouncementChannel); - await insiderRole1.ModifyAsync(mentionable: true); - if (insiderChannel2 != "") - await insiderRole2.ModifyAsync(mentionable: true); - - var msg = await announcementChannel.SendMessageAsync(pingMsgString); - - await insiderRole1.ModifyAsync(mentionable: false); - if (insiderChannel2 != "") - await insiderRole2.ModifyAsync(mentionable: false); - - if (announcementChannel.Type is DiscordChannelType.News) - await announcementChannel.CrosspostMessageAsync(msg); - } - - if (lockdownTime == "auto") - { - if (Program.cfgjson.InsiderAnnouncementChannel == 0) - lockdownTime = "1h"; - else - lockdownTime = "0"; - } - - if (lockdownTime != "0") - { - TimeSpan lockDuration; - try - { - lockDuration = HumanDateParser.HumanDateParser.Parse(lockdownTime).Subtract(DateTime.Now); - } - catch - { - lockDuration = TimeSpan.FromHours(2); - } - - await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: ctx.Channel, duration: lockDuration); - } - } - - } -} diff --git a/Commands/InteractionCommands/BanInteractions.cs b/Commands/InteractionCommands/BanInteractions.cs deleted file mode 100644 index 3205d23c..00000000 --- a/Commands/InteractionCommands/BanInteractions.cs +++ /dev/null @@ -1,188 +0,0 @@ -using static Cliptok.Helpers.BanHelpers; - -namespace Cliptok.Commands.InteractionCommands -{ - internal class BanInteractions : ApplicationCommandModule - { - [SlashCommand("ban", "Bans a user from the server, either permanently or temporarily.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.BanMembers)] - public async Task BanSlashCommand(InteractionContext ctx, - [Option("user", "The user to ban")] DiscordUser user, - [Option("reason", "The reason the user is being banned")] string reason, - [Option("keep_messages", "Whether to keep the users messages when banning")] bool keepMessages = false, - [Option("time", "The length of time the user is banned for")] string time = null, - [Option("appeal_link", "Whether to show the user an appeal URL in the DM")] bool appealable = false, - [Option("compromised_account", "Whether to include special instructions for compromised accounts")] bool compromisedAccount = false - ) - { - // Initial response to avoid the 3 second timeout, will edit later. - var eout = new DiscordInteractionResponseBuilder().AsEphemeral(true); - await ctx.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource, eout); - - // Edits need a webhook rather than interaction..? - DiscordWebhookBuilder webhookOut = new(); - int messageDeleteDays = 7; - if (keepMessages) - messageDeleteDays = 0; - - if (user.IsBot) - { - webhookOut.Content = $"{Program.cfgjson.Emoji.Error} To prevent accidents, I won't ban bots. If you really need to do this, do it manually in Discord."; - await ctx.EditResponseAsync(webhookOut); - return; - } - - DiscordMember targetMember; - - try - { - targetMember = await ctx.Guild.GetMemberAsync(user.Id); - if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator)) - { - webhookOut.Content = $"{Program.cfgjson.Emoji.Error} As a Trial Moderator you cannot perform moderation actions on other staff members."; - await ctx.EditResponseAsync(webhookOut); - return; - } - } - catch - { - // do nothing :/ - } - - TimeSpan banDuration; - if (time is null) - banDuration = default; - else - { - try - { - banDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.DateTime); - } - catch - { - webhookOut.Content = $"{Program.cfgjson.Emoji.Error} There was an error parsing your supplied ban length!"; - await ctx.EditResponseAsync(webhookOut); - return; - } - - } - - DiscordMember member; - try - { - member = await ctx.Guild.GetMemberAsync(user.Id); - } - catch - { - member = null; - } - - if (member is null) - { - await BanFromServerAsync(user.Id, reason, ctx.User.Id, ctx.Guild, messageDeleteDays, ctx.Channel, banDuration, appealable, compromisedAccount); - } - else - { - if (DiscordHelpers.AllowedToMod(ctx.Member, member)) - { - if (DiscordHelpers.AllowedToMod(await ctx.Guild.GetMemberAsync(ctx.Client.CurrentUser.Id), member)) - { - await BanFromServerAsync(user.Id, reason, ctx.User.Id, ctx.Guild, messageDeleteDays, ctx.Channel, banDuration, appealable, compromisedAccount); - } - else - { - webhookOut.Content = $"{Program.cfgjson.Emoji.Error} I don't have permission to ban **{DiscordHelpers.UniqueUsername(user)}**!"; - await ctx.EditResponseAsync(webhookOut); - return; - } - } - else - { - webhookOut.Content = $"{Program.cfgjson.Emoji.Error} You don't have permission to ban **{DiscordHelpers.UniqueUsername(user)}**!"; - await ctx.EditResponseAsync(webhookOut); - return; - } - } - - webhookOut.Content = $"{Program.cfgjson.Emoji.Success} User was successfully bonked."; - await ctx.EditResponseAsync(webhookOut); - } - - [SlashCommand("unban", "Unbans a user who has been previously banned.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.BanMembers)] - public async Task SlashUnbanCommand(InteractionContext ctx, [Option("user", "The ID or mention of the user to unban. Ignore the suggestions, IDs work.")] SnowflakeObject userId, [Option("reason", "Used in audit log only currently")] string reason = "No reason specified.") - { - DiscordUser targetUser = default; - try - { - targetUser = await ctx.Client.GetUserAsync(userId.Id); - } - catch (Exception ex) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Exception of type `{ex.GetType()}` thrown fetching user:\n```\n{ex.Message}\n{ex.StackTrace}```", ephemeral: true); - return; - } - if ((await Program.db.HashExistsAsync("bans", targetUser.Id))) - { - await UnbanUserAsync(ctx.Guild, targetUser, $"[Unban by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); - } - else - { - bool banSuccess = await UnbanUserAsync(ctx.Guild, targetUser); - if (banSuccess) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Unbanned} Successfully unbanned **{DiscordHelpers.UniqueUsername(targetUser)}**."); - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be banned, *and* an error occurred while attempting to unban them anyway.\nPlease contact the bot owner if this wasn't expected, the error has been logged."); - } - } - } - - [SlashCommand("kick", "Kicks a user, removing them from the server until they rejoin.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.KickMembers)] - public async Task KickCmd(InteractionContext ctx, [Option("user", "The user you want to kick from the server.")] DiscordUser target, [Option("reason", "The reason for kicking this user.")] string reason = "No reason specified.") - { - if (target.IsBot) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} To prevent accidents, I won't kick bots. If you really need to do this, do it manually in Discord."); - return; - } - - reason = reason.Replace("`", "\\`").Replace("*", "\\*"); - - DiscordMember member; - try - { - member = await ctx.Guild.GetMemberAsync(target.Id); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be in the server!"); - return; - } - - if (DiscordHelpers.AllowedToMod(ctx.Member, member)) - { - if (DiscordHelpers.AllowedToMod(await ctx.Guild.GetMemberAsync(ctx.Client.CurrentUser.Id), member)) - { - await Kick.KickAndLogAsync(member, reason, ctx.Member); - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Ejected} {target.Mention} has been kicked: **{reason}**"); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!", ephemeral: true); - return; - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I don't have permission to kick **{DiscordHelpers.UniqueUsername(target)}**!", ephemeral: true); - return; - } - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You aren't allowed to kick **{DiscordHelpers.UniqueUsername(target)}**!", ephemeral: true); - return; - } - } - - } -} diff --git a/Commands/InteractionCommands/ContextCommands.cs b/Commands/InteractionCommands/ContextCommands.cs deleted file mode 100644 index 68e962db..00000000 --- a/Commands/InteractionCommands/ContextCommands.cs +++ /dev/null @@ -1,77 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class ContextCommands : ApplicationCommandModule - { - [ContextMenu(DiscordApplicationCommandType.MessageContextMenu, "Dump message data")] - public async Task DumpMessage(ContextMenuContext ctx) - { - var rawMsgData = JsonConvert.SerializeObject(ctx.TargetMessage, Formatting.Indented); - await ctx.RespondAsync(await StringHelpers.CodeOrHasteBinAsync(rawMsgData, "json"), ephemeral: true); - } - - [ContextMenu(DiscordApplicationCommandType.UserContextMenu, "Show Avatar", defaultPermission: true)] - public async Task ContextAvatar(ContextMenuContext ctx) - { - string avatarUrl = await LykosAvatarMethods.UserOrMemberAvatarURL(ctx.TargetUser, ctx.Guild); - - DiscordEmbedBuilder embed = new DiscordEmbedBuilder() - .WithColor(new DiscordColor(0xC63B68)) - .WithTimestamp(DateTime.UtcNow) - .WithImageUrl(avatarUrl) - .WithAuthor( - $"Avatar for {ctx.TargetUser.Username} (Click to open in browser)", - avatarUrl - ); - - await ctx.RespondAsync(null, embed, ephemeral: true); - } - - [ContextMenu(DiscordApplicationCommandType.UserContextMenu, "Show Notes", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task ShowNotes(ContextMenuContext ctx) - { - await ctx.RespondAsync(embed: await UserNoteHelpers.GenerateUserNotesEmbedAsync(ctx.TargetUser), ephemeral: true); - } - - [ContextMenu(DiscordApplicationCommandType.UserContextMenu, "Show Warnings", defaultPermission: true)] - public async Task ContextWarnings(ContextMenuContext ctx) - { - await ctx.RespondAsync(embed: await WarningHelpers.GenerateWarningsEmbedAsync(ctx.TargetUser), ephemeral: true); - } - - [ContextMenu(DiscordApplicationCommandType.UserContextMenu, "User Information", defaultPermission: true)] - public async Task ContextUserInformation(ContextMenuContext ctx) - { - await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(ctx.TargetUser, ctx.Guild), ephemeral: true); - } - - [ContextMenu(DiscordApplicationCommandType.UserContextMenu, "Hug", defaultPermission: true),] - public async Task Hug(ContextMenuContext ctx) - { - var user = ctx.TargetUser; - - if (user is not null) - { - switch (new Random().Next(4)) - { - case 0: - await ctx.RespondAsync($"*{ctx.User.Mention} snuggles {user.Mention}*"); - break; - - case 1: - await ctx.RespondAsync($"*{ctx.User.Mention} huggles {user.Mention}*"); - break; - - case 2: - await ctx.RespondAsync($"*{ctx.User.Mention} cuddles {user.Mention}*"); - break; - - case 3: - await ctx.RespondAsync($"*{ctx.User.Mention} hugs {user.Mention}*"); - break; - } - } - } - - } -} diff --git a/Commands/InteractionCommands/DebugInteractions.cs b/Commands/InteractionCommands/DebugInteractions.cs deleted file mode 100644 index 66ef6ad8..00000000 --- a/Commands/InteractionCommands/DebugInteractions.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Cliptok.Constants; - -namespace Cliptok.Commands.InteractionCommands -{ - internal class DebugInteractions : ApplicationCommandModule - { - [SlashCommand("scamcheck", "Check if a link or message is known to the anti-phishing API.", defaultPermission: false)] - [Description("Check if a link or message is known to the anti-phishing API.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task ScamCheck(InteractionContext ctx, [Option("input", "Domain or message content to scan.")] string content) - { - var urlMatches = Constants.RegexConstants.url_rx.Matches(content); - if (urlMatches.Count > 0 && Environment.GetEnvironmentVariable("CLIPTOK_ANTIPHISHING_ENDPOINT") is not null && Environment.GetEnvironmentVariable("CLIPTOK_ANTIPHISHING_ENDPOINT") != "useyourimagination") - { - var (match, httpStatus, responseText, _) = await APIs.PhishingAPI.PhishingAPICheckAsync(content); - - string responseToSend; - if (match) - { - responseToSend = $"Match found:\n"; - } - else - { - responseToSend = $"No valid match found.\nHTTP Status `{(int)httpStatus}`, result:\n"; - } - - responseToSend += await StringHelpers.CodeOrHasteBinAsync(responseText, "json"); - - await ctx.RespondAsync(responseToSend); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Anti-phishing API is not configured, nothing for me to do."); - } - } - - [SlashCommand("tellraw", "You know what you're here for.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task TellRaw(InteractionContext ctx, [Option("input", "???")] string input, [Option("reply_msg_id", "ID of message to use in a reply context.")] string replyID = "0", [Option("pingreply", "Ping pong.")] bool pingreply = true, [Option("channel", "Either mention or ID. Not a name.")] string discordChannel = default) - { - DiscordChannel channelObj = default; - - if (discordChannel == default) - channelObj = ctx.Channel; - else - { - ulong channelId; - if (!ulong.TryParse(discordChannel, out channelId)) - { - var captures = RegexConstants.channel_rx.Match(discordChannel).Groups[1].Captures; - if (captures.Count > 0) - channelId = Convert.ToUInt64(captures[0].Value); - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} The channel you gave can't be parsed. Please give either an ID or a mention of a channel.", ephemeral: true); - return; - } - } - try - { - channelObj = await ctx.Client.GetChannelAsync(channelId); - } - catch - { - // caught immediately after - } - if (channelObj == default) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I can't find a channel with the provided ID!", ephemeral: true); - return; - } - } - - try - { - await channelObj.SendMessageAsync(new DiscordMessageBuilder().WithContent(input).WithReply(Convert.ToUInt64(replyID), pingreply, false)); - } - catch - { - await ctx.RespondAsync($"Your dumb message didn't want to send. Congrats, I'm proud of you.", ephemeral: true); - return; - } - await ctx.RespondAsync($"I sent your stupid message to {channelObj.Mention}.", ephemeral: true); - await LogChannelHelper.LogMessageAsync("secret", - new DiscordMessageBuilder() - .WithContent($"{ctx.User.Mention} used tellraw in {channelObj.Mention}:") - .WithAllowedMentions(Mentions.None) - .AddEmbed(new DiscordEmbedBuilder().WithDescription(input)) - ); - } - - [SlashCommand("userinfo", "Retrieve information about a given user.")] - public async Task UserInfoSlashCommand(InteractionContext ctx, [Option("user", "The user to retrieve information about.")] DiscordUser user, [Option("public", "Whether to show the output publicly.")] bool publicMessage = false) - { - await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(user, ctx.Guild), ephemeral: !publicMessage); - } - - [SlashCommand("muteinfo", "Show information about the mute for a user.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task MuteInfoSlashCommand( - InteractionContext ctx, - [Option("user", "The user whose mute information to show.")] DiscordUser targetUser, - [Option("public", "Whether to show the output publicly. Default: false")] bool isPublic = false) - { - await ctx.RespondAsync(embed: await MuteHelpers.MuteStatusEmbed(targetUser, ctx.Guild), ephemeral: !isPublic); - } - - [SlashCommand("baninfo", "Show information about the ban for a user.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task BanInfoSlashCommand( - InteractionContext ctx, - [Option("user", "The user whose ban information to show.")] DiscordUser targetUser, - [Option("public", "Whether to show the output publicly. Default: false")] bool isPublic = false) - { - await ctx.RespondAsync(embed: await BanHelpers.BanStatusEmbed(targetUser, ctx.Guild), ephemeral: !isPublic); - } - } -} diff --git a/Commands/InteractionCommands/DehoistInteractions.cs b/Commands/InteractionCommands/DehoistInteractions.cs deleted file mode 100644 index 69a4efbf..00000000 --- a/Commands/InteractionCommands/DehoistInteractions.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class DehoistInteractions : ApplicationCommandModule - { - [SlashCommand("dehoist", "Dehoist a member, dropping them to the bottom of the list. Lasts until they change nickname.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.ManageNicknames)] - public async Task DehoistSlashCmd(InteractionContext ctx, [Option("member", "The member to dehoist.")] DiscordUser user) - { - DiscordMember member; - try - { - member = await ctx.Guild.GetMemberAsync(user.Id); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to find {user.Mention} as a member! Are they in the server?", ephemeral: true); - return; - } - - if (member.DisplayName[0] == DehoistHelpers.dehoistCharacter) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {member.Mention} is already dehoisted!", ephemeral: true); - } - - try - { - await member.ModifyAsync(a => - { - a.Nickname = DehoistHelpers.DehoistName(member.DisplayName); - a.AuditLogReason = $"[Dehoist by {DiscordHelpers.UniqueUsername(ctx.User)}]"; - }); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to dehoist {member.Mention}! Do I have permission?", ephemeral: true); - return; - } - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Successfuly dehoisted {member.Mention}!", mentions: false); - } - - [SlashCommandGroup("permadehoist", "Permanently/persistently dehoist members.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ManageNicknames)] - public class PermadehoistSlashCommands - { - [SlashCommand("enable", "Permanently dehoist a member. They will be automatically dehoisted until disabled.")] - public async Task PermadehoistEnableSlashCmd(InteractionContext ctx, [Option("member", "The member to permadehoist.")] DiscordUser discordUser) - { - var (success, isPermissionError) = await DehoistHelpers.PermadehoistMember(discordUser, ctx.User, ctx.Guild); - - if (success) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} Successfully permadehoisted {discordUser.Mention}!", mentions: false); - - if (!success & !isPermissionError) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {discordUser.Mention} is already permadehoisted!", mentions: false); - - if (!success && isPermissionError) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to permadehoist {discordUser.Mention}!", mentions: false); - } - - [SlashCommand("disable", "Disable permadehoist for a member.")] - public async Task PermadehoistDisableSlashCmd(InteractionContext ctx, [Option("member", "The member to remove the permadehoist for.")] DiscordUser discordUser) - { - var (success, isPermissionError) = await DehoistHelpers.UnpermadehoistMember(discordUser, ctx.User, ctx.Guild); - - if (success) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} Successfully removed the permadehoist for {discordUser.Mention}!", mentions: false); - - if (!success & !isPermissionError) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {discordUser.Mention} isn't permadehoisted!", mentions: false); - - if (!success && isPermissionError) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to remove the permadehoist for {discordUser.Mention}!", mentions: false); - } - - [SlashCommand("status", "Check the status of permadehoist for a member.")] - public async Task PermadehoistStatusSlashCmd(InteractionContext ctx, [Option("member", "The member whose permadehoist status to check.")] DiscordUser discordUser) - { - if (await Program.db.SetContainsAsync("permadehoists", discordUser.Id)) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} {discordUser.Mention} is permadehoisted.", mentions: false); - else - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} {discordUser.Mention} is not permadehoisted.", mentions: false); - } - } - } -} diff --git a/Commands/InteractionCommands/JoinwatchInteractions.cs b/Commands/InteractionCommands/JoinwatchInteractions.cs deleted file mode 100644 index b1ac0fd6..00000000 --- a/Commands/InteractionCommands/JoinwatchInteractions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class JoinwatchInteractions : ApplicationCommandModule - { - [SlashCommandGroup("joinwatch", "Watch for joins and leaves of a given user. Output goes to #investigations.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public class JoinwatchSlashCmds - { - [SlashCommand("add", "Watch for joins and leaves of a given user. Output goes to #investigations.")] - public async Task JoinwatchAdd(InteractionContext ctx, - [Option("user", "The user to watch for joins and leaves of.")] DiscordUser user, - [Option("note", "An optional note for context.")] string note = "") - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note add` instead, like this: `/note add user:{user.Id} note:{(string.IsNullOrEmpty(note) ? "" : note)} show_on_join_and_leave:True`"); - } - - [SlashCommand("remove", "Stop watching for joins and leaves of a user.")] - public async Task JoinwatchRemove(InteractionContext ctx, - [Option("user", "The user to stop watching for joins and leaves of.")] DiscordUser user) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note delete` instead, like this: `/note delete user:{user.Id} note:`"); - } - - [SlashCommand("status", "Check the joinwatch status for a user.")] - public async Task JoinwatchStatus(InteractionContext ctx, - [Option("user", "The user whose joinwatch status to check.")] DiscordUser user) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note list user:{user.Id}` to show all of this user's notes, or `/note details user:{user.Id} note:` for details on a specific note, instead. Notes with \"Show on Join & Leave\" enabled will behave like joinwatches."); - } - } - } - -} \ No newline at end of file diff --git a/Commands/InteractionCommands/LockdownInteractions.cs b/Commands/InteractionCommands/LockdownInteractions.cs deleted file mode 100644 index ffeeb53b..00000000 --- a/Commands/InteractionCommands/LockdownInteractions.cs +++ /dev/null @@ -1,166 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - class LockdownInteractions : ApplicationCommandModule - { - public static bool ongoingLockdown = false; - - [SlashCommandGroup("lockdown", "Lock the current channel or all channels in the server, preventing new messages. See also: unlock")] - [HomeServer, SlashRequireHomeserverPerm(ServerPermLevel.Moderator), RequireBotPermissions(permissions: DiscordPermission.ManageChannels)] - public class LockdownCmds - { - [SlashCommand("channel", "Lock the current channel. See also: unlock channel")] - public async Task LockdownChannelCommand( - InteractionContext ctx, - [Option("reason", "The reason for the lockdown.")] string reason = "No reason specified.", - [Option("time", "The length of time to lock the channel for.")] string time = null, - [Option("lockthreads", "Whether to lock this channel's threads. Disables sending messages, but does not archive them.")] bool lockThreads = false) - { - await ctx.DeferAsync(ephemeral: true); - - if (ctx.Channel.Type is DiscordChannelType.PublicThread or DiscordChannelType.PrivateThread or DiscordChannelType.NewsThread) - { - if (lockThreads) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock this channel!\n`/lockdown` with `lockthreads` cannot be used inside of a thread. If you meant to lock {ctx.Channel.Parent.Mention} and all of its threads, use the command there.\n\nIf you meant to only lock this thread, use `!lock` instead, or use `/lockdown` with `lockthreads` set to False.").AsEphemeral(true)); - return; - } - - var thread = (DiscordThreadChannel)ctx.Channel; - - await thread.ModifyAsync(a => - { - a.IsArchived = true; - a.Locked = true; - }); - - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent("Thread locked successfully!").AsEphemeral(true)); - return; - } - - TimeSpan? lockDuration = null; - - if (!string.IsNullOrWhiteSpace(time)) - { - lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.LocalDateTime); - } - - var currentChannel = ctx.Channel; - if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist.")); - return; - } - - if (ongoingLockdown) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request to avoid conflicts, sorry.")); - return; - } - - try - { - await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: currentChannel, duration: lockDuration, reason: reason, lockThreads: lockThreads); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent("Channel locked successfully.").AsEphemeral(true)); - } - catch (ArgumentException) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent("Failed to lock this channel!").AsEphemeral(true)); - } - } - - [SlashCommand("all", "Lock all lockable channels in the server. See also: unlock all")] - public async Task LockdownAllCommand( - InteractionContext ctx, - [Option("reason", "The reason for the lockdown.")] string reason = "", - [Option("time", "The length of time to lock the channels for.")] string time = null, - [Option("lockthreads", "Whether to lock threads. Disables sending messages, but does not archive them.")] bool lockThreads = false) - { - await ctx.DeferAsync(); - - ongoingLockdown = true; - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Loading} Working on it, please hold...")); - - TimeSpan? lockDuration = null; - - if (!string.IsNullOrWhiteSpace(time)) - { - lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.LocalDateTime); - } - - foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) - { - try - { - var channel = await ctx.Client.GetChannelAsync(chanID); - await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: channel, duration: lockDuration, reason: reason, lockThreads: lockThreads); - } - catch - { - - } - - } - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!")); - ongoingLockdown = false; - return; - } - } - - [SlashCommandGroup("unlock", "Unlock the current channel or all channels in the server, allowing new messages. See also: lockdown")] - [HomeServer, SlashRequireHomeserverPerm(ServerPermLevel.Moderator), RequireBotPermissions(permissions: DiscordPermission.ManageChannels)] - public class UnlockCmds - { - [SlashCommand("channel", "Unlock the current channel. See also: lockdown")] - public async Task UnlockChannelCommand(InteractionContext ctx, [Option("reason", "The reason for the unlock.")] string reason = "") - { - await ctx.DeferAsync(ephemeral: true); - - var currentChannel = ctx.Channel; - if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist.").AsEphemeral(true)); - return; - } - - if (ongoingLockdown) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request. sorry.").AsEphemeral(true)); - return; - } - try - { - await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent("Channel unlocked successfully.").AsEphemeral(true)); - } - catch (ArgumentException) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent("Failed to unlock this channel!").AsEphemeral(true)); - } - } - - [SlashCommand("all", "Unlock all lockable channels in the server. See also: lockdown all")] - public async Task UnlockAllCommand(InteractionContext ctx, [Option("reason", "The reason for the unlock.")] string reason = "") - { - await ctx.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource); - - ongoingLockdown = true; - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Loading} Working on it, please hold...")); - foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) - { - try - { - var currentChannel = await ctx.Client.GetChannelAsync(chanID); - await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member, reason, true); - } - catch - { - - } - } - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!")); - ongoingLockdown = false; - return; - } - } - } -} diff --git a/Commands/InteractionCommands/MuteInteractions.cs b/Commands/InteractionCommands/MuteInteractions.cs deleted file mode 100644 index bc80da64..00000000 --- a/Commands/InteractionCommands/MuteInteractions.cs +++ /dev/null @@ -1,157 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class MuteInteractions : ApplicationCommandModule - { - [SlashCommand("mute", "Mute a user, temporarily or permanently.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task MuteSlashCommand( - InteractionContext ctx, - [Option("user", "The user you wish to mute.")] DiscordUser targetUser, - [Option("reason", "The reason for the mute.")] string reason, - [Option("time", "The length of time to mute for.")] string time = "" - ) - { - await ctx.DeferAsync(ephemeral: true); - DiscordMember targetMember = default; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException) - { - // is this worth logging? - } - - if (targetMember != default && (await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); - return; - } - - TimeSpan muteDuration = default; - - if (time != "") - { - try - { - muteDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.DateTime); - } - catch - { - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Failed to parse time argument.")); - throw; - } - } - - await MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true); - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent("Command completed successfully.")); - } - - [SlashCommand("unmute", "Unmute a user.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task UnmuteSlashCommand( - InteractionContext ctx, - [Option("user", "The user you wish to mute.")] DiscordUser targetUser, - [Option("reason", "The reason for the unmute.")] string reason = "No reason specified." - ) - { - await ctx.DeferAsync(ephemeral: false); - - reason = $"[Manual unmute by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"; - - // todo: store per-guild - DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); - - DiscordMember member = default; - try - { - member = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException ex) - { - Program.discord.Logger.LogWarning(eventId: Program.CliptokEventID, exception: ex, message: "Failed to unmute {user} in {server} because they weren't in the server.", $"{DiscordHelpers.UniqueUsername(targetUser)}", ctx.Guild.Name); - } - - if ((await Program.db.HashExistsAsync("mutes", targetUser.Id)) || (member != default && member.Roles.Contains(mutedRole))) - { - await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Information} Successfully unmuted **{DiscordHelpers.UniqueUsername(targetUser)}**.")); - } - else - try - { - await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Warning} According to Discord that user is not muted, but I tried to unmute them anyway. Hope it works.")); - } - catch (Exception e) - { - Program.discord.Logger.LogError(e, "An error occurred unmuting {user}", targetUser.Id); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be muted, *and* an error occurred while attempting to unmute them anyway. Please contact the bot owner, the error has been logged.")); - } - } - - [SlashCommand("tqsmute", "Temporarily mute a user in tech support channels.")] - [SlashRequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] - public async Task TqsMuteSlashCommand( - InteractionContext ctx, - [Option("user", "The user to mute.")] DiscordUser targetUser, - [Option("reason", "The reason for the mute.")] string reason) - { - await ctx.DeferAsync(ephemeral: true); - - // only work if TQS mute role is configured - if (Program.cfgjson.TqsMutedRole == 0) - { - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} TQS mutes are not configured, so this command does nothing. Please contact the bot maintainer if this is unexpected.")); - return; - } - - // Only allow usage in #tech-support, #tech-support-forum, and their threads - if (ctx.Channel.Id != Program.cfgjson.TechSupportChannel && - ctx.Channel.Id != Program.cfgjson.SupportForumId && - ctx.Channel.Parent.Id != Program.cfgjson.TechSupportChannel && - ctx.Channel.Parent.Id != Program.cfgjson.SupportForumId) - { - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This command can only be used in <#{Program.cfgjson.TechSupportChannel}>, <#{Program.cfgjson.SupportForumId}>, and threads in those channels!")); - return; - } - - // Check if the user is already muted; disallow TQS-mute if so - - DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); - DiscordRole tqsMutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.TqsMutedRole); - - // Get member - DiscordMember targetMember = default; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException) - { - // blah - } - - if (await Program.db.HashExistsAsync("mutes", targetUser.Id) || (targetMember is not null && (targetMember.Roles.Contains(mutedRole) || targetMember.Roles.Contains(tqsMutedRole)))) - { - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, that user is already muted.")); - return; - } - - // Check if user to be muted is staff or TQS, and disallow if so - if (targetMember != default && (await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TechnicalQueriesSlayer && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TechnicalQueriesSlayer || targetMember.IsBot)) - { - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, you cannot mute other TQS or staff members.")); - return; - } - - // mute duration is static for TQS mutes - TimeSpan muteDuration = TimeSpan.FromHours(Program.cfgjson.TqsMuteDurationHours); - - await MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true, true); - await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent("Done. Please open a modmail thread for this user if you haven't already!")); - } - } -} diff --git a/Commands/InteractionCommands/RoleInteractions.cs b/Commands/InteractionCommands/RoleInteractions.cs deleted file mode 100644 index dcec4ebf..00000000 --- a/Commands/InteractionCommands/RoleInteractions.cs +++ /dev/null @@ -1,122 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class RoleInteractions : ApplicationCommandModule - { - [SlashCommand("grant", "Grant a user Tier 1, bypassing any verification requirements.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task SlashGrant(InteractionContext ctx, [Option("user", "The user to grant Tier 1 to.")] DiscordUser _) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works. Please right click (or tap and hold on mobile) the user and click \"Verify Member\" if available."); - } - - [HomeServer] - [SlashCommandGroup("roles", "Opt in/out of roles.")] - internal class RoleSlashCommands - { - [SlashCommand("grant", "Opt into a role.")] - public async Task GrantRole( - InteractionContext ctx, - [Autocomplete(typeof(RolesAutocompleteProvider))] - [Option("role", "The role to opt into.")] string role) - { - DiscordMember member = ctx.Member; - - ulong roleId = role switch - { - "insiderCanary" => Program.cfgjson.UserRoles.InsiderCanary, - "insiderDev" => Program.cfgjson.UserRoles.InsiderDev, - "insiderBeta" => Program.cfgjson.UserRoles.InsiderBeta, - "insiderRP" => Program.cfgjson.UserRoles.InsiderRP, - "insider10RP" => Program.cfgjson.UserRoles.Insider10RP, - "patchTuesday" => Program.cfgjson.UserRoles.PatchTuesday, - "giveaways" => Program.cfgjson.UserRoles.Giveaways, - "cts" => Program.cfgjson.CommunityTechSupportRoleID, - _ => 0 - }; - - if (roleId == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Invalid role! Please choose from the list.", ephemeral: true); - return; - } - - if (roleId == Program.cfgjson.CommunityTechSupportRoleID && await GetPermLevelAsync(ctx.Member) < ServerPermLevel.TechnicalQueriesSlayer) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.NoPermissions} You must be a TQS member to get the CTS role!", ephemeral: true); - return; - } - - var roleData = await ctx.Guild.GetRoleAsync(roleId); - - await member.GrantRoleAsync(roleData, $"/roles grant used by {DiscordHelpers.UniqueUsername(ctx.User)}"); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} The role {roleData.Mention} has been successfully granted!", ephemeral: true, mentions: false); - } - - [SlashCommand("remove", "Opt out of a role.")] - public async Task RemoveRole( - InteractionContext ctx, - [Autocomplete(typeof(RolesAutocompleteProvider))] - [Option("role", "The role to opt out of.")] string role) - { - DiscordMember member = ctx.Member; - - ulong roleId = role switch - { - "insiderCanary" => Program.cfgjson.UserRoles.InsiderCanary, - "insiderDev" => Program.cfgjson.UserRoles.InsiderDev, - "insiderBeta" => Program.cfgjson.UserRoles.InsiderBeta, - "insiderRP" => Program.cfgjson.UserRoles.InsiderRP, - "insider10RP" => Program.cfgjson.UserRoles.Insider10RP, - "patchTuesday" => Program.cfgjson.UserRoles.PatchTuesday, - "giveaways" => Program.cfgjson.UserRoles.Giveaways, - "cts" => Program.cfgjson.CommunityTechSupportRoleID, - _ => 0 - }; - - if (roleId == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Invalid role! Please choose from the list.", ephemeral: true); - return; - } - - var roleData = await ctx.Guild.GetRoleAsync(roleId); - - await member.RevokeRoleAsync(roleData, $"/roles remove used by {DiscordHelpers.UniqueUsername(ctx.User)}"); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} The role {roleData.Mention} has been successfully removed!", ephemeral: true, mentions: false); - } - } - - internal class RolesAutocompleteProvider : IAutocompleteProvider - { - public async Task> Provider(AutocompleteContext ctx) - { - Dictionary options = new() - { - { "Windows 11 Insiders (Canary)", "insiderCanary" }, - { "Windows 11 Insiders (Dev)", "insiderDev" }, - { "Windows 11 Insiders (Beta)", "insiderBeta" }, - { "Windows 11 Insiders (Release Preview)", "insiderRP" }, - { "Windows 10 Insiders (Release Preview)", "insider10RP" }, - { "Patch Tuesday", "patchTuesday" }, - { "Giveaways", "giveaways" }, - { "Community Tech Support (CTS)", "cts" } - }; - - var memberHasTqs = await GetPermLevelAsync(ctx.Member) >= ServerPermLevel.TechnicalQueriesSlayer; - - List list = new(); - - foreach (var option in options) - { - if (ctx.FocusedOption.Value.ToString() == "" || option.Key.Contains(ctx.FocusedOption.Value.ToString(), StringComparison.OrdinalIgnoreCase)) - { - if (option.Value == "cts" && !memberHasTqs) continue; - list.Add(new DiscordAutoCompleteChoice(option.Key, option.Value)); - } - } - - return list; - } - } - } -} diff --git a/Commands/InteractionCommands/StatusInteractions.cs b/Commands/InteractionCommands/StatusInteractions.cs deleted file mode 100644 index 40ac949f..00000000 --- a/Commands/InteractionCommands/StatusInteractions.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace Cliptok.Commands.InteractionCommands -{ - internal class StatusInteractions : ApplicationCommandModule - { - [SlashCommandGroup("status", "Status commands")] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - - public class StatusSlashCommands - { - - [SlashCommand("set", "Set Cliptoks status.", defaultPermission: false)] - public async Task StatusSetCommand( - InteractionContext ctx, - [Option("text", "The text to use for the status.")] string statusText, - [Choice("Custom", (long)DiscordActivityType.Custom)] - [Choice("Playing", (long)DiscordActivityType.Playing)] - [Choice("Streaming", (long)DiscordActivityType.Streaming)] - [Choice("Listening to", (long)DiscordActivityType.ListeningTo)] - [Choice("Watching", (long)DiscordActivityType.Watching)] - [Choice("Competing", (long)DiscordActivityType.Competing)] - [Option("type", "Defaults to custom. The type of status to use.")] long statusType = (long)DiscordActivityType.Custom - ) - { - if (statusText.Length > 128) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Status messages must be less than 128 characters."); - } - - await Program.db.StringSetAsync("config:status", statusText); - await Program.db.StringSetAsync("config:status_type", statusType); - - await ctx.Client.UpdateStatusAsync(new DiscordActivity(statusText, (DiscordActivityType)statusType)); - - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Status has been updated!\nType: `{((DiscordActivityType)statusType).ToString()}`\nText: `{statusText}`"); - } - - [SlashCommand("clear", "Clear Cliptoks status.", defaultPermission: false)] - public async Task StatusClearCommand(InteractionContext ctx) - { - await Program.db.KeyDeleteAsync("config:status"); - await Program.db.KeyDeleteAsync("config:status_type"); - - await ctx.Client.UpdateStatusAsync(new DiscordActivity()); - - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Deleted} Status has been cleared!"); - } - - } - } -} diff --git a/Commands/InteractionCommands/TechSupportInteractions.cs b/Commands/InteractionCommands/TechSupportInteractions.cs deleted file mode 100644 index 2f1dd4a2..00000000 --- a/Commands/InteractionCommands/TechSupportInteractions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Cliptok.Constants; - -namespace Cliptok.Commands.InteractionCommands -{ - public class TechSupportInteractions : ApplicationCommandModule - { - [SlashCommand("vcredist", "Outputs download URLs for the specified Visual C++ Redistributables version")] - public async Task RedistsCommand( - InteractionContext ctx, - - [Choice("Visual Studio 2015+ - v140", 140)] - [Choice("Visual Studio 2013 - v120", 120)] - [Choice("Visual Studio 2012 - v110", 110)] - [Choice("Visual Studio 2010 - v100", 100)] - [Choice("Visual Studio 2008 - v90", 90)] - [Choice("Visual Studio 2005 - v80", 80)] - [Option("version", "Visual Studio version number or year")] long version - ) - { - VcRedist redist = VcRedistConstants.VcRedists - .First((e) => - { - return version == e.Version; - }); - - DiscordEmbedBuilder embed = new DiscordEmbedBuilder() - .WithTitle($"Visual C++ {redist.Year}{(redist.Year == 2015 ? "+" : "")} Redistributables (version {redist.Version})") - .WithFooter("The above links are official and safe to download.") - .WithColor(new("7160e8")); - - foreach (var url in redist.DownloadUrls) - { - embed.AddField($"{url.Key.ToString("G")}", $"{url.Value}"); - } - - await ctx.RespondAsync(null, embed.Build(), false); - } - } -} diff --git a/Commands/InteractionCommands/UserNoteInteractions.cs b/Commands/InteractionCommands/UserNoteInteractions.cs deleted file mode 100644 index d39040a1..00000000 --- a/Commands/InteractionCommands/UserNoteInteractions.cs +++ /dev/null @@ -1,231 +0,0 @@ -using static Cliptok.Helpers.UserNoteHelpers; - -namespace Cliptok.Commands.InteractionCommands -{ - internal class UserNoteInteractions : ApplicationCommandModule - { - [SlashCommandGroup("note", "Manage user notes", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public class UserNoteSlashCommands - { - [SlashCommand("add", "Add a note to a user. Only visible to mods.")] - public async Task AddUserNoteAsync(InteractionContext ctx, - [Option("user", "The user to add a note for.")] DiscordUser user, - [Option("note", "The note to add.")] string noteText, - [Option("show_on_modmail", "Whether to show the note when the user opens a modmail thread. Default: true")] bool showOnModmail = true, - [Option("show_on_warn", "Whether to show the note when the user is warned. Default: true")] bool showOnWarn = true, - [Option("show_all_mods", "Whether to show this note to all mods, versus just yourself. Default: true")] bool showAllMods = true, - [Option("show_once", "Whether to show this note once and then discard it. Default: false")] bool showOnce = false, - [Option("show_on_join_and_leave", "Whether to show this note when the user joins & leaves. Works like joinwatch. Default: false")] bool showOnJoinAndLeave = false) - { - await ctx.DeferAsync(); - - // Assemble new note - long noteId = Program.db.StringIncrement("totalWarnings"); - UserNote note = new() - { - TargetUserId = user.Id, - ModUserId = ctx.User.Id, - NoteText = noteText, - ShowOnModmail = showOnModmail, - ShowOnWarn = showOnWarn, - ShowAllMods = showAllMods, - ShowOnce = showOnce, - ShowOnJoinAndLeave = showOnJoinAndLeave, - NoteId = noteId, - Timestamp = DateTime.Now, - Type = WarningType.Note - }; - - await Program.db.HashSetAsync(user.Id.ToString(), note.NoteId, JsonConvert.SerializeObject(note)); - - // Log to mod-logs - var embed = await GenerateUserNoteDetailEmbedAsync(note, user); - await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Information} New note for {user.Mention}!", embed); - - // Respond - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully added note!").AsEphemeral()); - } - - [SlashCommand("delete", "Delete a note.")] - public async Task RemoveUserNoteAsync(InteractionContext ctx, - [Option("user", "The user whose note to delete.")] DiscordUser user, - [Autocomplete(typeof(NotesAutocompleteProvider))][Option("note", "The note to delete.")] string targetNote) - { - // Get note - UserNote note; - try - { - note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); - } - catch - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); - return; - } - - // If user manually provided an ID of a warning, refuse the request and suggest /delwarn instead - if (note.Type == WarningType.Warning) - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/delwarn` instead, or make sure you've got the right note ID.").AsEphemeral()); - return; - } - - // Delete note - await Program.db.HashDeleteAsync(user.Id.ToString(), note.NoteId); - - // Log to mod-logs - var embed = new DiscordEmbedBuilder(await GenerateUserNoteDetailEmbedAsync(note, user)).WithColor(0xf03916); - await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Deleted} Note deleted: `{note.NoteId}` (belonging to {user.Mention}, deleted by {ctx.User.Mention})", embed); - - // Respond - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully deleted note!").AsEphemeral()); - } - - [SlashCommand("edit", "Edit a note for a user.")] - public async Task EditUserNoteAsync(InteractionContext ctx, - [Option("user", "The user to edit a note for.")] DiscordUser user, - [Autocomplete(typeof(NotesAutocompleteProvider))][Option("note", "The note to edit.")] string targetNote, - [Option("new_text", "The new note text. Leave empty to not change.")] string newNoteText = default, - [Option("show_on_modmail", "Whether to show the note when the user opens a modmail thread.")] bool? showOnModmail = null, - [Option("show_on_warn", "Whether to show the note when the user is warned.")] bool? showOnWarn = null, - [Option("show_all_mods", "Whether to show this note to all mods, versus just yourself.")] bool? showAllMods = null, - [Option("show_once", "Whether to show this note once and then discard it.")] bool? showOnce = null, - [Option("show_on_join_and_leave", "Whether to show this note when the user joins & leaves. Works like joinwatch. Default: false")] bool? showOnJoinAndLeave = false) - { - // Get note - UserNote note; - try - { - note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); - } - catch - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); - return; - } - - // If new text is not provided, use old text - if (newNoteText == default) - newNoteText = note.NoteText; - - // If no changes are made, refuse the request - if (note.NoteText == newNoteText && showOnModmail is null && showOnWarn is null && showAllMods is null && showOnce is null && showOnJoinAndLeave is null) - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You didn't change anything about the note!").AsEphemeral()); - return; - } - - // If user manually provided an ID of a warning, refuse the request and suggest /editwarn instead - if (note.Type == WarningType.Warning) - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/editwarn` instead, or make sure you've got the right note ID.").AsEphemeral()); - return; - } - - // For any options the user didn't provide, use options from the note - if (showOnModmail is null) - showOnModmail = note.ShowOnModmail; - if (showOnWarn is null) - showOnWarn = note.ShowOnWarn; - if (showAllMods is null) - showAllMods = note.ShowAllMods; - if (showOnce is null) - showOnce = note.ShowOnce; - if (showOnJoinAndLeave is null) - showOnJoinAndLeave = note.ShowOnJoinAndLeave; - - // Assemble new note - note.ModUserId = ctx.User.Id; - note.NoteText = newNoteText; - note.ShowOnModmail = (bool)showOnModmail; - note.ShowOnWarn = (bool)showOnWarn; - note.ShowAllMods = (bool)showAllMods; - note.ShowOnce = (bool)showOnce; - note.ShowOnJoinAndLeave = (bool)showOnJoinAndLeave; - note.Type = WarningType.Note; - - await Program.db.HashSetAsync(user.Id.ToString(), note.NoteId, JsonConvert.SerializeObject(note)); - - // Log to mod-logs - var embed = await GenerateUserNoteDetailEmbedAsync(note, user); - await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Information} Note edited: `{note.NoteId}` (belonging to {user.Mention})", embed); - - // Respond - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully edited note!").AsEphemeral()); - } - - [SlashCommand("list", "List all notes for a user.")] - public async Task ListUserNotesAsync(InteractionContext ctx, - [Option("user", "The user whose notes to list.")] DiscordUser user, - [Option("public", "Whether to show the notes in public chat. Default: false")] bool showPublicly = false) - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().AddEmbed(await GenerateUserNotesEmbedAsync(user)).AsEphemeral(!showPublicly)); - } - - [SlashCommand("details", "Show the details of a specific note for a user.")] - public async Task ShowUserNoteAsync(InteractionContext ctx, - [Option("user", "The user whose note to show details for.")] DiscordUser user, - [Autocomplete(typeof(NotesAutocompleteProvider))][Option("note", "The note to show.")] string targetNote, - [Option("public", "Whether to show the note in public chat. Default: false")] bool showPublicly = false) - { - // Get note - UserNote note; - try - { - note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); - } - catch - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); - return; - } - - // If user manually provided an ID of a warning, refuse the request and suggest /warndetails instead - if (note.Type == WarningType.Warning) - { - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/warndetails` instead, or make sure you've got the right note ID.").AsEphemeral()); - return; - } - - // Respond - await ctx.CreateResponseAsync(new DiscordInteractionResponseBuilder().AddEmbed(await GenerateUserNoteDetailEmbedAsync(note, user)).AsEphemeral(!showPublicly)); - } - - private class NotesAutocompleteProvider : IAutocompleteProvider - { - public async Task> Provider(AutocompleteContext ctx) - { - var list = new List(); - - var useroption = ctx.Options.FirstOrDefault(x => x.Name == "user"); - if (useroption == default) - { - return list; - } - - var user = await ctx.Client.GetUserAsync((ulong)useroption.Value); - - var notes = Program.db.HashGetAll(user.Id.ToString()) - .Where(x => JsonConvert.DeserializeObject(x.Value).Type == WarningType.Note).ToDictionary( - x => x.Name.ToString(), - x => JsonConvert.DeserializeObject(x.Value) - ).OrderByDescending(x => x.Value.NoteId); - - foreach (var note in notes) - { - if (list.Count >= 25) - break; - - string noteString = $"{StringHelpers.Pad(note.Value.NoteId)} - {StringHelpers.Truncate(note.Value.NoteText, 29, true)} - {TimeHelpers.TimeToPrettyFormat(DateTime.Now - note.Value.Timestamp, true)}"; - - if (ctx.FocusedOption.Value.ToString() == "" || note.Value.NoteText.Contains((string)ctx.FocusedOption.Value) || noteString.ToLower().Contains(ctx.FocusedOption.Value.ToString().ToLower())) - list.Add(new DiscordAutoCompleteChoice(noteString, StringHelpers.Pad(note.Value.NoteId))); - } - - return list; - } - } - } - } -} \ No newline at end of file diff --git a/Commands/InteractionCommands/WarningInteractions.cs b/Commands/InteractionCommands/WarningInteractions.cs deleted file mode 100644 index 31e52ca4..00000000 --- a/Commands/InteractionCommands/WarningInteractions.cs +++ /dev/null @@ -1,339 +0,0 @@ -using static Cliptok.Helpers.WarningHelpers; - -namespace Cliptok.Commands.InteractionCommands -{ - internal class WarningInteractions : ApplicationCommandModule - { - [SlashCommand("warn", "Formally warn a user, usually for breaking the server rules.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task WarnSlashCommand(InteractionContext ctx, - [Option("user", "The user to warn.")] DiscordUser user, - [Option("reason", "The reason they're being warned.")] string reason, - [Option("reply_msg_id", "The ID of a message to reply to, must be in the same channel.")] string replyMsgId = "0", - [Option("channel", "The channel to warn the user in, implied if not supplied.")] DiscordChannel channel = null - ) - { - // Initial response to avoid the 3 second timeout, will edit later. - var eout = new DiscordInteractionResponseBuilder().AsEphemeral(true); - await ctx.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource, eout); - - // Edits need a webhook rather than interaction..? - DiscordWebhookBuilder webhookOut; - - DiscordMember targetMember; - - try - { - targetMember = await ctx.Guild.GetMemberAsync(user.Id); - if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) - { - webhookOut = new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} As a Trial Moderator you cannot perform moderation actions on other staff members or bots."); - await ctx.EditResponseAsync(webhookOut); - return; - } - } - catch - { - // do nothing :/ - } - - if (channel is null) - channel = ctx.Channel; - - if (channel is null) - channel = await ctx.Client.GetChannelAsync(ctx.Interaction.ChannelId); - - var messageBuild = new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Warning} {user.Mention} was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); - - if (replyMsgId != "0") - { - if (!ulong.TryParse(replyMsgId, out var msgId)) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Invalid reply message ID! Please try again.").AsEphemeral(true)); - return; - } - messageBuild.WithReply(msgId, true, false); - } - - var msg = await channel.SendMessageAsync(messageBuild); - - _ = await WarningHelpers.GiveWarningAsync(user, ctx.User, reason, msg, channel); - webhookOut = new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Success} User was warned successfully: {DiscordHelpers.MessageLink(msg)}"); - await ctx.EditResponseAsync(webhookOut); - } - - [SlashCommand("warnings", "Fetch the warnings for a user.")] - public async Task WarningsSlashCommand(InteractionContext ctx, - [Option("user", "The user to find the warnings for.")] DiscordUser user, - [Option("public", "Whether to show the warnings in public chat. Do not disrupt chat with this.")] bool publicWarnings = false - ) - { - var eout = new DiscordInteractionResponseBuilder().AddEmbed(await WarningHelpers.GenerateWarningsEmbedAsync(user)); - if (!publicWarnings) - eout.AsEphemeral(true); - - await ctx.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, eout); - } - - [SlashCommand("transfer_warnings", "Transfer warnings from one user to another.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task TransferWarningsSlashCommand(InteractionContext ctx, - [Option("source_user", "The user currently holding the warnings.")] DiscordUser sourceUser, - [Option("target_user", "The user receiving the warnings.")] DiscordUser targetUser, - [Option("merge", "Whether to merge the source user's warnings and the target user's warnings.")] bool merge = false, - [Option("force_override", "DESTRUCTIVE OPERATION: Whether to OVERRIDE and DELETE the target users existing warnings.")] bool forceOverride = false - ) - { - await ctx.DeferAsync(ephemeral: false); - - if (sourceUser == targetUser) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} The source and target users cannot be the same!")); - return; - } - - var sourceWarnings = await Program.db.HashGetAllAsync(sourceUser.Id.ToString()); - var targetWarnings = await Program.db.HashGetAllAsync(targetUser.Id.ToString()); - - if (sourceWarnings.Length == 0) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} The source user has no warnings to transfer.").AddEmbed(await GenerateWarningsEmbedAsync(sourceUser))); - return; - } - else if (merge) - { - foreach (var warning in sourceWarnings) - { - await Program.db.HashSetAsync(targetUser.Id.ToString(), warning.Name, warning.Value); - } - await Program.db.KeyDeleteAsync(sourceUser.Id.ToString()); - } - else if (targetWarnings.Length > 0 && !forceOverride) - { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Warning} **CAUTION**: The target user has warnings.\n\n" + - $"If you are sure you want to **OVERRIDE** and **DELETE** these warnings, please consider the consequences before adding `force_override: True` to the command.\nIf you wish to **NOT** override the target's warnings, please use `merge: True` instead.") - .AddEmbed(await GenerateWarningsEmbedAsync(targetUser))); - return; - } - else if (targetWarnings.Length > 0 && forceOverride) - { - await Program.db.KeyDeleteAsync(targetUser.Id.ToString()); - await Program.db.KeyRenameAsync(sourceUser.Id.ToString(), targetUser.Id.ToString()); - } - else - { - await Program.db.KeyRenameAsync(sourceUser.Id.ToString(), targetUser.Id.ToString()); - } - - string operationText = ""; - if (merge) - operationText = "merge "; - else if (forceOverride) - operationText = "force "; - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Information} Warnings from {sourceUser.Mention} were {operationText}transferred to {targetUser.Mention} by `{DiscordHelpers.UniqueUsername(ctx.User)}`") - .AddEmbed(await GenerateWarningsEmbedAsync(targetUser)) - ); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully {operationText}transferred warnings from {sourceUser.Mention} to {targetUser.Mention}!")); - } - - internal partial class WarningsAutocompleteProvider : IAutocompleteProvider - { - public async Task> Provider(AutocompleteContext ctx) - { - var list = new List(); - - var useroption = ctx.Options.FirstOrDefault(x => x.Name == "user"); - if (useroption == default) - { - return list; - } - - var user = await ctx.Client.GetUserAsync((ulong)useroption.Value); - - var warnings = Program.db.HashGetAll(user.Id.ToString()) - .Where(x => JsonConvert.DeserializeObject(x.Value).Type == WarningType.Warning).ToDictionary( - x => x.Name.ToString(), - x => JsonConvert.DeserializeObject(x.Value) - ).OrderByDescending(x => x.Value.WarningId); - - foreach (var warning in warnings) - { - if (list.Count >= 25) - break; - - string warningString = $"{StringHelpers.Pad(warning.Value.WarningId)} - {StringHelpers.Truncate(warning.Value.WarnReason, 29, true)} - {TimeHelpers.TimeToPrettyFormat(DateTime.Now - warning.Value.WarnTimestamp, true)}"; - - if (ctx.FocusedOption.Value.ToString() == "" || warning.Value.WarnReason.Contains((string)ctx.FocusedOption.Value) || warningString.ToLower().Contains(ctx.FocusedOption.Value.ToString().ToLower())) - list.Add(new DiscordAutoCompleteChoice(warningString, StringHelpers.Pad(warning.Value.WarningId))); - } - - return list; - //return Task.FromResult((IEnumerable)list); - } - } - - [SlashCommand("warndetails", "Search for a warning and return its details.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task WarndetailsSlashCommand(InteractionContext ctx, - [Option("user", "The user to fetch a warning for.")] DiscordUser user, - [Autocomplete(typeof(WarningsAutocompleteProvider)), Option("warning", "Type to search! Find the warning you want to fetch.")] string warning, - [Option("public", "Whether to show the output publicly.")] bool publicWarnings = false - ) - { - if (warning.Contains(' ')) - { - warning = warning.Split(' ')[0]; - } - - long warnId; - try - { - warnId = Convert.ToInt64(warning); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); - return; - } - - UserWarning warningObject = GetWarning(user.Id, warnId); - - if (warningObject is null) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); - else if (warningObject.Type == WarningType.Note) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note details` instead, or make sure you've got the right warning ID.", ephemeral: true); - else - { - await ctx.DeferAsync(ephemeral: !publicWarnings); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().AddEmbed(await FancyWarnEmbedAsync(warningObject, true, userID: user.Id))); - } - } - - [SlashCommand("delwarn", "Search for a warning and delete it!", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task DelwarnSlashCommand(InteractionContext ctx, - [Option("user", "The user to delete a warning for.")] DiscordUser targetUser, - [Autocomplete(typeof(WarningsAutocompleteProvider))][Option("warning", "Type to search! Find the warning you want to delete.")] string warningId, - [Option("public", "Whether to show the output publicly. Default: false")] bool showPublic = false - ) - { - if (warningId.Contains(' ')) - { - warningId = warningId.Split(' ')[0]; - } - - long warnId; - try - { - warnId = Convert.ToInt64(warningId); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); - return; - } - - UserWarning warning = GetWarning(targetUser.Id, warnId); - - if (warning is null) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); - else if (warning.Type == WarningType.Note) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note delete` instead, or make sure you've got the right warning ID.", ephemeral: true); - } - else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!", ephemeral: true); - } - else - { - await ctx.DeferAsync(ephemeral: !showPublic); - - bool success = await DelWarningAsync(warning, targetUser.Id); - if (success) - { - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Deleted} Warning deleted:" + - $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention}, deleted by {ctx.Member.Mention})") - .AddEmbed(await FancyWarnEmbedAsync(warning, true, 0xf03916, true, targetUser.Id)) - .WithAllowedMentions(Mentions.None) - ); - - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Deleted} Successfully deleted warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})")); - - - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to delete warning `{StringHelpers.Pad(warnId)}` from {targetUser.Mention}!\nPlease contact the bot author.", ephemeral: true); - } - } - } - - [SlashCommand("editwarn", "Search for a warning and edit it!", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task EditWarnSlashCommand(InteractionContext ctx, - [Option("user", "The user to fetch a warning for.")] DiscordUser user, - [Autocomplete(typeof(WarningsAutocompleteProvider))][Option("warning", "Type to search! Find the warning you want to edit.")] string warning, - [Option("new_reason", "The new reason for the warning")] string reason, - [Option("public", "Whether to show the output publicly. Default: false")] bool showPublic = false) - { - if (warning.Contains(' ')) - { - warning = warning.Split(' ')[0]; - } - - long warnId; - try - { - warnId = Convert.ToInt64(warning); - } - catch - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); - return; - } - - if (string.IsNullOrWhiteSpace(reason)) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You haven't given a new reason to set for the warning!", ephemeral: true); - return; - } - - var warningObject = GetWarning(user.Id, warnId); - - if (warningObject is null) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); - else if (warningObject.Type == WarningType.Note) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note edit` instead, or make sure you've got the right warning ID.", ephemeral: true); - } - else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warningObject.ModUserId != ctx.User.Id && warningObject.ModUserId != ctx.Client.CurrentUser.Id) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!", ephemeral: true); - } - else - { - await ctx.DeferAsync(ephemeral: !showPublic); - - await EditWarning(user, warnId, ctx.User, reason); - - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Information} Warning edited:" + - $"`{StringHelpers.Pad(warnId)}` (belonging to {user.Mention})") - .AddEmbed(await FancyWarnEmbedAsync(GetWarning(user.Id, warnId), true, userID: user.Id)) - ); - - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Information} Successfully edited warning `{StringHelpers.Pad(warnId)}` (belonging to {user.Mention})") - .AddEmbed(await FancyWarnEmbedAsync(GetWarning(user.Id, warnId), userID: user.Id))); - } - } - } -} \ No newline at end of file diff --git a/Commands/JoinwatchCmds.cs b/Commands/JoinwatchCmds.cs new file mode 100644 index 00000000..5e684a50 --- /dev/null +++ b/Commands/JoinwatchCmds.cs @@ -0,0 +1,48 @@ +namespace Cliptok.Commands +{ + public class JoinwatchCmds + { + [Command("joinwatch")] + [Description("Watch for joins and leaves of a given user. Output goes to #investigations.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public class JoinwatchCmd + { + [DefaultGroupCommand] + [Command("toggle")] + [Description("Toggle joinwatch for a given user.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task JoinwatchToggle(CommandContext ctx, + [Parameter("user"), Description("The user to watch for joins and leaves of.")] DiscordUser user, + [Parameter("note"), Description("An optional note for context.")] string note = "") + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. To add a note for this user, please use `/note add user:{user.Id} note:{(string.IsNullOrEmpty(note) ? "" : note)} show_on_join_and_leave:True`; to remove one, use `/note delete user:{user.Id} note:`."); + } + + [Command("add")] + [Description("Watch for joins and leaves of a given user. Output goes to #investigations.")] + public async Task JoinwatchAdd(CommandContext ctx, + [Parameter("user"), Description("The user to watch for joins and leaves of.")] DiscordUser user, + [Parameter("note"), Description("An optional note for context.")] string note = "") + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note add` instead, like this: `/note add user:{user.Id} note:{(string.IsNullOrEmpty(note) ? "" : note)} show_on_join_and_leave:True`"); + } + + [Command("remove")] + [Description("Stop watching for joins and leaves of a user.")] + public async Task JoinwatchRemove(CommandContext ctx, + [Parameter("user"), Description("The user to stop watching for joins and leaves of.")] DiscordUser user) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note delete` instead, like this: `/note delete user:{user.Id} note:`"); + } + + [Command("status")] + [Description("Check the joinwatch status for a user.")] + public async Task JoinwatchStatus(CommandContext ctx, + [Parameter("user"), Description("The user whose joinwatch status to check.")] DiscordUser user) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. Please use `/note list user:{user.Id}` to show all of this user's notes, or `/note details user:{user.Id} note:` for details on a specific note, instead. Notes with \"Show on Join & Leave\" enabled will behave like joinwatches."); + } + } + } +} \ No newline at end of file diff --git a/Commands/Kick.cs b/Commands/KickCmds.cs similarity index 80% rename from Commands/Kick.cs rename to Commands/KickCmds.cs index 07587917..bd00d764 100644 --- a/Commands/Kick.cs +++ b/Commands/KickCmds.cs @@ -1,12 +1,13 @@ -namespace Cliptok.Commands +namespace Cliptok.Commands { - internal class Kick : BaseCommandModule + public class KickCmds { [Command("kick")] - [Aliases("yeet", "shoo", "goaway", "defenestrate")] - [Description("Kicks a user, removing them from the server until they rejoin. Generally not very useful.")] - [RequirePermissions(permissions: DiscordPermission.KickMembers), HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task KickCmd(CommandContext ctx, DiscordUser target, [RemainingText] string reason = "No reason specified.") + [TextAlias("yeet", "shoo", "goaway", "defenestrate")] + [Description("Kicks a user, removing them from the server until they rejoin.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(DiscordPermission.KickMembers)] + public async Task KickCmd(CommandContext ctx, [Parameter("user"), Description("The user you want to kick from the server.")] DiscordUser target, [Parameter("reason"), Description("The reason for kicking this user.")] string reason = "No reason specified.") { if (target.IsBot) { @@ -31,27 +32,29 @@ public async Task KickCmd(CommandContext ctx, DiscordUser target, [RemainingText { if (DiscordHelpers.AllowedToMod(await ctx.Guild.GetMemberAsync(ctx.Client.CurrentUser.Id), member)) { - await ctx.Message.DeleteAsync(); await KickAndLogAsync(member, reason, ctx.Member); await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Ejected} {target.Mention} has been kicked: **{reason}**"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!", ephemeral: true); return; } else { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I don't have permission to kick **{DiscordHelpers.UniqueUsername(target)}**!"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I don't have permission to kick **{DiscordHelpers.UniqueUsername(target)}**!", ephemeral: true); return; } } else { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You aren't allowed to kick **{DiscordHelpers.UniqueUsername(target)}**!"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You aren't allowed to kick **{DiscordHelpers.UniqueUsername(target)}**!", ephemeral: true); return; } } - [Command("masskick")] + [Command("masskicktextcmd")] + [TextAlias("masskick")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task MassKickCmd(CommandContext ctx, [RemainingText] string input) + public async Task MassKickCmd(TextCommandContext ctx, [RemainingText] string input) { List inputString = input.Replace("\n", " ").Replace("\r", "").Split(' ').ToList(); @@ -75,7 +78,8 @@ public async Task MassKickCmd(CommandContext ctx, [RemainingText] string input) List> taskList = new(); int successes = 0; - var loading = await ctx.RespondAsync("Processing, please wait."); + await ctx.RespondAsync("Processing, please wait."); + var loading = await ctx.GetResponseAsync(); foreach (ulong user in users) { @@ -134,6 +138,5 @@ await LogChannelHelper.LogMessageAsync("mod", return false; } } - } -} +} \ No newline at end of file diff --git a/Commands/Lists.cs b/Commands/ListCmds.cs similarity index 83% rename from Commands/Lists.cs rename to Commands/ListCmds.cs index 93ecdd63..3ebd4909 100644 --- a/Commands/Lists.cs +++ b/Commands/ListCmds.cs @@ -1,6 +1,6 @@ namespace Cliptok.Commands { - internal class Lists : BaseCommandModule + internal class ListCmds { public class GitHubDispatchBody { @@ -23,10 +23,12 @@ public class GitHubDispatchInputs public string User { get; set; } } - [Command("listupdate")] + [Command("listupdatetextcmd")] + [TextAlias("listupdate")] [Description("Updates the private lists from the GitHub repository, then reloads them into memory.")] + [AllowedProcessors(typeof(TextCommandProcessor))] [RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task ListUpdate(CommandContext ctx) + public async Task ListUpdate(TextCommandContext ctx) { if (Program.cfgjson.GitListDirectory is null || Program.cfgjson.GitListDirectory == "") { @@ -35,7 +37,8 @@ public async Task ListUpdate(CommandContext ctx) } string command = $"cd Lists/{Program.cfgjson.GitListDirectory} && git pull"; - DiscordMessage msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Updating private lists.."); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Updating private lists.."); + DiscordMessage msg = await ctx.GetResponseAsync(); ShellResult finishedShell = RunShellCommand(command); @@ -53,11 +56,13 @@ public async Task ListUpdate(CommandContext ctx) } - [Command("listadd")] + [Command("listaddtextcmd")] + [TextAlias("listadd")] [Description("Add a piece of text to a public list.")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] public async Task ListAdd( - CommandContext ctx, + TextCommandContext ctx, [Description("The filename of the public list to add to. For example scams.txt")] string fileName, [RemainingText, Description("The text to add the list. Can be in a codeblock and across multiple line.")] string content ) @@ -134,8 +139,9 @@ await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} An error with code `{resp [Command("scamcheck")] [Description("Check if a link or message is known to the anti-phishing API.")] - [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task ScamCheck(CommandContext ctx, [RemainingText, Description("Domain or message content to scan.")] string content) + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task ScamCheck(CommandContext ctx, [Parameter("input"), Description("Domain or message content to scan.")] string content) { var urlMatches = Constants.RegexConstants.url_rx.Matches(content); if (urlMatches.Count > 0 && Environment.GetEnvironmentVariable("CLIPTOK_ANTIPHISHING_ENDPOINT") is not null && Environment.GetEnvironmentVariable("CLIPTOK_ANTIPHISHING_ENDPOINT") != "useyourimagination") @@ -146,7 +152,6 @@ public async Task ScamCheck(CommandContext ctx, [RemainingText, Description("Dom if (match) { responseToSend = $"Match found:\n"; - } else { @@ -163,25 +168,13 @@ public async Task ScamCheck(CommandContext ctx, [RemainingText, Description("Dom } } - [Command("joinwatch")] - [Aliases("joinnotify", "leavewatch", "leavenotify")] - [Description("Watch for joins and leaves of a given user. Output goes to #investigations.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task JoinWatch( - CommandContext ctx, - [Description("The user to watch for joins and leaves of.")] DiscordUser user, - [Description("An optional note for context."), RemainingText] string note = "" - ) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works; all joinwatches have been converted to notes. To add a note for this user, please use `/note add user:{user.Id} note:{(string.IsNullOrEmpty(note) ? "" : note)} show_on_join_and_leave:True`; to remove one, use `/note delete user:{user.Id} note:`."); - } - - [Command("appealblock")] - [Aliases("superduperban", "ablock")] + [Command("appealblocktextcmd")] + [TextAlias("appealblock", "superduperban", "ablock")] [Description("Prevents a user from submitting ban appeals.")] + [AllowedProcessors(typeof(TextCommandProcessor))] [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] public async Task AppealBlock( - CommandContext ctx, + TextCommandContext ctx, [Description("The user to block from ban appeals.")] DiscordUser user ) { diff --git a/Commands/Lockdown.cs b/Commands/Lockdown.cs deleted file mode 100644 index ae175927..00000000 --- a/Commands/Lockdown.cs +++ /dev/null @@ -1,141 +0,0 @@ -namespace Cliptok.Commands -{ - class Lockdown : BaseCommandModule - { - public bool ongoingLockdown = false; - - [Command("lockdown")] - [Aliases("lock")] - [Description("Locks the current channel, preventing any new messages. See also: unlock")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequireBotPermissions(permissions: DiscordPermission.ManageChannels)] - public async Task LockdownCommand( - CommandContext ctx, - [RemainingText, Description("The time and reason for the lockdown. For example '3h' or '3h spam'. Default is permanent with no reason.")] string timeAndReason = "" - ) - { - if (ctx.Channel.Type is DiscordChannelType.PublicThread or DiscordChannelType.PrivateThread or DiscordChannelType.NewsThread) - { - var thread = (DiscordThreadChannel)ctx.Channel; - - await thread.ModifyAsync(a => - { - a.IsArchived = true; - a.Locked = true; - }); - return; - } - - bool timeParsed = false; - TimeSpan? lockDuration = null; - string reason = ""; - - if (timeAndReason != "") - { - string possibleTime = timeAndReason.Split(' ').First(); - try - { - lockDuration = HumanDateParser.HumanDateParser.Parse(possibleTime).Subtract(ctx.Message.Timestamp.DateTime); - timeParsed = true; - } - catch - { - // keep null - } - - reason = timeAndReason; - - if (timeParsed) - { - int i = reason.IndexOf(" ") + 1; - - if (i == 0) - reason = ""; - else - reason = reason[i..]; - } - } - - var currentChannel = ctx.Channel; - if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist."); - return; - } - - if (ongoingLockdown) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request to avoid conflicts, sorry."); - return; - } - - if (timeAndReason == "all") - { - ongoingLockdown = true; - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it, please hold..."); - foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) - { - try - { - var channel = await ctx.Client.GetChannelAsync(chanID); - await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: channel); - } - catch - { - - } - - } - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!"); - ongoingLockdown = false; - return; - - } - - await ctx.Message.DeleteAsync(); - - await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: currentChannel, duration: lockDuration, reason: reason); - } - - [Command("unlock")] - [Description("Unlocks a previously locked channel. See also: lockdown")] - [Aliases("unlockdown"), HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequireBotPermissions(permissions: DiscordPermission.ManageChannels)] - public async Task UnlockCommand(CommandContext ctx, [RemainingText] string reason = "") - { - var currentChannel = ctx.Channel; - if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist."); - return; - } - - if (ongoingLockdown) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request. sorry."); - return; - } - - if (reason == "all") - { - ongoingLockdown = true; - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it, please hold..."); - foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) - { - try - { - currentChannel = await ctx.Client.GetChannelAsync(chanID); - await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member); - } - catch - { - - } - } - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!"); - ongoingLockdown = false; - return; - } - await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member, reason); - } - - } -} diff --git a/Commands/LockdownCmds.cs b/Commands/LockdownCmds.cs new file mode 100644 index 00000000..051010ad --- /dev/null +++ b/Commands/LockdownCmds.cs @@ -0,0 +1,223 @@ +namespace Cliptok.Commands +{ + public class LockdownCmds + { + public static bool ongoingLockdown = false; + + [Command("lockdown")] + [Description("Lock the current channel or all channels in the server, preventing new messages. See also: unlock")] + [TextAlias("lock")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions([DiscordPermission.ManageChannels], [])] + public class LockdownCmd + { + [DefaultGroupCommand] + [Command("channel")] + [Description("Lock the current channel. See also: unlock channel")] + public async Task LockdownChannelCommand( + CommandContext ctx, + [Parameter("reason"), Description("The reason for the lockdown.")] string reason = "No reason specified.", + [Parameter("time"), Description("The length of time to lock the channel for.")] string time = null, + [Parameter("lockthreads"), Description("Whether to lock this channel's threads. Disables sending messages, but does not archive them.")] bool lockThreads = false) + { + if (ctx is SlashCommandContext) + await ctx.As().DeferResponseAsync(ephemeral: true); + + if (ctx.Channel.Type is DiscordChannelType.PublicThread or DiscordChannelType.PrivateThread or DiscordChannelType.NewsThread) + { + if (lockThreads) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock this channel!\n`/lockdown` with `lockthreads` cannot be used inside of a thread. If you meant to lock {ctx.Channel.Parent.Mention} and all of its threads, use the command there.\n\nIf you meant to only lock this thread, use `!lock` instead, or use `/lockdown` with `lockthreads` set to False.").AsEphemeral(true)); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Denied} You can't lock this channel!\n`/lockdown` with `lockthreads` cannot be used inside of a thread. If you meant to lock {ctx.Channel.Parent.Mention} and all of its threads, use the command there.\n\nIf you meant to only lock this thread, use `!lock` instead, or use `/lockdown` with `lockthreads` set to False."); + return; + } + + var thread = (DiscordThreadChannel)ctx.Channel; + + await thread.ModifyAsync(a => + { + a.IsArchived = true; + a.Locked = true; + }); + + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent("Thread locked successfully!").AsEphemeral(true)); + return; + } + + TimeSpan? lockDuration = null; + + if (!string.IsNullOrWhiteSpace(time)) + { + if (ctx is SlashCommandContext) + lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.As().Interaction.CreationTimestamp.DateTime); + else + lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.As().Message.Timestamp.DateTime); + } + + var currentChannel = ctx.Channel; + if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) + { + if (ctx is SlashCommandContext) + ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist.")); + else + ctx.RespondAsync($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist."); + return; + } + + if (ongoingLockdown) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request to avoid conflicts, sorry.")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request to avoid conflicts, sorry."); + return; + } + + if (ctx is TextCommandContext) + await ctx.As().Message.DeleteAsync(); + + try + { + await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: currentChannel, duration: lockDuration, reason: reason, lockThreads: lockThreads); + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent("Channel locked successfully.").AsEphemeral(true)); + } + catch (ArgumentException) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent("Failed to lock this channel!").AsEphemeral(true)); + } + } + + [Command("all")] + [Description("Lock all lockable channels in the server. See also: unlock all")] + public async Task LockdownAllCommand( + CommandContext ctx, + [Parameter("reason"), Description("The reason for the lockdown.")] string reason = "", + [Parameter("time"), Description("The length of time to lock the channels for.")] string time = null, + [Parameter("lockthreads"), Description("Whether to lock threads. Disables sending messages, but does not archive them.")] bool lockThreads = false) + { + if (ctx is SlashCommandContext) + await ctx.DeferResponseAsync(); + + ongoingLockdown = true; + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Loading} Working on it, please hold...")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it, please hold..."); + + TimeSpan? lockDuration = null; + + if (!string.IsNullOrWhiteSpace(time)) + { + if (ctx is SlashCommandContext) + lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.As().Interaction.CreationTimestamp.DateTime); + else + lockDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.As().Message.Timestamp.DateTime); + } + + foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) + { + try + { + var channel = await ctx.Client.GetChannelAsync(chanID); + await LockdownHelpers.LockChannelAsync(user: ctx.User, channel: channel, duration: lockDuration, reason: reason, lockThreads: lockThreads); + } + catch + { + + } + + } + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!"); + ongoingLockdown = false; + return; + } + } + + [Command("unlock")] + [TextAlias("unlockdown")] + [Description("Unlock the current channel or all channels in the server, allowing new messages. See also: lockdown")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions([DiscordPermission.ManageChannels], [])] + public class UnlockCmds + { + [DefaultGroupCommand] + [Command("channel")] + [Description("Unlock the current channel. See also: lockdown")] + public async Task UnlockChannelCommand(CommandContext ctx, [Parameter("reason"), Description("The reason for the unlock.")] string reason = "") + { + if (ctx is SlashCommandContext) + await ctx.As().DeferResponseAsync(ephemeral: true); + + var currentChannel = ctx.Channel; + if (!Program.cfgjson.LockdownEnabledChannels.Contains(currentChannel.Id)) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist.").AsEphemeral(true)); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Denied} You can't lock or unlock this channel!\nIf this is in error, add its ID (`{currentChannel.Id}`) to the lockdown whitelist."); + return; + } + + if (ongoingLockdown) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request. sorry.").AsEphemeral(true)); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} A mass lockdown or unlock is already ongoing. Refusing your request. sorry."); + return; + } + try + { + await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member); + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent("Channel locked successfully.").AsEphemeral(true)); + } + catch (ArgumentException) + { + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent("Failed to lock this channel!").AsEphemeral(true)); + } + } + + [Command("all")] + [Description("Unlock all lockable channels in the server. See also: lockdown all")] + public async Task UnlockAllCommand(CommandContext ctx, [Parameter("reason"), Description("The reason for the unlock.")] string reason = "") + { + if (ctx is SlashCommandContext) + ctx.DeferResponseAsync(); + + ongoingLockdown = true; + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Loading} Working on it, please hold...")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Loading} Working on it, please hold..."); + foreach (var chanID in Program.cfgjson.LockdownEnabledChannels) + { + try + { + var currentChannel = await ctx.Client.GetChannelAsync(chanID); + await LockdownHelpers.UnlockChannel(currentChannel, ctx.Member, reason, true); + } + catch + { + + } + } + if (ctx is SlashCommandContext) + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Done!")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Done!"); + ongoingLockdown = false; + return; + } + } + } +} \ No newline at end of file diff --git a/Commands/MuteCmds.cs b/Commands/MuteCmds.cs new file mode 100644 index 00000000..0d87864b --- /dev/null +++ b/Commands/MuteCmds.cs @@ -0,0 +1,290 @@ +namespace Cliptok.Commands +{ + public class MuteCmds + { + [Command("mute")] + [Description("Mute a user, temporarily or permanently.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task MuteSlashCommand( + SlashCommandContext ctx, + [Parameter("user"), Description("The user you wish to mute.")] DiscordUser targetUser, + [Parameter("reason"), Description("The reason for the mute.")] string reason, + [Parameter("time"), Description("The length of time to mute for.")] string time = "" + ) + { + await ctx.DeferResponseAsync(ephemeral: true); + DiscordMember targetMember = default; + try + { + targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); + } + catch (DSharpPlus.Exceptions.NotFoundException) + { + // is this worth logging? + } + + if (targetMember != default && (await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) + { + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); + return; + } + + TimeSpan muteDuration = default; + + if (time != "") + { + try + { + muteDuration = HumanDateParser.HumanDateParser.Parse(time).Subtract(ctx.Interaction.CreationTimestamp.LocalDateTime); + } + catch + { + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Failed to parse time argument.")); + throw; + } + } + + await MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true); + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent("Command completed successfully.")); + } + + [Command("unmute")] + [Description("Unmute a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task UnmuteSlashCommand( + SlashCommandContext ctx, + [Parameter("user"), Description("The user you wish to mute.")] DiscordUser targetUser, + [Parameter("reason"), Description("The reason for the unmute.")] string reason = "No reason specified." + ) + { + await ctx.DeferResponseAsync(ephemeral: false); + + reason = $"[Manual unmute by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"; + + // todo: store per-guild + DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); + + DiscordMember member = default; + try + { + member = await ctx.Guild.GetMemberAsync(targetUser.Id); + } + catch (DSharpPlus.Exceptions.NotFoundException ex) + { + Program.discord.Logger.LogWarning(eventId: Program.CliptokEventID, exception: ex, message: "Failed to unmute {user} in {server} because they weren't in the server.", $"{DiscordHelpers.UniqueUsername(targetUser)}", ctx.Guild.Name); + } + + if ((await Program.db.HashExistsAsync("mutes", targetUser.Id)) || (member != default && member.Roles.Contains(mutedRole))) + { + await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Information} Successfully unmuted **{DiscordHelpers.UniqueUsername(targetUser)}**.")); + } + else + try + { + await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Warning} According to Discord that user is not muted, but I tried to unmute them anyway. Hope it works.")); + } + catch (Exception e) + { + Program.discord.Logger.LogError(e, "An error occurred unmuting {user}", targetUser.Id); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be muted, *and* an error occurred while attempting to unmute them anyway. Please contact the bot owner, the error has been logged.")); + } + } + + [Command("tqsmute")] + [Description("Temporarily mute a user in tech support channels.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] + public async Task TqsMuteSlashCommand( + CommandContext ctx, + [Parameter("user"), Description("The user to mute.")] DiscordUser targetUser, + [Parameter("reason"), Description("The reason for the mute.")] string reason) + { + if (ctx is SlashCommandContext) + await ctx.As().DeferResponseAsync(ephemeral: true); + else + await ctx.As().Message.DeleteAsync(); + + // only work if TQS mute role is configured + if (Program.cfgjson.TqsMutedRole == 0) + { + if (ctx is SlashCommandContext) + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} TQS mutes are not configured, so this command does nothing. Please contact the bot maintainer if this is unexpected.")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} TQS mutes are not configured, so this command does nothing. Please contact the bot maintainer if this is unexpected."); + return; + } + + // Only allow usage in #tech-support, #tech-support-forum, and their threads + if (ctx.Channel.Id != Program.cfgjson.TechSupportChannel && + ctx.Channel.Id != Program.cfgjson.SupportForumId && + ctx.Channel.Parent.Id != Program.cfgjson.TechSupportChannel && + ctx.Channel.Parent.Id != Program.cfgjson.SupportForumId) + { + if (ctx is SlashCommandContext) + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This command can only be used in <#{Program.cfgjson.TechSupportChannel}>, <#{Program.cfgjson.SupportForumId}>, and threads in those channels!")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command can only be used in <#{Program.cfgjson.TechSupportChannel}>, <#{Program.cfgjson.SupportForumId}>, and threads in those channels!"); + return; + } + + // Check if the user is already muted; disallow TQS-mute if so + + DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); + DiscordRole tqsMutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.TqsMutedRole); + + // Get member + DiscordMember targetMember = default; + try + { + targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); + } + catch (DSharpPlus.Exceptions.NotFoundException) + { + // blah + } + + if (await Program.db.HashExistsAsync("mutes", targetUser.Id) || (targetMember is not null && (targetMember.Roles.Contains(mutedRole) || targetMember.Roles.Contains(tqsMutedRole)))) + { + if (ctx is SlashCommandContext) + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, that user is already muted.")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, that user is already muted."); + return; + } + + // Check if user to be muted is staff or TQS, and disallow if so + if (targetMember != default && (await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TechnicalQueriesSlayer && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TechnicalQueriesSlayer || targetMember.IsBot)) + { + if (ctx is SlashCommandContext) + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, you cannot mute other TQS or staff members.")); + else + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, you cannot mute other TQS or staff members."); + return; + } + + // mute duration is static for TQS mutes + TimeSpan muteDuration = TimeSpan.FromHours(Program.cfgjson.TqsMuteDurationHours); + + await MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true, true); + if (ctx is SlashCommandContext) + await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent("Done. Please open a modmail thread for this user if you haven't already!")); + } + + [Command("muteinfo")] + [Description("Show information about the mute for a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task MuteInfoSlashCommand( + SlashCommandContext ctx, + [Parameter("user"), Description("The user whose mute information to show.")] DiscordUser targetUser, + [Parameter("public"), Description("Whether to show the output publicly. Default: false")] bool isPublic = false) + { + await ctx.RespondAsync(embed: await MuteHelpers.MuteStatusEmbed(targetUser, ctx.Guild), ephemeral: !isPublic); + } + + [Command("unmutetextcmd")] + [TextAlias("unmute", "umute")] + [Description("Unmutes a previously muted user, typically ahead of the standard expiration time. See also: mute")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public async Task UnmuteCmd(TextCommandContext ctx, [Description("The user you're trying to unmute.")] DiscordUser targetUser, string reason = "No reason provided.") + { + reason = $"[Manual unmute by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"; + + // todo: store per-guild + DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); + DiscordRole tqsMutedRole = default; + if (Program.cfgjson.TqsMutedRole != 0) + tqsMutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.TqsMutedRole); + + DiscordMember member = default; + try + { + member = await ctx.Guild.GetMemberAsync(targetUser.Id); + } + catch (DSharpPlus.Exceptions.NotFoundException ex) + { + Program.discord.Logger.LogWarning(eventId: Program.CliptokEventID, exception: ex, message: "Failed to unmute {user} in {server} because they weren't in the server.", $"{DiscordHelpers.UniqueUsername(targetUser)}", ctx.Guild.Name); + } + + if ((await Program.db.HashExistsAsync("mutes", targetUser.Id)) || (member != default && (member.Roles.Contains(mutedRole) || member.Roles.Contains(tqsMutedRole)))) + { + await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Information} Successfully unmuted **{DiscordHelpers.UniqueUsername(targetUser)}**."); + } + else + try + { + await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Warning} According to Discord that user is not muted, but I tried to unmute them anyway. Hope it works."); + } + catch (Exception e) + { + Program.discord.Logger.LogError(e, "An error occurred unmuting {user}", targetUser.Id); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be muted, *and* an error occurred while attempting to unmute them anyway. Please contact the bot owner, the error has been logged."); + } + } + + [Command("mutetextcmd")] + [TextAlias("mute")] + [Description("Mutes a user, preventing them from sending messages until they're unmuted. See also: unmute")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public async Task MuteCmd( + TextCommandContext ctx, [Description("The user you're trying to mute")] DiscordUser targetUser, + [RemainingText, Description("Combined argument for the time and reason for the mute. For example '1h rule 7' or 'rule 10'")] string timeAndReason = "No reason specified." + ) + { + DiscordMember targetMember = default; + try + { + targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); + } + catch (DSharpPlus.Exceptions.NotFoundException) + { + // is this worth logging? + } + + if (targetMember != default && ((await GetPermLevelAsync(ctx.Member))) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) + { + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); + return; + } + + await ctx.Message.DeleteAsync(); + bool timeParsed = false; + + TimeSpan muteDuration = default; + string possibleTime = timeAndReason.Split(' ').First(); + string reason = timeAndReason; + + try + { + muteDuration = HumanDateParser.HumanDateParser.Parse(possibleTime).Subtract(ctx.Message.Timestamp.DateTime); + timeParsed = true; + } + catch + { + // keep default + } + + if (timeParsed) + { + int i = reason.IndexOf(" ") + 1; + reason = reason[i..]; + } + + if (timeParsed && possibleTime == reason) + reason = "No reason specified."; + + _ = MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true); + } + } +} \ No newline at end of file diff --git a/Commands/Mutes.cs b/Commands/Mutes.cs deleted file mode 100644 index 9f8116d0..00000000 --- a/Commands/Mutes.cs +++ /dev/null @@ -1,161 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Mutes : BaseCommandModule - { - [Command("unmute")] - [Aliases("umute")] - [Description("Unmutes a previously muted user, typically ahead of the standard expiration time. See also: mute")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task UnmuteCmd(CommandContext ctx, [Description("The user you're trying to unmute.")] DiscordUser targetUser, string reason = "No reason provided.") - { - reason = $"[Manual unmute by {DiscordHelpers.UniqueUsername(ctx.User)}]: {reason}"; - - // todo: store per-guild - DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); - DiscordRole tqsMutedRole = default; - if (Program.cfgjson.TqsMutedRole != 0) - tqsMutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.TqsMutedRole); - - DiscordMember member = default; - try - { - member = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException ex) - { - Program.discord.Logger.LogWarning(eventId: Program.CliptokEventID, exception: ex, message: "Failed to unmute {user} in {server} because they weren't in the server.", $"{DiscordHelpers.UniqueUsername(targetUser)}", ctx.Guild.Name); - } - - if ((await Program.db.HashExistsAsync("mutes", targetUser.Id)) || (member != default && (member.Roles.Contains(mutedRole) || member.Roles.Contains(tqsMutedRole)))) - { - await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Information} Successfully unmuted **{DiscordHelpers.UniqueUsername(targetUser)}**."); - } - else - try - { - await MuteHelpers.UnmuteUserAsync(targetUser, reason, true, ctx.User); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Warning} According to Discord that user is not muted, but I tried to unmute them anyway. Hope it works."); - } - catch (Exception e) - { - Program.discord.Logger.LogError(e, "An error occurred unmuting {user}", targetUser.Id); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That user doesn't appear to be muted, *and* an error occurred while attempting to unmute them anyway. Please contact the bot owner, the error has been logged."); - } - } - - [Command("mute")] - [Description("Mutes a user, preventing them from sending messages until they're unmuted. See also: unmute")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task MuteCmd( - CommandContext ctx, [Description("The user you're trying to mute")] DiscordUser targetUser, - [RemainingText, Description("Combined argument for the time and reason for the mute. For example '1h rule 7' or 'rule 10'")] string timeAndReason = "No reason specified." - ) - { - DiscordMember targetMember = default; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException) - { - // is this worth logging? - } - - if (targetMember != default && ((await GetPermLevelAsync(ctx.Member))) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); - return; - } - - await ctx.Message.DeleteAsync(); - bool timeParsed = false; - - TimeSpan muteDuration = default; - string possibleTime = timeAndReason.Split(' ').First(); - string reason = timeAndReason; - - try - { - muteDuration = HumanDateParser.HumanDateParser.Parse(possibleTime).Subtract(ctx.Message.Timestamp.DateTime); - timeParsed = true; - } - catch - { - // keep default - } - - if (timeParsed) - { - int i = reason.IndexOf(" ") + 1; - reason = reason[i..]; - } - - if (timeParsed && possibleTime == reason) - reason = "No reason specified."; - - _ = MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true); - } - - [Command("tqsmute")] - [Description( - "Temporarily mutes a user, preventing them from sending messages in #tech-support and related channels until they're unmuted.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] - public async Task TqsMuteCmd( - CommandContext ctx, [Description("The user to mute")] DiscordUser targetUser, - [RemainingText, Description("The reason for the mute")] string reason = "No reason specified.") - { - if (Program.cfgjson.TqsMutedRole == 0) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} TQS mutes are not configured, so this command does nothing. Please contact the bot maintainer if this is unexpected."); - return; - } - - // Only allow usage in #tech-support, #tech-support-forum, and their threads - if (ctx.Channel.Id != Program.cfgjson.TechSupportChannel && - ctx.Channel.Id != Program.cfgjson.SupportForumId && - ctx.Channel.Parent.Id != Program.cfgjson.TechSupportChannel && - ctx.Channel.Parent.Id != Program.cfgjson.SupportForumId) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command can only be used in <#{Program.cfgjson.TechSupportChannel}>, <#{Program.cfgjson.SupportForumId}>, and threads in those channels!"); - return; - } - - // Check if the user is already muted; disallow TQS-mute if so - - DiscordRole mutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.MutedRole); - DiscordRole tqsMutedRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.TqsMutedRole); - - // Get member - DiscordMember targetMember = default; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - } - catch (DSharpPlus.Exceptions.NotFoundException) - { - // blah - } - - if (await Program.db.HashExistsAsync("mutes", targetUser.Id) || (targetMember != default && (targetMember.Roles.Contains(mutedRole) || targetMember.Roles.Contains(tqsMutedRole)))) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, that user is already muted."); - return; - } - - // Check if user to be muted is staff or TQS, and disallow if so - if (targetMember != default && (await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TechnicalQueriesSlayer && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TechnicalQueriesSlayer || targetMember.IsBot)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, you cannot mute other TQS or staff members."); - return; - } - - await ctx.Message.DeleteAsync(); - - // mute duration is static for TQS mutes - TimeSpan muteDuration = TimeSpan.FromHours(Program.cfgjson.TqsMuteDurationHours); - - MuteHelpers.MuteUserAsync(targetUser, reason, ctx.User.Id, ctx.Guild, ctx.Channel, muteDuration, true, true); - } - } -} diff --git a/Commands/InteractionCommands/NicknameLockInteraction.cs b/Commands/NicknameLockCmds.cs similarity index 69% rename from Commands/InteractionCommands/NicknameLockInteraction.cs rename to Commands/NicknameLockCmds.cs index 3c7e3de0..7f948483 100644 --- a/Commands/InteractionCommands/NicknameLockInteraction.cs +++ b/Commands/NicknameLockCmds.cs @@ -1,19 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - public class NicknameLockInteraction : ApplicationCommandModule + public class NicknameLockCmds { - [SlashCommandGroup("nicknamelock", "Prevent a member from changing their nickname.", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ManageNicknames)] + [Command("nicknamelock")] + [Description("Prevent a member from changing their nickname.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ManageNicknames)] public class NicknameLockSlashCommands { - [SlashCommand("enable", "Prevent a member from changing their nickname.")] - public async Task NicknameLockEnableSlashCmd(InteractionContext ctx, [Option("member", "The member to nickname lock.")] DiscordUser discordUser, [Option("nickname", "The nickname to use. Will use current nickname if not set.")] string nickname = "") + [Command("enable")] + [Description("Prevent a member from changing their nickname.")] + public async Task NicknameLockEnableSlashCmd(SlashCommandContext ctx, [Parameter("member"), Description("The member to nickname lock.")] DiscordUser discordUser, [Parameter("nickname"), Description("The nickname to use. Will use current nickname if not set.")] string nickname = "") { DiscordMember member = default; @@ -44,8 +41,9 @@ public class NicknameLockSlashCommands } } - [SlashCommand("disable", "Allow a member to change their nickname again.")] - public async Task NicknameLockDisableSlashCmd(InteractionContext ctx, [Option("member", "The member to remove the nickname lock for.")] DiscordUser discordUser) + [Command("disable")] + [Description("Allow a member to change their nickname again.")] + public async Task NicknameLockDisableSlashCmd(SlashCommandContext ctx, [Parameter("member"), Description("The member to remove the nickname lock for.")] DiscordUser discordUser) { DiscordMember member = default; @@ -74,8 +72,9 @@ public class NicknameLockSlashCommands } } - [SlashCommand("status", "Check the status of nickname lock for a member.")] - public async Task NicknameLockStatusSlashCmd(InteractionContext ctx, [Option("member", "The member whose nickname lock status to check.")] DiscordUser discordUser) + [Command("status")] + [Description("Check the status of nickname lock for a member.")] + public async Task NicknameLockStatusSlashCmd(SlashCommandContext ctx, [Parameter("member"), Description("The member whose nickname lock status to check.")] DiscordUser discordUser) { if ((await Program.db.HashGetAsync("nicknamelock", discordUser.Id)).HasValue) await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} {discordUser.Mention} is nickname locked.", mentions: false); @@ -84,5 +83,4 @@ public class NicknameLockSlashCommands } } } - -} +} \ No newline at end of file diff --git a/Commands/Raidmode.cs b/Commands/Raidmode.cs deleted file mode 100644 index 0da42988..00000000 --- a/Commands/Raidmode.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Raidmode : BaseCommandModule - { - [Group("clipraidmode")] - [Description("Manage the server's raidmode, preventing joins while on.")] - [RequireHomeserverPerm(ServerPermLevel.Moderator)] - class RaidmodeCommands : BaseCommandModule - { - [GroupCommand] - [Description("Check whether raidmode is enabled or not, and when it ends.")] - [Aliases("status")] - public async Task RaidmodeStatus(CommandContext ctx) - { - if (Program.db.HashExists("raidmode", ctx.Guild.Id)) - { - string output = $"{Program.cfgjson.Emoji.On} Raidmode is currently **enabled**."; - ulong expirationTimeUnix = (ulong)Program.db.HashGet("raidmode", ctx.Guild.Id); - output += $"\nRaidmode ends "; - await ctx.RespondAsync(output); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Banned} Raidmode is currently **disabled**."); - } - } - - [Command("on")] - [Description("Enable raidmode.")] - public async Task RaidmodeOn(CommandContext ctx, [Description("The amount of time to keep raidmode enabled for. Default is 3 hours.")] string duration = default) - { - if (Program.db.HashExists("raidmode", ctx.Guild.Id)) - { - string output = $"{Program.cfgjson.Emoji.On} Raidmode is already **enabled**."; - - ulong expirationTimeUnix = (ulong)Program.db.HashGet("raidmode", ctx.Guild.Id); - output += $"\nRaidmode ends "; - await ctx.RespondAsync(output); - } - else - { - DateTime parsedExpiration; - - if (duration == default) - parsedExpiration = DateTime.Now.AddHours(3); - else - parsedExpiration = HumanDateParser.HumanDateParser.Parse(duration); - - long unixExpiration = TimeHelpers.ToUnixTimestamp(parsedExpiration); - Program.db.HashSet("raidmode", ctx.Guild.Id, unixExpiration); - - await ctx.RespondAsync($"{Program.cfgjson.Emoji.On} Raidmode is now **enabled** and will end ."); - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.On} Raidmode was **enabled** by {ctx.User.Mention} and ends .") - .WithAllowedMentions(Mentions.None) - ); - } - } - - [Command("off")] - [Description("Disable raidmode.")] - public async Task RaidmdodeOff(CommandContext ctx) - { - if (Program.db.HashExists("raidmode", ctx.Guild.Id)) - { - long expirationTimeUnix = (long)Program.db.HashGet("raidmode", ctx.Guild.Id); - Program.db.HashDelete("raidmode", ctx.Guild.Id); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} Raidmode is now **disabled**.\nIt was supposed to end ."); - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Off} Raidmode was **disabled** by {ctx.User.Mention}.\nIt was supposed to end .") - .WithAllowedMentions(Mentions.None) - ); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Off} Raidmode is already **disabled**."); - } - } - } - } -} diff --git a/Commands/InteractionCommands/RaidmodeInteractions.cs b/Commands/RaidmodeCmds.cs similarity index 70% rename from Commands/InteractionCommands/RaidmodeInteractions.cs rename to Commands/RaidmodeCmds.cs index 89763642..3ee3d2b1 100644 --- a/Commands/InteractionCommands/RaidmodeInteractions.cs +++ b/Commands/RaidmodeCmds.cs @@ -1,15 +1,26 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - internal class RaidmodeInteractions : ApplicationCommandModule + public class RaidmodeCmds { - [SlashCommandGroup("raidmode", "Commands relating to Raidmode", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.Moderator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public class RaidmodeSlashCommands : ApplicationCommandModule + [Command("raidmode")] + [TextAlias("clipraidmode")] + [Description("Commands relating to Raidmode")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public class RaidmodeSlashCommands { - [SlashCommand("status", "Check the current state of raidmode.")] - public async Task RaidmodeStatus(InteractionContext ctx) + [DefaultGroupCommand] + [Command("status")] + [Description("Check the current state of raidmode.")] + public async Task RaidmodeStatus(CommandContext ctx) { + // Avoid conflicts with Modmail's !raidmode + if (ctx is TextCommandContext + && !ctx.As().Message.Content.Contains("clipraidmode") + && !ctx.As().Message.Content.Contains("c!raidmode")) + return; + if (Program.db.HashExists("raidmode", ctx.Guild.Id)) { string output = $"Raidmode is currently **enabled**."; @@ -30,12 +41,19 @@ public async Task RaidmodeStatus(InteractionContext ctx) } - [SlashCommand("on", "Enable raidmode. Defaults to 3 hour length if not specified.")] - public async Task RaidmodeOnSlash(InteractionContext ctx, - [Option("duration", "How long to keep raidmode enabled for.")] string duration = default, - [Option("allowed_account_age", "How old an account can be to be allowed to bypass raidmode. Relative to right now.")] string allowedAccountAge = "" + [Command("on")] + [Description("Enable raidmode. Defaults to 3 hour length if not specified.")] + public async Task RaidmodeOnSlash(CommandContext ctx, + [Parameter("duration"), Description("How long to keep raidmode enabled for.")] string duration = default, + [Parameter("allowed_account_age"), Description("How old an account can be to be allowed to bypass raidmode. Relative to right now.")] string allowedAccountAge = "" ) { + // Avoid conflicts with Modmail's !raidmode + if (ctx is TextCommandContext + && !ctx.As().Message.Content.Contains("clipraidmode") + && !ctx.As().Message.Content.Contains("c!raidmode")) + return; + if (Program.db.HashExists("raidmode", ctx.Guild.Id)) { string output = $"Raidmode is already **enabled**."; @@ -96,9 +114,16 @@ public async Task RaidmodeOnSlash(InteractionContext ctx, } } - [SlashCommand("off", "Disable raidmode immediately.")] - public async Task RaidmodeOffSlash(InteractionContext ctx) + [Command("off")] + [Description("Disable raidmode immediately.")] + public async Task RaidmodeOffSlash(CommandContext ctx) { + // Avoid conflicts with Modmail's !raidmode + if (ctx is TextCommandContext + && !ctx.As().Message.Content.Contains("clipraidmode") + && !ctx.As().Message.Content.Contains("c!raidmode")) + return; + if (Program.db.HashExists("raidmode", ctx.Guild.Id)) { long expirationTimeUnix = (long)Program.db.HashGet("raidmode", ctx.Guild.Id); @@ -123,6 +148,5 @@ public async Task RaidmodeOffSlash(InteractionContext ctx) } } } - } -} +} \ No newline at end of file diff --git a/Commands/Reminders.cs b/Commands/Reminders.cs deleted file mode 100644 index c152e95a..00000000 --- a/Commands/Reminders.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace Cliptok.Commands -{ - public class Reminders : BaseCommandModule - { - public class Reminder - { - [JsonProperty("userID")] - public ulong UserID { get; set; } - - [JsonProperty("channelID")] - public ulong ChannelID { get; set; } - - [JsonProperty("messageID")] - public ulong MessageID { get; set; } - - [JsonProperty("messageLink")] - public string MessageLink { get; set; } - - [JsonProperty("reminderText")] - public string ReminderText { get; set; } - - [JsonProperty("reminderTime")] - public DateTime ReminderTime { get; set; } - - [JsonProperty("originalTime")] - public DateTime OriginalTime { get; set; } - } - - [Command("remindme")] - [Description("Set a reminder for yourself. Example: !reminder 1h do the thing")] - [Aliases("reminder", "rember", "wemember", "remember", "remind")] - [RequireHomeserverPerm(ServerPermLevel.Tier4, WorkOutside = true)] - public async Task RemindMe( - CommandContext ctx, - [Description("The amount of time to wait before reminding you. For example: 2s, 5m, 1h, 1d")] string timetoParse, - [RemainingText, Description("The text to send when the reminder triggers.")] string reminder - ) - { - DateTime t = HumanDateParser.HumanDateParser.Parse(timetoParse); - if (t <= DateTime.Now) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time can't be in the past!"); - return; - } -#if !DEBUG - else if (t < (DateTime.Now + TimeSpan.FromSeconds(59))) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time must be at least a minute in the future!"); - return; - } -#endif - string guildId; - - if (ctx.Channel.IsPrivate) - guildId = "@me"; - else - guildId = ctx.Guild.Id.ToString(); - - var reminderObject = new Reminder() - { - UserID = ctx.User.Id, - ChannelID = ctx.Channel.Id, - MessageID = ctx.Message.Id, - MessageLink = $"https://discord.com/channels/{guildId}/{ctx.Channel.Id}/{ctx.Message.Id}", - ReminderText = reminder, - ReminderTime = t, - OriginalTime = DateTime.Now - }; - - await Program.db.ListRightPushAsync("reminders", JsonConvert.SerializeObject(reminderObject)); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()"); // (In roughly **{TimeHelpers.TimeToPrettyFormat(t.Subtract(ctx.Message.Timestamp.DateTime), false)}**)"); - } - - } -} diff --git a/Commands/RoleCmds.cs b/Commands/RoleCmds.cs new file mode 100644 index 00000000..6c6e59ec --- /dev/null +++ b/Commands/RoleCmds.cs @@ -0,0 +1,430 @@ +namespace Cliptok.Commands +{ + public class RoleCmds + { + [Command("grant")] + [Description("Grant a user Tier 1, bypassing any verification requirements.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task Grant(CommandContext ctx, [Parameter("user"), Description("The user to grant Tier 1 to.")] DiscordUser _) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} This command is deprecated and no longer works. Please right click (or tap and hold on mobile) the user and click \"Verify Member\" if available."); + } + + [HomeServer] + [Command("roles")] + [Description("Opt in/out of roles.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + internal class RoleSlashCommands + { + [Command("grant")] + [Description("Opt into a role.")] + public async Task GrantRole( + SlashCommandContext ctx, + [SlashAutoCompleteProvider(typeof(RolesAutocompleteProvider))] + [Parameter("role"), Description("The role to opt into.")] string role) + { + DiscordMember member = ctx.Member; + + ulong roleId = role switch + { + "insiderCanary" => Program.cfgjson.UserRoles.InsiderCanary, + "insiderDev" => Program.cfgjson.UserRoles.InsiderDev, + "insiderBeta" => Program.cfgjson.UserRoles.InsiderBeta, + "insiderRP" => Program.cfgjson.UserRoles.InsiderRP, + "insider10RP" => Program.cfgjson.UserRoles.Insider10RP, + "patchTuesday" => Program.cfgjson.UserRoles.PatchTuesday, + "giveaways" => Program.cfgjson.UserRoles.Giveaways, + "cts" => Program.cfgjson.CommunityTechSupportRoleID, + _ => 0 + }; + + if (roleId == 0) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Invalid role! Please choose from the list.", ephemeral: true); + return; + } + + if (roleId == Program.cfgjson.CommunityTechSupportRoleID && await GetPermLevelAsync(ctx.Member) < ServerPermLevel.TechnicalQueriesSlayer) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.NoPermissions} You must be a TQS member to get the CTS role!", ephemeral: true); + return; + } + + var roleData = await ctx.Guild.GetRoleAsync(roleId); + + await member.GrantRoleAsync(roleData, $"/roles grant used by {DiscordHelpers.UniqueUsername(ctx.User)}"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} The role {roleData.Mention} has been successfully granted!", ephemeral: true, mentions: false); + } + + [Command("remove")] + [Description("Opt out of a role.")] + public async Task RemoveRole( + SlashCommandContext ctx, + [SlashAutoCompleteProvider(typeof(RolesAutocompleteProvider))] + [Parameter("role"), Description("The role to opt out of.")] string role) + { + DiscordMember member = ctx.Member; + + ulong roleId = role switch + { + "insiderCanary" => Program.cfgjson.UserRoles.InsiderCanary, + "insiderDev" => Program.cfgjson.UserRoles.InsiderDev, + "insiderBeta" => Program.cfgjson.UserRoles.InsiderBeta, + "insiderRP" => Program.cfgjson.UserRoles.InsiderRP, + "insider10RP" => Program.cfgjson.UserRoles.Insider10RP, + "patchTuesday" => Program.cfgjson.UserRoles.PatchTuesday, + "giveaways" => Program.cfgjson.UserRoles.Giveaways, + "cts" => Program.cfgjson.CommunityTechSupportRoleID, + _ => 0 + }; + + if (roleId == 0) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Invalid role! Please choose from the list.", ephemeral: true); + return; + } + + var roleData = await ctx.Guild.GetRoleAsync(roleId); + + await member.RevokeRoleAsync(roleData, $"/roles remove used by {DiscordHelpers.UniqueUsername(ctx.User)}"); + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} The role {roleData.Mention} has been successfully removed!", ephemeral: true, mentions: false); + } + } + + internal class RolesAutocompleteProvider : IAutoCompleteProvider + { + public async ValueTask> AutoCompleteAsync(AutoCompleteContext ctx) + { + Dictionary options = new() + { + { "Windows 11 Insiders (Canary)", "insiderCanary" }, + { "Windows 11 Insiders (Dev)", "insiderDev" }, + { "Windows 11 Insiders (Beta)", "insiderBeta" }, + { "Windows 11 Insiders (Release Preview)", "insiderRP" }, + { "Windows 10 Insiders (Release Preview)", "insider10RP" }, + { "Patch Tuesday", "patchTuesday" }, + { "Giveaways", "giveaways" }, + { "Community Tech Support (CTS)", "cts" } + }; + + var memberHasTqs = await GetPermLevelAsync(ctx.Member) >= ServerPermLevel.TechnicalQueriesSlayer; + + List list = new(); + + foreach (var option in options) + { + var focusedOption = ctx.Options.FirstOrDefault(option => option.Focused); + if (focusedOption.Value.ToString() == "" || option.Key.Contains(focusedOption.Value.ToString(), StringComparison.OrdinalIgnoreCase)) + { + if (option.Value == "cts" && !memberHasTqs) continue; + list.Add(new DiscordAutoCompleteChoice(option.Key, option.Value)); + } + } + + return list; + } + } + + public static async Task GiveUserRoleAsync(TextCommandContext ctx, ulong role) + { + await GiveUserRolesAsync(ctx, x => (ulong)x.GetValue(Program.cfgjson.UserRoles, null) == role); + } + + public static async Task GiveUserRolesAsync(TextCommandContext ctx, Func predicate) + { + if (Program.cfgjson.UserRoles is null) + { + // Config hasn't been updated yet. + return; + } + + DiscordGuild guild = await Program.discord.GetGuildAsync(ctx.Guild.Id); + String response = ""; + System.Reflection.PropertyInfo[] roleIds = Program.cfgjson.UserRoles.GetType().GetProperties().Where(predicate).ToArray(); + + for (int i = 0; i < roleIds.Length; i++) + { + // quick patch to exclude giveaways role + if ((ulong)roleIds[i].GetValue(Program.cfgjson.UserRoles, null) == Program.cfgjson.UserRoles.Giveaways) + continue; + + DiscordRole roleToGrant = await guild.GetRoleAsync((ulong)roleIds[i].GetValue(Program.cfgjson.UserRoles, null)); + await ctx.Member.GrantRoleAsync(roleToGrant); + + if (roleIds.Length == 1) + { + response += roleToGrant.Mention; + } + else + { + response += i == roleIds.Length - 1 ? $"and {roleToGrant.Mention}" : $"{roleToGrant.Mention}{(roleIds.Length != 2 ? "," : String.Empty)} "; + } + } + + await ctx.Channel.SendMessageAsync($"{ctx.User.Mention} has joined the {response} role{(roleIds.Length != 1 ? "s" : String.Empty)}."); + } + + public static async Task RemoveUserRoleAsync(TextCommandContext ctx, ulong role) + { + // In case we ever decide to have individual commands to remove roles. + await RemoveUserRolesAsync(ctx, x => (ulong)x.GetValue(Program.cfgjson.UserRoles, null) == role); + } + + public static async Task RemoveUserRolesAsync(TextCommandContext ctx, Func predicate) + { + if (Program.cfgjson.UserRoles is null) + { + // Config hasn't been updated yet. + return; + } + + DiscordGuild guild = await Program.discord.GetGuildAsync(ctx.Guild.Id); + System.Reflection.PropertyInfo[] roleIds = Program.cfgjson.UserRoles.GetType().GetProperties().Where(predicate).ToArray(); + foreach (System.Reflection.PropertyInfo roleId in roleIds) + { + // quick patch to exclude giveaways role + if ((ulong)roleId.GetValue(Program.cfgjson.UserRoles, null) == Program.cfgjson.UserRoles.Giveaways) + continue; + + DiscordRole roleToGrant = await guild.GetRoleAsync((ulong)roleId.GetValue(Program.cfgjson.UserRoles, null)); + await ctx.Member.RevokeRoleAsync(roleToGrant); + } + + await ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, ":CliptokSuccess:")); + } + + [ + Command("swap-insider-rptextcmd"), + TextAlias("swap-insider-rp", "swap-insiders-rp"), + Description("Removes the Windows 11 Insiders (Release Preview) role and replaces it with Windows 10 Insiders (Release Preview) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task SwapInsiderRpCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); + } + + [ + Command("swap-insider-devtextcmd"), + TextAlias("swap-insider-dev", "swap-insiders-dev", "swap-insider-canary", "swap-insiders-canary", "swap-insider-can", "swap-insiders-can"), + AllowedProcessors(typeof(TextCommandProcessor)), + Description("Removes the Windows 11 Insiders (Canary) role and replaces it with Windows 10 Insiders (Dev) role"), + HomeServer, + UserRolesPresent + ] + public async Task SwapInsiderDevCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); + } + + + [ + Command("join-insider-devtextcmd"), + TextAlias("join-insider-dev", "join-insiders-dev"), + Description("Gives you the Windows 11 Insiders (Dev) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinInsiderDevCmd(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); + } + + [ + Command("join-insider-canarytextcmd"), + TextAlias("join-insider-canary", "join-insiders-canary", "join-insider-can", "join-insiders-can"), + Description("Gives you the Windows 11 Insiders (Canary) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinInsiderCanaryCmd(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); + } + + + [ + Command("join-insider-betatextcmd"), + TextAlias("join-insider-beta", "join-insiders-beta"), + Description("Gives you the Windows 11 Insiders (Beta) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinInsiderBetaCmd(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderBeta); + } + + [ + Command("join-insider-rptextcmd"), + TextAlias("join-insider-rp", "join-insiders-rp", "join-insiders-11-rp", "join-insider-11-rp"), + Description("Gives you the Windows 11 Insiders (Release Preview) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinInsiderRPCmd(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); + } + + [ + Command("join-insider-10textcmd"), + TextAlias("join-insider-10", "join-insiders-10"), + Description("Gives you to the Windows 10 Insiders (Release Preview) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinInsiders10Cmd(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); + } + + [ + Command("join-patch-tuesdaytextcmd"), + TextAlias("join-patch-tuesday"), + Description("Gives you the 💻 Patch Tuesday role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task JoinPatchTuesday(TextCommandContext ctx) + { + await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.PatchTuesday); + } + + [ + Command("keep-me-updatedtextcmd"), + TextAlias("keep-me-updated"), + Description("Gives you all opt-in roles"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task KeepMeUpdated(TextCommandContext ctx) + { + await GiveUserRolesAsync(ctx, x => true); + } + + [ + Command("leave-insiderstextcmd"), + TextAlias("leave-insiders", "leave-insider"), + Description("Removes you from Insider roles"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsiders(TextCommandContext ctx) + { + foreach (ulong roleId in new ulong[] { Program.cfgjson.UserRoles.InsiderDev, Program.cfgjson.UserRoles.InsiderBeta, Program.cfgjson.UserRoles.InsiderRP, Program.cfgjson.UserRoles.InsiderCanary, Program.cfgjson.UserRoles.InsiderDev }) + { + await RemoveUserRoleAsync(ctx, roleId); + } + + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Insider} You are no longer receiving Windows Insider notifications. If you ever wish to receive Insider notifications again, you can check the <#740272437719072808> description for the commands."); + var msg = await ctx.GetResponseAsync(); + await Task.Delay(10000); + await msg.DeleteAsync(); + } + + [ + Command("dont-keep-me-updatedtextcmd"), + TextAlias("dont-keep-me-updated"), + Description("Takes away from you all opt-in roles"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task DontKeepMeUpdated(TextCommandContext ctx) + { + await RemoveUserRolesAsync(ctx, x => true); + } + + [ + Command("leave-insider-devtextcmd"), + TextAlias("leave-insider-dev", "leave-insiders-dev"), + Description("Removes the Windows 11 Insiders (Dev) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsiderDevCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); + } + + [ + Command("leave-insider-canarytextcmd"), + TextAlias("leave-insider-canary", "leave-insiders-canary", "leave-insider-can", "leave-insiders-can"), + Description("Removes the Windows 11 Insiders (Canary) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsiderCanaryCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); + } + + [ + Command("leave-insider-betatextcmd"), + TextAlias("leave-insider-beta", "leave-insiders-beta"), + Description("Removes the Windows 11 Insiders (Beta) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsiderBetaCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderBeta); + } + + [ + Command("leave-insider-10textcmd"), + TextAlias("leave-insider-10", "leave-insiders-10"), + Description("Removes the Windows 10 Insiders (Release Preview) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsiderRPCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); + } + + [ + Command("leave-insider-rptextcmd"), + TextAlias("leave-insider-rp", "leave-insiders-rp", "leave-insiders-11-rp", "leave-insider-11-rp"), + Description("Removes the Windows 11 Insiders (Release Preview) role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeaveInsider10RPCmd(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); + } + + [ + Command("leave-patch-tuesdaytextcmd"), + TextAlias("leave-patch-tuesday"), + Description("Removes the 💻 Patch Tuesday role"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + UserRolesPresent + ] + public async Task LeavePatchTuesday(TextCommandContext ctx) + { + await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.PatchTuesday); + } + } +} \ No newline at end of file diff --git a/Commands/InteractionCommands/RulesInteractions.cs b/Commands/RulesCmds.cs similarity index 79% rename from Commands/InteractionCommands/RulesInteractions.cs rename to Commands/RulesCmds.cs index 0cf4aa2d..ecaeaceb 100644 --- a/Commands/InteractionCommands/RulesInteractions.cs +++ b/Commands/RulesCmds.cs @@ -1,16 +1,19 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - public class RulesInteractions : ApplicationCommandModule + public class RulesCmds { [HomeServer] - [SlashCommandGroup("rules", "Misc. commands related to server rules", defaultPermission: true)] + [Command("rules")] + [Description("Misc. commands related to server rules")] + [AllowedProcessors(typeof(SlashCommandProcessor))] internal class RulesSlashCommands { - [SlashCommand("all", "Shows all of the community rules.", defaultPermission: true)] - public async Task RulesAllCommand(InteractionContext ctx, [Option("public", "Whether to show the response publicly.")] bool? isPublic = null) + [Command("all")] + [Description("Shows all of the community rules.")] + public async Task RulesAllCommand(SlashCommandContext ctx, [Parameter("public"), Description("Whether to show the response publicly.")] bool? isPublic = null) { var publicResponse = await DeterminePublicResponse(ctx.Member, ctx.Channel, isPublic); - + List rules = default; try @@ -36,11 +39,14 @@ public async Task RulesAllCommand(InteractionContext ctx, [Option("public", "Whe } - [SlashCommand("rule", "Shows a specific rule.", defaultPermission: true)] - public async Task RuleCommand(InteractionContext ctx, [Option("rule_number", "The rule number to show.")] long ruleNumber, [Option("public", "Whether to show the response publicly.")] bool? isPublic = null) + [Command("rule")] + [Description("Shows a specific rule.")] + public async Task RuleCommand(SlashCommandContext ctx, + [Parameter("rule_number"), Description("The rule number to show.")] long ruleNumber, + [Parameter("public"), Description("Whether to show the response publicly.")] bool? isPublic = null) { - var publicResponse = await DeterminePublicResponse(ctx.Member, ctx.Channel, isPublic); - + var publicResponse = await DeterminePublicResponse(ctx.Member, ctx.Channel, isPublic); + IReadOnlyList rules = default; try @@ -66,11 +72,14 @@ public async Task RuleCommand(InteractionContext ctx, [Option("rule_number", "Th await ctx.RespondAsync(embed: embed, ephemeral: !publicResponse); } - [SlashCommand("search", "Search for a rule by keyword.", defaultPermission: true)] - public async Task RuleSearchCommand(InteractionContext ctx, [Option("keyword", "The keyword to search for.")] string keyword, [Option("public", "Whether to show the response publicly.")] bool? isPublic = null) + [Command("search")] + [Description("Search for a rule by keyword.")] + public async Task RuleSearchCommand(SlashCommandContext ctx, + [Parameter("keyword"), Description("The keyword to search for.")] string keyword, + [Parameter("public"), Description("Whether to show the response publicly.")] bool? isPublic = null) { var publicResponse = await DeterminePublicResponse(ctx.Member, ctx.Channel, isPublic); - + List rules = default; try @@ -102,7 +111,7 @@ public async Task RuleSearchCommand(InteractionContext ctx, [Option("keyword", " await ctx.RespondAsync(embed: embed, ephemeral: !publicResponse); } - + // Returns: true for public response, false for private private async Task DeterminePublicResponse(DiscordMember member, DiscordChannel channel, bool? isPublic) { @@ -110,18 +119,18 @@ private async Task DeterminePublicResponse(DiscordMember member, DiscordCh { if (isPublic is null) return true; - + return isPublic.Value; } - + if (await GetPermLevelAsync(member) >= ServerPermLevel.TrialModerator) { if (isPublic is null) return false; - + return isPublic.Value; } - + return false; } } diff --git a/Commands/SecurityActions.cs b/Commands/SecurityActions.cs deleted file mode 100644 index b1c7851a..00000000 --- a/Commands/SecurityActions.cs +++ /dev/null @@ -1,117 +0,0 @@ -namespace Cliptok.Commands -{ - public class SecurityActions : BaseCommandModule - { - [Command("pausedms")] - [Description("Temporarily pause DMs between server members.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task PauseDMs(CommandContext ctx, [Description("The amount of time to pause DMs for."), RemainingText] string time) - { - if (string.IsNullOrWhiteSpace(time)) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You must provide an amount of time to pause DMs for!"); - return; - } - - // need to make our own api calls because D#+ can't do this natively? - - // parse time from message - DateTime t; - try - { - t = HumanDateParser.HumanDateParser.Parse(time); - } - catch (HumanDateParser.ParseException) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I didn't understand that time! Please try again."); - return; - } - if (t <= DateTime.Now) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time can't be in the past!"); - return; - } - if (t > DateTime.Now.AddHours(24)) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time can't be greater than 24 hours!"); - return; - } - var dmsDisabledUntil = t.ToUniversalTime().ToString("o"); - - // get current security actions to avoid unintentionally resetting invites_disabled_until - var currentActions = await SecurityActionHelpers.GetCurrentSecurityActions(ctx.Guild.Id); - JToken invitesDisabledUntil; - if (currentActions is null || !currentActions.HasValues) - invitesDisabledUntil = null; - else - invitesDisabledUntil = currentActions["invites_disabled_until"]; - - // create json body - var newSecurityActions = JsonConvert.SerializeObject(new - { - invites_disabled_until = invitesDisabledUntil, - dms_disabled_until = dmsDisabledUntil, - }); - - // set actions - var setActionsResponse = await SecurityActionHelpers.SetCurrentSecurityActions(ctx.Guild.Id, newSecurityActions); - - // respond - if (setActionsResponse.IsSuccessStatusCode) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Successfully paused DMs for **{TimeHelpers.TimeToPrettyFormat(t.Subtract(ctx.Message.Timestamp.DateTime), false)}**!"); - else - { - ctx.Client.Logger.LogError("Failed to set Security Actions.\nPayload: {payload}\nResponse: {statuscode} {body}", newSecurityActions.ToString(), (int)setActionsResponse.StatusCode, await setActionsResponse.Content.ReadAsStringAsync()); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Something went wrong and I wasn't able to pause DMs! Discord returned status code `{setActionsResponse.StatusCode}`."); - } - } - - [Command("unpausedms")] - [Description("Unpause DMs between server members.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task UnpauseDMs(CommandContext ctx) - { - // need to make our own api calls because D#+ can't do this natively? - - // get current security actions to avoid unintentionally resetting invites_disabled_until - var currentActions = await SecurityActionHelpers.GetCurrentSecurityActions(ctx.Guild.Id); - JToken dmsDisabledUntil, invitesDisabledUntil; - if (currentActions is null || !currentActions.HasValues) - { - dmsDisabledUntil = null; - invitesDisabledUntil = null; - } - else - { - dmsDisabledUntil = currentActions["dms_disabled_until"]; - invitesDisabledUntil = currentActions["invites_disabled_until"]; - } - - // if dms are already unpaused, return error - if (dmsDisabledUntil is null) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} DMs are already unpaused!"); - return; - } - - // create json body - var newSecurityActions = JsonConvert.SerializeObject(new - { - invites_disabled_until = invitesDisabledUntil, - dms_disabled_until = (object)null, - }); - - // set actions - var setActionsResponse = await SecurityActionHelpers.SetCurrentSecurityActions(ctx.Guild.Id, newSecurityActions); - - // respond - if (setActionsResponse.IsSuccessStatusCode) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Successfully unpaused DMs!"); - else - { - ctx.Client.Logger.LogError("Failed to set Security Actions.\nPayload: {payload}\nResponse: {statuscode} {body}", newSecurityActions.ToString(), (int)setActionsResponse.StatusCode, await setActionsResponse.Content.ReadAsStringAsync()); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Something went wrong and I wasn't able to unpause DMs! Discord returned status code `{setActionsResponse.StatusCode}`."); - } - } - } -} \ No newline at end of file diff --git a/Commands/InteractionCommands/SecurityActionInteractions.cs b/Commands/SecurityActionsCmds.cs similarity index 82% rename from Commands/InteractionCommands/SecurityActionInteractions.cs rename to Commands/SecurityActionsCmds.cs index 43c2014c..7bbaa901 100644 --- a/Commands/InteractionCommands/SecurityActionInteractions.cs +++ b/Commands/SecurityActionsCmds.cs @@ -1,10 +1,12 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - public class SecurityActionInteractions : ApplicationCommandModule + public class SecurityActionsCmds { - [SlashCommand("pausedms", "Temporarily pause DMs between server members.", defaultPermission: false)] - [HomeServer, SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task SlashPauseDMs(InteractionContext ctx, [Option("time", "The amount of time to pause DMs for. Cannot be greater than 24 hours.")] string time) + [Command("pausedms")] + [Description("Temporarily pause DMs between server members.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task SlashPauseDMs(CommandContext ctx, [Parameter("time"), Description("The amount of time to pause DMs for. Cannot be greater than 24 hours.")] string time) { // need to make our own api calls because D#+ can't do this natively? @@ -50,9 +52,11 @@ public async Task SlashPauseDMs(InteractionContext ctx, [Option("time", "The amo } } - [SlashCommand("unpausedms", "Unpause DMs between server members.", defaultPermission: false)] - [HomeServer, SlashRequireHomeserverPerm(ServerPermLevel.Moderator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] - public async Task SlashUnpauseDMs(InteractionContext ctx) + [Command("unpausedms")] + [Description("Unpause DMs between server members.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + [HomeServer, RequireHomeserverPerm(ServerPermLevel.Moderator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task SlashUnpauseDMs(CommandContext ctx) { // need to make our own api calls because D#+ can't do this natively? diff --git a/Commands/InteractionCommands/SlowmodeInteractions.cs b/Commands/SlowmodeCmds.cs similarity index 80% rename from Commands/InteractionCommands/SlowmodeInteractions.cs rename to Commands/SlowmodeCmds.cs index 0c6c1509..6b15256a 100644 --- a/Commands/InteractionCommands/SlowmodeInteractions.cs +++ b/Commands/SlowmodeCmds.cs @@ -1,14 +1,16 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - internal class SlowmodeInteractions : ApplicationCommandModule + public class SlowmodeCmds { - [SlashCommand("slowmode", "Slow down the channel...", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator)] - [SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] + [Command("slowmode")] + [Description("Slow down the channel...")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] public async Task SlowmodeSlashCommand( - InteractionContext ctx, - [Option("slow_time", "Allowed time between each users messages. 0 for off. A number of seconds or a parseable time.")] string timeToParse, - [Option("channel", "The channel to slow down, if not the current one.")] DiscordChannel channel = default + SlashCommandContext ctx, + [Parameter("slow_time"), Description("Allowed time between each users messages. 0 for off. A number of seconds or a parseable time.")] string timeToParse, + [Parameter("channel"), Description("The channel to slow down, if not the current one.")] DiscordChannel channel = default ) { if (channel == default) @@ -64,13 +66,12 @@ await ctx.RespondAsync($"{Program.cfgjson.Emoji.ClockTime} Slowmode has been set }; embed.WithFooter(Program.discord.CurrentUser.Username, Program.discord.CurrentUser.AvatarUrl) .AddField("Message", ex.Message); - if (ex is ArgumentException) + if (ex is ArgumentException or DSharpPlus.Commands.Exceptions.ArgumentParseException) embed.AddField("Note", "This usually means that you used the command incorrectly.\n" + "Please double-check how to use this command."); await ctx.RespondAsync(embed: embed.Build(), ephemeral: true).ConfigureAwait(false); } } } - } -} +} \ No newline at end of file diff --git a/Commands/StatusCmds.cs b/Commands/StatusCmds.cs new file mode 100644 index 00000000..66758b57 --- /dev/null +++ b/Commands/StatusCmds.cs @@ -0,0 +1,50 @@ +namespace Cliptok.Commands +{ + internal class StatusCmds + { + [Command("status")] + [Description("Status commands")] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + + public class StatusSlashCommands + { + + [Command("set")] + [Description("Set Cliptoks status.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public async Task StatusSetCommand( + SlashCommandContext ctx, + [Parameter("text"), Description("The text to use for the status.")] string statusText, + [Parameter("type"), Description("Defaults to custom. The type of status to use.")] DiscordActivityType statusType = DiscordActivityType.Custom + ) + { + if (statusText.Length > 128) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Status messages must be less than 128 characters."); + } + + await Program.db.StringSetAsync("config:status", statusText); + await Program.db.StringSetAsync("config:status_type", (long)statusType); + + await ctx.Client.UpdateStatusAsync(new DiscordActivity(statusText, statusType)); + + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} Status has been updated!\nType: `{statusType.ToString()}`\nText: `{statusText}`"); + } + + [Command("clear")] + [Description("Clear Cliptoks status.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public async Task StatusClearCommand(SlashCommandContext ctx) + { + await Program.db.KeyDeleteAsync("config:status"); + await Program.db.KeyDeleteAsync("config:status_type"); + + await ctx.Client.UpdateStatusAsync(new DiscordActivity()); + + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Deleted} Status has been cleared!"); + } + + } + } +} diff --git a/Commands/TechSupport.cs b/Commands/TechSupport.cs deleted file mode 100644 index 6a73ceb9..00000000 --- a/Commands/TechSupport.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Cliptok.Commands -{ - internal class TechSupport : BaseCommandModule - { - [Command("ask")] - [Description("Outputs information on how and where to ask tech support questions. Replying to a message while triggering the command will mirror the reply in the response.")] - [HomeServer] - public async Task AskCmd(CommandContext ctx, [Description("Optional, a user to ping with the information")] DiscordUser user = default) - { - await ctx.Message.DeleteAsync(); - DiscordEmbedBuilder embed = new DiscordEmbedBuilder() - .WithColor(13920845); - if (ctx.Channel.Id == Program.cfgjson.TechSupportChannel || ctx.Channel.ParentId == Program.cfgjson.SupportForumId) - { - embed.Title = "**__Need help?__**"; - embed.Description = $"You are in the right place! Please state your question with *plenty of detail* and mention the <@&{Program.cfgjson.CommunityTechSupportRoleID}> role and someone may be able to help you.\n\n" + - $"Details includes error codes and other specific information."; - } - else - { - embed.Title = "**__Need Help Or Have a Problem?__**"; - embed.Description = $"You're probably looking for <#{Program.cfgjson.TechSupportChannel}> or <#{Program.cfgjson.SupportForumId}>!\n\n" + - $"Once there, please be sure to provide **plenty of details**, ping the <@&{Program.cfgjson.CommunityTechSupportRoleID}> role, and *be patient!*\n\n" + - $"Look under the `🔧 Support` category for the appropriate channel for your issue. See <#413274922413195275> for more info."; - } - - if (user != default) - { - await ctx.Channel.SendMessageAsync(user.Mention, embed); - } - else if (ctx.Message.ReferencedMessage is not null) - { - var messageBuild = new DiscordMessageBuilder() - .AddEmbed(embed) - .WithReply(ctx.Message.ReferencedMessage.Id, mention: true); - - await ctx.Channel.SendMessageAsync(messageBuild); - } - else - { - await ctx.Channel.SendMessageAsync(embed); - } - } - - } -} diff --git a/Commands/TechSupportCmds.cs b/Commands/TechSupportCmds.cs new file mode 100644 index 00000000..0c0a2eef --- /dev/null +++ b/Commands/TechSupportCmds.cs @@ -0,0 +1,126 @@ +using Cliptok.Constants; + +namespace Cliptok.Commands +{ + internal class TechSupportCmds + { + [Command("vcredist")] + [Description("Outputs download URLs for the specified Visual C++ Redistributables version")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public async Task RedistsCommand( + SlashCommandContext ctx, + + [SlashChoiceProvider(typeof(VcRedistChoiceProvider))] + [Parameter("version"), Description("Visual Studio version number or year")] long version + ) + { + VcRedist redist = VcRedistConstants.VcRedists + .First((e) => + { + return version == e.Version; + }); + + DiscordEmbedBuilder embed = new DiscordEmbedBuilder() + .WithTitle($"Visual C++ {redist.Year}{(redist.Year == 2015 ? "+" : "")} Redistributables (version {redist.Version})") + .WithFooter("The above links are official and safe to download.") + .WithColor(new("7160e8")); + + foreach (var url in redist.DownloadUrls) + { + embed.AddField($"{url.Key.ToString("G")}", $"{url.Value}"); + } + + await ctx.RespondAsync(null, embed.Build(), false); + } + + [Command("asktextcmd")] + [TextAlias("ask")] + [Description("Outputs information on how and where to ask tech support questions. Replying to a message while triggering the command will mirror the reply in the response.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer] + public async Task AskCmd(TextCommandContext ctx, [Description("Optional, a user to ping with the information")] DiscordUser user = default) + { + await ctx.Message.DeleteAsync(); + DiscordEmbedBuilder embed = new DiscordEmbedBuilder() + .WithColor(13920845); + if (ctx.Channel.Id == Program.cfgjson.TechSupportChannel || ctx.Channel.ParentId == Program.cfgjson.SupportForumId) + { + embed.Title = "**__Need help?__**"; + embed.Description = $"You are in the right place! Please state your question with *plenty of detail* and mention the <@&{Program.cfgjson.CommunityTechSupportRoleID}> role and someone may be able to help you.\n\n" + + $"Details includes error codes and other specific information."; + } + else + { + embed.Title = "**__Need Help Or Have a Problem?__**"; + embed.Description = $"You're probably looking for <#{Program.cfgjson.TechSupportChannel}> or <#{Program.cfgjson.SupportForumId}>!\n\n" + + $"Once there, please be sure to provide **plenty of details**, ping the <@&{Program.cfgjson.CommunityTechSupportRoleID}> role, and *be patient!*\n\n" + + $"Look under the `🔧 Support` category for the appropriate channel for your issue. See <#413274922413195275> for more info."; + } + + if (user != default) + { + await ctx.Channel.SendMessageAsync(user.Mention, embed); + } + else if (ctx.Message.ReferencedMessage is not null) + { + var messageBuild = new DiscordMessageBuilder() + .AddEmbed(embed) + .WithReply(ctx.Message.ReferencedMessage.Id, mention: true); + + await ctx.Channel.SendMessageAsync(messageBuild); + } + else + { + await ctx.Channel.SendMessageAsync(embed); + } + } + + [Command("on-call")] + [Description("Give yourself the CTS role.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer] + [RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] + public async Task OnCallCommand(CommandContext ctx) + { + var ctsRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.CommunityTechSupportRoleID); + await ctx.Member.GrantRoleAsync(ctsRole, "Used !on-call"); + await ctx.RespondAsync(new DiscordMessageBuilder().AddEmbed(new DiscordEmbedBuilder() + .WithTitle($"{Program.cfgjson.Emoji.On} Received Community Tech Support Role") + .WithDescription($"{ctx.User.Mention} is available to help out in **#tech-support**.\n(Use `!off-call` when you're no longer available)") + .WithColor(DiscordColor.Green) + )); + } + + [Command("off-call")] + [Description("Remove the CTS role.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer] + [RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] + public async Task OffCallCommand(CommandContext ctx) + { + var ctsRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.CommunityTechSupportRoleID); + await ctx.Member.RevokeRoleAsync(ctsRole, "Used !off-call"); + await ctx.RespondAsync(new DiscordMessageBuilder().AddEmbed(new DiscordEmbedBuilder() + .WithTitle($"{Program.cfgjson.Emoji.Off} Removed Community Tech Support Role") + .WithDescription($"{ctx.User.Mention} is no longer available to help out in **#tech-support**.\n(Use `!on-call` again when you're available)") + .WithColor(DiscordColor.Red) + )); + } + } + + internal class VcRedistChoiceProvider : IChoiceProvider + { + public async ValueTask> ProvideAsync(CommandParameter _) + { + return new List + { + new("Visual Studio 2015+ - v140", "140"), + new("Visual Studio 2013 - v120", "120"), + new("Visual Studio 2012 - v110", "110"), + new("Visual Studio 2010 - v100", "100"), + new("Visual Studio 2008 - v90", "90"), + new("Visual Studio 2005 - v80", "80") + }; + } + } +} diff --git a/Commands/TechSupportCommands.cs b/Commands/TechSupportCommands.cs deleted file mode 100644 index ed7576a7..00000000 --- a/Commands/TechSupportCommands.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Cliptok.Commands -{ - internal class TechSupportCommands : BaseCommandModule - { - [Command("on-call")] - [Description("Give yourself the CTS role.")] - [RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] - public async Task OnCallCommand(CommandContext ctx) - { - var ctsRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.CommunityTechSupportRoleID); - await ctx.Member.GrantRoleAsync(ctsRole, "Used !on-call"); - await ctx.RespondAsync(new DiscordMessageBuilder().AddEmbed(new DiscordEmbedBuilder() - .WithTitle($"{Program.cfgjson.Emoji.On} Received Community Tech Support Role") - .WithDescription($"{ctx.User.Mention} is available to help out in **#tech-support**.\n(Use `!off-call` when you're no longer available)") - .WithColor(DiscordColor.Green) - )); - } - - [Command("off-call")] - [Description("Remove the CTS role.")] - [RequireHomeserverPerm(ServerPermLevel.TechnicalQueriesSlayer)] - public async Task OffCallCommand(CommandContext ctx) - { - var ctsRole = await ctx.Guild.GetRoleAsync(Program.cfgjson.CommunityTechSupportRoleID); - await ctx.Member.RevokeRoleAsync(ctsRole, "Used !off-call"); - await ctx.RespondAsync(new DiscordMessageBuilder().AddEmbed(new DiscordEmbedBuilder() - .WithTitle($"{Program.cfgjson.Emoji.Off} Removed Community Tech Support Role") - .WithDescription($"{ctx.User.Mention} is no longer available to help out in **#tech-support**.\n(Use `!on-call` again when you're available)") - .WithColor(DiscordColor.Red) - )); - } - } -} diff --git a/Commands/Threads.cs b/Commands/Threads.cs deleted file mode 100644 index dd7fa4ea..00000000 --- a/Commands/Threads.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Threads : BaseCommandModule - { - [Command("archive")] - [Description("Archive the current thread or another thread.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task ArchiveCommand(CommandContext ctx, DiscordChannel channel = default) - { - if (channel == default) - channel = ctx.Channel; - - if (channel.Type is not DiscordChannelType.PrivateThread && channel.Type is not DiscordChannelType.PublicThread && channel.Type is not DiscordChannelType.NewsThread) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {channel.Mention} is not a thread!"); - return; - } - - var thread = (DiscordThreadChannel)channel; - - await thread.ModifyAsync(a => - { - a.IsArchived = true; - a.Locked = false; - }); - } - - [Command("lockthread")] - [Description("Lock the current thread or another thread.")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task LockThreadCommand(CommandContext ctx, DiscordChannel channel = default) - { - if (channel == default) - channel = ctx.Channel; - - if (channel.Type is not DiscordChannelType.PrivateThread && channel.Type is not DiscordChannelType.PublicThread) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {channel.Mention} is not a thread!"); - return; - } - - var thread = (DiscordThreadChannel)channel; - - await thread.ModifyAsync(a => - { - a.IsArchived = true; - a.Locked = true; - }); - } - - [Command("unarchive")] - [Description("Unarchive a thread")] - [HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task UnarchiveCommand(CommandContext ctx, DiscordChannel channel = default) - { - if (channel == default) - channel = ctx.Channel; - - if (channel.Type is not DiscordChannelType.PrivateThread && channel.Type is not DiscordChannelType.PublicThread) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {channel.Mention} is not a thread!"); - return; - } - - var thread = (DiscordThreadChannel)(channel); - - await thread.ModifyAsync(a => - { - a.IsArchived = false; - a.Locked = false; - }); - } - } -} diff --git a/Commands/Timestamp.cs b/Commands/Timestamp.cs deleted file mode 100644 index 47c07e5e..00000000 --- a/Commands/Timestamp.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Timestamp : BaseCommandModule - { - [Group("timestamp")] - [Aliases("ts", "time")] - [Description("Returns various timestamps for a given Discord ID/snowflake")] - [HomeServer] - class TimestampCmds : BaseCommandModule - { - [GroupCommand] - [Aliases("u", "unix", "epoch")] - [Description("Returns the Unix timestamp of a given Discord ID/snowflake")] - public async Task TimestampUnixCmd(CommandContext ctx, [Description("The ID/snowflake to fetch the Unix timestamp for")] ulong snowflake) - { - var msSinceEpoch = snowflake >> 22; - var msUnix = msSinceEpoch + 1420070400000; - await ctx.RespondAsync($"{msUnix / 1000}"); - } - - [Command("relative")] - [Aliases("r")] - [Description("Returns the amount of time between now and a given Discord ID/snowflake")] - public async Task TimestampRelativeCmd(CommandContext ctx, [Description("The ID/snowflake to fetch the relative timestamp for")] ulong snowflake) - { - var msSinceEpoch = snowflake >> 22; - var msUnix = msSinceEpoch + 1420070400000; - await ctx.RespondAsync($"{Program.cfgjson.Emoji.ClockTime} "); - } - - [Command("fulldate")] - [Aliases("f", "datetime")] - [Description("Returns the fully-formatted date and time of a given Discord ID/snowflake")] - public async Task TimestampFullCmd(CommandContext ctx, [Description("The ID/snowflake to fetch the full timestamp for")] ulong snowflake) - { - var msSinceEpoch = snowflake >> 22; - var msUnix = msSinceEpoch + 1420070400000; - await ctx.RespondAsync($"{Program.cfgjson.Emoji.ClockTime} "); - } - - } - - } -} diff --git a/Commands/InteractionCommands/TrackingInteractions.cs b/Commands/TrackingCmds.cs similarity index 81% rename from Commands/InteractionCommands/TrackingInteractions.cs rename to Commands/TrackingCmds.cs index df9ce63c..ebfd0d8e 100644 --- a/Commands/InteractionCommands/TrackingInteractions.cs +++ b/Commands/TrackingCmds.cs @@ -1,15 +1,18 @@ -namespace Cliptok.Commands.InteractionCommands +namespace Cliptok.Commands { - internal class TrackingInteractions : ApplicationCommandModule + internal class TrackingCmds { - [SlashCommandGroup("tracking", "Commands to manage message tracking of users", defaultPermission: false)] - [SlashRequireHomeserverPerm(ServerPermLevel.TrialModerator), SlashCommandPermissions(permissions: DiscordPermission.ModerateMembers)] + [Command("tracking")] + [Description("Commands to manage message tracking of users")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] public class TrackingSlashCommands { - [SlashCommand("add", "Track a users messages.")] - public async Task TrackingAddSlashCmd(InteractionContext ctx, [Option("member", "The member to track.")] DiscordUser discordUser, [Option("channels", "Optional channels to filter to. Use IDs or mentions, and separate with commas or spaces.")] string channels = "") + [Command("add")] + [Description("Track a users messages.")] + public async Task TrackingAddSlashCmd(SlashCommandContext ctx, [Parameter("member"), Description("The member to track.")] DiscordUser discordUser, [Parameter("channels"), Description("Optional channels to filter to. Use IDs or mentions, and separate with commas or spaces.")] string channels = "") { - await ctx.DeferAsync(ephemeral: false); + await ctx.DeferResponseAsync(ephemeral: false); var channelsUpdated = false; @@ -36,7 +39,7 @@ public async Task TrackingAddSlashCmd(InteractionContext ctx, [Option("member", else { // Invalid ID; couldn't parse as ulong - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't parse \"{channel}\" as a channel ID or mention! Please double-check it and try again.")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't parse \"{channel}\" as a channel ID or mention! Please double-check it and try again.")); return; } } @@ -86,11 +89,11 @@ public async Task TrackingAddSlashCmd(InteractionContext ctx, [Option("member", { if (channelsUpdated) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully updated tracking for {discordUser.Mention}!")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully updated tracking for {discordUser.Mention}!")); return; } - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This user is already tracked!")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This user is already tracked!")); return; } @@ -103,7 +106,7 @@ public async Task TrackingAddSlashCmd(InteractionContext ctx, [Option("member", await thread.SendMessageAsync($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in this thread! :eyes:"); thread.AddThreadMemberAsync(ctx.Member); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in {thread.Mention}!")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in {thread.Mention}!")); } else @@ -112,18 +115,19 @@ public async Task TrackingAddSlashCmd(InteractionContext ctx, [Option("member", await Program.db.HashSetAsync("trackingThreads", discordUser.Id, thread.Id); await thread.SendMessageAsync($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in this thread! :eyes:"); await thread.AddThreadMemberAsync(ctx.Member); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in {thread.Mention}!")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.On} Now tracking {discordUser.Mention} in {thread.Mention}!")); } } - [SlashCommand("remove", "Stop tracking a users messages.")] - public async Task TrackingRemoveSlashCmd(InteractionContext ctx, [Option("member", "The member to track.")] DiscordUser discordUser) + [Command("remove")] + [Description("Stop tracking a users messages.")] + public async Task TrackingRemoveSlashCmd(SlashCommandContext ctx, [Parameter("member"), Description("The member to track.")] DiscordUser discordUser) { - await ctx.DeferAsync(ephemeral: false); + await ctx.DeferResponseAsync(ephemeral: false); if (!Program.db.SetContains("trackedUsers", discordUser.Id)) { - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This user is not being tracked.")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} This user is not being tracked.")); return; } @@ -139,7 +143,7 @@ await thread.ModifyAsync(thread => thread.IsArchived = true; }); - await ctx.FollowUpAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Off} No longer tracking {discordUser.Mention}! Thread has been archived for now.")); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Off} No longer tracking {discordUser.Mention}! Thread has been archived for now.")); } } diff --git a/Commands/UserNoteCmds.cs b/Commands/UserNoteCmds.cs new file mode 100644 index 00000000..a1b1a2bd --- /dev/null +++ b/Commands/UserNoteCmds.cs @@ -0,0 +1,249 @@ +using static Cliptok.Helpers.UserNoteHelpers; + +namespace Cliptok.Commands +{ + internal class UserNoteCmds + { + [Command("Show Notes")] + [SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [AllowedProcessors(typeof(UserCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task ShowNotes(UserCommandContext ctx, DiscordUser targetUser) + { + await ctx.RespondAsync(embed: await UserNoteHelpers.GenerateUserNotesEmbedAsync(targetUser), ephemeral: true); + } + + [Command("note")] + [Description("Manage user notes")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public class UserNoteSlashCommands + { + [Command("add")] + [Description("Add a note to a user. Only visible to mods.")] + public async Task AddUserNoteAsync(SlashCommandContext ctx, + [Parameter("user"), Description("The user to add a note for.")] DiscordUser user, + [Parameter("note"), Description("The note to add.")] string noteText, + [Parameter("show_on_modmail"), Description("Whether to show the note when the user opens a modmail thread. Default: true")] bool showOnModmail = true, + [Parameter("show_on_warn"), Description("Whether to show the note when the user is warned. Default: true")] bool showOnWarn = true, + [Parameter("show_all_mods"), Description("Whether to show this note to all mods, versus just yourself. Default: true")] bool showAllMods = true, + [Parameter("show_once"), Description("Whether to show this note once and then discard it. Default: false")] bool showOnce = false, + [Parameter("show_on_join_and_leave"), Description("Whether to show this note when the user joins & leaves. Works like joinwatch. Default: false")] bool showOnJoinAndLeave = false) + { + await ctx.DeferResponseAsync(); + + // Assemble new note + long noteId = Program.db.StringIncrement("totalWarnings"); + UserNote note = new() + { + TargetUserId = user.Id, + ModUserId = ctx.User.Id, + NoteText = noteText, + ShowOnModmail = showOnModmail, + ShowOnWarn = showOnWarn, + ShowAllMods = showAllMods, + ShowOnce = showOnce, + ShowOnJoinAndLeave = showOnJoinAndLeave, + NoteId = noteId, + Timestamp = DateTime.Now, + Type = WarningType.Note + }; + + await Program.db.HashSetAsync(user.Id.ToString(), note.NoteId, JsonConvert.SerializeObject(note)); + + // Log to mod-logs + var embed = await GenerateUserNoteDetailEmbedAsync(note, user); + await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Information} New note for {user.Mention}!", embed); + + // Respond + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully added note!").AsEphemeral()); + } + + [Command("delete")] + [Description("Delete a note.")] + public async Task RemoveUserNoteAsync(SlashCommandContext ctx, + [Parameter("user"), Description("The user whose note to delete.")] DiscordUser user, + [SlashAutoCompleteProvider(typeof(NotesAutocompleteProvider))][Parameter("note"), Description("The note to delete.")] string targetNote) + { + // Get note + UserNote note; + try + { + note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); + } + catch + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); + return; + } + + // If user manually provided an ID of a warning, refuse the request and suggest /delwarn instead + if (note.Type == WarningType.Warning) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/delwarn` instead, or make sure you've got the right note ID.").AsEphemeral()); + return; + } + + // Delete note + await Program.db.HashDeleteAsync(user.Id.ToString(), note.NoteId); + + // Log to mod-logs + var embed = new DiscordEmbedBuilder(await GenerateUserNoteDetailEmbedAsync(note, user)).WithColor(0xf03916); + await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Deleted} Note deleted: `{note.NoteId}` (belonging to {user.Mention}, deleted by {ctx.User.Mention})", embed); + + // Respond + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully deleted note!").AsEphemeral()); + } + + [Command("edit")] + [Description("Edit a note for a user.")] + public async Task EditUserNoteAsync(SlashCommandContext ctx, + [Parameter("user"), Description("The user to edit a note for.")] DiscordUser user, + [SlashAutoCompleteProvider(typeof(NotesAutocompleteProvider))][Parameter("note"), Description("The note to edit.")] string targetNote, + [Parameter("new_text"), Description("The new note text. Leave empty to not change.")] string newNoteText = default, + [Parameter("show_on_modmail"), Description("Whether to show the note when the user opens a modmail thread.")] bool? showOnModmail = null, + [Parameter("show_on_warn"), Description("Whether to show the note when the user is warned.")] bool? showOnWarn = null, + [Parameter("show_all_mods"), Description("Whether to show this note to all mods, versus just yourself.")] bool? showAllMods = null, + [Parameter("show_once"), Description("Whether to show this note once and then discard it.")] bool? showOnce = null, + [Parameter("show_on_join_and_leave"), Description("Whether to show this note when the user joins & leaves. Works like joinwatch.")] bool? showOnJoinAndLeave = null) + { + // Get note + UserNote note; + try + { + note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); + } + catch + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); + return; + } + + // If new text is not provided, use old text + if (newNoteText == default) + newNoteText = note.NoteText; + + // If no changes are made, refuse the request + if (note.NoteText == newNoteText && showOnModmail is null && showOnWarn is null && showAllMods is null && showOnce is null && showOnJoinAndLeave is null) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} You didn't change anything about the note!").AsEphemeral()); + return; + } + + // If user manually provided an ID of a warning, refuse the request and suggest /editwarn instead + if (note.Type == WarningType.Warning) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/editwarn` instead, or make sure you've got the right note ID.").AsEphemeral()); + return; + } + + // For any options the user didn't provide, use options from the note + if (showOnModmail is null) + showOnModmail = note.ShowOnModmail; + if (showOnWarn is null) + showOnWarn = note.ShowOnWarn; + if (showAllMods is null) + showAllMods = note.ShowAllMods; + if (showOnce is null) + showOnce = note.ShowOnce; + if (showOnJoinAndLeave is null) + showOnJoinAndLeave = note.ShowOnJoinAndLeave; + + // Assemble new note + note.ModUserId = ctx.User.Id; + note.NoteText = newNoteText; + note.ShowOnModmail = (bool)showOnModmail; + note.ShowOnWarn = (bool)showOnWarn; + note.ShowAllMods = (bool)showAllMods; + note.ShowOnce = (bool)showOnce; + note.ShowOnJoinAndLeave = (bool)showOnJoinAndLeave; + note.Type = WarningType.Note; + + await Program.db.HashSetAsync(user.Id.ToString(), note.NoteId, JsonConvert.SerializeObject(note)); + + // Log to mod-logs + var embed = await GenerateUserNoteDetailEmbedAsync(note, user); + await LogChannelHelper.LogMessageAsync("mod", $"{Program.cfgjson.Emoji.Information} Note edited: `{note.NoteId}` (belonging to {user.Mention})", embed); + + // Respond + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully edited note!").AsEphemeral()); + } + + [Command("list")] + [Description("List all notes for a user.")] + public async Task ListUserNotesAsync(SlashCommandContext ctx, + [Parameter("user"), Description("The user whose notes to list.")] DiscordUser user, + [Parameter("public"), Description("Whether to show the notes in public chat. Default: false")] bool showPublicly = false) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().AddEmbed(await GenerateUserNotesEmbedAsync(user)).AsEphemeral(!showPublicly)); + } + + [Command("details")] + [Description("Show the details of a specific note for a user.")] + public async Task ShowUserNoteAsync(SlashCommandContext ctx, + [Parameter("user"), Description("The user whose note to show details for.")] DiscordUser user, + [SlashAutoCompleteProvider(typeof(NotesAutocompleteProvider))][Parameter("note"), Description("The note to show.")] string targetNote, + [Parameter("public"), Description("Whether to show the note in public chat. Default: false")] bool showPublicly = false) + { + // Get note + UserNote note; + try + { + note = JsonConvert.DeserializeObject(await Program.db.HashGetAsync(user.Id.ToString(), Convert.ToInt64(targetNote))); + } + catch + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find that note! Make sure you've got the right ID.").AsEphemeral()); + return; + } + + // If user manually provided an ID of a warning, refuse the request and suggest /warndetails instead + if (note.Type == WarningType.Warning) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} That's a warning, not a note! Try using `/warndetails` instead, or make sure you've got the right note ID.").AsEphemeral()); + return; + } + + // Respond + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().AddEmbed(await GenerateUserNoteDetailEmbedAsync(note, user)).AsEphemeral(!showPublicly)); + } + + private class NotesAutocompleteProvider : IAutoCompleteProvider + { + public async ValueTask> AutoCompleteAsync(AutoCompleteContext ctx) + { + var list = new List(); + + var useroption = ctx.Options.FirstOrDefault(x => x.Name == "user"); + if (useroption == default) + { + return list; + } + + var user = await ctx.Client.GetUserAsync((ulong)useroption.Value); + + var notes = Program.db.HashGetAll(user.Id.ToString()) + .Where(x => JsonConvert.DeserializeObject(x.Value).Type == WarningType.Note).ToDictionary( + x => x.Name.ToString(), + x => JsonConvert.DeserializeObject(x.Value) + ).OrderByDescending(x => x.Value.NoteId); + + foreach (var note in notes) + { + if (list.Count >= 25) + break; + + string noteString = $"{StringHelpers.Pad(note.Value.NoteId)} - {StringHelpers.Truncate(note.Value.NoteText, 29, true)} - {TimeHelpers.TimeToPrettyFormat(DateTime.Now - note.Value.Timestamp, true)}"; + + var focusedOption = ctx.Options.FirstOrDefault(option => option.Focused); + if (focusedOption is not null) + if (note.Value.NoteText.Contains((string)focusedOption.Value) || noteString.ToLower().Contains(focusedOption.Value.ToString().ToLower())) + list.Add(new DiscordAutoCompleteChoice(noteString, StringHelpers.Pad(note.Value.NoteId))); + } + + return list; + } + } + } + } +} \ No newline at end of file diff --git a/Commands/UserRoles.cs b/Commands/UserRoles.cs deleted file mode 100644 index 0b874ec7..00000000 --- a/Commands/UserRoles.cs +++ /dev/null @@ -1,269 +0,0 @@ -namespace Cliptok.Commands -{ - [UserRolesPresent] - public class UserRoleCmds : BaseCommandModule - { - public static async Task GiveUserRoleAsync(CommandContext ctx, ulong role) - { - await GiveUserRolesAsync(ctx, x => (ulong)x.GetValue(Program.cfgjson.UserRoles, null) == role); - } - - public static async Task GiveUserRolesAsync(CommandContext ctx, Func predicate) - { - if (Program.cfgjson.UserRoles is null) - { - // Config hasn't been updated yet. - return; - } - - DiscordGuild guild = await Program.discord.GetGuildAsync(ctx.Guild.Id); - String response = ""; - System.Reflection.PropertyInfo[] roleIds = Program.cfgjson.UserRoles.GetType().GetProperties().Where(predicate).ToArray(); - - for (int i = 0; i < roleIds.Length; i++) - { - // quick patch to exclude giveaways role - if ((ulong)roleIds[i].GetValue(Program.cfgjson.UserRoles, null) == Program.cfgjson.UserRoles.Giveaways) - continue; - - DiscordRole roleToGrant = await guild.GetRoleAsync((ulong)roleIds[i].GetValue(Program.cfgjson.UserRoles, null)); - await ctx.Member.GrantRoleAsync(roleToGrant); - - if (roleIds.Length == 1) - { - response += roleToGrant.Mention; - } - else - { - response += i == roleIds.Length - 1 ? $"and {roleToGrant.Mention}" : $"{roleToGrant.Mention}{(roleIds.Length != 2 ? "," : String.Empty)} "; - } - } - - await ctx.Channel.SendMessageAsync($"{ctx.User.Mention} has joined the {response} role{(roleIds.Length != 1 ? "s" : String.Empty)}."); - } - - public static async Task RemoveUserRoleAsync(CommandContext ctx, ulong role) - { - // In case we ever decide to have individual commands to remove roles. - await RemoveUserRolesAsync(ctx, x => (ulong)x.GetValue(Program.cfgjson.UserRoles, null) == role); - } - - public static async Task RemoveUserRolesAsync(CommandContext ctx, Func predicate) - { - if (Program.cfgjson.UserRoles is null) - { - // Config hasn't been updated yet. - return; - } - - DiscordGuild guild = await Program.discord.GetGuildAsync(ctx.Guild.Id); - System.Reflection.PropertyInfo[] roleIds = Program.cfgjson.UserRoles.GetType().GetProperties().Where(predicate).ToArray(); - foreach (System.Reflection.PropertyInfo roleId in roleIds) - { - // quick patch to exclude giveaways role - if ((ulong)roleId.GetValue(Program.cfgjson.UserRoles, null) == Program.cfgjson.UserRoles.Giveaways) - continue; - - DiscordRole roleToGrant = await guild.GetRoleAsync((ulong)roleId.GetValue(Program.cfgjson.UserRoles, null)); - await ctx.Member.RevokeRoleAsync(roleToGrant); - } - - await ctx.Message.CreateReactionAsync(DiscordEmoji.FromName(ctx.Client, ":CliptokSuccess:")); - } - - [ - Command("swap-insider-rp"), - Aliases("swap-insiders-rp"), - Description("Removes the Windows 11 Insiders (Release Preview) role and replaces it with Windows 10 Insiders (Release Preview) role"), - HomeServer - ] - public async Task SwapInsiderRpCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); - } - - [ - Command("swap-insider-dev"), - Aliases("swap-insiders-dev", "swap-insider-canary", "swap-insiders-canary", "swap-insider-can", "swap-insiders-can"), - Description("Removes the Windows 11 Insiders (Canary) role and replaces it with Windows 10 Insiders (Dev) role"), - HomeServer - ] - public async Task SwapInsiderDevCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); - } - - - [ - Command("join-insider-dev"), - Aliases("join-insiders-dev"), - Description("Gives you the Windows 11 Insiders (Dev) role"), - HomeServer - ] - public async Task JoinInsiderDevCmd(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); - } - - [ - Command("join-insider-canary"), - Aliases("join-insiders-canary", "join-insider-can", "join-insiders-can"), - Description("Gives you the Windows 11 Insiders (Canary) role"), - HomeServer - ] - public async Task JoinInsiderCanaryCmd(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); - } - - - [ - Command("join-insider-beta"), - Aliases("join-insiders-beta"), - Description("Gives you the Windows 11 Insiders (Beta) role"), - HomeServer - ] - public async Task JoinInsiderBetaCmd(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderBeta); - } - - [ - Command("join-insider-rp"), - Aliases("join-insiders-rp", "join-insiders-11-rp", "join-insider-11-rp"), - Description("Gives you the Windows 11 Insiders (Release Preview) role"), - HomeServer - ] - public async Task JoinInsiderRPCmd(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); - } - - [ - Command("join-insider-10"), - Aliases("join-insiders-10"), - Description("Gives you to the Windows 10 Insiders (Release Preview) role"), - HomeServer - ] - public async Task JoinInsiders10Cmd(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); - } - - [ - Command("join-patch-tuesday"), - Description("Gives you the 💻 Patch Tuesday role"), - HomeServer - ] - public async Task JoinPatchTuesday(CommandContext ctx) - { - await GiveUserRoleAsync(ctx, Program.cfgjson.UserRoles.PatchTuesday); - } - - [ - Command("keep-me-updated"), - Description("Gives you all opt-in roles"), - HomeServer - ] - public async Task KeepMeUpdated(CommandContext ctx) - { - await GiveUserRolesAsync(ctx, x => true); - } - - [ - Command("leave-insiders"), - Aliases("leave-insider"), - Description("Removes you from Insider roles"), - HomeServer - ] - public async Task LeaveInsiders(CommandContext ctx) - { - foreach (ulong roleId in new ulong[] { Program.cfgjson.UserRoles.InsiderDev, Program.cfgjson.UserRoles.InsiderBeta, Program.cfgjson.UserRoles.InsiderRP, Program.cfgjson.UserRoles.InsiderCanary, Program.cfgjson.UserRoles.InsiderDev }) - { - await RemoveUserRoleAsync(ctx, roleId); - } - - var msg = await ctx.RespondAsync($"{Program.cfgjson.Emoji.Insider} You are no longer receiving Windows Insider notifications. If you ever wish to receive Insider notifications again, you can check the <#740272437719072808> description for the commands."); - await Task.Delay(10000); - await msg.DeleteAsync(); - } - - [ - Command("dont-keep-me-updated"), - Description("Takes away from you all opt-in roles"), - HomeServer - ] - public async Task DontKeepMeUpdated(CommandContext ctx) - { - await RemoveUserRolesAsync(ctx, x => true); - } - - [ - Command("leave-insider-dev"), - Aliases("leave-insiders-dev"), - Description("Removes the Windows 11 Insiders (Dev) role"), - HomeServer - ] - public async Task LeaveInsiderDevCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderDev); - } - - [ - Command("leave-insider-canary"), - Aliases("leave-insiders-canary", "leave-insider-can", "leave-insiders-can"), - Description("Removes the Windows 11 Insiders (Canary) role"), - HomeServer - ] - public async Task LeaveInsiderCanaryCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderCanary); - } - - [ - Command("leave-insider-beta"), - Aliases("leave-insiders-beta"), - Description("Removes the Windows 11 Insiders (Beta) role"), - HomeServer - ] - public async Task LeaveInsiderBetaCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderBeta); - } - - [ - Command("leave-insider-10"), - Aliases("leave-insiders-10"), - Description("Removes the Windows 10 Insiders (Release Preview) role"), - HomeServer - ] - public async Task LeaveInsiderRPCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.Insider10RP); - } - - [ - Command("leave-insider-rp"), - Aliases("leave-insiders-rp", "leave-insiders-11-rp", "leave-insider-11-rp"), - Description("Removes the Windows 11 Insiders (Release Preview) role"), - HomeServer - ] - public async Task LeaveInsider10RPCmd(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.InsiderRP); - } - - [ - Command("leave-patch-tuesday"), - Description("Removes the 💻 Patch Tuesday role"), - HomeServer - ] - public async Task LeavePatchTuesday(CommandContext ctx) - { - await RemoveUserRoleAsync(ctx, Program.cfgjson.UserRoles.PatchTuesday); - } - - } -} diff --git a/Commands/Utility.cs b/Commands/Utility.cs deleted file mode 100644 index fff1edc6..00000000 --- a/Commands/Utility.cs +++ /dev/null @@ -1,77 +0,0 @@ -namespace Cliptok.Commands -{ - internal class Utility : BaseCommandModule - { - [Command("ping")] - [Description("Pong? This command lets you know whether I'm working well.")] - public async Task Ping(CommandContext ctx) - { - ctx.Client.Logger.LogDebug(ctx.Client.GetConnectionLatency(Program.cfgjson.ServerID).ToString()); - DiscordMessage return_message = await ctx.Message.RespondAsync("Pinging..."); - ulong ping = (return_message.Id - ctx.Message.Id) >> 22; - char[] choices = new char[] { 'a', 'e', 'o', 'u', 'i', 'y' }; - char letter = choices[Program.rand.Next(0, choices.Length)]; - await return_message.ModifyAsync($"P{letter}ng! 🏓\n" + - $"• It took me `{ping}ms` to reply to your message!\n" + - $"• Last Websocket Heartbeat took `{Math.Round(ctx.Client.GetConnectionLatency(0).TotalMilliseconds, 0)}ms`!"); - } - - [Command("edit")] - [Description("Edit a message.")] - [RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task Edit( - CommandContext ctx, - [Description("The ID of the message to edit.")] ulong messageId, - [RemainingText, Description("New message content.")] string content - ) - { - var msg = await ctx.Channel.GetMessageAsync(messageId); - - if (msg is null || msg.Author.Id != ctx.Client.CurrentUser.Id) - return; - - await ctx.Message.DeleteAsync(); - - await msg.ModifyAsync(content); - } - - [Command("editappend")] - [Description("Append content to an existing bot message with a newline.")] - [RequireHomeserverPerm(ServerPermLevel.Moderator)] - public async Task EditAppend( - CommandContext ctx, - [Description("The ID of the message to edit")] ulong messageId, - [RemainingText, Description("Content to append on the end of the message.")] string content - ) - { - var msg = await ctx.Channel.GetMessageAsync(messageId); - - if (msg is null || msg.Author.Id != ctx.Client.CurrentUser.Id) - return; - - var newContent = msg.Content + "\n" + content; - if (newContent.Length > 2000) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} New content exceeded 2000 characters."); - } - else - { - await ctx.Message.DeleteAsync(); - await msg.ModifyAsync(newContent); - } - } - - [Command("userinfo")] - [Description("Show info about a user.")] - [Aliases("user-info", "whois")] - public async Task UserInfoCommand( - CommandContext ctx, - DiscordUser user = null) - { - if (user is null) - user = ctx.User; - - await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(user, ctx.Guild)); - } - } -} diff --git a/Commands/UtilityCmds.cs b/Commands/UtilityCmds.cs new file mode 100644 index 00000000..d8b3ebc7 --- /dev/null +++ b/Commands/UtilityCmds.cs @@ -0,0 +1,130 @@ +namespace Cliptok.Commands +{ + public class UtilityCmds + { + [Command("Dump message data")] + [SlashCommandTypes(DiscordApplicationCommandType.MessageContextMenu)] + [AllowedProcessors(typeof(MessageCommandProcessor))] + public async Task DumpMessage(MessageCommandContext ctx, DiscordMessage targetMessage) + { + var rawMsgData = JsonConvert.SerializeObject(targetMessage, Formatting.Indented); + await ctx.RespondAsync(await StringHelpers.CodeOrHasteBinAsync(rawMsgData, "json"), ephemeral: true); + } + + [Command("Show Avatar")] + [SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [AllowedProcessors(typeof(UserCommandProcessor))] + public async Task ContextAvatar(UserCommandContext ctx, DiscordUser targetUser) + { + string avatarUrl = await LykosAvatarMethods.UserOrMemberAvatarURL(targetUser, ctx.Guild); + + DiscordEmbedBuilder embed = new DiscordEmbedBuilder() + .WithColor(new DiscordColor(0xC63B68)) + .WithTimestamp(DateTime.UtcNow) + .WithImageUrl(avatarUrl) + .WithAuthor( + $"Avatar for {targetUser.Username} (Click to open in browser)", + avatarUrl + ); + + await ctx.RespondAsync(null, embed, ephemeral: true); + } + + [Command("User Information")] + [SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [AllowedProcessors(typeof(UserCommandProcessor))] + public async Task ContextUserInformation(UserCommandContext ctx, DiscordUser targetUser) + { + await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(targetUser, ctx.Guild), ephemeral: true); + } + + [Command("edittextcmd")] + [TextAlias("edit")] + [Description("Edit a message.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task Edit( + TextCommandContext ctx, + [Description("The ID of the message to edit.")] ulong messageId, + [RemainingText, Description("New message content.")] string content + ) + { + var msg = await ctx.Channel.GetMessageAsync(messageId); + + if (msg is null || msg.Author.Id != ctx.Client.CurrentUser.Id) + return; + + await ctx.Message.DeleteAsync(); + + await msg.ModifyAsync(content); + } + + [Command("editappendtextcmd")] + [TextAlias("editappend")] + [Description("Append content to an existing bot message with a newline.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator)] + public async Task EditAppend( + TextCommandContext ctx, + [Description("The ID of the message to edit")] ulong messageId, + [RemainingText, Description("Content to append on the end of the message.")] string content + ) + { + var msg = await ctx.Channel.GetMessageAsync(messageId); + + if (msg is null || msg.Author.Id != ctx.Client.CurrentUser.Id) + return; + + var newContent = msg.Content + "\n" + content; + if (newContent.Length > 2000) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} New content exceeded 2000 characters."); + } + else + { + await ctx.Message.DeleteAsync(); + await msg.ModifyAsync(newContent); + } + } + + [Command("timestamptextcmd")] + [TextAlias("timestamp", "ts", "time")] + [Description("Returns various timestamps for a given Discord ID/snowflake")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [HomeServer] + class TimestampCmds + { + [DefaultGroupCommand] + [Command("unix")] + [TextAlias("u", "epoch")] + [Description("Returns the Unix timestamp of a given Discord ID/snowflake")] + public async Task TimestampUnixCmd(TextCommandContext ctx, [Description("The ID/snowflake to fetch the Unix timestamp for")] ulong snowflake) + { + var msSinceEpoch = snowflake >> 22; + var msUnix = msSinceEpoch + 1420070400000; + await ctx.RespondAsync($"{msUnix / 1000}"); + } + + [Command("relative")] + [TextAlias("r")] + [Description("Returns the amount of time between now and a given Discord ID/snowflake")] + public async Task TimestampRelativeCmd(TextCommandContext ctx, [Description("The ID/snowflake to fetch the relative timestamp for")] ulong snowflake) + { + var msSinceEpoch = snowflake >> 22; + var msUnix = msSinceEpoch + 1420070400000; + await ctx.RespondAsync($"{Program.cfgjson.Emoji.ClockTime} "); + } + + [Command("fulldate")] + [TextAlias("f", "datetime")] + [Description("Returns the fully-formatted date and time of a given Discord ID/snowflake")] + public async Task TimestampFullCmd(TextCommandContext ctx, [Description("The ID/snowflake to fetch the full timestamp for")] ulong snowflake) + { + var msSinceEpoch = snowflake >> 22; + var msUnix = msSinceEpoch + 1420070400000; + await ctx.RespondAsync($"{Program.cfgjson.Emoji.ClockTime} "); + } + + } + } +} \ No newline at end of file diff --git a/Commands/WarningCmds.cs b/Commands/WarningCmds.cs new file mode 100644 index 00000000..89d04c52 --- /dev/null +++ b/Commands/WarningCmds.cs @@ -0,0 +1,712 @@ +using static Cliptok.Helpers.WarningHelpers; + +namespace Cliptok.Commands +{ + internal class WarningCmds + { + [Command("Show Warnings")] + [SlashCommandTypes(DiscordApplicationCommandType.UserContextMenu)] + [AllowedProcessors(typeof(UserCommandProcessor))] + public async Task ContextWarnings(UserCommandContext ctx, DiscordUser targetUser) + { + await ctx.RespondAsync(embed: await WarningHelpers.GenerateWarningsEmbedAsync(targetUser), ephemeral: true); + } + + [Command("warn")] + [Description("Formally warn a user, usually for breaking the server rules.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task WarnSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to warn.")] DiscordUser user, + [Parameter("reason"), Description("The reason they're being warned.")] string reason, + [Parameter("reply_msg_id"), Description("The ID of a message to reply to, must be in the same channel.")] string replyMsgId = "0", + [Parameter("channel"), Description("The channel to warn the user in, implied if not supplied.")] DiscordChannel channel = null + ) + { + // Initial response to avoid the 3 second timeout, will edit later. + await ctx.DeferResponseAsync(true); + + // Edits need a webhook rather than interaction..? + DiscordWebhookBuilder webhookOut; + + DiscordMember targetMember; + + try + { + targetMember = await ctx.Guild.GetMemberAsync(user.Id); + if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) + { + webhookOut = new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Error} As a Trial Moderator you cannot perform moderation actions on other staff members or bots."); + await ctx.EditResponseAsync(webhookOut); + return; + } + } + catch + { + // do nothing :/ + } + + if (channel is null) + channel = ctx.Channel; + + var messageBuild = new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Warning} {user.Mention} was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); + + if (replyMsgId != "0") + { + if (!ulong.TryParse(replyMsgId, out var msgId)) + { + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Invalid reply message ID! Please try again.").AsEphemeral(true)); + return; + } + messageBuild.WithReply(msgId, true, false); + } + + var msg = await channel.SendMessageAsync(messageBuild); + + _ = await WarningHelpers.GiveWarningAsync(user, ctx.User, reason, msg, channel); + webhookOut = new DiscordWebhookBuilder().WithContent($"{Program.cfgjson.Emoji.Success} User was warned successfully: {DiscordHelpers.MessageLink(msg)}"); + await ctx.EditResponseAsync(webhookOut); + } + + [Command("warnings")] + [Description("Fetch the warnings for a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public async Task WarningsSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to find the warnings for.")] DiscordUser user, + [Parameter("public"), Description("Whether to show the warnings in public chat. Do not disrupt chat with this.")] bool publicWarnings = false + ) + { + var eout = new DiscordInteractionResponseBuilder().AddEmbed(await WarningHelpers.GenerateWarningsEmbedAsync(user)); + if (!publicWarnings) + eout.AsEphemeral(true); + + await ctx.RespondAsync(eout); + } + + [Command("transfer_warnings")] + [Description("Transfer warnings from one user to another.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.Moderator)] + [RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task TransferWarningsSlashCommand(SlashCommandContext ctx, + [Parameter("source_user"), Description("The user currently holding the warnings.")] DiscordUser sourceUser, + [Parameter("target_user"), Description("The user receiving the warnings.")] DiscordUser targetUser, + [Parameter("merge"), Description("Whether to merge the source user's warnings and the target user's warnings.")] bool merge = false, + [Parameter("force_override"), Description("DESTRUCTIVE OPERATION: Whether to OVERRIDE and DELETE the target users existing warnings.")] bool forceOverride = false + ) + { + await ctx.DeferResponseAsync(false); + + if (sourceUser == targetUser) + { + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} The source and target users cannot be the same!")); + return; + } + + var sourceWarnings = await Program.db.HashGetAllAsync(sourceUser.Id.ToString()); + var targetWarnings = await Program.db.HashGetAllAsync(targetUser.Id.ToString()); + + if (sourceWarnings.Length == 0) + { + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} The source user has no warnings to transfer.").AddEmbed(await GenerateWarningsEmbedAsync(sourceUser))); + return; + } + else if (merge) + { + foreach (var warning in sourceWarnings) + { + await Program.db.HashSetAsync(targetUser.Id.ToString(), warning.Name, warning.Value); + } + await Program.db.KeyDeleteAsync(sourceUser.Id.ToString()); + } + else if (targetWarnings.Length > 0 && !forceOverride) + { + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Warning} **CAUTION**: The target user has warnings.\n\n" + + $"If you are sure you want to **OVERRIDE** and **DELETE** these warnings, please consider the consequences before adding `force_override: True` to the command.\nIf you wish to **NOT** override the target's warnings, please use `merge: True` instead.") + .AddEmbed(await GenerateWarningsEmbedAsync(targetUser))); + return; + } + else if (targetWarnings.Length > 0 && forceOverride) + { + await Program.db.KeyDeleteAsync(targetUser.Id.ToString()); + await Program.db.KeyRenameAsync(sourceUser.Id.ToString(), targetUser.Id.ToString()); + } + else + { + await Program.db.KeyRenameAsync(sourceUser.Id.ToString(), targetUser.Id.ToString()); + } + + string operationText = ""; + if (merge) + operationText = "merge "; + else if (forceOverride) + operationText = "force "; + await LogChannelHelper.LogMessageAsync("mod", + new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Information} Warnings from {sourceUser.Mention} were {operationText}transferred to {targetUser.Mention} by `{DiscordHelpers.UniqueUsername(ctx.User)}`") + .AddEmbed(await GenerateWarningsEmbedAsync(targetUser)) + ); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Success} Successfully {operationText}transferred warnings from {sourceUser.Mention} to {targetUser.Mention}!")); + } + + internal partial class WarningsAutocompleteProvider : IAutoCompleteProvider + { + public async ValueTask> AutoCompleteAsync(AutoCompleteContext ctx) + { + var list = new List(); + + var useroption = ctx.Options.FirstOrDefault(x => x.Name == "user"); + if (useroption == default) + { + return list; + } + + var user = await ctx.Client.GetUserAsync((ulong)useroption.Value); + + var warnings = Program.db.HashGetAll(user.Id.ToString()) + .Where(x => JsonConvert.DeserializeObject(x.Value).Type == WarningType.Warning).ToDictionary( + x => x.Name.ToString(), + x => JsonConvert.DeserializeObject(x.Value) + ).OrderByDescending(x => x.Value.WarningId); + + foreach (var warning in warnings) + { + if (list.Count >= 25) + break; + + string warningString = $"{StringHelpers.Pad(warning.Value.WarningId)} - {StringHelpers.Truncate(warning.Value.WarnReason, 29, true)} - {TimeHelpers.TimeToPrettyFormat(DateTime.Now - warning.Value.WarnTimestamp, true)}"; + + var focusedOption = ctx.Options.FirstOrDefault(option => option.Focused); + if (focusedOption is not null) + if (warning.Value.WarnReason.Contains((string)focusedOption.Value) || warningString.ToLower().Contains(focusedOption.Value.ToString().ToLower())) + list.Add(new DiscordAutoCompleteChoice(warningString, StringHelpers.Pad(warning.Value.WarningId))); + } + + return list; + //return Task.FromResult((IEnumerable)list); + } + } + + [Command("warndetails")] + [Description("Search for a warning and return its details.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task WarndetailsSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to fetch a warning for.")] DiscordUser user, + [SlashAutoCompleteProvider(typeof(WarningsAutocompleteProvider)), Parameter("warning"), Description("Type to search! Find the warning you want to fetch.")] string warning, + [Parameter("public"), Description("Whether to show the output publicly.")] bool publicWarnings = false + ) + { + if (warning.Contains(' ')) + { + warning = warning.Split(' ')[0]; + } + + long warnId; + try + { + warnId = Convert.ToInt64(warning); + } + catch + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); + return; + } + + UserWarning warningObject = GetWarning(user.Id, warnId); + + if (warningObject is null) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); + else if (warningObject.Type == WarningType.Note) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note details` instead, or make sure you've got the right warning ID.", ephemeral: true); + else + { + await ctx.DeferResponseAsync(ephemeral: !publicWarnings); + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().AddEmbed(await FancyWarnEmbedAsync(warningObject, true, userID: user.Id))); + } + } + + [Command("delwarn")] + [Description("Search for a warning and delete it!")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task DelwarnSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to delete a warning for.")] DiscordUser targetUser, + [SlashAutoCompleteProvider(typeof(WarningsAutocompleteProvider))][Parameter("warning"), Description("Type to search! Find the warning you want to delete.")] string warningId, + [Parameter("public"), Description("Whether to show the output publicly. Default: false")] bool showPublic = false + ) + { + if (warningId.Contains(' ')) + { + warningId = warningId.Split(' ')[0]; + } + + long warnId; + try + { + warnId = Convert.ToInt64(warningId); + } + catch + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); + return; + } + + UserWarning warning = GetWarning(targetUser.Id, warnId); + + if (warning is null) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); + else if (warning.Type == WarningType.Note) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note delete` instead, or make sure you've got the right warning ID.", ephemeral: true); + } + else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!", ephemeral: true); + } + else + { + await ctx.DeferResponseAsync(ephemeral: !showPublic); + + bool success = await DelWarningAsync(warning, targetUser.Id); + if (success) + { + await LogChannelHelper.LogMessageAsync("mod", + new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Deleted} Warning deleted:" + + $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention}, deleted by {ctx.Member.Mention})") + .AddEmbed(await FancyWarnEmbedAsync(warning, true, 0xf03916, true, targetUser.Id)) + .WithAllowedMentions(Mentions.None) + ); + + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Deleted} Successfully deleted warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})")); + + + } + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to delete warning `{StringHelpers.Pad(warnId)}` from {targetUser.Mention}!\nPlease contact the bot author.", ephemeral: true); + } + } + } + + [Command("editwarn")] + [Description("Search for a warning and edit it!")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator), RequirePermissions(DiscordPermission.ModerateMembers)] + public async Task EditWarnSlashCommand(SlashCommandContext ctx, + [Parameter("user"), Description("The user to fetch a warning for.")] DiscordUser user, + [SlashAutoCompleteProvider(typeof(WarningsAutocompleteProvider))][Parameter("warning"), Description("Type to search! Find the warning you want to edit.")] string warning, + [Parameter("new_reason"), Description("The new reason for the warning")] string reason, + [Parameter("public"), Description("Whether to show the output publicly. Default: false")] bool showPublic = false) + { + if (warning.Contains(' ')) + { + warning = warning.Split(' ')[0]; + } + + long warnId; + try + { + warnId = Convert.ToInt64(warning); + } + catch + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Looks like your warning option was invalid! Give it another go?", ephemeral: true); + return; + } + + if (string.IsNullOrWhiteSpace(reason)) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You haven't given a new reason to set for the warning!", ephemeral: true); + return; + } + + var warningObject = GetWarning(user.Id, warnId); + + if (warningObject is null) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again.", ephemeral: true); + else if (warningObject.Type == WarningType.Note) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note edit` instead, or make sure you've got the right warning ID.", ephemeral: true); + } + else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warningObject.ModUserId != ctx.User.Id && warningObject.ModUserId != ctx.Client.CurrentUser.Id) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!", ephemeral: true); + } + else + { + await ctx.DeferResponseAsync(ephemeral: !showPublic); + + await EditWarning(user, warnId, ctx.User, reason); + + await LogChannelHelper.LogMessageAsync("mod", + new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Information} Warning edited:" + + $"`{StringHelpers.Pad(warnId)}` (belonging to {user.Mention})") + .AddEmbed(await FancyWarnEmbedAsync(GetWarning(user.Id, warnId), true, userID: user.Id)) + ); + + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Information} Successfully edited warning `{StringHelpers.Pad(warnId)}` (belonging to {user.Mention})") + .AddEmbed(await FancyWarnEmbedAsync(GetWarning(user.Id, warnId), userID: user.Id))); + } + } + + [ + Command("warntextcmd"), + Description("Issues a formal warning to a user."), + TextAlias("warn", "wam", "warm"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) + ] + public async Task WarnCmd( + TextCommandContext ctx, + [Description("The user you are warning. Accepts many formats.")] DiscordUser targetUser, + [RemainingText, Description("The reason for giving this warning.")] string reason = null + ) + { + DiscordMember targetMember; + try + { + targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); + if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) + { + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); + return; + } + } + catch + { + // do nothing :/ + } + + var reply = ctx.Message.ReferencedMessage; + + await ctx.Message.DeleteAsync(); + if (reason is null) + { + await ctx.Member.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} Reason must be included for the warning command to work."); + return; + } + + var messageBuild = new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Warning} <@{targetUser.Id}> was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); + + if (reply is not null) + messageBuild.WithReply(reply.Id, true, false); + + var msg = await ctx.Channel.SendMessageAsync(messageBuild); + _ = await GiveWarningAsync(targetUser, ctx.User, reason, msg, ctx.Channel); + } + + [ + Command("anonwarntextcmd"), + TextAlias("anonwarn", "anonwam", "anonwarm"), + Description("Issues a formal warning to a user from a private channel."), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) + ] + public async Task AnonWarnCmd( + TextCommandContext ctx, + [Description("The channel you wish for the warning message to appear in.")] DiscordChannel targetChannel, + [Description("The user you are warning. Accepts many formats.")] DiscordUser targetUser, + [RemainingText, Description("The reason for giving this warning.")] string reason = null + ) + { + DiscordMember targetMember; + try + { + targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); + if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) + { + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); + return; + } + } + catch + { + // do nothing :/ + } + + await ctx.Message.DeleteAsync(); + if (reason is null) + { + await ctx.Member.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} Reason must be included for the warning command to work."); + return; + } + DiscordMessage msg = await targetChannel.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} {targetUser.Mention} was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); + await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} {targetUser.Mention} was warned in {targetChannel.Mention}: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); + _ = await GiveWarningAsync(targetUser, ctx.User, reason, msg, ctx.Channel); + } + + [ + Command("warningstextcmd"), + TextAlias("warnings", "infractions", "warnfractions", "wammings", "wamfractions"), + Description("Shows a list of warnings that a user has been given. For more in-depth information, use the 'warnlookup' command."), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer + ] + public async Task WarningCmd( + TextCommandContext ctx, + [Description("The user you want to look up warnings for. Accepts many formats.")] DiscordUser targetUser = null + ) + { + if (targetUser is null) + targetUser = ctx.User; + + await ctx.RespondAsync(null, await GenerateWarningsEmbedAsync(targetUser)); + } + + [ + Command("delwarntextcmd"), + TextAlias("delwarn", "delwarm", "delwam", "deletewarn", "delwarning", "deletewarning"), + Description("Delete a warning that was issued by mistake or later became invalid."), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) + ] + public async Task DelwarnCmd( + TextCommandContext ctx, + [Description("The user you're removing a warning from. Accepts many formats.")] DiscordUser targetUser, + [Description("The ID of the warning you want to delete.")] long warnId + ) + { + UserWarning warning = GetWarning(targetUser.Id, warnId); + if (warning is null) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); + else if (warning.Type == WarningType.Note) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note delete` instead, or make sure you've got the right warning ID."); + } + else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!"); + } + else + { + bool success = await DelWarningAsync(warning, targetUser.Id); + if (success) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Deleted} Successfully deleted warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})"); + + await LogChannelHelper.LogMessageAsync("mod", + new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Deleted} Warning deleted:" + + $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention}, deleted by {ctx.Member.Mention})") + .AddEmbed(await FancyWarnEmbedAsync(warning, true, 0xf03916, true, targetUser.Id)) + .WithAllowedMentions(Mentions.None) + ); + } + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to delete warning `{StringHelpers.Pad(warnId)}` from {targetUser.Mention}!\nPlease contact the bot author."); + } + } + } + + [ + Command("warnlookuptextcmd"), + Description("Looks up information about a warning. Shows only publicly available information."), + TextAlias("warnlookup", "warning", "warming", "waming", "wamming", "lookup", "lookylooky", "peek", "investigate", "what-did-i-do-wrong-there", "incident"), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer + ] + public async Task WarnlookupCmd( + TextCommandContext ctx, + [Description("The user you're looking at a warning for. Accepts many formats.")] DiscordUser targetUser, + [Description("The ID of the warning you want to see")] long warnId + ) + { + UserWarning warning = GetWarning(targetUser.Id, warnId); + if (warning is null || warning.Type == WarningType.Note) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); + else + await ctx.RespondAsync(null, await FancyWarnEmbedAsync(warning, userID: targetUser.Id)); + } + + [ + Command("warndetailstextcmd"), + TextAlias("warndetails", "warninfo", "waminfo", "wamdetails", "warndetail", "wamdetail"), + Description("Check the details of a warning in depth. Shows extra information (Such as responsible Mod) that may not be wanted to be public."), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + RequireHomeserverPerm(ServerPermLevel.TrialModerator) + ] + public async Task WarnDetailsCmd( + TextCommandContext ctx, + [Description("The user you're looking up detailed warn information for. Accepts many formats.")] DiscordUser targetUser, + [Description("The ID of the warning you're looking at in detail.")] long warnId + ) + { + UserWarning warning = GetWarning(targetUser.Id, warnId); + + if (warning is null) + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); + else if (warning.Type == WarningType.Note) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note details` instead, or make sure you've got the right warning ID."); + } + else + await ctx.RespondAsync(null, await FancyWarnEmbedAsync(warning, true, userID: targetUser.Id)); + + } + + [ + Command("editwarntextcmd"), + TextAlias("editwarn", "warnedit", "editwarning"), + Description("Edit the reason of an existing warning.\n" + + "The Moderator who is editing the reason will become responsible for the case."), + AllowedProcessors(typeof(TextCommandProcessor)), + HomeServer, + RequireHomeserverPerm(ServerPermLevel.TrialModerator) + ] + public async Task EditwarnCmd( + TextCommandContext ctx, + [Description("The user you're editing a warning for. Accepts many formats.")] DiscordUser targetUser, + [Description("The ID of the warning you want to edit.")] long warnId, + [RemainingText, Description("The new reason for the warning.")] string newReason) + { + if (string.IsNullOrWhiteSpace(newReason)) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You haven't given a new reason to set for the warning!"); + return; + } + + await ctx.RespondAsync("Processing your request..."); + var msg = await ctx.GetResponseAsync(); + var warning = GetWarning(targetUser.Id, warnId); + if (warning is null) + await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); + else if (warning.Type == WarningType.Note) + { + await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note edit` instead, or make sure you've got the right warning ID."); + } + else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) + { + await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!"); + } + else + { + await EditWarning(targetUser, warnId, ctx.User, newReason); + await msg.ModifyAsync($"{Program.cfgjson.Emoji.Information} Successfully edited warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})", + await FancyWarnEmbedAsync(GetWarning(targetUser.Id, warnId), userID: targetUser.Id)); + + await LogChannelHelper.LogMessageAsync("mod", + new DiscordMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Information} Warning edited:" + + $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})") + .AddEmbed(await FancyWarnEmbedAsync(GetWarning(targetUser.Id, warnId), true, userID: targetUser.Id)) + ); + } + } + + [Command("mostwarningstextcmd")] + [TextAlias("mostwarnings")] + [Description("Who has the most warnings???")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public async Task MostWarningsCmd(TextCommandContext ctx) + { + await DiscordHelpers.SafeTyping(ctx.Channel); + + var server = Program.redis.GetServer(Program.redis.GetEndPoints()[0]); + var keys = server.Keys(); + + Dictionary counts = new(); + foreach (var key in keys) + { + if (ulong.TryParse(key.ToString(), out ulong number)) + { + counts[key.ToString()] = Program.db.HashGetAll(key).Count(x => JsonConvert.DeserializeObject(x.Value.ToString()).Type == WarningType.Warning); + } + } + + List> myList = counts.ToList(); + myList.Sort( + delegate (KeyValuePair pair1, + KeyValuePair pair2) + { + return pair1.Value.CompareTo(pair2.Value); + } + ); + + var user = await ctx.Client.GetUserAsync(Convert.ToUInt64(myList.Last().Key)); + await ctx.RespondAsync($":thinking: The user with the most warnings is **{DiscordHelpers.UniqueUsername(user)}** with a total of **{myList.Last().Value} warnings!**\nThis includes users who have left or been banned."); + } + + [Command("mostwarningsdaytextcmd")] + [TextAlias("mostwarningsday")] + [Description("Which day has the most warnings???")] + [AllowedProcessors(typeof(TextCommandProcessor))] + [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] + public async Task MostWarningsDayCmd(TextCommandContext ctx) + { + await DiscordHelpers.SafeTyping(ctx.Channel); + + var server = Program.redis.GetServer(Program.redis.GetEndPoints()[0]); + var keys = server.Keys(); + + Dictionary counts = new(); + Dictionary noAutoCounts = new(); + + foreach (var key in keys) + { + if (ulong.TryParse(key.ToString(), out ulong number)) + { + var warningsOutput = Program.db.HashGetAll(key.ToString()).ToDictionary( + x => x.Name.ToString(), + x => JsonConvert.DeserializeObject(x.Value) + ); + + foreach (var warning in warningsOutput) + { + if (warning.Value.Type != WarningType.Warning) continue; + + var day = warning.Value.WarnTimestamp.ToString("yyyy-MM-dd"); + if (!counts.ContainsKey(day)) + { + counts[day] = 1; + } + else + { + counts[day] += 1; + } + if (warning.Value.ModUserId != 159985870458322944 && warning.Value.ModUserId != Program.discord.CurrentUser.Id) + { + if (!noAutoCounts.ContainsKey(day)) + { + noAutoCounts[day] = 1; + } + else + { + noAutoCounts[day] += 1; + } + } + } + } + } + + List> countList = counts.ToList(); + countList.Sort( + delegate (KeyValuePair pair1, + KeyValuePair pair2) + { + return pair1.Value.CompareTo(pair2.Value); + } + ); + + List> noAutoCountList = noAutoCounts.ToList(); + noAutoCountList.Sort( + delegate (KeyValuePair pair1, + KeyValuePair pair2) + { + return pair1.Value.CompareTo(pair2.Value); + } + ); + + await ctx.RespondAsync($":thinking: As far as I can tell, the day with the most warnings issued was **{countList.Last().Key}** with a total of **{countList.Last().Value} warnings!**" + + $"\nExcluding automatic warnings, the most was on **{noAutoCountList.Last().Key}** with a total of **{noAutoCountList.Last().Value}** warnings!"); + } + } +} \ No newline at end of file diff --git a/Commands/Warnings.cs b/Commands/Warnings.cs deleted file mode 100644 index 5f19c08a..00000000 --- a/Commands/Warnings.cs +++ /dev/null @@ -1,349 +0,0 @@ -using static Cliptok.Helpers.WarningHelpers; - -namespace Cliptok.Commands -{ - - public class Warnings : BaseCommandModule - { - [ - Command("warn"), - Description("Issues a formal warning to a user."), - Aliases("wam", "warm"), - HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) - ] - public async Task WarnCmd( - CommandContext ctx, - [Description("The user you are warning. Accepts many formats.")] DiscordUser targetUser, - [RemainingText, Description("The reason for giving this warning.")] string reason = null - ) - { - DiscordMember targetMember; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); - return; - } - } - catch - { - // do nothing :/ - } - - var reply = ctx.Message.ReferencedMessage; - - await ctx.Message.DeleteAsync(); - if (reason is null) - { - await ctx.Member.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} Reason must be included for the warning command to work."); - return; - } - - var messageBuild = new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Warning} <@{targetUser.Id}> was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); - - if (reply is not null) - messageBuild.WithReply(reply.Id, true, false); - - var msg = await ctx.Channel.SendMessageAsync(messageBuild); - _ = await GiveWarningAsync(targetUser, ctx.User, reason, msg, ctx.Channel); - } - - [ - Command("anonwarn"), - Description("Issues a formal warning to a user from a private channel."), - Aliases("anonwam", "anonwarm"), - HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) - ] - public async Task AnonWarnCmd( - CommandContext ctx, - [Description("The channel you wish for the warning message to appear in.")] DiscordChannel targetChannel, - [Description("The user you are warning. Accepts many formats.")] DiscordUser targetUser, - [RemainingText, Description("The reason for giving this warning.")] string reason = null - ) - { - DiscordMember targetMember; - try - { - targetMember = await ctx.Guild.GetMemberAsync(targetUser.Id); - if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && ((await GetPermLevelAsync(targetMember)) >= ServerPermLevel.TrialModerator || targetMember.IsBot)) - { - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot perform moderation actions on other staff members or bots."); - return; - } - } - catch - { - // do nothing :/ - } - - await ctx.Message.DeleteAsync(); - if (reason is null) - { - await ctx.Member.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} Reason must be included for the warning command to work."); - return; - } - DiscordMessage msg = await targetChannel.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} {targetUser.Mention} was warned: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); - await ctx.Channel.SendMessageAsync($"{Program.cfgjson.Emoji.Warning} {targetUser.Mention} was warned in {targetChannel.Mention}: **{reason.Replace("`", "\\`").Replace("*", "\\*")}**"); - _ = await GiveWarningAsync(targetUser, ctx.User, reason, msg, ctx.Channel); - } - - [ - Command("warnings"), - Description("Shows a list of warnings that a user has been given. For more in-depth information, use the 'warnlookup' command."), - Aliases("infractions", "warnfractions", "wammings", "wamfractions"), - HomeServer - ] - public async Task WarningCmd( - CommandContext ctx, - [Description("The user you want to look up warnings for. Accepts many formats.")] DiscordUser targetUser = null - ) - { - if (targetUser is null) - targetUser = ctx.User; - - await ctx.RespondAsync(null, await GenerateWarningsEmbedAsync(targetUser)); - } - - [ - Command("delwarn"), - Description("Delete a warning that was issued by mistake or later became invalid."), - Aliases("delwarm", "delwam", "deletewarn", "delwarning", "deletewarning", "removewarning", "removewarn"), - HomeServer, RequireHomeserverPerm(ServerPermLevel.TrialModerator) - ] - public async Task DelwarnCmd( - CommandContext ctx, - [Description("The user you're removing a warning from. Accepts many formats.")] DiscordUser targetUser, - [Description("The ID of the warning you want to delete.")] long warnId - ) - { - UserWarning warning = GetWarning(targetUser.Id, warnId); - if (warning is null) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); - else if (warning.Type == WarningType.Note) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note delete` instead, or make sure you've got the right warning ID."); - } - else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!"); - } - else - { - bool success = await DelWarningAsync(warning, targetUser.Id); - if (success) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Deleted} Successfully deleted warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})"); - - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Deleted} Warning deleted:" + - $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention}, deleted by {ctx.Member.Mention})") - .AddEmbed(await FancyWarnEmbedAsync(warning, true, 0xf03916, true, targetUser.Id)) - .WithAllowedMentions(Mentions.None) - ); - } - else - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Failed to delete warning `{StringHelpers.Pad(warnId)}` from {targetUser.Mention}!\nPlease contact the bot author."); - } - } - } - - [ - Command("warnlookup"), - Description("Looks up information about a warning. Shows only publicly available information."), - Aliases("warning", "warming", "waming", "wamming", "lookup", "lookylooky", "peek", "investigate", "what-did-i-do-wrong-there", "incident"), - HomeServer - ] - public async Task WarnlookupCmd( - CommandContext ctx, - [Description("The user you're looking at a warning for. Accepts many formats.")] DiscordUser targetUser, - [Description("The ID of the warning you want to see")] long warnId - ) - { - UserWarning warning = GetWarning(targetUser.Id, warnId); - if (warning is null || warning.Type == WarningType.Note) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); - else - await ctx.RespondAsync(null, await FancyWarnEmbedAsync(warning, userID: targetUser.Id)); - } - - [ - Command("warndetails"), - Aliases("warninfo", "waminfo", "wamdetails", "warndetail", "wamdetail"), - Description("Check the details of a warning in depth. Shows extra information (Such as responsible Mod) that may not be wanted to be public."), - HomeServer, - RequireHomeserverPerm(ServerPermLevel.TrialModerator) - ] - public async Task WarnDetailsCmd( - CommandContext ctx, - [Description("The user you're looking up detailed warn information for. Accepts many formats.")] DiscordUser targetUser, - [Description("The ID of the warning you're looking at in detail.")] long warnId - ) - { - UserWarning warning = GetWarning(targetUser.Id, warnId); - - if (warning is null) - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); - else if (warning.Type == WarningType.Note) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note details` instead, or make sure you've got the right warning ID."); - } - else - await ctx.RespondAsync(null, await FancyWarnEmbedAsync(warning, true, userID: targetUser.Id)); - - } - - [ - Command("editwarn"), - Aliases("warnedit", "editwarning"), - Description("Edit the reason of an existing warning.\n" + - "The Moderator who is editing the reason will become responsible for the case."), - HomeServer, - RequireHomeserverPerm(ServerPermLevel.TrialModerator) - ] - public async Task EditwarnCmd( - CommandContext ctx, - [Description("The user you're editing a warning for. Accepts many formats.")] DiscordUser targetUser, - [Description("The ID of the warning you want to edit.")] long warnId, - [RemainingText, Description("The new reason for the warning.")] string newReason) - { - if (string.IsNullOrWhiteSpace(newReason)) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} You haven't given a new reason to set for the warning!"); - return; - } - - var msg = await ctx.RespondAsync("Processing your request..."); - var warning = GetWarning(targetUser.Id, warnId); - if (warning is null) - await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} I couldn't find a warning for that user with that ID! Please check again."); - else if (warning.Type == WarningType.Note) - { - await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} That's a note, not a warning! Try using `/note edit` instead, or make sure you've got the right warning ID."); - } - else if ((await GetPermLevelAsync(ctx.Member)) == ServerPermLevel.TrialModerator && warning.ModUserId != ctx.User.Id && warning.ModUserId != ctx.Client.CurrentUser.Id) - { - await msg.ModifyAsync($"{Program.cfgjson.Emoji.Error} {ctx.User.Mention}, as a Trial Moderator you cannot edit or delete warnings that aren't issued by you or the bot!"); - } - else - { - await EditWarning(targetUser, warnId, ctx.User, newReason); - await msg.ModifyAsync($"{Program.cfgjson.Emoji.Information} Successfully edited warning `{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})", - await FancyWarnEmbedAsync(GetWarning(targetUser.Id, warnId), userID: targetUser.Id)); - - await LogChannelHelper.LogMessageAsync("mod", - new DiscordMessageBuilder() - .WithContent($"{Program.cfgjson.Emoji.Information} Warning edited:" + - $"`{StringHelpers.Pad(warnId)}` (belonging to {targetUser.Mention})") - .AddEmbed(await FancyWarnEmbedAsync(GetWarning(targetUser.Id, warnId), true, userID: targetUser.Id)) - ); - } - } - - [Command("mostwarnings"), Description("Who has the most warnings???")] - [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task MostWarningsCmd(CommandContext ctx) - { - await DiscordHelpers.SafeTyping(ctx.Channel); - - var server = Program.redis.GetServer(Program.redis.GetEndPoints()[0]); - var keys = server.Keys(); - - Dictionary counts = new(); - foreach (var key in keys) - { - if (ulong.TryParse(key.ToString(), out ulong number)) - { - counts[key.ToString()] = Program.db.HashGetAll(key).Count(x => JsonConvert.DeserializeObject(x.Value.ToString()).Type == WarningType.Warning); - } - } - - List> myList = counts.ToList(); - myList.Sort( - delegate (KeyValuePair pair1, - KeyValuePair pair2) - { - return pair1.Value.CompareTo(pair2.Value); - } - ); - - var user = await ctx.Client.GetUserAsync(Convert.ToUInt64(myList.Last().Key)); - await ctx.RespondAsync($":thinking: The user with the most warnings is **{DiscordHelpers.UniqueUsername(user)}** with a total of **{myList.Last().Value} warnings!**\nThis includes users who have left or been banned."); - } - - [Command("mostwarningsday"), Description("Which day has the most warnings???")] - [RequireHomeserverPerm(ServerPermLevel.TrialModerator)] - public async Task MostWarningsDayCmd(CommandContext ctx) - { - await DiscordHelpers.SafeTyping(ctx.Channel); - - var server = Program.redis.GetServer(Program.redis.GetEndPoints()[0]); - var keys = server.Keys(); - - Dictionary counts = new(); - Dictionary noAutoCounts = new(); - - foreach (var key in keys) - { - if (ulong.TryParse(key.ToString(), out ulong number)) - { - var warningsOutput = Program.db.HashGetAll(key.ToString()).ToDictionary( - x => x.Name.ToString(), - x => JsonConvert.DeserializeObject(x.Value) - ); - - foreach (var warning in warningsOutput) - { - if (warning.Value.Type != WarningType.Warning) continue; - - var day = warning.Value.WarnTimestamp.ToString("yyyy-MM-dd"); - if (!counts.ContainsKey(day)) - { - counts[day] = 1; - } - else - { - counts[day] += 1; - } - if (warning.Value.ModUserId != 159985870458322944 && warning.Value.ModUserId != Program.discord.CurrentUser.Id) - { - if (!noAutoCounts.ContainsKey(day)) - { - noAutoCounts[day] = 1; - } - else - { - noAutoCounts[day] += 1; - } - } - } - } - } - - List> countList = counts.ToList(); - countList.Sort( - delegate (KeyValuePair pair1, - KeyValuePair pair2) - { - return pair1.Value.CompareTo(pair2.Value); - } - ); - - List> noAutoCountList = noAutoCounts.ToList(); - noAutoCountList.Sort( - delegate (KeyValuePair pair1, - KeyValuePair pair2) - { - return pair1.Value.CompareTo(pair2.Value); - } - ); - - await ctx.RespondAsync($":thinking: As far as I can tell, the day with the most warnings issued was **{countList.Last().Key}** with a total of **{countList.Last().Value} warnings!**" + - $"\nExcluding automatic warnings, the most was on **{noAutoCountList.Last().Key}** with a total of **{noAutoCountList.Last().Value}** warnings!"); - } - } -} diff --git a/Events/ErrorEvents.cs b/Events/ErrorEvents.cs index 7f8bb503..2fd1dc8d 100644 --- a/Events/ErrorEvents.cs +++ b/Events/ErrorEvents.cs @@ -4,16 +4,40 @@ namespace Cliptok.Events { public class ErrorEvents { - public static async Task CommandsNextService_CommandErrored(CommandsNextExtension _, CommandErrorEventArgs e) + public static async Task CommandErrored(CommandsExtension _, CommandErroredEventArgs e) { - if (e.Exception is CommandNotFoundException && (e.Command is null || e.Command.QualifiedName != "help")) + // Because we no longer have DSharpPlus.CommandsNext or DSharpPlus.SlashCommands (only DSharpPlus.Commands), we can't point to different + // error handlers based on command type in our command handler configuration. Instead, we can start here, and jump to the correct + // handler based on the command type. + + // This is a lazy approach that just takes error type and points to the error handlers we already had. + // Maybe it can be improved later? + + if (e.Context is TextCommandContext) + { + // Text command error + await TextCommandErrored(e); + } + else if (e.Context is SlashCommandContext) + { + // Interaction command error (slash, user ctx, message ctx) + await InteractionEvents.SlashCommandErrored(e); + } + } + + public static async Task TextCommandErrored(CommandErroredEventArgs e) + { + // strip out "textcmd" from text command names + var commandName = e.Context.Command.FullName.Replace("textcmd", ""); + + if (e.Exception is CommandNotFoundException && (e.Context.Command is null || commandName != "help")) return; // avoid conflicts with modmail - if (e.Command.QualifiedName == "edit" || e.Command.QualifiedName == "timestamp") + if (commandName == "edit" || commandName == "timestamp") return; - e.Context.Client.Logger.LogError(CliptokEventID, e.Exception, "Exception occurred during {user}s invocation of {command}", e.Context.User.Username, e.Context.Command.QualifiedName); + e.Context.Client.Logger.LogError(CliptokEventID, e.Exception, "Exception occurred during {user}s invocation of {command}", e.Context.User.Username, commandName); var exs = new List(); if (e.Exception is AggregateException ae) @@ -23,22 +47,66 @@ public static async Task CommandsNextService_CommandErrored(CommandsNextExtensio foreach (var ex in exs) { - if (ex is CommandNotFoundException && (e.Command is null || e.Command.QualifiedName != "help")) + if (ex is CommandNotFoundException && (e.Context.Command is null || commandName != "help")) return; + + // If the only exception thrown was an ArgumentParseException, run permission checks. + // If the user fails the permission checks, show a permission error instead of the ArgumentParseException. + if (ex is ArgumentParseException && exs.Count == 1) + { + var att = e.Context.Command.Attributes.FirstOrDefault(x => x is RequireHomeserverPermAttribute) as RequireHomeserverPermAttribute; + var level = (await GetPermLevelAsync(e.Context.Member)); + var levelText = level.ToString(); + if (level == ServerPermLevel.Nothing && Program.rand.Next(1, 100) == 69) + levelText = $"naught but a thing, my dear human. Congratulations, you win {Program.rand.Next(1, 10)} bonus points."; + + if (att is not null && level < att.TargetLvl) + { + await e.Context.RespondAsync( + $"{Program.cfgjson.Emoji.NoPermissions} Invalid permissions to use command **{commandName}**!\n" + + $"Required: `{att.TargetLvl}`\nYou have: `{levelText}`"); + return; + } + } + + if (ex is ChecksFailedException cfex && (commandName != "help")) + { + // Iterate over RequireHomeserverPermAttribute failures. + // Only evaluate the last one, so that if we are looking at a command in a group (say, debug shutdown), + // we only evaluate against permissions for the command (shutdown) instead of the group (debug) in case they differ. + var permErrIndex = 1; + foreach(var permErr in cfex.Errors.Where(x => x.ContextCheckAttribute is RequireHomeserverPermAttribute)) + { + // Only evaluate the last failed RequireHomeserverPermAttribute + if (permErrIndex == cfex.Errors.Count(x => x.ContextCheckAttribute is RequireHomeserverPermAttribute)) + { + var att = permErr.ContextCheckAttribute as RequireHomeserverPermAttribute; + var level = (await GetPermLevelAsync(e.Context.Member)); + var levelText = level.ToString(); + if (level == ServerPermLevel.Nothing && Program.rand.Next(1, 100) == 69) + levelText = $"naught but a thing, my dear human. Congratulations, you win {Program.rand.Next(1, 10)} bonus points."; - if (ex is ChecksFailedException && (e.Command.Name != "help")) + await e.Context.RespondAsync( + $"{Program.cfgjson.Emoji.NoPermissions} Invalid permissions to use command **{commandName}**!\n" + + $"Required: `{att.TargetLvl}`\nYou have: `{levelText}`"); + + return; + } + permErrIndex++; + } return; + } var embed = new DiscordEmbedBuilder { Color = new DiscordColor("#FF0000"), Title = "An exception occurred when executing a command", - Description = $"{cfgjson.Emoji.BSOD} `{e.Exception.GetType()}` occurred when executing `{e.Command.QualifiedName}`.", + Description = $"{cfgjson.Emoji.BSOD} `{e.Exception.GetType()}` occurred when executing `{commandName}`.", Timestamp = DateTime.UtcNow }; embed.WithFooter(discord.CurrentUser.Username, discord.CurrentUser.AvatarUrl) - .AddField("Message", ex.Message); - if (e.Exception is System.ArgumentException) + .AddField("Message", ex.Message.Replace("textcmd", "")); + if (e.Exception is System.ArgumentException or DSharpPlus.Commands.Exceptions.ArgumentParseException) embed.AddField("Note", "This usually means that you used the command incorrectly.\n" + "Please double-check how to use this command."); await e.Context.RespondAsync(embed: embed.Build()).ConfigureAwait(false); diff --git a/Events/InteractionEvents.cs b/Events/InteractionEvents.cs index 3b660474..db1a1f60 100644 --- a/Events/InteractionEvents.cs +++ b/Events/InteractionEvents.cs @@ -32,7 +32,7 @@ public static async Task ComponentInteractionCreateEvent(DiscordClient _, Compon } else if (e.Id == "clear-confirm-callback") { - Dictionary> messagesToClear = Commands.InteractionCommands.ClearInteractions.MessagesToClear; + Dictionary> messagesToClear = Commands.ClearCmds.MessagesToClear; if (!messagesToClear.ContainsKey(e.Message.Id)) { @@ -70,7 +70,7 @@ await LogChannelHelper.LogDeletedMessagesAsync( { await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate); - var overridesPendingAddition = Commands.Debug.OverridesPendingAddition; + var overridesPendingAddition = Commands.DebugCmds.OverridesPendingAddition; if (!overridesPendingAddition.ContainsKey(e.Message.Id)) { await e.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($"{cfgjson.Emoji.Error} {e.User.Mention}, this action has already been completed!").WithReply(e.Message.Id)); @@ -134,7 +134,7 @@ await LogChannelHelper.LogDeletedMessagesAsync( { await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate); - var overridesPendingAddition = Commands.Debug.OverridesPendingAddition; + var overridesPendingAddition = Commands.DebugCmds.OverridesPendingAddition; if (!overridesPendingAddition.ContainsKey(e.Message.Id)) { await e.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($"{cfgjson.Emoji.Error} {e.User.Mention}, this action has already been completed!").WithReply(e.Message.Id)); @@ -157,7 +157,7 @@ await LogChannelHelper.LogDeletedMessagesAsync( await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate); - var overridesPendingAddition = Commands.Debug.OverridesPendingAddition; + var overridesPendingAddition = Commands.DebugCmds.OverridesPendingAddition; if (!overridesPendingAddition.ContainsKey(e.Message.Id)) { await e.Channel.SendMessageAsync(new DiscordMessageBuilder().WithContent($"{cfgjson.Emoji.Error} {e.User.Mention}, this action has already been completed!").WithReply(e.Message.Id)); @@ -207,54 +207,27 @@ await LogChannelHelper.LogDeletedMessagesAsync( } - public static async Task SlashCommandErrorEvent(SlashCommandsExtension _, DSharpPlus.SlashCommands.EventArgs.SlashCommandErrorEventArgs e) + public static async Task SlashCommandErrored(CommandErroredEventArgs e) { - if (e.Exception is SlashExecutionChecksFailedException slex) + if (e.Exception is ChecksFailedException slex) { - foreach (var check in slex.FailedChecks) - if (check is SlashRequireHomeserverPermAttribute att && e.Context.CommandName != "edit") + foreach (var check in slex.Errors) + if (check.ContextCheckAttribute is RequireHomeserverPermAttribute att && e.Context.Command.Name != "edit") { var level = (await GetPermLevelAsync(e.Context.Member)); var levelText = level.ToString(); if (level == ServerPermLevel.Nothing && rand.Next(1, 100) == 69) levelText = $"naught but a thing, my dear human. Congratulations, you win {rand.Next(1, 10)} bonus points."; - await e.Context.CreateResponseAsync( - DiscordInteractionResponseType.ChannelMessageWithSource, - new DiscordInteractionResponseBuilder().WithContent( - $"{cfgjson.Emoji.NoPermissions} Invalid permission level to use command **{e.Context.CommandName}**!\n" + + await e.Context.RespondAsync(new DiscordInteractionResponseBuilder().WithContent( + $"{cfgjson.Emoji.NoPermissions} Invalid permission level to use command **{e.Context.Command.Name}**!\n" + $"Required: `{att.TargetLvl}`\n" + $"You have: `{levelText}`") .AsEphemeral(true) ); } } - e.Context.Client.Logger.LogError(CliptokEventID, e.Exception, "Error during invocation of interaction command {command} by {user}", e.Context.CommandName, $"{DiscordHelpers.UniqueUsername(e.Context.User)}"); - } - - public static async Task ContextCommandErrorEvent(SlashCommandsExtension _, DSharpPlus.SlashCommands.EventArgs.ContextMenuErrorEventArgs e) - { - if (e.Exception is SlashExecutionChecksFailedException slex) - { - foreach (var check in slex.FailedChecks) - if (check is SlashRequireHomeserverPermAttribute att && e.Context.CommandName != "edit") - { - var level = (await GetPermLevelAsync(e.Context.Member)); - var levelText = level.ToString(); - if (level == ServerPermLevel.Nothing && rand.Next(1, 100) == 69) - levelText = $"naught but a thing, my dear human. Congratulations, you win {rand.Next(1, 10)} bonus points."; - - await e.Context.CreateResponseAsync( - DiscordInteractionResponseType.ChannelMessageWithSource, - new DiscordInteractionResponseBuilder().WithContent( - $"{cfgjson.Emoji.NoPermissions} Invalid permission level to use command **{e.Context.CommandName}**!\n" + - $"Required: `{att.TargetLvl}`\n" + - $"You have: `{levelText}`") - .AsEphemeral(true) - ); - } - } - e.Context.Client.Logger.LogError(CliptokEventID, e.Exception, "Error during invocation of context command {command} by {user}", e.Context.CommandName, $"{DiscordHelpers.UniqueUsername(e.Context.User)}"); + e.Context.Client.Logger.LogError(CliptokEventID, e.Exception, "Error during invocation of interaction command {command} by {user}", e.Context.Command.Name, $"{DiscordHelpers.UniqueUsername(e.Context.User)}"); } } diff --git a/GlobalUsings.cs b/GlobalUsings.cs index 3b26ef20..16e89996 100644 --- a/GlobalUsings.cs +++ b/GlobalUsings.cs @@ -3,12 +3,20 @@ global using Cliptok.Events; global using Cliptok.Helpers; global using DSharpPlus; -global using DSharpPlus.CommandsNext; -global using DSharpPlus.CommandsNext.Attributes; -global using DSharpPlus.CommandsNext.Exceptions; +global using DSharpPlus.Commands; +global using DSharpPlus.Commands.ArgumentModifiers; +global using DSharpPlus.Commands.ContextChecks; +global using DSharpPlus.Commands.EventArgs; +global using DSharpPlus.Commands.Exceptions; +global using DSharpPlus.Commands.Processors.MessageCommands; +global using DSharpPlus.Commands.Processors.SlashCommands; +global using DSharpPlus.Commands.Processors.SlashCommands.ArgumentModifiers; +global using DSharpPlus.Commands.Processors.TextCommands; +global using DSharpPlus.Commands.Processors.UserCommands; +global using DSharpPlus.Commands.Trees; +global using DSharpPlus.Commands.Trees.Metadata; global using DSharpPlus.Entities; global using DSharpPlus.EventArgs; -global using DSharpPlus.SlashCommands; global using Microsoft.Extensions.Logging; global using Newtonsoft.Json; global using Newtonsoft.Json.Linq; @@ -17,6 +25,7 @@ global using StackExchange.Redis; global using System; global using System.Collections.Generic; +global using System.ComponentModel; global using System.Diagnostics; global using System.IO; global using System.Linq; diff --git a/Helpers/InteractionHelpers.cs b/Helpers/InteractionHelpers.cs index e69c112a..70b42ad6 100644 --- a/Helpers/InteractionHelpers.cs +++ b/Helpers/InteractionHelpers.cs @@ -2,12 +2,12 @@ { public static class BaseContextExtensions { - public static async Task PrepareResponseAsync(this BaseContext ctx) + public static async Task PrepareResponseAsync(this CommandContext ctx) { - await ctx.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource); + await ctx.DeferResponseAsync(); } - public static async Task RespondAsync(this BaseContext ctx, string text = null, DiscordEmbed embed = null, bool ephemeral = false, bool mentions = true, params DiscordComponent[] components) + public static async Task RespondAsync(this CommandContext ctx, string text = null, DiscordEmbed embed = null, bool ephemeral = false, bool mentions = true, params DiscordComponent[] components) { DiscordInteractionResponseBuilder response = new(); @@ -18,10 +18,10 @@ public static async Task RespondAsync(this BaseContext ctx, string text = null, response.AsEphemeral(ephemeral); response.AddMentions(mentions ? Mentions.All : Mentions.None); - await ctx.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, response); + await ctx.RespondAsync(response); } - public static async Task EditAsync(this BaseContext ctx, string text = null, DiscordEmbed embed = null, params DiscordComponent[] components) + public static async Task EditAsync(this CommandContext ctx, string text = null, DiscordEmbed embed = null, params DiscordComponent[] components) { DiscordWebhookBuilder response = new(); @@ -32,7 +32,7 @@ public static async Task EditAsync(this BaseContext ctx, string text = null, Dis await ctx.EditResponseAsync(response); } - public static async Task FollowAsync(this BaseContext ctx, string text = null, DiscordEmbed embed = null, bool ephemeral = false, params DiscordComponent[] components) + public static async Task FollowAsync(this CommandContext ctx, string text = null, DiscordEmbed embed = null, bool ephemeral = false, params DiscordComponent[] components) { DiscordFollowupMessageBuilder response = new(); @@ -44,7 +44,7 @@ public static async Task FollowAsync(this BaseContext ctx, string text = null, D response.AsEphemeral(ephemeral); - await ctx.FollowUpAsync(response); + await ctx.FollowupAsync(response); } } } diff --git a/Program.cs b/Program.cs index 5ea2efa3..7e882fc5 100644 --- a/Program.cs +++ b/Program.cs @@ -1,8 +1,8 @@ using DSharpPlus.Extensions; using DSharpPlus.Net.Gateway; -using DSharpPlus.SlashCommands; using Serilog.Sinks.Grafana.Loki; using System.Reflection; +using DSharpPlus.Commands.Processors.TextCommands.Parsing; namespace Cliptok { @@ -35,10 +35,9 @@ public async Task ResumeAttemptedAsync(IGatewayClient _) { } } - class Program : BaseCommandModule + class Program { public static DiscordClient discord; - static CommandsNextExtension commands; public static Random rnd = new(); public static ConfigJson cfgjson; public static ConnectionMultiplexer redis; @@ -171,19 +170,38 @@ static async Task Main(string[] _) discordBuilder.ConfigureServices(services => { services.Replace(); -#pragma warning disable CS0618 // Type or member is obsolete - services.AddSlashCommandsExtension(slash => - { - slash.SlashCommandErrored += InteractionEvents.SlashCommandErrorEvent; - slash.ContextMenuErrored += InteractionEvents.ContextCommandErrorEvent; + }); + + discordBuilder.UseCommands((_, builder) => + { + builder.CommandErrored += ErrorEvents.CommandErrored; - var slashCommandClasses = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.IsClass && t.Namespace == "Cliptok.Commands.InteractionCommands" && !t.IsNested); - foreach (var type in slashCommandClasses) - slash.RegisterCommands(type, cfgjson.ServerID); + // Register commands + var commandClasses = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.IsClass && t.Namespace == "Cliptok.Commands"); + foreach (var type in commandClasses) + if (type.Name == "GlobalCmds") + builder.AddCommands(type); + else + builder.AddCommands(type, cfgjson.ServerID); + + // Register command checks + builder.AddCheck(); + builder.AddCheck(); + builder.AddCheck(); + builder.AddCheck(); + + // Set custom prefixes from config.json + TextCommandProcessor textCommandProcessor = new(new TextCommandConfiguration + { + PrefixResolver = new DefaultPrefixResolver(true, Program.cfgjson.Core.Prefixes.ToArray()).ResolvePrefixAsync }); + builder.AddProcessor(textCommandProcessor); + }, new CommandsConfiguration + { + // Disable the default D#+ error handler because we are using our own + UseDefaultCommandErrorHandler = false }); -#pragma warning restore CS0618 // Type or member is obsolete discordBuilder.ConfigureExtraFeatures(clientConfig => { clientConfig.LogUnknownEvents = false; @@ -215,18 +233,6 @@ static async Task Main(string[] _) .HandleAutoModerationRuleExecuted(AutoModEvents.AutoModerationRuleExecuted) ); - discordBuilder.UseCommandsNext(commands => - { - var commandClasses = Assembly.GetExecutingAssembly().GetTypes().Where(t => t.IsClass && t.Namespace == "Cliptok.Commands" && !t.IsNested); - foreach (var type in commandClasses) - commands.RegisterCommands(type); - - commands.CommandErrored += ErrorEvents.CommandsNextService_CommandErrored; - }, new CommandsNextConfiguration - { - StringPrefixes = cfgjson.Core.Prefixes - }); - // TODO(erisa): At some point we might be forced to ConnectAsync() the builder directly // and then we will need to rework some other pieces that rely on Program.discord discord = discordBuilder.Build(); diff --git a/Tasks/ReminderTasks.cs b/Tasks/ReminderTasks.cs index 30d49ce9..eeb08e76 100644 --- a/Tasks/ReminderTasks.cs +++ b/Tasks/ReminderTasks.cs @@ -8,7 +8,7 @@ public static async Task CheckRemindersAsync() foreach (var reminder in Program.db.ListRange("reminders", 0, -1)) { bool DmFallback = false; - var reminderObject = JsonConvert.DeserializeObject(reminder); + var reminderObject = JsonConvert.DeserializeObject(reminder); if (reminderObject.ReminderTime <= DateTime.Now) { var user = await Program.discord.GetUserAsync(reminderObject.UserID);