diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..f213c82 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "tangzx.emmylua" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a86103..1ac8801 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,5 @@ { - "Lua.diagnostics.globals": [ - "Client", - "Discordia", - "Module", - "discordia" - ], - "Lua.runtime.version": "LuaJIT" + "Lua.diagnostics.globals": ["Client", "Discordia", "Module", "discordia"], + "Lua.runtime.version": "LuaJIT", + "editor.formatOnSave": false } diff --git a/README.md b/README.md index 4993204..d0b4aad 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # Not a Bot Source code of the bot of the french programming discord server NaN (Not a Name). https://discord.gg/zcWp9sC + +## Requirements + +- [Luvit](https://luvit.io/) runtime + +## Configuration + +The bot reads its config from `config.lua`, which is git-ignored (don't commit your token). + +1. Copy the default config: + + ```bash + cp config.lua.default config.lua + ``` + +2. Edit `config.lua` and set at minimum: + - `Token` — your Discord bot token, from the [Discord Developer Portal](https://discord.com/developers/applications) (Bot tab > Reset/Copy Token). + - `OwnerUserId` — your Discord user ID (enable Developer Mode in Discord, right-click your name, "Copy ID"). + +For development, create a separate test bot application on the Discord Developer Portal and invite it to a private test server. Use this bot's token in your local `config.lua` file to ensure that no code runs on the production bot or server. + +### Gateway intents + +The bot requires all gateway intents except for `guildIntegrations` (see `bot.lua`). Two of these are privileged and must be enabled manually on the Developer Portal (under the "Bot" tab) for your application, or the bot won't work: + +- **Server Members Intent** (`guildMembers`) +- **Presence Intent** (`guildPresences`) + +`AutoloadModules` lists which `module_*.lua` files load at startup — trim it while developing to only the module(s) you're working on, to reduce noise and side effects. + +## Running the bot + +```bash +luvit bot.lua +``` diff --git a/bot.lua b/bot.lua index fe6df2c..8bc054e 100644 --- a/bot.lua +++ b/bot.lua @@ -1,7 +1,6 @@ -- Copyright (C) 2018 Jérôme Leclercq -- This file is part of the "Not a Bot" application -- For conditions of distribution and use, see copyright notice in LICENSE - local discordia = require('discordia') local enums = discordia.enums local wrap = coroutine.wrap @@ -270,6 +269,23 @@ Bot.ConfigTypeParser = { end } +Bot.ConfigTypeToCommandOptionType = { + [Bot.ConfigType.Boolean] = enums.commandOptionType.boolean, + [Bot.ConfigType.Category] = enums.commandOptionType.channel, + [Bot.ConfigType.Channel] = enums.commandOptionType.channel, + [Bot.ConfigType.Custom] = enums.commandOptionType.string, + [Bot.ConfigType.Duration] = enums.commandOptionType.string, + [Bot.ConfigType.Emoji] = enums.commandOptionType.string, + [Bot.ConfigType.Guild] = enums.commandOptionType.string, + [Bot.ConfigType.Integer] = enums.commandOptionType.integer, + [Bot.ConfigType.Member] = enums.commandOptionType.user, + [Bot.ConfigType.Message] = enums.commandOptionType.string, + [Bot.ConfigType.Number] = enums.commandOptionType.number, + [Bot.ConfigType.Role] = enums.commandOptionType.role, + [Bot.ConfigType.String] = enums.commandOptionType.string, + [Bot.ConfigType.User] = enums.commandOptionType.user, +} + client:onSync("ready", function () print("Logged in as " .. client.user.username) end) @@ -290,7 +306,8 @@ function Bot:Save() local stopwatch = discordia.Stopwatch() for _, moduleTable in pairs(self.Modules) do - self:ProtectedCall(string.format("Module (%s) persistent data save", moduleTable.Name), moduleTable.SavePersistentData, moduleTable) + self:ProtectedCall(string.format("Module (%s) persistent data save", moduleTable.Name), + moduleTable.SavePersistentData, moduleTable) end client:info("Modules data saved (%.3fs)", stopwatch.milliseconds / 1000) diff --git a/bot_commands.lua b/bot_commands.lua index cd97f0d..c4fc085 100644 --- a/bot_commands.lua +++ b/bot_commands.lua @@ -8,13 +8,19 @@ local enums = discordia.enums local MAX_NUMBER_OF_EMBED_FIELDS = 25 +local applicationId +client:onceSync("ready", function () + local info = client:getApplicationInformation() + applicationId = info and info.id or client.user.id +end) + function Bot:BuildUsage(commandTable) local usage = {} for k,v in ipairs(commandTable.Args) do if (v.Optional) then table.insert(usage, string.format("[%s]", v.Name)) else - table.insert(usage, string.format("%s", v.Name)) + table.insert(usage, string.format("<%s>", v.Name)) end end @@ -50,7 +56,8 @@ function Bot:ParseCommandArgs(member, expectedArgs, args) values[argIndex] = nil -- Do not increment argumentIndex, try to parse it again as the next parameter else - return false, string.format("Invalid value for argument %d (%s)%s", argIndex, argData.Name, err and ": " .. err or "") + return false, + string.format("Invalid value for argument %d (%s)%s", argIndex, argData.Name, err and ": " .. err or "") end end @@ -63,6 +70,20 @@ function Bot:RegisterCommand(values) error("Command \"" .. name .. " already exists") end + local subcommands + if (values.Subcommands) then + subcommands = {} + for _, sub in ipairs(values.Subcommands) do + subcommands[sub.Name:lower()] = { + Name = sub.Name:lower(), + Description = sub.Description, + Args = sub.Args or {}, + PrivilegeCheck = sub.PrivilegeCheck, + Func = sub.Func + } + end + end + local command = { Args = values.Args, Function = values.Func, @@ -70,7 +91,12 @@ function Bot:RegisterCommand(values) Name = name, PrivilegeCheck = values.PrivilegeCheck, Silent = values.Silent ~= nil and values.Silent, - BotAware = values.BotAware ~= nil and values.BotAware + BotAware = values.BotAware ~= nil and values.BotAware, + Slash = values.Slash, + ContextMenu = values.ContextMenu, + Subcommands = subcommands, + SlashFunc = values.Slash and values.Slash.Func or values.Func, + ContextMenuFunc = values.ContextMenu and values.ContextMenu.Func or values.Func } self.Commands[name] = command @@ -80,19 +106,97 @@ function Bot:UnregisterCommand(commandName) self.Commands[commandName:lower()] = nil end -local prefixes = { - function (content, guild) - local prefix = Config.Prefix - if guild then - local serverconfig = Bot:GetModuleForGuild(guild, "serverconfig") - if serverconfig then - local config = serverconfig:GetConfig(guild) - if config then - prefix = config.Prefix +Bot.ApplicationCommandIds = {} + +local function buildOptionsFromArgs(argsList) + local options = {} + for _, argData in ipairs(argsList or {}) do + table.insert(options, { + name = argData.Name:lower(), + description = argData.Description or argData.Name, + type = Bot.ConfigTypeToCommandOptionType[argData.Type], + required = not argData.Optional, + autocomplete = argData.Autocomplete ~= nil or nil + }) + end + return options +end + +function Bot:SyncApplicationCommandsForGuild(guild, commandNames) + for _, name in ipairs(commandNames) do + local commandTable = self.Commands[name] + if (commandTable) then + if (commandTable.Subcommands) then + local options = {} + for _, sub in pairs(commandTable.Subcommands) do + local subOptions = buildOptionsFromArgs(sub.Args) + table.insert(options, { + name = sub.Name, + description = sub.Description or sub.Name, + type = 1, + options = #subOptions > 0 and subOptions or nil + }) + end + local payload = { + name = name, + description = type(commandTable.Help) == "function" and commandTable.Help(guild) or commandTable.Help + or name, + type = 1, + options = options + } + local cmd, err = client._api:createGuildApplicationCommand(applicationId, guild.id, payload) + if (cmd) then + self.ApplicationCommandIds[guild.id .. ":slash:" .. name] = cmd.id + else + self.Client:error("Failed to register slash command %s: %s", name, err) + end + elseif (commandTable.Slash) then + local options = buildOptionsFromArgs(commandTable.Args) + local description = commandTable.Slash.Description + if (type(commandTable.Help) == "function") then + description = description or commandTable.Help(guild) + else + description = description or commandTable.Help + end + local payload = { name = name, description = description, type = 1, options = #options > 0 and options + or nil } + local cmd, err = client._api:createGuildApplicationCommand(applicationId, guild.id, payload) + if (cmd) then + self.ApplicationCommandIds[guild.id .. ":slash:" .. name] = cmd.id + else + self.Client:error("Failed to register slash command %s: %s", name, err) + end + end + if (commandTable.ContextMenu) then + local menuType = commandTable.ContextMenu.Type == "message" and 3 or 2 + local payload = { name = name, type = menuType } + local cmd, err = client._api:createGuildApplicationCommand(applicationId, guild.id, payload) + if (cmd) then + self.ApplicationCommandIds[guild.id .. ":context:" .. name] = cmd.id + else + self.Client:error("Failed to register context menu command %s: %s", name, err) + end + end end end end +function Bot:UnsyncApplicationCommandsForGuild(guild, commandNames) + for _, name in ipairs(commandNames) do + for _, kind in ipairs({ "slash", "context" }) do + local key = guild.id .. ":" .. kind .. ":" .. name + local id = self.ApplicationCommandIds[key] + if (id) then + client._api:deleteGuildApplicationCommand(applicationId, guild.id, id) + self.ApplicationCommandIds[key] = nil + end + end + end +end + +local prefixes = { + function (content, guild) + local prefix = Bot:GetGuildPrefix(guild) return content:startswith(prefix, true) and content:sub(#prefix + 1) or nil end, function (content) @@ -136,25 +240,35 @@ client:on('messageCreate', function(message) end if (commandTable.PrivilegeCheck) then - local success, ret = Bot:ProtectedCall("Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, message.member) + local success, ret = Bot:ProtectedCall( + "Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, message.member + ) if (not success) then message:reply("An error occurred") return end if (not ret) then - print(string.format("%s tried to use command %s on guild %s", message.author.tag, commandName, message.guild.name)) + print( + string.format( + "%s tried to use command %s on guild %s", message.author.tag, commandName, message.guild.name + ) + ) return end end - local args, err = Bot:ParseCommandArgs(message.member, commandTable.Args, string.GetArguments(args, #commandTable.Args)) + local args, err = Bot:ParseCommandArgs( + message.member, commandTable.Args, string.GetArguments(args, #commandTable.Args) + ) if (not args) then message:reply(err) return end - Bot:ProtectedCall("Command " .. commandName, commandTable.Function, message, table.unpack(args, 1, #commandTable.Args)) + Bot:ProtectedCall( + "Command " .. commandName, commandTable.Function, message, table.unpack(args, 1, #commandTable.Args) + ) if (commandTable.Silent) then message:delete() @@ -167,7 +281,9 @@ local function getCommands(member) for commandName, commandTable in pairs(Bot.Commands) do local visible = true if (commandTable.PrivilegeCheck) then - local success, ret = Bot:ProtectedCall("Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, member) + local success, ret = Bot:ProtectedCall( + "Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, member + ) if (not success or not ret) then visible = false end @@ -187,11 +303,15 @@ local function getCommands(member) helpStr = commandTable.Help(member.guild) end + if commandTable.Args ~= nil then table.insert(commandsFields, { name = string.format("**Command: %s**", commandTable.Name), - value = string.format("**Description:** %s\n**Usage:** %s %s", helpStr, commandTable.Name, Bot:BuildUsage(commandTable)) + value = string.format( + "**Description:** %s\n**Usage:** %s %s", helpStr, commandTable.Name, Bot:BuildUsage(commandTable) + ) }) end + end return commandsFields end @@ -223,7 +343,204 @@ local function getHelpButtonsComponent(guild, selectedPage, nbPages) return components end +local function resolveArgsFromOptions(guild, argsList, options) + local optionsByName = {} + for _, opt in ipairs(options or {}) do + optionsByName[opt.name:lower()] = opt.value + end + + local args = {} + for i, argData in ipairs(argsList) do + local raw = optionsByName[argData.Name:lower()] + if (raw == nil) then + if (not argData.Optional) then + return nil, "Missing required argument: " .. argData.Name + end + elseif (argData.Type == Bot.ConfigType.Member) then + args[i] = guild:getMember(raw) + elseif (argData.Type == Bot.ConfigType.User) then + args[i] = Bot:DecodeUser(raw) + elseif (argData.Type == Bot.ConfigType.Duration) then + args[i] = Bot.ConfigTypeParameter[Bot.ConfigType.Duration](tostring(raw), guild) + elseif (argData.Type == Bot.ConfigType.Channel or argData.Type == Bot.ConfigType.Category) then + args[i] = guild:getChannel(raw) + elseif (argData.Type == Bot.ConfigType.Role) then + args[i] = guild:getRole(raw) + else + args[i] = raw + end + end + return args +end + +function Bot:DispatchApplicationCommand(interaction) + local guild = interaction.guild + if (not guild) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "This command can only be used in a server.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + + local data = interaction.data + local commandTable = self.Commands[data.name:lower()] + if (not commandTable) then return end + + local member = interaction.member + if (commandTable.PrivilegeCheck) then + local success, ret = self:ProtectedCall( + "Command " .. data.name .. " privilege check", commandTable.PrivilegeCheck, member + ) + if (not success or not ret) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "You are not authorized to use this command.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + end + + if (commandTable.Subcommands) then + local subOption = data.options and data.options[1] + local sub = subOption and commandTable.Subcommands[subOption.name:lower()] + if (not sub) then return end + + if (sub.PrivilegeCheck) then + local success, ret = self:ProtectedCall( + "Command " .. data.name .. " " .. sub.Name .. " privilege check", sub.PrivilegeCheck, member + ) + if (not success or not ret) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "You are not authorized to use this command.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + end + + local args, err = resolveArgsFromOptions(guild, sub.Args, subOption.options) + if (not args) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = err, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + + return self:ProtectedCall( + "Command " .. data.name .. " " .. sub.Name, sub.Func, interaction, table.unpack(args, 1, #sub.Args) + ) + end + + local args, func + if (data.target_id) then + if (commandTable.ContextMenu and commandTable.ContextMenu.Type == "message") then + local targetMessage = interaction.channel and interaction.channel:getMessage(data.target_id) + if (not targetMessage) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "Target message not found.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + args = { targetMessage } + else + local targetMember = guild:getMember(data.target_id) + if (not targetMember) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "Target is not a member of this server.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + args = { targetMember } + end + func = commandTable.ContextMenuFunc + else + local err + args, err = resolveArgsFromOptions(guild, commandTable.Args, data.options) + if (not args) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = err, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + func = commandTable.SlashFunc + end + + self:ProtectedCall("Command " .. data.name, func, interaction, table.unpack(args, 1, #commandTable.Args)) +end + +function Bot:DispatchApplicationCommandAutocomplete(interaction) + local guild = interaction.guild + local data = interaction.data + local commandTable = self.Commands[data.name:lower()] + + local focused + for _, opt in ipairs(data.options or {}) do + if (opt.focused) then + focused = opt + end + end + + local choices = {} + if (guild and commandTable and focused) then + local argData + for _, a in ipairs(commandTable.Args or {}) do + if (a.Name:lower() == focused.name:lower()) then + argData = a + break + end + end + + if (argData and argData.Autocomplete) then + local success, ret = self:ProtectedCall( + "Autocomplete " .. data.name, argData.Autocomplete, guild, interaction.member, focused.value or "" + ) + if (success and type(ret) == "table") then + choices = ret + if (#choices > 25) then + local clipped = {} + for i = 1, 25 do + clipped[i] = choices[i] + end + choices = clipped + end + end + end + end + + interaction:respond({ + type = enums.interactionResponseType.applicationCommandAutocompleteResult, + data = { choices = choices } + }) +end + client:on("interactionCreate", function (interaction) + if (interaction.type == enums.interactionRequestType.applicationCommand) then + return Bot:DispatchApplicationCommand(interaction) + end + + if (interaction.type == enums.interactionRequestType.applicationCommandAutocomplete) then + return Bot:DispatchApplicationCommandAutocomplete(interaction) + end + local guild = interaction.guild if (not guild) then return @@ -231,7 +548,7 @@ client:on("interactionCreate", function (interaction) local member = interaction.member local interactionId = interaction.data.custom_id - if (not string.match(interactionId, "^help_button")) then + if (not interactionId or not string.match(interactionId, "^help_button")) then return end @@ -245,8 +562,7 @@ client:on("interactionCreate", function (interaction) local page = { table.unpack( - commandsFields, - ((selectedPage - 1) * MAX_NUMBER_OF_EMBED_FIELDS) + 1, + commandsFields, ((selectedPage - 1) * MAX_NUMBER_OF_EMBED_FIELDS) + 1, selectedPage * MAX_NUMBER_OF_EMBED_FIELDS ) } @@ -285,14 +601,21 @@ Bot:RegisterCommand({ end if (commandTable.PrivilegeCheck) then - local success, ret = Bot:ProtectedCall("Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, member) + local success, ret = Bot:ProtectedCall( + "Command " .. commandName .. " privilege check", commandTable.PrivilegeCheck, member + ) if (not success) then message:reply("An error occurred") return end if (not ret) then - print(string.format("%s tried to access command %s via help on guild %s", message.author.tag, commandName, guild.name)) + print( + string.format( + "%s tried to access command %s via help on guild %s", message.author.tag, commandName, + guild.name + ) + ) return end end @@ -304,7 +627,9 @@ Bot:RegisterCommand({ table.insert(commandsFields, { name = string.format("**Command: %s**", commandName), - value = string.format("**Description:** %s\n**Usage:** %s %s", helpStr, commandName, Bot:BuildUsage(commandTable)) + value = string.format( + "**Description:** %s\n**Usage:** %s %s", helpStr, commandName, Bot:BuildUsage(commandTable) + ) }) else commandsFields = getCommands(member) diff --git a/bot_modules.lua b/bot_modules.lua index 5c49f0e..0a762cc 100644 --- a/bot_modules.lua +++ b/bot_modules.lua @@ -154,7 +154,7 @@ local configTypeValidation = { return true end, - [Bot.ConfigType.User] = util.ValidateSnowflake, + [Bot.ConfigType.User] = util.ValidateSnowflake } local validateConfigType = function (configTable, value, guildId) @@ -220,7 +220,10 @@ function ModuleMetatable:_PrepareConfig(context, config, values, guildId) else local success, err = validateConfigType(configTable, value, guildId) if (not success) then - self:LogWarning("%s has invalid value (%s) for option %s (%s), resetting...", context, tostring(value), configTable.Name, err or "") + self:LogWarning( + "%s has invalid value (%s) for option %s (%s), resetting...", context, tostring(value), + configTable.Name, err or "" + ) reset = true end end @@ -271,6 +274,10 @@ function ModuleMetatable:DisableForGuild(guild, dontSave) self:SaveGuildConfig(guild) end + if (self._ApplicationCommands and #self._ApplicationCommands > 0) then + Bot:UnsyncApplicationCommandsForGuild(guild, self._ApplicationCommands) + end + self:LogInfo(guild, "Module disabled") return true else @@ -305,6 +312,10 @@ function ModuleMetatable:EnableForGuild(guild, ignoreCheck, dontSave) self:Save(guild) end + if (self._ApplicationCommands and #self._ApplicationCommands > 0) then + Bot:SyncApplicationCommandsForGuild(guild, self._ApplicationCommands) + end + self:LogInfo(guild, "Module enabled (%.3fs)", stopwatch.milliseconds / 1000) return true end @@ -341,9 +352,7 @@ function ModuleMetatable:GetGuildData(guildId, noCreate) local guildData = self._Guilds[guildId] if (not guildData and not noCreate) then guildData = {} - guildData.Config = { - _Enabled = false - } + guildData.Config = { _Enabled = false } guildData.Data = {} guildData.PersistentData = {} guildData._Ready = false @@ -388,7 +397,10 @@ for k, func in pairs({"error", "info", "warning"}) do if (type(guild) == "string") then Bot.Client[func](Bot.Client, "[%s][%s] %s", "<*>", moduleTable.Name, string.format(guild, ...)) else - Bot.Client[func](Bot.Client, "[%s][%s] %s", guild and guild.name or "", moduleTable.Name, string.format(...)) + Bot.Client[func]( + Bot.Client, "[%s][%s] %s", guild and guild.name or "", moduleTable.Name, + string.format(...) + ) end end end @@ -411,6 +423,12 @@ function ModuleMetatable:RegisterCommand(values) table.insert(self._Commands, values.Name) + if (values.Slash or values.ContextMenu or values.Subcommands) then + print("Application commands found") + self._ApplicationCommands = self._ApplicationCommands or {} + table.insert(self._ApplicationCommands, values.Name) + end + return Bot:RegisterCommand(values) end @@ -439,11 +457,12 @@ function ModuleMetatable:SaveGlobalPersistentData() end end - function ModuleMetatable:LoadGuildConfig(guild) local guildData = self:GetGuildData(guild.id) - local config, err = Bot:UnserializeFromFile(string.format("data/module_%s/guild_%s/config.json", self.Name, guild.id)) + local config, err = Bot:UnserializeFromFile( + string.format("data/module_%s/guild_%s/config.json", self.Name, guild.id) + ) if (config) then self:_PrepareGuildConfig(guild.id, config) @@ -502,14 +521,18 @@ function ModuleMetatable:SavePersistentData(guild) end end - function Bot:CallModuleFunction(moduleTable, functionName, ...) - return self:ProtectedCall(string.format("Module (%s) function (%s)", moduleTable.Name, functionName), moduleTable[functionName], moduleTable, ...) + return self:ProtectedCall( + string.format("Module (%s) function (%s)", moduleTable.Name, functionName), moduleTable[functionName], + moduleTable, ... + ) end function Bot:CallOnReady(moduleTable) if (moduleTable.OnReady) then - wrap(function () self:CallModuleFunction(moduleTable, "OnReady") end)() + wrap(function () + self:CallModuleFunction(moduleTable, "OnReady") + end)() end end @@ -579,12 +602,19 @@ function Bot:LoadModule(moduleTable) for configName, configValue in pairs(configTable) do local option = validConfigOptions[configName] if (not option) then - return false, string.format("[%s] Option #%s has invalid key \"%s\"", configTable.Name, optionIndex, configName) + return false, + string.format( + "[%s] Option #%s has invalid key \"%s\"", configTable.Name, optionIndex, configName + ) end local expectedType = option[1] if (expectedType ~= "any" and type(configValue) ~= expectedType) then - return false, string.format("[%s] Option #%s has key \"%s\" which has invalid type %s (expected %s)", configTable.Name, optionIndex, configName, type(configValue), expectedType) + return false, + string.format( + "[%s] Option #%s has key \"%s\" which has invalid type %s (expected %s)", configTable.Name, + optionIndex, configName, type(configValue), expectedType + ) end end @@ -603,7 +633,10 @@ function Bot:LoadModule(moduleTable) end if (configTable.Default == nil and not configTable.Optional) then - return false, string.format("[%s] Option #%s is not optional and has no default value", configTable.Name, optionIndex) + return false, + string.format( + "[%s] Option #%s is not optional and has no default value", configTable.Name, optionIndex + ) end if (configTable.Global) then @@ -626,7 +659,13 @@ function Bot:LoadModule(moduleTable) return false, "Module tried to bind hook \"" .. eventName .. "\" which doesn't exist" end - moduleEvents[eventName] = {Module = moduleTable, Callback = function (moduleTable, ...) self:CallModuleFunction(moduleTable, key, ...) end} + moduleEvents[eventName] = { + Module = moduleTable, + Callback = function (moduleTable, ...) + self + :CallModuleFunction(moduleTable, key, ...) + end + } end end end @@ -642,19 +681,17 @@ function Bot:LoadModule(moduleTable) moduleTable:_PrepareGlobalConfig() - -- Loading finished, call callback - self.Modules[moduleTable.Name] = moduleTable - if (moduleTable.OnLoaded) then local success, err = self:CallModuleFunction(moduleTable, "OnLoaded") if (not success or not err) then - self.Modules[moduleTable.Name] = nil - err = err or "OnLoaded hook returned false" return false, err end end + -- Loading finished, call callback + self.Modules[moduleTable.Name] = moduleTable + local loadTime = stopwatch.milliseconds / 1000 self.Client:info("[<*>][%s] Loaded module (%.3fs)", moduleTable.Name, stopwatch.milliseconds / 1000) @@ -711,14 +748,18 @@ function Bot:LoadModuleData(moduleTable) guildData.Config = config moduleTable:_PrepareGuildConfig(guildId, guildData.Config) else - self.Client:error("Failed to load config of guild %s (%s module): %s", guildId, moduleTable.Name, err) + self.Client:error( + "Failed to load config of guild %s (%s module): %s", guildId, moduleTable.Name, err + ) end local persistentData, err = self:UnserializeFromFile(path .. "/persistentdata.json") if (persistentData) then guildData.PersistentData = persistentData else - self.Client:error("Failed to load persistent data of guild %s (%s module): %s", guildId, moduleTable.Name, err) + self.Client:error( + "Failed to load persistent data of guild %s (%s module): %s", guildId, moduleTable.Name, err + ) end end elseif (entry.type == "file") then @@ -835,7 +876,6 @@ Bot.Client:onSync("ready", function () isReady = true end) - Bot:RegisterCommand({ Name = "modulelist", Args = {}, @@ -867,7 +907,7 @@ Bot:RegisterCommand({ embed = { title = "Module list", fields = { - {name = "Loaded modules", value = table.concat(moduleListStr, '\n')}, + { name = "Loaded modules", value = table.concat(moduleListStr, '\n') } }, timestamp = discordia.Date():toISO('T', 'Z') } @@ -967,7 +1007,9 @@ Bot:RegisterCommand({ if (message.member.id == Config.OwnerUserId) then for k,configTable in pairs(moduleTable._GlobalConfig) do - table.insert(fields, GenerateField(guild, configTable, rawget(moduleTable.GlobalConfig, configTable.Name))) + table.insert( + fields, GenerateField(guild, configTable, rawget(moduleTable.GlobalConfig, configTable.Name)) + ) end end @@ -975,7 +1017,8 @@ Bot:RegisterCommand({ if (moduleTable.Global) then enabledText = ":globe_with_meridians: This module is global and cannot be enabled nor disabled on a guild basis" elseif (moduleTable:IsEnabledForGuild(guild)) then - enabledText = ":white_check_mark: Module **enabled** (use `!disable " .. moduleTable.Name .. "` to disable it)" + enabledText = ":white_check_mark: Module **enabled** (use `!disable " .. moduleTable.Name + .. "` to disable it)" else enabledText = ":x: Module **disabled** (use `!enable " .. moduleTable.Name .. "` to enable it)" end @@ -985,7 +1028,12 @@ Bot:RegisterCommand({ title = "Configuration for " .. moduleTable.Name .. " module", description = string.format("%s\n\nConfiguration list:", enabledText, moduleTable.Name), fields = fields, - footer = {text = string.format("Use `!config %s add/remove/reset/set/show ConfigName ` to change configuration settings.", moduleTable.Name)} + footer = { + text = string.format( + "Use `!config %s add/remove/reset/set/show ConfigName ` to change configuration settings.", + moduleTable.Name + ) + } } }) elseif (action == "show") then @@ -1019,7 +1067,8 @@ Bot:RegisterCommand({ end if (not configTable.Array and (action == "add" or action == "remove")) then - message:reply("Configuration **" .. configTable.Name .. "** is not an array, use the *set* action to change its value") + message:reply("Configuration **" .. configTable.Name + .. "** is not an array, use the *set* action to change its value") return end @@ -1039,7 +1088,9 @@ Bot:RegisterCommand({ newValue = valueParser(value, guild) if (newValue == nil) then - message:reply("Failed to parse new value (type: " .. Bot.ConfigTypeString[configTable.Type] .. ")") + message:reply( + "Failed to parse new value (type: " .. Bot.ConfigTypeString[configTable.Type] .. ")" + ) return end end @@ -1076,7 +1127,9 @@ Bot:RegisterCommand({ end if (configTable.ArrayMaxSize and #values >= configTable.ArrayMaxSize) then - message:reply("Too many values (this setting can only have up to " .. configTable.ArrayMaxSize .. " values)") + message:reply( + "Too many values (this setting can only have up to " .. configTable.ArrayMaxSize .. " values)" + ) return end @@ -1137,7 +1190,9 @@ Bot:RegisterCommand({ } }) else - message:reply("Invalid action \"" .. action .. "\" (valid actions are *add*, *remove*, *reset*, *set* or *show*)") + message:reply( + "Invalid action \"" .. action .. "\" (valid actions are *add*, *remove*, *reset*, *set* or *show*)" + ) end end }) @@ -1186,7 +1241,11 @@ Bot:RegisterCommand({ embed = { title = "Configuration for " .. moduleTable.Name .. " module", description = "Configuration was too big and has been sent as a file", - footer = {text = string.format("Use `!configraw %s update` to change configuration.", moduleTable.Name)} + footer = { + text = string.format( + "Use `!configraw %s update` to change configuration.", moduleTable.Name + ) + } } }) message:reply({ file = {"config.json", fieldJson} }) @@ -1195,7 +1254,11 @@ Bot:RegisterCommand({ embed = { title = "Configuration for " .. moduleTable.Name .. " module", description = string.format("```json\n%s```", json.encode(fields, { indent = 1 })), - footer = {text = string.format("Use `!configraw %s update` to change configuration.", moduleTable.Name)} + footer = { + text = string.format( + "Use `!configraw %s update` to change configuration.", moduleTable.Name + ) + } } }) end @@ -1282,7 +1345,9 @@ Bot:RegisterCommand({ moduleTable:HandleConfigUpdate(nil, globalConfig, fieldName) wasGlobalConfigModified = true - table.insert(fieldDescriptions, GenerateField(guild, configTable, fieldValue, false)) + table.insert( + fieldDescriptions, GenerateField(guild, configTable, fieldValue, false) + ) end) else errorFields[fieldName] = err @@ -1303,7 +1368,9 @@ Bot:RegisterCommand({ table.insert(msg, "Field errors detected, please check their value (no configuration has been updated)") if (#ignoredFields > 0) then - table.insert(msg, " - Ignored field (not matching a configuration): " .. table.concat(ignoredFields, ", ")) + table.insert( + msg, " - Ignored field (not matching a configuration): " .. table.concat(ignoredFields, ", ") + ) end if (next(errorFields)) then diff --git a/bot_utility.lua b/bot_utility.lua index e11621c..c804cb6 100644 --- a/bot_utility.lua +++ b/bot_utility.lua @@ -139,6 +139,18 @@ function Bot:DecodeRole(guild, message) return role end +function Bot:ParseModalFields(interaction) + local fields = {} + for _, row in ipairs(interaction.data.components or {}) do + local component = row.components[1] + if (component) then + fields[component.custom_id] = component.value + end + end + + return fields +end + function Bot:DecodeUser(message) assert(message) @@ -158,6 +170,21 @@ function Bot:DecodeUser(message) return user end +function Bot:GetGuildPrefix(guild) + local prefix = Config.Prefix + if (guild) then + local serverconfig = self:GetModuleForGuild(guild, "serverconfig") + if (serverconfig) then + local config = serverconfig:GetConfig(guild) + if (config and config.Prefix) then + prefix = config.Prefix + end + end + end + + return prefix or "!" +end + function Bot:GenerateMessageLink(message) local guildId = message.guild and message.guild.id or "@me" return string.format("https://discord.com/channels/%s/%s/%s", guildId, message.channel.id, message.id) diff --git a/deps/discordia/libs/client/API.lua b/deps/discordia/libs/client/API.lua index be4f89b..df949d8 100644 --- a/deps/discordia/libs/client/API.lua +++ b/deps/discordia/libs/client/API.lua @@ -321,7 +321,7 @@ end function API:createGuildApplicationCommand(application_id, guild_id, payload, query) local endpoint = f(endpoints.APPLICATION_GUILD_COMMANDS, application_id, guild_id) - return self:request("POST", endpoint, payload, query, payload) + return self:request("POST", endpoint, payload, query) end function API:getGuildApplicationCommand(application_id, guild_id, command_id, query) diff --git a/deps/discordia/libs/containers/Message.lua b/deps/discordia/libs/containers/Message.lua index 265a4ac..a53f9fc 100644 --- a/deps/discordia/libs/containers/Message.lua +++ b/deps/discordia/libs/containers/Message.lua @@ -427,10 +427,18 @@ end @m reply @t http @p content string/table +@p opts table @r Message -@d Equivalent to `Message.channel:send(content)`. +@d Equivalent to `Message.channel:send(content)`, but sent as a real Discord +reply (message_reference) to this message. Pass `opts.mention = true` to ping +the replied-to user. ]=] -function Message:reply(content) +function Message:reply(content, opts) + if type(content) == 'table' then + content.reference = content.reference or { message = self, mention = opts and opts.mention } + else + content = { content = content, reference = { message = self, mention = opts and opts.mention } } + end return self._parent:send(content) end diff --git a/deps/discordia/libs/enums.lua b/deps/discordia/libs/enums.lua index 3078ac9..b17390c 100644 --- a/deps/discordia/libs/enums.lua +++ b/deps/discordia/libs/enums.lua @@ -430,6 +430,8 @@ enums.interactionRequestType = enum { ping = 1, applicationCommand = 2, messageComponent = 3, + applicationCommandAutocomplete = 4, + modalSubmit = 5, } enums.interactionResponseType = enum { diff --git a/module_ban.lua b/module_ban.lua index e9b025a..917693c 100644 --- a/module_ban.lua +++ b/module_ban.lua @@ -36,22 +36,8 @@ function Module:GetConfigTable() } end -function Module:OnLoaded() - self:RegisterCommand({ - Name = "ban", - Args = { - {Name = "target", Type = Bot.ConfigType.User}, - {Name = "duration", Type = Bot.ConfigType.Duration, Optional = true}, - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, - }, - PrivilegeCheck = function (member) return self:CheckPermissions(member) end, - - Help = "Bans a member", - Silent = true, - Func = function (commandMessage, targetUser, duration, reason) - local guild = commandMessage.guild +function Module:PerformBan(guild, bannedBy, targetUser, duration, reason) local config = self:GetConfig(guild) - local bannedBy = commandMessage.member -- Duration if (not duration) then @@ -66,8 +52,7 @@ function Module:OnLoaded() local bannedByRole = bannedBy.highestRole local targetRole = targetMember.highestRole if (targetRole.position >= bannedByRole.position) then - commandMessage:reply("You cannot ban that user due to your lower permissions.") - return + return false, "You cannot ban that user due to your lower permissions." end end @@ -81,11 +66,16 @@ function Module:OnLoaded() durationText = "" end - privateChannel:send(string.format("You have been banned from **%s** by %s (%s)\n%s", commandMessage.guild.name, bannedBy.user.mentionString, #reason > 0 and ("reason: " .. reason) or "no reason given", durationText)) + privateChannel:send( + string.format( + "You have been banned from **%s** by %s (%s)\n%s", guild.name, bannedBy.user.mentionString, + #reason > 0 and ("reason: " .. reason) or "no reason given", durationText + ) + ) end end - local data = self:GetData(commandMessage.guild) + local data = self:GetData(guild) data.BanInProgress[targetUser.id] = true if (guild:banUser(targetUser, reason, 0)) then local durationText @@ -95,28 +85,20 @@ function Module:OnLoaded() durationText = "permanent" end - commandMessage:reply(string.format("%s has banned %s (%s)%s", bannedBy.name, targetUser.tag, durationText, #reason > 0 and (" for the reason: " .. reason) or "")) + self:RegisterBan(guild, targetUser.id, bannedBy.user, duration, reason) - self:RegisterBan(commandMessage.guild, targetUser.id, commandMessage.author, duration, reason) + return true, + string.format( + "%s has banned %s (%s)%s", bannedBy.name, targetUser.tag, durationText, + #reason > 0 and (" for the reason: " .. reason) or "" + ) else data.BanInProgress[targetUser.id] = nil - commandMessage:reply(string.format("Failed to ban %s", targetUser.tag)) + return false, string.format("Failed to ban %s", targetUser.tag) end end - }) - - self:RegisterCommand({ - Name = "unban", - Args = { - {Name = "target", Type = Bot.ConfigType.User}, - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, - }, - PrivilegeCheck = function (member) return self:CheckPermissions(member) end, - Help = "Unbans a member", - Silent = true, - Func = function (commandMessage, targetUser, reason) - local guild = commandMessage.guild +function Module:PerformUnban(guild, unbannedBy, targetUser, reason) local config = self:GetConfig(guild) -- Reason @@ -125,26 +107,142 @@ function Module:OnLoaded() if (config.SendPrivateMessage) then local privateChannel = targetUser:getPrivateChannel() if (privateChannel) then - privateChannel:send(string.format("You have been unbanned from **%s** by %s (%s)", commandMessage.guild.name, commandMessage.member.user.mentionString, #reason > 0 and ("reason: " .. reason) or "no reason given")) + privateChannel:send( + string.format( + "You have been unbanned from **%s** by %s (%s)", guild.name, unbannedBy.user.mentionString, + #reason > 0 and ("reason: " .. reason) or "no reason given" + ) + ) end end - local data = self:GetData(commandMessage.guild) - local success, err = guild:unbanUser(targetUser, reason) if (success) then - commandMessage:reply(string.format("%s has unbanned %s%s", commandMessage.member.name, targetUser.tag, #reason > 0 and (" for the reason: " .. reason) or "")) + return true, + string.format( + "%s has unbanned %s%s", unbannedBy.name, targetUser.tag, + #reason > 0 and (" for the reason: " .. reason) or "" + ) else - commandMessage:reply(string.format("Failed to unban %s: %s", targetUser.tag, err)) + return false, string.format("Failed to unban %s: %s", targetUser.tag, err) + end +end + +local function BuildBanModal(targetMember) + return { + type = enums.interactionResponseType.modal, + data = { + custom_id = "ban_modal_" .. targetMember.id, + title = "Ban " .. targetMember.tag, + components = { + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "duration", + style = enums.textInputStyle.short, + label = "Duration", + placeholder = "30m, 2h, 1d... (leave empty for default duration)", + required = false + } + } + }, + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "reason", + style = enums.textInputStyle.paragraph, + label = "Reason", + required = false + } + } + } + } + } + } +end + +function Module:OnLoaded() + self:RegisterCommand({ + Name = "ban", + Args = { + { Name = "target", Type = Bot.ConfigType.User, Description = "Member to ban" }, + { + Name = "duration", + Type = Bot.ConfigType.Duration, + Description = "Duration to ban for (seconds by default, or use m/h/d/w suffix, e.g. 30m, 2h)", + Optional = true + }, + { Name = "reason", Type = Bot.ConfigType.String, Description = "Ban reason", Optional = true } + }, + PrivilegeCheck = function (member) return self:CheckPermissions(member) end, + + Help = "Bans a member", + Silent = true, + Func = function (commandMessage, targetUser, duration, reason) + local success, text = self:PerformBan( + commandMessage.guild, commandMessage.member, targetUser, duration, reason + ) + commandMessage:reply(text) + end, + Slash = { + Description = "Ban a member", + Func = function (interaction, targetUser, duration, reason) + local success, text = self:PerformBan( + interaction.guild, interaction.member, targetUser, duration, reason + ) + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) + end + }, + ContextMenu = { + Type = "user", + Func = function (interaction, targetMember) + interaction:respond(BuildBanModal(targetMember)) end + } + }) + + self:RegisterCommand({ + Name = "unban", + Args = { + { Name = "target", Type = Bot.ConfigType.User, Description = "Member to unban" }, + { Name = "reason", Type = Bot.ConfigType.String, Description = "Unban reason", Optional = true } + }, + PrivilegeCheck = function (member) return self:CheckPermissions(member) end, + + Help = "Unbans a member", + Silent = true, + Func = function (commandMessage, targetUser, reason) + local success, text = self:PerformUnban(commandMessage.guild, commandMessage.member, targetUser, reason) + commandMessage:reply(text) + end, + Slash = { + Description = "Unban a member", + Func = function (interaction, targetUser, reason) + local success, text = self:PerformUnban(interaction.guild, interaction.member, targetUser, reason) + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) end + } }) self:RegisterCommand({ Name = "updatebanduration", Args = { - {Name = "target", Type = Bot.ConfigType.User}, - {Name = "new_duration", Type = Bot.ConfigType.Duration}, + { Name = "target", Type = Bot.ConfigType.User, Description = "Banned member to update" }, + { + Name = "new_duration", + Type = Bot.ConfigType.Duration, + Description = "New ban duration (seconds by default, or use m/h/d/w suffix, e.g. 30m, 2h)" + } }, PrivilegeCheck = function (member) return self:CheckPermissions(member) end, @@ -154,10 +252,12 @@ function Module:OnLoaded() local guild = commandMessage.guild if (self:UpdateBanDuration(guild, targetUser.id, newDuration)) then - commandMessage:reply(string.format("%s has updated %s ban duration (%s)", - commandMessage.member.name, - targetUser.tag, - newDuration > 0 and ("unbanned " .. util.DiscordRelativeTime(newDuration)) or "banned permanently")) + commandMessage:reply( + string.format( + "%s has updated %s ban duration (%s)", commandMessage.member.name, targetUser.tag, + newDuration > 0 and ("unbanned " .. util.DiscordRelativeTime(newDuration)) or "banned permanently" + ) + ) else commandMessage:reply(string.format("%s is not banned", targetUser.tag)) end @@ -166,6 +266,40 @@ function Module:OnLoaded() return true end +function Module:OnInteractionCreate(interaction) + if (interaction.type ~= enums.interactionRequestType.modalSubmit) then + return + end + + local guild = interaction.guild + if (not guild) then + return + end + + local customId = interaction.data.custom_id + local banTargetId = customId:match("^ban_modal_(%d+)$") + if (not banTargetId) then + return + end + + if (not self:CheckPermissions(interaction.member)) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = "You are not authorized to use this command.", flags = enums.interactionResponseFlag.ephemeral } + }) + end + + local fields = Bot:ParseModalFields(interaction) + local targetMember = guild:getMember(banTargetId) + local duration = Bot.ConfigTypeParameter[Bot.ConfigType.Duration](fields.duration or "", guild) + local success, text = self:PerformBan(guild, interaction.member, targetMember, duration, fields.reason) + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) +end + function Module:OnEnable(guild) local persistentData = self:GetPersistentData(guild) persistentData.BannedUsers = persistentData.BannedUsers or {} @@ -205,7 +339,9 @@ function Module:OnReady() if (banData and banData.ExpirationTime and now >= banData.ExpirationTime) then local user = client:getUser(unbanData.UserId) if (user) then - self:LogInfo(guild, "Unbanning %s (duration expired)", user and user.tag or unbanData.UserId) + self:LogInfo( + guild, "Unbanning %s (duration expired)", user and user.tag or unbanData.UserId + ) guild:unbanUser(unbanData.UserId, "Ban duration expired") end @@ -326,13 +462,14 @@ function Module:SyncBans(guild) for _, ban in pairs(guildBans) do local user = ban.user if (not bannedUsers[user.id]) then - self:LogInfo(guild, "Found banned user %s in guild which is not logged (ban reason: %s)", user.tag, ban.reason or "") + self:LogInfo( + guild, "Found banned user %s in guild which is not logged (ban reason: %s)", user.tag, + ban.reason or "" + ) missingBanData[user.id] = true - bannedUsers[user.id] = { - Reason = ban.reason - } + bannedUsers[user.id] = { Reason = ban.reason } end guildBanned[user.id] = {} @@ -344,7 +481,9 @@ function Module:SyncBans(guild) if (not guildBanned[userId]) then local user = client:getUser(userId) if (user) then - self:LogWarning(guild, "User %s is logged as banned but is not found in the guild ban list, removing...", user.tag) + self:LogWarning( + guild, "User %s is logged as banned but is not found in the guild ban list, removing...", user.tag + ) end table.insert(unbannedUsers, userId) @@ -383,7 +522,10 @@ function Module:SyncBans(guild) local bannedBy = log:getMember() local date = discordia.Date.fromSnowflake(log.id) - self:LogInfo(guild, "Found audit log data for %s ban (banned by %s at %s)", bannedUser.tag, bannedBy.tag, date:toHeader()) + self:LogInfo( + guild, "Found audit log data for %s ban (banned by %s at %s)", bannedUser.tag, bannedBy.tag, + date:toHeader() + ) self:UpdateBanData(guild, log.targetId, bannedBy.id, date) missingBanData[bannedUser.id] = nil @@ -400,7 +542,10 @@ function Module:SyncBans(guild) end if (not table.empty(missingBanData)) then - self:LogWarning(guild, "%s bans without audit log remains, these will be counted as permanent bans made by unknowns", table.count(missingBanData)) + self:LogWarning( + guild, "%s bans without audit log remains, these will be counted as permanent bans made by unknowns", + table.count(missingBanData) + ) end self:SavePersistentData(guild) @@ -431,7 +576,10 @@ function Module:OnUserBan(user, guild) local date = discordia.Date.fromSnowflake(log.id) self:RegisterBan(guild, user.id, bannedBy.user, 0, log.reason or "") - self:LogInfo(guild, "Registered manual ban of %s by %s at %s (reason: %s)", log:getTarget().tag, bannedBy.tag, date:toHeader(), log.reason or "") + self:LogInfo( + guild, "Registered manual ban of %s by %s at %s (reason: %s)", log:getTarget().tag, bannedBy.tag, + date:toHeader(), log.reason or "" + ) self:SavePersistentData(guild) return @@ -439,7 +587,9 @@ function Module:OnUserBan(user, guild) end end - self:LogWarning(guild, "Failed to retrieve informations about manual ban of %s at %s", user.tag, discordia.Date():toHeader()) + self:LogWarning( + guild, "Failed to retrieve informations about manual ban of %s at %s", user.tag, discordia.Date():toHeader() + ) else data.BanInProgress[user.id] = nil end diff --git a/module_clean_urls.lua b/module_clean_urls.lua index 37d268a..ec6ccfc 100644 --- a/module_clean_urls.lua +++ b/module_clean_urls.lua @@ -2,7 +2,6 @@ local bot = Bot local client = Client ---@type discordia local discordia = Discordia -local prefix = Config.Prefix local enums = discordia.enums local http = require("coro-http") local linkShorteners = require("./data_linkshorteners") @@ -17,10 +16,8 @@ local wrap = coroutine.wrap To preserve the flow of an happening conversation, a webhook is used to mimic the user that initially posted the message. ]] - Module.Name = "clean_urls" - function Module:GetConfigTable() return { { @@ -102,153 +99,28 @@ local function escapeRegex(str) end end - Module.DefaultRules = { - "action_object_map", - "action_type_map", - "action_ref_map", - "spm@*.aliexpress.com", - "scm@*.aliexpress.com", - "aff_platform", - "aff_trace_key", - "algo_expid@*.aliexpress.*", - "algo_pvid@*.aliexpress.*", - "btsid", - "ws_ab_test", - "pd_rd_*@amazon.*", - "_encoding@amazon.*", - "psc@amazon.*", - "tag@amazon.*", - "ref_@amazon.*", - "pf_rd_*@amazon.*", - "pf@amazon.*", - "crid@amazon.*", - "keywords@amazon.*", - "sprefix@amazon.*", - "smid@amazon.*", - "creative*@amazon.*", - "th@amazon.*", - "linkCode@amazon.*", - "sr@amazon.*", - "ie@amazon.*", - "node@amazon.*", - "qid@amazon.*", - "dib@amazon.*", - "dib_tag@amazon.*", - "ref@amazon.*", - "callback@bilibili.com", - "cvid@bing.com", - "form@bing.com", - "sk@bing.com", - "sp@bing.com", - "sc@bing.com", - "qs@bing.com", - "pq@bing.com", - "sc_cid", - "mkt_tok", - "trk", - "trkCampaign", - "ga_*", - "gclid", - "gclsrc", - "hmb_campaign", - "hmb_medium", - "hmb_source", - "spReportId", - "spJobID", - "spUserID", - "spMailingID", - "itm_*", - "s_cid", - "elqTrackId", - "elqTrack", - "assetType", - "assetId", - "recipientId", - "campaignId", - "siteId", - "mc_cid", - "mc_eid", - "pk_*", - "sc_campaign", - "sc_channel", - "sc_content", - "sc_medium", - "sc_outcome", - "sc_geo", - "sc_country", - "nr_email_referer", - "vero_conv", - "vero_id", - "yclid", - "_openstat", - "mbid", - "cmpid", - "cid", - "c_id", - "campaign_id", - "Campaign", - "hash@ebay.*", - "fb_action_ids", - "fb_action_types", - "fb_ref", - "fb_source", - "fbclid", - "refsrc@facebook.com", - "hrc@facebook.com", - "gs_l", - "gs_lcp@google.*", - "ved@google.*", - "ei@google.*", - "sei@google.*", - "gws_rd@google.*", - "gs_gbg@google.*", - "gs_mss@google.*", - "gs_rn@google.*", - "_hsenc", - "_hsmi", - "__hssc", - "__hstc", - "hsCtaTracking", - "source@sourceforge.net", - "position@sourceforge.net", - "t@*.twitter.com", - "s@*.twitter.com", - "ref_*@*.twitter.com", - "t@*.x.com", - "s@*.x.com", - "ref_*@*.x.com", - "t@*.fixupx.com", - "s@*.fixupx.com", - "ref_*@*.fixupx.com", - "t@*.fxtwitter.com", - "s@*.fxtwitter.com", - "ref_*@*.fxtwitter.com", - "t@*.twittpr.com", - "s@*.twittpr.com", - "ref_*@*.twittpr.com", - "t@*.fixvx.com", - "s@*.fixvx.com", - "ref_*@*.fixvx.com", - "tt_medium", - "tt_content", - "lr@yandex.*", - "redircnt@yandex.*", - "feature@*.youtube.com", - "kw@*.youtube.com", - "si@*.youtube.com", - "pp@*.youtube.com", - "si@*.youtu.be", - "wt_zmc", - "utm_source", - "utm_content", - "utm_medium", - "utm_campaign", - "utm_term", - "si@open.spotify.com", - "igshid", - "igsh", - "share_id@reddit.com", + "action_object_map", "action_type_map", "action_ref_map", "spm@*.aliexpress.com", "scm@*.aliexpress.com", + "aff_platform", "aff_trace_key", "algo_expid@*.aliexpress.*", "algo_pvid@*.aliexpress.*", "btsid", "ws_ab_test", + "pd_rd_*@amazon.*", "_encoding@amazon.*", "psc@amazon.*", "tag@amazon.*", "ref_@amazon.*", "pf_rd_*@amazon.*", + "pf@amazon.*", "crid@amazon.*", "keywords@amazon.*", "sprefix@amazon.*", "smid@amazon.*", "creative*@amazon.*", + "th@amazon.*", "linkCode@amazon.*", "sr@amazon.*", "ie@amazon.*", "node@amazon.*", "qid@amazon.*", "dib@amazon.*", + "dib_tag@amazon.*", "ref@amazon.*", "callback@bilibili.com", "cvid@bing.com", "form@bing.com", "sk@bing.com", + "sp@bing.com", "sc@bing.com", "qs@bing.com", "pq@bing.com", "sc_cid", "mkt_tok", "trk", "trkCampaign", "ga_*", + "gclid", "gclsrc", "hmb_campaign", "hmb_medium", "hmb_source", "spReportId", "spJobID", "spUserID", "spMailingID", + "itm_*", "s_cid", "elqTrackId", "elqTrack", "assetType", "assetId", "recipientId", "campaignId", "siteId", "mc_cid", + "mc_eid", "pk_*", "sc_campaign", "sc_channel", "sc_content", "sc_medium", "sc_outcome", "sc_geo", "sc_country", + "nr_email_referer", "vero_conv", "vero_id", "yclid", "_openstat", "mbid", "cmpid", "cid", "c_id", "campaign_id", + "Campaign", "hash@ebay.*", "fb_action_ids", "fb_action_types", "fb_ref", "fb_source", "fbclid", + "refsrc@facebook.com", "hrc@facebook.com", "gs_l", "gs_lcp@google.*", "ved@google.*", "ei@google.*", "sei@google.*", + "gws_rd@google.*", "gs_gbg@google.*", "gs_mss@google.*", "gs_rn@google.*", "_hsenc", "_hsmi", "__hssc", "__hstc", + "hsCtaTracking", "source@sourceforge.net", "position@sourceforge.net", "t@*.twitter.com", "s@*.twitter.com", + "ref_*@*.twitter.com", "t@*.x.com", "s@*.x.com", "ref_*@*.x.com", "t@*.fixupx.com", "s@*.fixupx.com", + "ref_*@*.fixupx.com", "t@*.fxtwitter.com", "s@*.fxtwitter.com", "ref_*@*.fxtwitter.com", "t@*.twittpr.com", + "s@*.twittpr.com", "ref_*@*.twittpr.com", "t@*.fixvx.com", "s@*.fixvx.com", "ref_*@*.fixvx.com", "tt_medium", + "tt_content", "lr@yandex.*", "redircnt@yandex.*", "feature@*.youtube.com", "kw@*.youtube.com", "si@*.youtube.com", + "pp@*.youtube.com", "si@*.youtu.be", "wt_zmc", "utm_source", "utm_content", "utm_medium", "utm_campaign", "utm_term", + "si@open.spotify.com", "igshid", "igsh", "share_id@reddit.com" } Module.FixServices = { @@ -265,7 +137,7 @@ Module.FixServices = { ["twitch.tv"] = "fxtwitch.tv", -- Use vxtwitter instead of fxtwitter since it includes greedy analytics ["twitter.com"] = "vxtwitter.com", - ["x.com"] = "fixvx.com", + ["x.com"] = "fixvx.com" } ---@type table @@ -279,7 +151,6 @@ function Module:CreateRules() -- Can be extended with a config option in the future local rules = self.DefaultRules - for _, rule in ipairs(rules) do local splitRule = rule:split("@") local pattern = "^" .. escapeRegex(splitRule[1]):gsub("%%%*", ".-") .. "$" @@ -394,7 +265,6 @@ function Module:Replacer(match, config, data) return match end - local wl = config.Whitelist for _, rule in ipairs(wl) do @@ -497,7 +367,7 @@ local ansiColoursForeground = { blue = 34, magenta = 35, cyan = 36, - white = 37, + white = 37 } ---@param rule string @@ -513,10 +383,11 @@ local function formatRule(rule) local splitted = splitRule[2]:split("%.") - return string.format("\x1b[%dm%s\x1b[0m\x1b[%dm@\x1b[0m\x1b[%dm%s\x1b[0m\x1b[%dm.\x1b[0m\x1b[%dm%s\x1b[0m", colour, - splitRule[1], ansiColoursForeground.magenta, ansiColoursForeground.yellow, splitted[1], - ansiColoursForeground.blue, - ansiColoursForeground.red, splitted[2]) + return string.format( + "\x1b[%dm%s\x1b[0m\x1b[%dm@\x1b[0m\x1b[%dm%s\x1b[0m\x1b[%dm.\x1b[0m\x1b[%dm%s\x1b[0m", colour, splitRule[1], + ansiColoursForeground.magenta, ansiColoursForeground.yellow, splitted[1], ansiColoursForeground.blue, + ansiColoursForeground.red, splitted[2] + ) end function Module:OnLoaded() @@ -526,7 +397,12 @@ function Module:OnLoaded() Name = "cleanurl", Args = { { Name = "url", Description = "The URL to clean", Type = bot.ConfigType.String }, - { Name = "deleteInvokation", Description = "Delete the message that invoked the command", Type = bot.ConfigType.Boolean, Optional = true } + { + Name = "deleteInvokation", + Description = "Delete the message that invoked the command", + Type = bot.ConfigType.Boolean, + Optional = true + } }, Func = function(cmd, url, deleteInvokation) local config = self:GetConfig(cmd.guild) @@ -540,8 +416,20 @@ function Module:OnLoaded() if deleteInvokation then cmd.message:delete() end - end - + end, + Slash = { + Description = "Clean tracking parameters from a URL", + Func = function (interaction, url) + local config = self:GetConfig(interaction.guild) + local data = self:GetData(interaction.guild) + local replaced = self:Replacer(url, config, data) + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = replaced } + }) + end + } }) self:RegisterCommand({ @@ -563,7 +451,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "addcleanrule", Args = { - { Name = "rule", Description = "The rule to add", Type = bot.ConfigType.String, } + { Name = "rule", Description = "The rule to add", Type = bot.ConfigType.String } }, Func = function(cmd, rule) if not rule then @@ -578,7 +466,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "removecleanrule", Args = { - { Name = "rule", Description = "The rule to remove", Type = bot.ConfigType.String, } + { Name = "rule", Description = "The rule to remove", Type = bot.ConfigType.String } }, Func = function(cmd, rule) if not rule then @@ -648,7 +536,7 @@ function Module:CleanMessage(message, config, data) return end - if (message.content:startswith(prefix, true)) then + if (message.content:startswith(bot:GetGuildPrefix(message.guild), true)) then return end if message.content:find("http[s]?://") then @@ -673,9 +561,8 @@ function Module:OnMessageCreate(message) ---@type GuildTextChannel ---@diagnostic disable-next-line: assign-type-mismatch - local realChannel = (message.channel.isThread) and - (message.client:getChannel(message.channel._parent_id)) or - message.channel + local realChannel = (message.channel.isThread) and (message.client:getChannel(message.channel._parent_id)) + or message.channel local config = self:GetConfig(message.guild) local data = self:GetData(message.guild) @@ -707,7 +594,6 @@ function Module:OnMessageCreate(message) _attachments[message.id] = attachments end - if config.DeleteInvokationOnAutoCleanUrls then pcall(message.delete, message) end @@ -722,7 +608,7 @@ function Module:OnMessageCreate(message) type = enums.componentType.button, style = enums.buttonStyle.danger, label = Bot:Format(message.guild, "CLEAN_URLS_DELETE_BUTTON_LABEL"), - custom_id = "delete_" .. message.author.id, + custom_id = "clean_url_delete_" .. message.author.id } } } @@ -736,10 +622,8 @@ function Module:OnMessageCreate(message) avatar_url = message.author.avatarURL, username = message.author.globalName or message.author.username, content = replaced, - components = components, - }, - { wait = true, thread_id = threadId }, - newAttachments + components = components + }, { wait = true, thread_id = threadId }, newAttachments ) _attachments[message.id] = nil @@ -752,7 +636,8 @@ function Module:OnMessageCreate(message) if self.UsersHanging[message.author.id] then client._api:editWebhookMessage(webhook.id, webhook.token, msg.id, { components = {} - }, { thread_id = threadId }) + }, { thread_id = threadId } + ) self.UsersHanging[message.author.id] = nil end end) @@ -775,10 +660,18 @@ end ---@param interaction Interaction function Module:OnInteractionCreate(interaction) + if (interaction.type ~= enums.interactionRequestType.messageComponent) then + return + end + local customId = interaction.data.custom_id local guild = interaction.guild - local authorId = customId:match("delete_(%d+)") + if not customId:startswith("clean_url_delete_") then + return + end + + local authorId = customId:match("clean_url_delete_(%d+)") if not authorId then return end @@ -803,7 +696,7 @@ function Module:OnInteractionCreate(interaction) type = enums.interactionResponseType.channelMessageWithSource, data = { flags = enums.interactionResponseFlag.ephemeral, - content = Bot:Format(guild, "CLEAN_URLS_DELETED_MESSAGE"), + content = Bot:Format(guild, "CLEAN_URLS_DELETED_MESSAGE") } }) diff --git a/module_kick.lua b/module_kick.lua index 8cdb48f..5c225e1 100644 --- a/module_kick.lua +++ b/module_kick.lua @@ -39,29 +39,14 @@ function Module:GetConfigTable() } end -function Module:OnLoaded() - self:RegisterCommand({ - Name = "kick", - Args = { - {Name = "target", Type = Bot.ConfigType.Member}, - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, - }, - PrivilegeCheck = function (member) return self:CheckPermissions(member) end, - - Help = "Kicks a member", - Silent = true, - Func = function (commandMessage, targetMember, reason) - local config = self:GetConfig(commandMessage.guild) - - local guild = commandMessage.guild - local kickedBy = commandMessage.member +function Module:PerformKick(guild, kickedBy, targetMember, reason) + local config = self:GetConfig(guild) -- Permission check local kickedByRole = kickedBy.highestRole local targetRole = targetMember.highestRole if (targetRole.position >= kickedByRole.position) then - commandMessage:reply("You cannot kick that user due to your lower permissions.") - return + return false, "You cannot kick that user due to your lower permissions." end if (config.PrivateMessage) then @@ -77,12 +62,101 @@ function Module:OnLoaded() end if (targetMember:kick(string.format("Kicked by %s%s", kickedBy.mentionString, reason and string.format(": %s", reason) or ""))) then - commandMessage:reply(string.format("%s has kicked %s%s", kickedBy.user.tag, targetMember.user.tag, reason and string.format(": %s", reason) or "")) + return true, string.format("%s has kicked %s%s", kickedBy.user.tag, targetMember.user.tag, reason and string.format(": %s", reason) or "") else - commandMessage:reply(string.format("Failed to kick %s", targetMember.user.tag)) + return false, string.format("Failed to kick %s", targetMember.user.tag) + end end + +local function BuildKickModal(targetMember) + return { + type = enums.interactionResponseType.modal, + data = { + custom_id = "kick_modal_" .. targetMember.id, + title = "Kick " .. targetMember.tag, + components = { + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "reason", + style = enums.textInputStyle.paragraph, + label = "Reason", + required = false + } + } + } + } + } + } end + +function Module:OnLoaded() + self:RegisterCommand({ + Name = "kick", + Args = { + { Name = "target", Type = Bot.ConfigType.Member, Description = "Member to kick" }, + { Name = "reason", Type = Bot.ConfigType.String, Description = "Kick reason", Optional = true } + }, + PrivilegeCheck = function (member) return self:CheckPermissions(member) end, + + Help = "Kicks a member", + Silent = true, + Func = function (commandMessage, targetMember, reason) + local success, text = self:PerformKick(commandMessage.guild, commandMessage.member, targetMember, reason) + commandMessage:reply(text) + end, + Slash = { + Description = "Kick a member", + Func = function (interaction, targetMember, reason) + local success, text = self:PerformKick(interaction.guild, interaction.member, targetMember, reason) + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) + end + }, + ContextMenu = { + Type = "user", + Func = function (interaction, targetMember) + interaction:respond(BuildKickModal(targetMember)) + end + } }) return true end + +function Module:OnInteractionCreate(interaction) + if (interaction.type ~= enums.interactionRequestType.modalSubmit) then + return + end + + local guild = interaction.guild + if (not guild) then + return + end + + local customId = interaction.data.custom_id + local kickTargetId = customId:match("^kick_modal_(%d+)$") + if (not kickTargetId) then + return + end + + if (not self:CheckPermissions(interaction.member)) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = "You are not authorized to use this command.", flags = enums.interactionResponseFlag.ephemeral } + }) + end + + local fields = Bot:ParseModalFields(interaction) + local targetMember = guild:getMember(kickTargetId) + local success, text = self:PerformKick(guild, interaction.member, targetMember, fields.reason) + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) +end diff --git a/module_message.lua b/module_message.lua index 7b0bdb5..9fd6749 100644 --- a/module_message.lua +++ b/module_message.lua @@ -78,30 +78,15 @@ local function ValidateString(str) return true end -local footerFields = { - icon_url = ValidateString, - text = ValidateString -} +local footerFields = { icon_url = ValidateString, text = ValidateString } -local imageFields = { - url = ValidateString -} +local imageFields = { url = ValidateString } -local thumbnailFields = { - url = ValidateString -} +local thumbnailFields = { url = ValidateString } -local authorFields = { - name = ValidateString, - url = ValidateString, - icon_url = ValidateString -} +local authorFields = { name = ValidateString, url = ValidateString, icon_url = ValidateString } -local fieldFields = { - name = ValidateString, - value = ValidateString, - inline = ValidateBoolean -} +local fieldFields = { name = ValidateString, value = ValidateString, inline = ValidateBoolean } local embedFields = { title = ValidateString, @@ -172,7 +157,7 @@ local embedFields = { end return true - end, + end } local function validateRole(value, metadata) @@ -375,10 +360,7 @@ local function ValidateActionRowComponent(component, metadata) return true end -local emojiFields = { - id = util.ValidateSnowflake, - name = ValidateString -} +local emojiFields = { id = util.ValidateSnowflake, name = ValidateString } local buttonFields = { type = function(type) @@ -609,7 +591,9 @@ local function ValidateMessageData(data, member, guild, actions) return false, "MessageData must be an object" end - local success, err = ValidateFields(data, messageFields, false, { member = member, guild = guild, actions = actions }) + local success, err = ValidateFields( + data, messageFields, false, { member = member, guild = guild, actions = actions } + ) if (not success) then return false, "MessageData" .. err end @@ -687,7 +671,7 @@ function Module:GetConfigTable() end return true - end, + end }, { Name = "Aliases", @@ -713,7 +697,7 @@ function Module:GetConfigTable() end return true - end, + end }, { Name = "DeleteInvokation", @@ -757,9 +741,7 @@ function Module:ParseContentParameter(content, commandMessage, actions) if (message and message.member:hasPermission(message.channel, enums.permission.viewChannel)) then return GetMessageFields(message) else - return { - content = content - } + return { content = content } end end elseif (commandMessage.attachments) then @@ -822,6 +804,48 @@ function Module:ReplaceData(data, triggeringMember) return data end +function Module:BuildReplyListText(guild) + local config = self:GetConfig(guild) + local replies = config.Replies + local aliases = config.Aliases + local result = "```replies aliases\n\n" + + for kr, _ in pairs(replies) do + -- Find the alias of each reply, if there is one + local alias = "" + for ka, kv in pairs(aliases) do + if kr == kv then + alias = ka + end + end + + result = result .. string.format("%-28s %s\n", kr, alias) + end + + result = result .. "```" + return result +end + +function Module:PerformReply(guild, triggeringMember, name) + local config = self:GetConfig(guild) + local reply = config.Replies[name] or config.Replies[config.Aliases[name]] + if (not reply) then + return nil, string.format("No reply is registered for %s", name) + end + + reply = table.deepcopy(reply) + + local success, err = ValidateMessageData(reply, triggeringMember, guild) + if (not success) then + return nil, err + end + + reply.content = self:ReplaceData(reply.content, triggeringMember) + reply.embed = self:ReplaceData(reply.embed, triggeringMember) + + return reply +end + function Module:RegisterAction(guild, messageId, actions) if next(actions) == nil then -- not actions @@ -849,7 +873,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "rawmessage", Args = { - { Name = "message", Type = Bot.ConfigType.Message }, + { Name = "message", Type = Bot.ConfigType.Message } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -887,7 +911,7 @@ function Module:OnLoaded() Name = "sendmessage", Args = { { Name = "channel", Type = Bot.ConfigType.Channel, Optional = true }, - { Name = "content", Type = Bot.ConfigType.String, Optional = true }, + { Name = "content", Type = Bot.ConfigType.String, Optional = true } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -902,7 +926,8 @@ function Module:OnLoaded() local member = commandMessage.member channel = channel or commandMessage.channel - if (not member:hasPermission(channel, enums.permission.viewChannel) or not member:hasPermission(channel, enums.permission.sendMessages)) then + if (not member:hasPermission(channel, enums.permission.viewChannel) + or not member:hasPermission(channel, enums.permission.sendMessages)) then commandMessage:reply("You don't have the permission to send messages in that channel") return end @@ -923,7 +948,7 @@ function Module:OnLoaded() Name = "editmessage", Args = { { Name = "message", Type = Bot.ConfigType.Message }, - { Name = "content", Type = Bot.ConfigType.String, Optional = true }, + { Name = "content", Type = Bot.ConfigType.String, Optional = true } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -941,7 +966,8 @@ function Module:OnLoaded() end local member = commandMessage.member - if (not member:hasPermission(message.channel, enums.permission.viewChannel) or not member:hasPermission(message.channel, enums.permission.sendMessages)) then + if (not member:hasPermission(message.channel, enums.permission.viewChannel) + or not member:hasPermission(message.channel, enums.permission.sendMessages)) then commandMessage:reply("You don't have the permission to send messages in that channel") return end @@ -962,7 +988,7 @@ function Module:OnLoaded() Name = "addreply", Args = { { Name = "trigger", Type = Bot.ConfigType.String }, - { Name = "content", Type = Bot.ConfigType.String, Optional = true }, + { Name = "content", Type = Bot.ConfigType.String, Optional = true } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -986,7 +1012,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "removereply", Args = { - { Name = "trigger", Type = Bot.ConfigType.String }, + { Name = "trigger", Type = Bot.ConfigType.String } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -1012,33 +1038,93 @@ function Module:OnLoaded() Help = "List all replies and their aliases", Func = function(commandMessage) - local config = self:GetConfig(commandMessage.guild) - local replies = config.Replies - local aliases = config.Aliases - local result = "```replies aliases\n\n" - - for kr, _ in pairs(replies) do - -- Find the alias of each reply, if there is one - local alias = "" - for ka, kv in pairs(aliases) do - if kr == kv then - alias = ka + commandMessage:reply(self:BuildReplyListText(commandMessage.guild)) + end + }) + + self:RegisterCommand({ + Name = "reply", + Args = { + { + Name = "name", + Type = Bot.ConfigType.String, + Description = "Reply trigger/alias, or \"list\" to show all available replies", + Autocomplete = function (guild, _member, current) + local config = self:GetConfig(guild) + + local names = { "list" } + for trigger, _ in pairs(config.Replies) do + table.insert(names, trigger) end + for alias, _ in pairs(config.Aliases) do + table.insert(names, alias) + end + + table.sort(names) + + current = current:lower() + local choices = {} + for _, name in ipairs(names) do + if (#current == 0 or name:lower():find(current, 1, true)) then + table.insert(choices, { name = name, value = name }) + end + end + + return choices end + } + }, - result = result .. string.format("%-28s %s\n", kr, alias) + Help = "Sends a predefined reply, or lists them with \"list\"", + Func = function (commandMessage, name) + if (name:lower() == "list") then + commandMessage:reply(self:BuildReplyListText(commandMessage.guild)) + return end - result = result .. "```" - commandMessage:reply(result) + local reply, err = self:PerformReply(commandMessage.guild, commandMessage.member, name) + if (not reply) then + commandMessage:reply(err) + return end + + RemoveTableKey(reply, "deleteInvokation") + commandMessage:reply(reply) + end, + Slash = { + Description = "Send a predefined reply, or list them with \"list\"", + Func = function (interaction, name) + if (name:lower() == "list") then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = self:BuildReplyListText(interaction.guild) + } + }) + end + + local reply, err = self:PerformReply(interaction.guild, interaction.member, name) + if (not reply) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = err, flags = enums.interactionResponseFlag.ephemeral } + }) + end + + RemoveTableKey(reply, "deleteInvokation") + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = reply + }) + end + } }) self:RegisterCommand({ Name = "addalias", Args = { { Name = "alias", Type = Bot.ConfigType.String }, - { Name = "trigger", Type = Bot.ConfigType.String }, + { Name = "trigger", Type = Bot.ConfigType.String } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -1049,8 +1135,9 @@ function Module:OnLoaded() config.Replies = config.Replies or {} if (config.Replies[alias]) then - commandMessage:reply(string.format( - "A reply is already registered for `%s`, thus, cannot be an alias of itself.", alias)) + commandMessage:reply( + string.format("A reply is already registered for `%s`, thus, cannot be an alias of itself.", alias) + ) return end @@ -1066,7 +1153,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "removealias", Args = { - { Name = "alias", Type = Bot.ConfigType.String }, + { Name = "alias", Type = Bot.ConfigType.String } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -1091,7 +1178,7 @@ function Module:OnLoaded() Name = "editreply", Args = { { Name = "trigger", Type = Bot.ConfigType.String }, - { Name = "content", Type = Bot.ConfigType.String, Optional = true }, + { Name = "content", Type = Bot.ConfigType.String, Optional = true } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -1122,7 +1209,7 @@ function Module:OnLoaded() { Name = "channel", Type = Bot.ConfigType.Channel, Optional = true }, { Name = "afterMessage", Type = Bot.ConfigType.Message, Optional = true }, { Name = "limit", Type = Bot.ConfigType.Integer, Optional = true }, - { Name = "fromFirstMessage", Type = Bot.ConfigType.Boolean, Optional = true }, + { Name = "fromFirstMessage", Type = Bot.ConfigType.Boolean, Optional = true } }, PrivilegeCheck = function(member) return self:CheckPermissions(member) end, @@ -1133,7 +1220,8 @@ function Module:OnLoaded() -- Don't allow everyone to bypass limit and get all messages (would require a lot of API calls) if limit > 1000 then commandMessage:reply( - "Only bot owner can ask to retrieve more than 1000+ messages at once, due to the number of API calls required to fetch messages") + "Only the bot owner can request to retrieve more than 1,000 messages at a time, due to the number of API calls required to fetch the messages." + ) return end end @@ -1155,8 +1243,9 @@ function Module:OnLoaded() commandMessage.channel:broadcastTyping() - local messages, err = Bot:FetchChannelMessages(targetChannel, afterMessage and afterMessage.id or nil, limit, - not fromFirstMessage) + local messages, err = Bot:FetchChannelMessages( + targetChannel, afterMessage and afterMessage.id or nil, limit, not fromFirstMessage + ) if not messages then commandMessage:reply(string.format("An error occurred: %s", err)) return @@ -1167,8 +1256,10 @@ function Module:OnLoaded() local jsonSave = json.encode(messageData, { indent = 1 }) commandMessage:reply({ - content = string.format("%d message(s) of channel %s have been saved to following file", #messages, - targetChannel.mentionString), + content = string.format( + "%d message(s) from the %s channel were saved to the following file", #messages, + targetChannel.mentionString + ), file = { "messages.json", jsonSave @@ -1201,22 +1292,15 @@ function Module:OnMessageCreate(message) return end - local config = self:GetConfig(message.guild) local content = string.trim(trimPreprendedMention(message.content)) - local reply = config.Replies[content] or config.Replies[config.Aliases[content]] - if (reply) then - reply = table.deepcopy(reply) - - local success, err = ValidateMessageData(reply, message.member, message.guild) - if (not success) then + if (config.Replies[content] or config.Replies[config.Aliases[content]]) then + local reply, err = self:PerformReply(message.guild, message.member, content) + if (not reply) then message:reply(err) return end - reply.content = self:ReplaceData(reply.content, message.member) - reply.embed = self:ReplaceData(reply.embed, message.member) - local deleteInvokation = RemoveTableKey(reply, "deleteInvokation") if deleteInvokation == nil then deleteInvokation = config.DeleteInvokation @@ -1316,7 +1400,7 @@ function Module:OnInteractionCreate(interaction) end interaction:editResponse({ - content = #messages > 0 and table.concat(messages, "\n") or "Nothing to do", + content = #messages > 0 and table.concat(messages, "\n") or "Nothing to do" }) -- C'est saaaaaaaaaaaaale diff --git a/module_modmail.lua b/module_modmail.lua index 8a14810..01e2bdc 100644 --- a/module_modmail.lua +++ b/module_modmail.lua @@ -110,7 +110,7 @@ function Module:OnLoaded() Name = "newticket", Args = { {Name = "member", Type = Bot.ConfigType.Member, Optional = true}, - {Name = "message", Type = Bot.ConfigType.String, Optional = true}, + { Name = "message", Type = Bot.ConfigType.String, Optional = true } }, Help = "Allows you to contact the server staff in private", @@ -134,7 +134,7 @@ function Module:OnLoaded() Name = "modticket", Args = { {Name = "member", Type = Bot.ConfigType.Member}, - {Name = "message", Type = Bot.ConfigType.String, Optional = true}, + { Name = "message", Type = Bot.ConfigType.String, Optional = true } }, PrivilegeCheck = function (member) local guild = member.guild @@ -156,7 +156,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "closeticket", Args = { - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, + { Name = "reason", Type = Bot.ConfigType.String, Optional = true } }, Help = "When used in a ticket channel, close it", @@ -164,9 +164,15 @@ function Module:OnLoaded() Func = function (commandMessage, reason) local ret = self:HandleTicketClose(commandMessage.member, commandMessage, reason, false) if (ret == nil) then - commandMessage:reply(bot:Format(commandMessage.guild, "MODMAIL_NOTACTIVETICKET", commandMessage.member.user.mentionString)) + commandMessage:reply( + bot:Format( + commandMessage.guild, "MODMAIL_NOTACTIVETICKET", commandMessage.member.user.mentionString + ) + ) elseif (ret == false) then - commandMessage:reply(bot:Format(commandMessage.guild, "MODMAIL_NOTAUTHORIZED", commandMessage.member.user.mentionString)) + commandMessage:reply( + bot:Format(commandMessage.guild, "MODMAIL_NOTAUTHORIZED", commandMessage.member.user.mentionString) + ) end end }) @@ -174,13 +180,175 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "createticketform", Args = { - {Name = "channel", Type = Bot.ConfigType.Channel}, + { Name = "channel", Type = Bot.ConfigType.Channel } }, PrivilegeCheck = function (member) return member:hasPermission(enums.permission.administrator) end, Help = "Creates a button in the specified channel to open the ticket form", Silent = true, Func = function (commandMessage, channel) + self:PostTicketForm(channel, commandMessage.guild) + end + }) + + self:RegisterCommand({ + Name = "modmail", + Subcommands = { + { + Name = "new", + Description = "Contact the server staff in private", + Args = { + { + Name = "member", + Type = Bot.ConfigType.Member, + Optional = true, + Description = "Open the ticket for someone else (requires ticket handling role)" + }, + { + Name = "message", + Type = Bot.ConfigType.String, + Optional = true, + Description = "Message to include with the ticket" + } + }, + Func = function (interaction, targetMember, reason) + local fromMember = interaction.member + + local authorized, err = self:CheckOpenTicketPermission(fromMember, targetMember) + if not authorized then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = err, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + + local ticketChannel + ticketChannel, err = self:OpenTicket(fromMember, targetMember or fromMember, reason, true) + if (not ticketChannel) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = err, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = ticketChannel.mentionString, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + }, + { + Name = "mod", + Description = "Open a moderation ticket for someone (target can't talk)", + Args = { + { Name = "member", Type = Bot.ConfigType.Member, Description = "Member to open the ticket for" }, + { + Name = "message", + Type = Bot.ConfigType.String, + Optional = true, + Description = "Message to include with the ticket" + } + }, + PrivilegeCheck = function (member) + local config = self:GetConfig(member.guild) + return util.MemberHasAnyRole(member, config.TicketHandlingRoles) + end, + Func = function (interaction, targetMember, reason) + local success, err = self:OpenTicket(interaction.member, targetMember, reason, false) + if (not success) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = err, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = success.mentionString, + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + }, + { + Name = "close", + Description = "Close the current ticket", + Args = { + { Name = "reason", Type = Bot.ConfigType.String, Optional = true, Description = "Close reason" } + }, + Func = function (interaction, reason) + local ret = self:HandleTicketClose( + interaction.member, { channel = interaction.channel, guild = interaction.guild }, reason, false + ) + local content, embed + local ephemeral = false + if (ret == nil) then + content = bot:Format( + interaction.guild, "MODMAIL_NOTACTIVETICKET", interaction.member.mentionString + ) + ephemeral = true + elseif (ret == false) then + content = bot:Format( + interaction.guild, "MODMAIL_NOTAUTHORIZED", interaction.member.mentionString + ) + ephemeral = true + else + embed = { + description = bot:Format( + interaction.guild, "MODMAIL_TICKETCLOSED_CONFIRMATION", interaction.member.mentionString + ), + color = 0x2ECC71 + } + end + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = content, + embeds = embed and { embed } or nil, + flags = ephemeral and enums.interactionResponseFlag.ephemeral or nil + } + }) + end + }, + { + Name = "form", + Description = "Post a button in a channel to let members open a ticket themselves", + Args = { + { Name = "channel", Type = Bot.ConfigType.Channel, Description = "Channel to post the button in" } + }, + PrivilegeCheck = function (member) return member:hasPermission(enums.permission.administrator) end, + Func = function (interaction, channel) + self:PostTicketForm(channel, interaction.guild) + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { + content = "Ticket form posted.", + flags = enums.interactionResponseFlag.ephemeral + } + }) + end + } + } + }) + + return true +end + +function Module:PostTicketForm(channel, guild) channel:send({ components = { { @@ -190,17 +358,13 @@ function Module:OnLoaded() type = enums.componentType.button, style = enums.buttonStyle.primary, custom_id = "modmail_openticketform", - label = bot:Format(commandMessage.guild, "MODMAIL_OPENTICKET_BUTTON_LABEL") + label = bot:Format(guild, "MODMAIL_OPENTICKET_BUTTON_LABEL") } } } } }) end - }) - - return true -end function Module:OnUnload() self.Timer:Stop() @@ -279,7 +443,10 @@ function Module:HandleTicketClose(member, message, reason, reactionClose) end end - local closeMessage = bot:Format(guild, "MODMAIL_TICKETCLOSE_MESSAGE", member.user.mentionString, util.DiscordRelativeTime(config.DeleteDuration)) + local closeMessage = bot:Format( + guild, "MODMAIL_TICKETCLOSE_MESSAGE", member.user.mentionString, + util.DiscordRelativeTime(config.DeleteDuration) + ) if (reason and #reason > 0) then local author = member.user @@ -313,7 +480,8 @@ function Module:HandleTicketClose(member, message, reason, reactionClose) if (ticketMember) then local permissions = ticketChannel:getPermissionOverwriteFor(ticketMember) - if (not permissions or not permissions:setPermissions(enums.permission.viewChannel, enums.permission.sendMessages)) then + if (not permissions + or not permissions:setPermissions(enums.permission.viewChannel, enums.permission.sendMessages)) then ticketChannel:sendf("Failed to deny send messages permission to %s.", ticketMember.mentionString) end end @@ -323,10 +491,7 @@ function Module:HandleTicketClose(member, message, reason, reactionClose) if (channel) then local author if (ticketMember) then - author = { - name = ticketMember.tag, - icon_url = ticketMember.avatarURL - } + author = { name = ticketMember.tag, icon_url = ticketMember.avatarURL } end local fields @@ -352,10 +517,7 @@ function Module:HandleTicketClose(member, message, reason, reactionClose) end local jsonSave = json.encode(bot:MessagesToTable(messages), { indent = 1}) - file = { - "messages.json", - jsonSave - } + file = { "messages.json", jsonSave } fields = fields or {} table.insert(fields, { @@ -428,16 +590,25 @@ function Module:OpenTicket(fromMember, targetMember, reason, twoWays) if (data.activeChannels[targetMember.user.id]) then if (targetMember == fromMember) then - return false, string.format("you already have an active ticket on this server, %s.", targetMember.user.mentionString) + return false, + string.format("you already have an active ticket on this server, %s.", targetMember.user.mentionString) else - return false, string.format("%s already has an active ticket on this server.", targetMember.user.tag, targetMember.user.mentionString) + return false, + string.format( + "%s already has an active ticket on this server.", targetMember.user.tag, + targetMember.user.mentionString + ) end return end if (config.MaxConcurrentChannels > 0 and table.count(data.activeChannels) >= config.MaxConcurrentChannels) then - return false, string.format("sorry %s, but there are actually too many tickets open at the same time, please retry in a moment", fromMember.user.mentionString) + return false, + string.format( + "sorry %s, but there are actually too many tickets open at the same time, please retry in a moment", + fromMember.user.mentionString + ) end local modmailCategory = guild:getChannel(config.Category) @@ -450,7 +621,10 @@ function Module:OpenTicket(fromMember, targetMember, reason, twoWays) filteredUsername = "empty" end - local ticketChannel, err = modmailCategory:createTextChannel(string.format("%s-%s", filteredUsername, targetMember.user.discriminator)) + local channelName = targetMember.user.discriminator == "0" and filteredUsername + or string.format("%s-%s", filteredUsername, targetMember.user.discriminator) + + local ticketChannel, err = modmailCategory:createTextChannel(channelName) if (not ticketChannel) then print(err) return false, "failed to create the channel, this is likely a bug." @@ -483,10 +657,12 @@ function Module:OpenTicket(fromMember, targetMember, reason, twoWays) desc = targetMember.mentionString .. " has opened a new ticket (" .. ticketChannel.mentionString .. ")" elseif (twoWays) then color = 65280 - desc = fromMember.mentionString .. " has opened a new ticket for " .. targetMember.mentionString .. " (" .. ticketChannel.mentionString .. ")" + desc = fromMember.mentionString .. " has opened a new ticket for " .. targetMember.mentionString .. " (" + .. ticketChannel.mentionString .. ")" else color = 16776960 - desc = fromMember.mentionString .. " has opened a moderator ticket for " .. targetMember.mentionString .. " (" .. ticketChannel.mentionString .. ")" + desc = fromMember.mentionString .. " has opened a moderator ticket for " .. targetMember.mentionString + .. " (" .. ticketChannel.mentionString .. ")" end local fields @@ -533,7 +709,9 @@ function Module:OpenTicket(fromMember, targetMember, reason, twoWays) if (targetMember == fromMember) then message = bot:Format(guild, "MODMAIL_TICKETOPENING_MESSAGE", targetMember.user.mentionString, guild.name) else - message = bot:Format(guild, "MODMAIL_TICKETOPENING_MESSAGE_MODERATION", targetMember.user.mentionString, guild.name) + message = bot:Format( + guild, "MODMAIL_TICKETOPENING_MESSAGE_MODERATION", targetMember.user.mentionString, guild.name + ) end local components = { @@ -553,10 +731,7 @@ function Module:OpenTicket(fromMember, targetMember, reason, twoWays) } } - local messageData = { - content = message, - components = components - } + local messageData = { content = message, components = components } local message = ticketChannel:send(messageData) message:pin() @@ -645,26 +820,25 @@ function Module:OnInteractionCreate(interaction) local interactionType = interaction.data.custom_id if interactionType == "modmail_closeticket" then - -- "Waiting" interaction:respond({ - type = enums.interactionResponseType.deferredChannelMessageWithSource, - data = { - flags = enums.interactionResponseFlag.ephemeral - } + type = enums.interactionResponseType.deferredUpdateMessage }) local ret = self:HandleTicketClose(interaction.member, interaction.message, nil, true) if (ret == nil) then - interaction:editResponse({ - content = bot:Format(guild, "MODMAIL_NOTACTIVETICKET", interaction.member.mentionString), + interaction.channel:send({ + content = bot:Format(guild, "MODMAIL_NOTACTIVETICKET", interaction.member.mentionString) }) elseif (ret == false) then - interaction:editResponse({ - content = bot:Format(guild, "MODMAIL_NOTAUTHORIZED", interaction.member.mentionString), + interaction.channel:send({ + content = bot:Format(guild, "MODMAIL_NOTAUTHORIZED", interaction.member.mentionString) }) else - interaction:editResponse({ - content = bot:Format(guild, "MODMAIL_TICKETCLOSED_CONFIRMATION", interaction.member.mentionString) + interaction.channel:send({ + embed = { + description = bot:Format(guild, "MODMAIL_TICKETCLOSED_CONFIRMATION", interaction.member.mentionString), + color = 0x2ECC71 + } }) end elseif interactionType == "modmail_openticketform" then diff --git a/module_mute.lua b/module_mute.lua index 26b85d2..695ca27 100644 --- a/module_mute.lua +++ b/module_mute.lua @@ -53,110 +53,238 @@ function Module:CheckPermissions(member) return false end -function Module:OnLoaded() - self:RegisterCommand({ - Name = "mute", - Args = { - {Name = "target", Type = Bot.ConfigType.Member}, - {Name = "duration", Type = Bot.ConfigType.Duration, Optional = true}, - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, - }, - PrivilegeCheck = function (member) return self:CheckPermissions(member) end, - - Help = "Mutes a member", - Silent = true, - Func = function (commandMessage, targetMember, duration, reason) - local guild = commandMessage.guild +function Module:PerformMute(guild, mutedBy, targetMember, duration, reason) local config = self:GetConfig(guild) - local mutedBy = commandMessage.member - - -- Duration - if (not duration) then - duration = config.DefaultMuteDuration - end - - -- Reason - if reason and #reason > 0 then - reason = " " .. bot:Format(guild, "MUTE_REASON", reason) - else - reason = "" - end + if (not duration) then duration = config.DefaultMuteDuration end + reason = (reason and #reason > 0) and (" " .. bot:Format(guild, "MUTE_REASON", reason)) or "" local mutedByRole = mutedBy.highestRole local targetRole = targetMember.highestRole if (targetRole.position >= mutedByRole.position) then - commandMessage:reply(bot:Format(guild, "MUTE_NOTAUTHORIZED")) - return + return false, bot:Format(guild, "MUTE_NOTAUTHORIZED") end if (config.SendPrivateMessage) then - local durationText + local durationText = "" if (duration > 0) then durationText = "\n" .. bot:Format(guild, "MUTE_YOU_WILL_BE_UNMUTED_IN", util.DiscordRelativeTime(duration)) - else - durationText = "" end local privateChannel = targetMember:getPrivateChannel() if (privateChannel) then - privateChannel:send(bot:Format(guild, "MUTE_PRIVATE_MESSAGE", guild.name, mutedBy.user.mentionString, reason, durationText)) + privateChannel:send( + bot:Format(guild, "MUTE_PRIVATE_MESSAGE", guild.name, mutedBy.user.mentionString, reason, durationText) + ) end end local success, err = self:Mute(guild, targetMember.id, duration) if (success) then - local durationText + local durationText = "" if (duration > 0) then durationText = "\n" .. bot:Format(guild, "MUTE_THEY_WILL_BE_UNMUTED_IN", util.DiscordRelativeTime(duration)) + end + + return true, bot:Format(guild, "MUTE_GUILD_MESSAGE", mutedBy.name, targetMember.tag, reason, durationText) else - durationText = "" + return false, bot:Format(guild, "MUTE_MUTE_FAILED", targetMember.tag, err) + end end - commandMessage:reply(bot:Format(guild, "MUTE_GUILD_MESSAGE", mutedBy.name, targetMember.tag, reason, durationText)) +function Module:PerformUnmute(guild, unmutedBy, targetUser, reason) + local config = self:GetConfig(guild) + reason = (reason and #reason > 0) and (" " .. bot:Format(guild, "MUTE_REASON", reason)) or "" + + if (config.SendPrivateMessage) then + local privateChannel = targetUser:getPrivateChannel() + if (privateChannel) then + privateChannel:send(bot:Format(guild, "MUTE_UNMUTE_MESSAGE", guild.name, unmutedBy.mentionString, reason)) + end + end + + local success, err = self:Unmute(guild, targetUser.id) + if (success) then + return true, bot:Format(guild, "MUTE_UNMUTE_GUILD_MESSAGE", unmutedBy.name, targetUser.tag, reason) else - commandMessage:reply(bot:Format(guild, "MUTE_MUTE_FAILED", targetMember.tag, err)) + return false, bot:Format(guild, "MUTE_UNMUTE_FAILED", targetUser.tag, err) + end +end + +local function BuildMuteModal(targetMember) + return { + type = enums.interactionResponseType.modal, + data = { + custom_id = "mute_modal_" .. targetMember.id, + title = "Mute " .. targetMember.tag, + components = { + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "duration", + style = enums.textInputStyle.short, + label = "Duration", + placeholder = "30m, 2h, 1d... (leave empty for default duration)", + required = false + } + } + }, + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "reason", + style = enums.textInputStyle.paragraph, + label = "Reason", + required = false + } + } + } + } + } + } +end + +local function BuildUnmuteModal(targetUser) + return { + type = enums.interactionResponseType.modal, + data = { + custom_id = "unmute_modal_" .. targetUser.id, + title = "Unmute " .. targetUser.tag, + components = { + { + type = enums.componentType.actionRow, + components = { + { + type = enums.componentType.textInput, + custom_id = "reason", + style = enums.textInputStyle.paragraph, + label = "Reason", + required = false + } + } + } + } + } + } +end + +function Module:OnLoaded() + self:RegisterCommand({ + Name = "mute", + Args = { + { Name = "target", Type = Bot.ConfigType.Member, Description = "Member to mutes" }, + { + Name = "duration", + Type = Bot.ConfigType.Duration, + Description = "Duration to mutes for (seconds by default, or use m/h/d/w suffix, e.g. 30m, 2h)", + Optional = true + }, + { Name = "reason", Type = Bot.ConfigType.String, Description = "Mutes reason", Optional = true } + }, + PrivilegeCheck = function (member) return self:CheckPermissions(member) end, + Help = "Mutes a member", + Silent = true, + Func = function (commandMessage, targetMember, duration, reason) + local success, text = self:PerformMute( + commandMessage.guild, commandMessage.member, targetMember, duration, reason + ) + commandMessage:reply(text) + end, + Slash = { + Description = "Mute a member", + Func = function (interaction, targetMember, duration, reason) + local success, text = self:PerformMute( + interaction.guild, interaction.member, targetMember, duration, reason + ) + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) end + }, + ContextMenu = { + Type = "user", + Func = function (interaction, targetMember) + interaction:respond(BuildMuteModal(targetMember)) end + } }) self:RegisterCommand({ Name = "unmute", Args = { - {Name = "target", Type = Bot.ConfigType.User}, - {Name = "reason", Type = Bot.ConfigType.String, Optional = true}, + { Name = "target", Type = Bot.ConfigType.User, Description = "Member to unmutes" }, + { Name = "reason", Type = Bot.ConfigType.String, Description = "Unmutes reason", Optional = true } }, PrivilegeCheck = function (member) return self:CheckPermissions(member) end, - Help = "Unmutes a member", Silent = true, Func = function (commandMessage, targetUser, reason) - local guild = commandMessage.guild - local config = self:GetConfig(guild) - - -- Reason - if reason and #reason > 0 then - reason = " " .. bot:Format(guild, "MUTE_REASON", reason) - else - reason = "" + local success, text = self:PerformUnmute(commandMessage.guild, commandMessage.member, targetUser, reason) + commandMessage:reply(text) + end, + Slash = { + Description = "Unmute a member", + Func = function (interaction, targetUser, reason) + local success, text = self:PerformUnmute(interaction.guild, interaction.member, targetUser, reason) + interaction:respond( + { type = enums.interactionResponseType.channelMessageWithSource, data = { content = text } } + ) + end + }, + ContextMenu = { + Type = "user", + Func = function (interaction, targetUser) + interaction:respond(BuildUnmuteModal(targetUser.user or targetUser)) end + } + }) - if (config.SendPrivateMessage) then - local privateChannel = targetUser:getPrivateChannel() - if (privateChannel) then - privateChannel:send(bot:Format(guild, "MUTE_UNMUTE_MESSAGE", guild.name, commandMessage.member.mentionString, reason)) + return true end + +function Module:OnInteractionCreate(interaction) + if (interaction.type ~= enums.interactionRequestType.modalSubmit) then + return end - local success, err = self:Unmute(guild, targetUser.id) - if (success) then - commandMessage:reply(bot:Format(guild, "MUTE_UNMUTE_GUILD_MESSAGE", commandMessage.member.name, targetUser.tag, reason)) - else - commandMessage:reply(bot:Format(guild, "MUTE_UNMUTE_FAILED", targetUser.tag, err)) + local guild = interaction.guild + if (not guild) then + return end + + local customId = interaction.data.custom_id + local muteTargetId = customId:match("^mute_modal_(%d+)$") + local unmuteTargetId = customId:match("^unmute_modal_(%d+)$") + if (not muteTargetId and not unmuteTargetId) then + return end + + if (not self:CheckPermissions(interaction.member)) then + return interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = "You are not authorized to use this command.", flags = enums.interactionResponseFlag.ephemeral } }) + end - return true + local fields = Bot:ParseModalFields(interaction) + local success, text + if (muteTargetId) then + local targetMember = guild:getMember(muteTargetId) + local duration = Bot.ConfigTypeParameter[Bot.ConfigType.Duration](fields.duration or "", guild) + success, text = self:PerformMute(guild, interaction.member, targetMember, duration, fields.reason) + else + local targetUser = Bot:DecodeUser(unmuteTargetId) + success, text = self:PerformUnmute(guild, interaction.member, targetUser, fields.reason) + end + + interaction:respond({ + type = enums.interactionResponseType.channelMessageWithSource, + data = { content = text } + }) end function Module:OnEnable(guild) @@ -212,9 +340,13 @@ function Module:CheckTextMutePermissions(channel) local deniedPermissions = permissions:getDeniedPermissions() -- :enable here just sets the bit, disabling the permissions - deniedPermissions:enable(enums.permission.addReactions, enums.permission.sendMessages, enums.permission.usePublicThreads, enums.permission.sendMessagesInThreads) + deniedPermissions:enable( + enums.permission.addReactions, enums.permission.sendMessages, enums.permission.usePublicThreads, + enums.permission.sendMessagesInThreads + ) - if permissions:getAllowedPermissions() ~= discordia.Permissions() or permissions:getDeniedPermissions() ~= deniedPermissions then + if permissions:getAllowedPermissions() ~= discordia.Permissions() + or permissions:getDeniedPermissions() ~= deniedPermissions then permissions:setPermissions('0', deniedPermissions) end end @@ -227,7 +359,6 @@ function Module:CheckVoiceMutePermissions(channel) return end - local permissions = channel:getPermissionOverwriteFor(mutedRole) assert(permissions) @@ -235,7 +366,8 @@ function Module:CheckVoiceMutePermissions(channel) -- :enable here just sets the bit, disabling the permissions deniedPermissions:enable(enums.permission.speak) - if permissions:getAllowedPermissions() ~= discordia.Permissions() or permissions:getDeniedPermissions() ~= deniedPermissions then + if permissions:getAllowedPermissions() ~= discordia.Permissions() + or permissions:getDeniedPermissions() ~= deniedPermissions then permissions:setPermissions('0', deniedPermissions) end end @@ -270,7 +402,9 @@ function Module:RegisterUnmute(guild, userId, timestamp) timer:Stop() end - data.UnmuteTimers[userId] = Bot:ScheduleTimer(timestamp, function () self:Unmute(guild, userId) end) + data.UnmuteTimers[userId] = Bot:ScheduleTimer(timestamp, function () + self:Unmute(guild, userId) + end) end end diff --git a/module_nickname.lua b/module_nickname.lua index f4658f8..f7753d6 100644 --- a/module_nickname.lua +++ b/module_nickname.lua @@ -89,11 +89,23 @@ function Module:HasHighestRolesThanTarget(user, target) end function Module:OnInteractionCreate(interaction) + if (interaction.type ~= enums.interactionRequestType.messageComponent) then + return + end + local guild = interaction.guild if not guild then return end + local interactionType = interaction.data.custom_id + local custom_id_start_remove = 'nickname_remove_' + local custom_id_start_page = 'nickname_page_' + + if not interactionType:startswith(custom_id_start_remove) and not interactionType:startswith(custom_id_start_page) then + return + end + local member = interaction.member if not self:CheckRoles(member) then return @@ -101,10 +113,6 @@ function Module:OnInteractionCreate(interaction) local config = self:GetConfig(guild) - local interactionType = interaction.data.custom_id - local custom_id_start_remove = 'nickname_remove_' - local custom_id_start_page = 'nickname_page_' - local userList = self:BuildRenamedUserList(guild) if #userList == 0 then diff --git a/module_pin.lua b/module_pin.lua index 820fed1..7ced6cb 100644 --- a/module_pin.lua +++ b/module_pin.lua @@ -63,7 +63,10 @@ function Module:HandleEmojiAdd(config, reaction) end alertChannel:send({ - content = string.format("A message has been auto-pinned in %s:\n%s", message.channel.mentionString, Bot:GenerateMessageLink(message)), + content = string.format( + "A message has been auto-pinned in %s:\n%s", message.channel.mentionString, + Bot:GenerateMessageLink(message) + ), embed = { author = { name = author.tag, @@ -149,7 +152,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "pin", Args = { - { Name = "", Type = bot.ConfigType.Message }, + { Name = "messageId", Type = bot.ConfigType.Message } }, Help = function (guild) return Bot:Format(guild, "PIN_PIN_HELP") end, @@ -183,7 +186,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "unpin", Args = { - { Name = "", Type = bot.ConfigType.Message }, + { Name = "messageId", Type = bot.ConfigType.Message } }, Help = function (guild) return Bot:Format(guild, "PIN_UNPIN_HELP") end, diff --git a/module_prune.lua b/module_prune.lua index 2ec064e..21acf24 100644 --- a/module_prune.lua +++ b/module_prune.lua @@ -15,6 +15,16 @@ local NB_MSG_MAX_LIMIT = 100 Module.Name = "prune" +function Module:GetConfigTable() + return { + { + Name = "Silent", + Description = "Delete the command message used to invoke prunefrom", + Type = Bot.ConfigType.Boolean, + Default = true + } + } +end local function bulkDeleteChunks(channel, messagesChunks) local nbDeletedMessages = 0 @@ -58,14 +68,15 @@ function Module:bulkDeleteByNumber(commandMessage, nbMessages) end if remainder > 0 then - table.insert(messagesToDelete, channel:getMessagesBefore(currentMessageId, remainder):toArray("id", hasValidDate)) + table.insert( + messagesToDelete, channel:getMessagesBefore(currentMessageId, remainder):toArray("id", hasValidDate) + ) end return bulkDeleteChunks(channel, messagesToDelete) end -function Module:bulkDeleteById(commandMessage, targetMessage) - local channel = commandMessage.channel +function Module:bulkDeleteById(channel, targetMessage, trimInvokingMessage) local currentMessageId = targetMessage.id local messagesToDelete = {} @@ -78,8 +89,11 @@ function Module:bulkDeleteById(commandMessage, targetMessage) end until next(messages) == nil - -- This command is silent so we don't have to remove the command message here + -- Text command invocation posts a message right after the range we just fetched; strip it. + -- Context-menu invocations don't post one, so this must stay conditional. + if (trimInvokingMessage) then table.remove(messagesToDelete[#messagesToDelete], #messagesToDelete[#messagesToDelete]) + end -- Delete also the selected message if hasValidDate(targetMessage) then @@ -93,7 +107,7 @@ function Module:OnLoaded() self:RegisterCommand({ Name = "prune", Args = { - { Name = "", Type = Bot.ConfigType.Integer } + { Name = "nbOfMessages", Type = Bot.ConfigType.Integer, Description = "Number of recent messages to delete" } }, PrivilegeCheck = hasManagePermission, Help = function (guild) return Bot:Format(guild, "PRUNE_HELP") end, @@ -102,29 +116,46 @@ function Module:OnLoaded() local guild = commandMessage.guild local nbDeletedMessages = self:bulkDeleteByNumber(commandMessage, nbMessages) - local response = ""; + local response = "" if nbDeletedMessages ~= nbMessages then response = string.format("%s\n", Bot:Format(guild, "PRUNE_CANNOT_DELETE")) end response = response .. Bot:Format(guild, "PRUNE_RESULT", nbDeletedMessages) return commandMessage:reply(response) + end, + Slash = { + Description = "Delete a number of recent messages in this channel", + Func = function (interaction, nbMessages) + local guild = interaction.guild + + interaction:respond({ type = Enums.interactionResponseType.deferredChannelMessageWithSource }) + + local nbDeletedMessages = self:bulkDeleteByNumber(interaction, nbMessages) + local response = "" + if nbDeletedMessages ~= nbMessages then + response = string.format("%s\n", Bot:Format(guild, "PRUNE_CANNOT_DELETE")) + end + response = response .. Bot:Format(guild, "PRUNE_RESULT", nbDeletedMessages) + + interaction:editResponse({ content = response }) end + } }) self:RegisterCommand({ Name = "prunefrom", Args = { - { Name = "", Type = Bot.ConfigType.Message } + { Name = "messageId", Type = Bot.ConfigType.Message } }, PrivilegeCheck = hasManagePermission, Help = function (guild) return Bot:Format(guild, "PRUNEFROM_HELP") end, - Silent = true, Func = function (commandMessage, targetMessage) local guild = commandMessage.guild - local nbDeletedMessages = self:bulkDeleteById(commandMessage, targetMessage) + local config = self:GetConfig(guild) + local nbDeletedMessages = self:bulkDeleteById(commandMessage.channel, targetMessage, config.Silent) - local response = ""; + local response = "" if not hasValidDate(targetMessage) then response = string.format("%s\n", Bot:Format(guild, "PRUNE_CANNOT_DELETE")) end @@ -132,7 +163,28 @@ function Module:OnLoaded() response = response .. Bot:Format(guild, "PRUNE_RESULT", nbDeletedMessages) return commandMessage:reply(response) + end, + ContextMenu = { + Type = "message", + Func = function (interaction, targetMessage) + local guild = interaction.guild + + interaction:respond({ + type = Enums.interactionResponseType.deferredChannelMessageWithSource + }) + + local nbDeletedMessages = self:bulkDeleteById(interaction.channel, targetMessage, false) + + local response = "" + if not hasValidDate(targetMessage) then + response = string.format("%s\n", Bot:Format(guild, "PRUNE_CANNOT_DELETE")) + end + + response = response .. Bot:Format(guild, "PRUNE_RESULT", nbDeletedMessages) + + interaction:editResponse({ content = response }) end + } }) return true diff --git a/module_quote.lua b/module_quote.lua index 918ebca..b726876 100644 --- a/module_quote.lua +++ b/module_quote.lua @@ -6,7 +6,6 @@ local client = Client local discordia = Discordia local bot = Bot local enums = discordia.enums -local prefix = Config.Prefix Module.Name = "quote" @@ -135,7 +134,7 @@ function Module:OnMessageCreate(message) return end - if (message.content:startswith(prefix, true)) then + if (message.content:startswith(bot:GetGuildPrefix(message.guild), true)) then return end